This commit is contained in:
shump
2026-01-15 11:11:42 -06:00
commit 7562a4ce14
67 changed files with 5636 additions and 0 deletions

110
.gitignore vendored Normal file
View File

@@ -0,0 +1,110 @@
##############################
# OS
##############################
.DS_Store
Thumbs.db
ehthumbs.db
Icon?
Desktop.ini
##############################
# IDEs / Editors
##############################
.vs/
.vscode/
.idea/
*.user
*.rsuser
*.suo
*.userosscache
*.sln.docstates
##############################
# Build Results
##############################
bin/
obj/
[Bb]uild/
[Ll]og/
##############################
# Test Results
##############################
TestResults/
*.trx
##############################
# .NET / MSBuild
##############################
*.nupkg
*.snupkg
.nuget/
.nuget/packages/
project.lock.json
project.fragment.lock.json
artifacts/
##############################
# Rider
##############################
.idea/
##############################
# Visual Studio Cache
##############################
*.VC.db
*.VC.VC.opendb
##############################
# XAML / UI Specific
##############################
GeneratedFiles/
*.g.cs
*.g.i.cs
*.designer.cs
*.xaml.cs.bak
##############################
# MAUI / Xamarin
##############################
**/Platforms/**/bin/
**/Platforms/**/obj/
*.apk
*.aab
*.ipa
*.keystore
##############################
# Debug / Profiling
##############################
*.pdb
*.mdb
*.dbg
*.cache
##############################
# Publish Output
##############################
publish/
PublishProfiles/
*.publish.xml
##############################
# Local Settings / Secrets
##############################
appsettings.Development.json
appsettings.Local.json
secrets.json
##############################
# Logs
##############################
*.log
logs/
##############################
# Resharper
##############################
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user

676
LICENSE Normal file
View File

@@ -0,0 +1,676 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2024 Ormentia LLC
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a "work based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form of a
work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that is
widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added under section 7 apply to the code; keep
intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License does not
authorize you to change the license terms of any part of the work
if those terms are not authorized by this License.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. However, if you offer access to copy the
Corresponding Source from a network server, you must ensure that
the Corresponding Source remains available for as long as needed to
satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for
incorporation into a dwelling. In determining whether a product is a
consumer product, doubtful cases shall be resolved in favor of coverage.
For a particular product received by a particular user, "normally used"
refers to a typical or common use of that class of product, regardless
of the status of the particular user or of the way in which the
particular user actually uses, or expects or is expected to use, the
product. A product is a consumer product regardless of whether the
product has substantial commercial, industrial or non-consumer uses,
unless such uses represent the only significant mode of use of the
product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied by
the Installation Information. But this requirement does not apply if
neither you nor any third party retains the ability to install modified
object code on the User Product (for example, the work has been
installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product, or for any part thereof, or to ensure that continued
operation of the modified object code will be satisfied by the original
manufacturer specifications for spare parts or customer support.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions; the
above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you do payment to
the third party based on the extent of your activity of conveying the
work, and under which the third party grants, to any of the parties
who would receive the covered work from you, a discriminatory patent
license (a) in connection with copies of the covered work conveyed by
you (or copies made from those copies), or (b) primarily for and in
connection with specific products or compilations that contain the
covered work, unless you entered into that arrangement, or that patent
license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not convey it at all. For example, if you agree to terms that
obligate you to collect a royalty for further conveying from those to
whom you convey the Program, the only way you could satisfy both those
terms and this License would be to refrain entirely from conveying the
Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions
of the GNU General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in
detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of this License "or
any later version" applies to it, you have the option of following
the terms and conditions either of that numbered version or of any
later version published by the Free Software Foundation. If the
Program does not specify a version number of this License, you may
choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of this License can be used, that proxy's public statement
of acceptance of a version permanently authorizes you to choose that
version for the Program.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE
COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING
ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF
THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO
LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY
OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF
THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be held locally according to their terms, reviewing courts
shall apply local law that most closely approximates an absolute
waiver of all civil liability in connection with the Program, unless a
warranty or assumption of liability accompanies a copy of the Program
in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) 2024 Ormentia Markus Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Ormentia Markus Copyright (C) 2024 Ormentia Markus Contributors
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

164
README.md Normal file
View File

@@ -0,0 +1,164 @@
# Ormentia Markus
> **⚠️ This project is currently under active development and may have bugs or incomplete features. Use at your own discretion.**
A modern, cross-platform markdown editor built with Avalonia UI and .NET 10.
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
![License](https://img.shields.io/badge/license-GPLv3-blue)
![.NET](https://img.shields.io/badge/.NET-10.0-purple)
![Status](https://img.shields.io/badge/status-under%20development-yellow)
## ✨ Features
- **Multi-tab Interface** - Work with multiple markdown files simultaneously
- **Live Preview** - Toggle between raw markdown and rendered preview
- **Rich Formatting** - Quick access to common markdown formatting (bold, italic, headings, lists, code, quotes)
- **Syntax Highlighting** - Code-aware editor with line numbers
- **Cross-Platform** - Runs on Windows, macOS, and Linux
- **Theme Support** - Toggle between light and dark modes
- **Adjustable Text Size** - Small, Medium, and Large text size options
- **PDF Export** - Export your markdown documents to PDF format
- **Session Persistence** - Automatically restores your open files and tabs
## 🚀 Quick Start
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download) or later
### Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/ormentia-markus.git
cd ormentia-markus/src/DesktopApp
```
2. Build the project:
```bash
dotnet build
```
3. Run the application:
```bash
dotnet run
```
### Building for Distribution
**macOS (ARM64):**
```bash
cd src/DesktopApp
dotnet publish -c Release -r osx-arm64
```
**macOS (Intel):**
```bash
cd src/DesktopApp
dotnet publish -c Release -r osx-x64
```
**Windows:**
```bash
cd src/DesktopApp
dotnet publish -c Release -r win-x64
```
**Linux:**
```bash
cd src/DesktopApp
dotnet publish -c Release -r linux-x64
```
## 📸 Screenshots
_Coming soon - Screenshots will be added as the UI stabilizes._
## 🏗️ Architecture
This application follows clean architecture principles with clear separation of concerns:
- **MVVM Pattern** - Using CommunityToolkit.Mvvm for data binding
- **Service Layer** - Business logic abstracted into testable services
- **Strategy Pattern** - Modular markdown rendering system
- **Dependency Injection** - Services injected via constructors
### Project Structure
```
ormentia-markus/
├── LICENSE # GPLv3 License
├── README.md # This file
└── src/
└── DesktopApp/ # Main application code
├── Constants/ # Application-wide constants
├── Models/ # Domain models
├── Services/ # Business logic services
├── ViewModels/ # MVVM view models
└── Views/ # UI components and renderers
```
The application uses:
- **MVVM Pattern** with CommunityToolkit.Mvvm
- **Service Layer** for business logic abstraction
- **Strategy Pattern** for modular markdown rendering
- **Dependency Injection** via constructor parameters
## 🛠️ Development
### Key Technologies
- **Avalonia UI 11.3.8** - Cross-platform UI framework
- **AvaloniaEdit 11.3.0** - Text editor with syntax highlighting
- **Markdig 0.43.0** - Markdown parsing
- **CommunityToolkit.Mvvm 8.2.1** - MVVM helpers
- **QuestPDF 2025.7.4** - PDF generation
- **Font Awesome** - Icon library
### Code Style
- XML documentation for all public APIs
- Nullable reference types enabled
- Consistent async/await patterns
- Constants instead of magic values
## 📋 Roadmap
- [ ] Spell checking
- [ ] Find and replace
- [ ] Export to HTML
- [ ] Custom theme colors
- [ ] Plugin system
- [ ] Git integration
- [ ] Local file browser for images
- [ ] Notification service for errors
- [ ] Settings persistence
- [ ] Recent files list
- [ ] Keyboard shortcuts customization
- [ ] Table insertion helper
## 🤝 Contributing
Contributions are welcome! Please follow these guidelines:
1. Follow the established architecture patterns
2. Add XML documentation for all public APIs
3. Use services for business logic
4. Keep ViewModels focused on presentation
5. Add constants instead of magic values
6. Maintain error handling consistency
## 📄 License
This project is licensed under the GNU General Public License v3.0 (GPLv3). See the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- Built with [Avalonia UI](https://avaloniaui.net/)
- Markdown parsing by [Markdig](https://github.com/xoofx/markdig)
- Icons by [Font Awesome](https://fontawesome.com/)
---
**Note**: This project is under active development. Features may change, and bugs may exist. Please report issues on GitHub.

25
src/App.axaml Normal file
View File

@@ -0,0 +1,25 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MarkdownEditor.App"
xmlns:local="using:MarkdownEditor"
Name="Ormentia Markus"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<NativeMenu.Menu>
<NativeMenu>
<!-- App menu items (macOS automatically adds app name as first menu) -->
<!-- Menu items will be added programmatically in App.axaml.cs -->
</NativeMenu>
</NativeMenu.Menu>
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
<!-- AvaloniaEdit styles (required for TextEditor to render!) -->
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles>
</Application>

139
src/App.axaml.cs Normal file
View File

@@ -0,0 +1,139 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using MarkdownEditor.ViewModels;
using MarkdownEditor.Views;
namespace MarkdownEditor;
/// <summary>
/// Main application class.
/// Handles application initialization and lifetime management.
/// </summary>
public partial class App : Application
{
/// <inheritdoc/>
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
SetupNativeMenu();
}
/// <summary>
/// Sets up the native macOS menu with About dialog.
/// </summary>
private void SetupNativeMenu()
{
var appMenu = NativeMenu.GetMenu(this);
if (appMenu != null)
{
// About Markus
var aboutItem = new NativeMenuItem { Header = "About Markus" };
aboutItem.Click += AboutMarkus_Click;
appMenu.Items.Add(aboutItem);
// Separator
appMenu.Items.Add(new NativeMenuItemSeparator());
// Services
appMenu.Items.Add(new NativeMenuItem { Header = "Services" });
// Separator
appMenu.Items.Add(new NativeMenuItemSeparator());
// Hide Ormentia Markus
appMenu.Items.Add(new NativeMenuItem
{
Header = "Hide Ormentia Markus",
Gesture = KeyGesture.Parse("Cmd+H")
});
// Hide Others
appMenu.Items.Add(new NativeMenuItem
{
Header = "Hide Others",
Gesture = KeyGesture.Parse("Cmd+Alt+H")
});
// Show All
appMenu.Items.Add(new NativeMenuItem { Header = "Show All" });
// Separator
appMenu.Items.Add(new NativeMenuItemSeparator());
// Quit
appMenu.Items.Add(new NativeMenuItem
{
Header = "Quit Ormentia Markus",
Gesture = KeyGesture.Parse("Cmd+Q")
});
}
}
/// <inheritdoc/>
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
// Check for file path in command line arguments
string? initialFilePath = null;
if (desktop.Args?.Length > 0)
{
// Get the first argument as potential file path
var arg = desktop.Args[0];
// Ensure it's a valid file path (not a flag/option)
if (!arg.StartsWith("-") && System.IO.File.Exists(arg))
{
initialFilePath = arg;
}
}
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(initialFilePath: initialFilePath),
};
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Disables Avalonia's built-in data annotation validation to prevent conflicts with CommunityToolkit.
/// </summary>
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
/// <summary>
/// Handles the About Markus menu item click event.
/// </summary>
private void AboutMarkus_Click(object? sender, EventArgs e)
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop &&
desktop.MainWindow != null)
{
var aboutWindow = new AboutWindow();
aboutWindow.ShowDialog(desktop.MainWindow);
}
}
}

BIN
src/Assets/AppIcon.icns Normal file

Binary file not shown.

BIN
src/Assets/AppIcon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

BIN
src/Assets/markus-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,100 @@
namespace MarkdownEditor.Constants;
/// <summary>
/// Application-wide constant values for configuration and styling.
/// Centralizes magic strings and numbers to improve maintainability.
/// </summary>
public static class AppConstants
{
/// <summary>
/// UI color constants for consistent theming.
/// </summary>
public static class Colors
{
public const string Background = "#1E1E1E";
public const string SecondaryBackground = "#252526";
public const string TertiaryBackground = "#2D2D2D";
public const string Border = "#3E3E42";
public const string Hover = "#3E3E42";
public const string Pressed = "#505050";
public const string Foreground = "#CCCCCC";
public const string ForegroundBright = "#FFFFFF";
public const string Accent = "#4A90E2";
public const string CodeBackground = "#2D2D2D";
public const string LineNumbers = "#858585";
public const string TextColor = "#D4D4D4";
}
/// <summary>
/// Font size constants for typography consistency.
/// </summary>
public static class FontSizes
{
public const int Heading1 = 32;
public const int Heading2 = 24;
public const int Heading3 = 19;
public const int Heading4 = 16;
public const int Heading5 = 13;
public const int Heading6 = 11;
public const int Editor = 14;
public const int Tab = 13;
}
/// <summary>
/// Markdown formatting symbols.
/// </summary>
public static class Markdown
{
public const string Bold = "**";
public const string Italic = "*";
public const string InlineCode = "`";
public const string CodeBlockStart = "```\n";
public const string CodeBlockEnd = "\n```";
public const string Heading1Prefix = "# ";
public const string Heading2Prefix = "## ";
public const string Heading3Prefix = "### ";
public const string BulletPrefix = "- ";
public const string NumberedPrefix = "1. ";
public const string QuotePrefix = "> ";
public const string DefaultPlaceholder = "text";
}
/// <summary>
/// File-related constants.
/// </summary>
public static class Files
{
public const string MarkdownExtension = "*.md";
public const string MarkdownExtensionAlt = "*.markdown";
public const string DefaultFileName = "untitled.md";
public const string MarkdownFileTypeName = "Markdown Files";
}
/// <summary>
/// Default UI text and messages.
/// </summary>
public static class Messages
{
public const string DefaultTabTitle = "Untitled";
public const string DefaultEditorContent = "# Welcome to Markdown Editor\n\nStart editing...";
public const string ErrorRenderingMarkdown = "Error rendering markdown";
public const string OpenFileDialogTitle = "Open Markdown File";
public const string SaveFileDialogTitle = "Save Markdown File";
public const string ImageLoadError = "Failed to load image";
public const string LocalImagePlaceholder = "{0}";
public const string SaveChangesBeforeClose = "You have unsaved changes. Do you want to save them before closing?";
public const string SaveChangesBeforeCloseMultiple = "You have {0} file(s) with unsaved changes. Do you want to save them before closing?";
}
/// <summary>
/// Window and layout dimensions.
/// </summary>
public static class Layout
{
public const int DefaultWindowWidth = 1200;
public const int DefaultWindowHeight = 800;
public const int ImageMaxWidth = 600;
public const int MinTabsToAllowClose = 1;
}
}

63
src/Info.plist Normal file
View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Ormentia Markus</string>
<key>CFBundleDisplayName</key>
<string>Ormentia Markus</string>
<key>CFBundleIdentifier</key>
<string>com.ormentia.markus</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleExecutable</key>
<string>OrmentiaMarkus</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>NSHumanReadableCopyright</key>
<string>© 2025 Ormentia. All rights reserved.</string>
<key>CFBundleGetInfoString</key>
<string>Markus 1.0.0 - A Simple Markdown Editor</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Markdown Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeIconFile</key>
<string>AppIcon</string>
<key>LSItemContentTypes</key>
<array>
<string>net.daringfireball.markdown</string>
</array>
<key>LSHandlerRank</key>
<string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Plain Text Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>md</string>
<string>markdown</string>
<string>mdown</string>
</array>
<key>LSHandlerRank</key>
<string>Alternate</string>
</dict>
</array>
</dict>
</plist>

48
src/MarkdownEditor.csproj Normal file
View File

@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationTitle>Ormentia Markus</ApplicationTitle>
<AssemblyName>OrmentiaMarkus</AssemblyName>
<Product>Ormentia Markus</Product>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<CFBundleName>Ormentia Markus</CFBundleName>
<CFBundleDisplayName>Ormentia Markus</CFBundleDisplayName>
<CFBundleIdentifier>com.ormentia.markus</CFBundleIdentifier>
<CFBundleVersion>1.0.0</CFBundleVersion>
<CFBundleShortVersionString>1.0.0</CFBundleShortVersionString>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'osx-x64' OR '$(RuntimeIdentifier)' == 'osx-arm64' OR '$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">
<Content Include="Info.plist">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.8" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.8">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="Markdig" Version="0.43.0" />
<PackageReference Include="Projektanker.Icons.Avalonia" Version="9.6.2" />
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="9.6.2" />
<PackageReference Include="QuestPDF" Version="2025.7.4" />
</ItemGroup>
</Project>

19
src/Models/SessionData.cs Normal file
View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace MarkdownEditor.Models;
/// <summary>
/// Represents a complete application session state.
/// </summary>
public class SessionData
{
/// <summary>
/// Gets or sets the list of tabs that were open in the session.
/// </summary>
public List<SessionTabInfo> Tabs { get; set; } = new();
/// <summary>
/// Gets or sets the index of the tab that was selected when the session was saved.
/// </summary>
public int SelectedTabIndex { get; set; }
}

View File

@@ -0,0 +1,31 @@
namespace MarkdownEditor.Models;
/// <summary>
/// Represents information about a tab in a saved session.
/// Includes file path (if saved) and content (for unsaved files).
/// </summary>
public class SessionTabInfo
{
/// <summary>
/// Gets or sets the file path if the tab represents a saved file.
/// Null if the tab is an unsaved document.
/// </summary>
public string? FilePath { get; set; }
/// <summary>
/// Gets or sets the content of the tab.
/// For saved files, this may be empty (will be reloaded from disk).
/// For unsaved files, this contains the current content.
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the display title of the tab.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether this tab has unsaved changes.
/// </summary>
public bool IsDirty { get; set; }
}

30
src/Models/TabInfo.cs Normal file
View File

@@ -0,0 +1,30 @@
namespace MarkdownEditor.Models;
/// <summary>
/// Represents metadata about a markdown document tab.
/// This model encapsulates the state of a document independently from the view model.
/// </summary>
public class TabInfo
{
/// <summary>
/// Gets or sets the display title of the tab.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the file system path of the document, if it has been saved.
/// Null indicates an unsaved document.
/// </summary>
public string? FilePath { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the document has unsaved changes.
/// </summary>
public bool IsDirty { get; set; }
/// <summary>
/// Gets the display title with dirty indicator.
/// </summary>
public string DisplayTitle => IsDirty ? $"{Title} *" : Title;
}

View File

@@ -0,0 +1,74 @@
namespace MarkdownEditor.Models;
/// <summary>
/// Represents a text selection range within a document.
/// Used for formatting operations on selected text.
/// </summary>
public class TextSelection
{
/// <summary>
/// Gets or sets the starting position of the selection.
/// </summary>
public int Start { get; set; }
/// <summary>
/// Gets or sets the ending position of the selection.
/// </summary>
public int End { get; set; }
/// <summary>
/// Gets the length of the selection.
/// </summary>
public int Length => System.Math.Max(0, End - Start);
/// <summary>
/// Initializes a new instance of the <see cref="TextSelection"/> class.
/// </summary>
public TextSelection()
{
Start = 0;
End = 0;
}
/// <summary>
/// Initializes a new instance of the <see cref="TextSelection"/> class with specified range.
/// </summary>
/// <param name="start">The start position.</param>
/// <param name="end">The end position.</param>
public TextSelection(int start, int end)
{
Start = start;
End = end;
}
/// <summary>
/// Validates that the selection is within the given text bounds.
/// </summary>
/// <param name="textLength">The maximum text length.</param>
/// <returns>True if the selection is valid, false otherwise.</returns>
public bool IsValid(int textLength)
{
return Start >= 0 && End <= textLength && Start <= End;
}
/// <summary>
/// Normalizes the selection to ensure it's within valid bounds.
/// </summary>
/// <param name="textLength">The maximum text length.</param>
public void Normalize(int textLength)
{
Start = System.Math.Max(0, System.Math.Min(Start, textLength));
End = System.Math.Max(Start, System.Math.Min(End, textLength));
}
/// <summary>
/// Copies values from another text selection.
/// </summary>
/// <param name="other">The selection to copy from.</param>
public void CopyFrom(TextSelection other)
{
Start = other.Start;
End = other.End;
}
}

35
src/Program.cs Normal file
View File

@@ -0,0 +1,35 @@
using Avalonia;
using System;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
namespace MarkdownEditor;
/// <summary>
/// Application entry point.
/// Configures and starts the Avalonia application.
/// </summary>
sealed class Program
{
/// <summary>
/// Application entry point.
/// </summary>
/// <param name="args">Command line arguments.</param>
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
/// <summary>
/// Builds and configures the Avalonia application.
/// This method is also used by the visual designer.
/// </summary>
/// <returns>Configured AppBuilder instance.</returns>
public static AppBuilder BuildAvaloniaApp()
{
IconProvider.Current.Register<FontAwesomeIconProvider>();
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -0,0 +1,51 @@
using System.IO;
using System.Threading.Tasks;
namespace MarkdownEditor.Services;
/// <summary>
/// Default implementation of <see cref="IFileService"/> using System.IO.
/// Provides file system operations for reading and writing markdown files.
/// </summary>
public class FileService : IFileService
{
/// <inheritdoc/>
public async Task<string> ReadFileAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new System.ArgumentException("File path cannot be null or empty.", nameof(path));
}
return await File.ReadAllTextAsync(path);
}
/// <inheritdoc/>
public async Task WriteFileAsync(string path, string content)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new System.ArgumentException("File path cannot be null or empty.", nameof(path));
}
await File.WriteAllTextAsync(path, content ?? string.Empty);
}
/// <inheritdoc/>
public bool FileExists(string path)
{
return !string.IsNullOrWhiteSpace(path) && File.Exists(path);
}
/// <inheritdoc/>
public string GetFileName(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
return Path.GetFileName(path);
}
}

View File

@@ -0,0 +1,41 @@
using System.Threading.Tasks;
namespace MarkdownEditor.Services;
/// <summary>
/// Interface for file system operations.
/// Abstracts file I/O to improve testability and separation of concerns.
/// </summary>
public interface IFileService
{
/// <summary>
/// Reads the entire contents of a file as text.
/// </summary>
/// <param name="path">The file path to read from.</param>
/// <returns>The file contents as a string.</returns>
/// <exception cref="System.IO.IOException">Thrown when the file cannot be read.</exception>
Task<string> ReadFileAsync(string path);
/// <summary>
/// Writes text content to a file, creating or overwriting as necessary.
/// </summary>
/// <param name="path">The file path to write to.</param>
/// <param name="content">The text content to write.</param>
/// <exception cref="System.IO.IOException">Thrown when the file cannot be written.</exception>
Task WriteFileAsync(string path, string content);
/// <summary>
/// Checks if a file exists at the specified path.
/// </summary>
/// <param name="path">The file path to check.</param>
/// <returns>True if the file exists, false otherwise.</returns>
bool FileExists(string path);
/// <summary>
/// Gets the file name from a full path.
/// </summary>
/// <param name="path">The full file path.</param>
/// <returns>The file name without directory information.</returns>
string GetFileName(string path);
}

View File

@@ -0,0 +1,39 @@
using AvaloniaEdit.Document;
using MarkdownEditor.Models;
namespace MarkdownEditor.Services;
/// <summary>
/// Interface for markdown text formatting operations.
/// Handles insertion of markdown syntax around selected text or at cursor positions.
/// </summary>
public interface IMarkdownFormattingService
{
/// <summary>
/// Wraps the selected text with before and after strings (e.g., ** for bold).
/// </summary>
/// <param name="document">The text document to modify.</param>
/// <param name="selection">The current text selection.</param>
/// <param name="before">The string to insert before the selection.</param>
/// <param name="after">The string to insert after the selection.</param>
/// <returns>The new text selection after the operation.</returns>
TextSelection WrapSelection(TextDocument document, TextSelection selection, string before, string after);
/// <summary>
/// Adds a prefix to the beginning of the current line (e.g., # for heading).
/// </summary>
/// <param name="document">The text document to modify.</param>
/// <param name="selection">The current text selection to determine the line.</param>
/// <param name="prefix">The prefix string to add to the line.</param>
/// <returns>The new text selection after the operation.</returns>
TextSelection AddLinePrefix(TextDocument document, TextSelection selection, string prefix);
/// <summary>
/// Gets the selected text from the document.
/// </summary>
/// <param name="document">The text document.</param>
/// <param name="selection">The text selection range.</param>
/// <returns>The selected text, or empty string if selection is invalid.</returns>
string GetSelectedText(TextDocument document, TextSelection selection);
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
namespace MarkdownEditor.Services;
/// <summary>
/// Service interface for exporting markdown content to PDF.
/// </summary>
public interface IPdfExportService
{
/// <summary>
/// Exports markdown content to a PDF file.
/// </summary>
/// <param name="markdownContent">The markdown content to export.</param>
/// <param name="filePath">The file path where the PDF should be saved.</param>
/// <param name="title">Optional document title.</param>
Task ExportToPdfAsync(string markdownContent, string filePath, string? title = null);
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using MarkdownEditor.Models;
namespace MarkdownEditor.Services;
/// <summary>
/// Interface for session persistence operations.
/// Handles saving and restoring application session state (open files, tabs, etc.).
/// </summary>
public interface ISessionService
{
/// <summary>
/// Saves the current session state (open tabs with their file paths and unsaved content).
/// </summary>
/// <param name="tabs">Collection of tab information including file paths and content.</param>
/// <param name="selectedTabIndex">Index of the currently selected tab.</param>
/// <returns>Task representing the async operation.</returns>
Task SaveSessionAsync(IEnumerable<SessionTabInfo> tabs, int selectedTabIndex);
/// <summary>
/// Loads the previously saved session state.
/// </summary>
/// <returns>The saved session data, or null if no session exists or loading fails.</returns>
Task<SessionData?> LoadSessionAsync();
/// <summary>
/// Clears the saved session data.
/// </summary>
/// <returns>Task representing the async operation.</returns>
Task ClearSessionAsync();
}

View File

@@ -0,0 +1,109 @@
using System;
using AvaloniaEdit.Document;
using MarkdownEditor.Constants;
using MarkdownEditor.Models;
namespace MarkdownEditor.Services;
/// <summary>
/// Default implementation of <see cref="IMarkdownFormattingService"/>.
/// Provides markdown formatting operations on text documents.
/// </summary>
public class MarkdownFormattingService : IMarkdownFormattingService
{
/// <inheritdoc/>
public TextSelection WrapSelection(TextDocument document, TextSelection selection, string before, string after)
{
if (document == null)
{
throw new ArgumentNullException(nameof(document));
}
if (selection == null)
{
throw new ArgumentNullException(nameof(selection));
}
// Normalize selection bounds
selection.Normalize(document.TextLength);
var selected = GetSelectedText(document, selection);
var newText = before + (string.IsNullOrEmpty(selected) ? AppConstants.Markdown.DefaultPlaceholder : selected) + after;
// Use Document API for thread-safe editing
document.BeginUpdate();
try
{
document.Remove(selection.Start, selection.Length);
document.Insert(selection.Start, newText);
}
finally
{
document.EndUpdate();
}
// Calculate new selection to highlight the wrapped content
var newStart = selection.Start + before.Length;
var textLength = (string.IsNullOrEmpty(selected) ? AppConstants.Markdown.DefaultPlaceholder : selected).Length;
return new TextSelection(newStart, newStart + textLength);
}
/// <inheritdoc/>
public TextSelection AddLinePrefix(TextDocument document, TextSelection selection, string prefix)
{
if (document == null)
{
throw new ArgumentNullException(nameof(document));
}
if (selection == null)
{
throw new ArgumentNullException(nameof(selection));
}
if (string.IsNullOrEmpty(prefix))
{
throw new ArgumentException("Prefix cannot be null or empty.", nameof(prefix));
}
// Normalize selection bounds
selection.Normalize(document.TextLength);
// Find the start of the current line
var text = document.Text;
var lineStart = text.LastIndexOf('\n', Math.Max(0, selection.Start - 1)) + 1;
// Use Document API for thread-safe editing
document.BeginUpdate();
try
{
document.Insert(lineStart, prefix);
}
finally
{
document.EndUpdate();
}
// Update selection position
var newPos = lineStart + prefix.Length;
return new TextSelection(newPos, newPos);
}
/// <inheritdoc/>
public string GetSelectedText(TextDocument document, TextSelection selection)
{
if (document == null || selection == null)
{
return string.Empty;
}
var length = selection.Length;
if (selection.Start >= 0 &&
length > 0 &&
selection.Start + length <= document.TextLength)
{
return document.GetText(selection.Start, length);
}
return string.Empty;
}
}

View File

@@ -0,0 +1,244 @@
using System;
using System.Threading.Tasks;
using Markdig;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace MarkdownEditor.Services;
/// <summary>
/// Service for exporting markdown content to PDF format.
/// </summary>
public class PdfExportService : IPdfExportService
{
/// <inheritdoc/>
public Task ExportToPdfAsync(string markdownContent, string filePath, string? title = null)
{
return Task.Run(() =>
{
// Configure QuestPDF license (Community license for open source projects)
QuestPDF.Settings.License = LicenseType.Community;
// Parse markdown to document
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
var document = Markdown.Parse(markdownContent, pipeline);
// Generate PDF
Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(50);
page.DefaultTextStyle(x => x.FontSize(12).FontFamily("Arial"));
page.Content()
.Column(column =>
{
column.Spacing(10);
RenderMarkdownDocument(column, document);
});
page.Footer()
.AlignCenter()
.Text(x =>
{
x.Span("Page ");
x.CurrentPageNumber();
x.Span(" of ");
x.TotalPages();
});
});
}).GeneratePdf(filePath);
});
}
private void RenderMarkdownDocument(ColumnDescriptor column, MarkdownDocument document)
{
foreach (var block in document)
{
RenderBlock(column, block);
}
}
private void RenderBlock(ColumnDescriptor column, Block block)
{
switch (block)
{
case HeadingBlock heading:
RenderHeading(column, heading);
break;
case ParagraphBlock paragraph:
RenderParagraph(column, paragraph);
break;
case CodeBlock code:
RenderCodeBlock(column, code);
break;
case ListBlock list:
RenderList(column, list);
break;
case QuoteBlock quote:
RenderQuote(column, quote);
break;
case ThematicBreakBlock:
column.Item().LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
break;
}
}
private void RenderHeading(ColumnDescriptor column, HeadingBlock heading)
{
var fontSize = heading.Level switch
{
1 => 24,
2 => 20,
3 => 16,
4 => 14,
5 => 12,
_ => 10
};
column.Item()
.PaddingTop(10)
.DefaultTextStyle(x => x.FontSize(fontSize).Bold())
.Text(text =>
{
RenderInlines(text, heading.Inline);
});
}
private void RenderParagraph(ColumnDescriptor column, ParagraphBlock paragraph)
{
column.Item()
.DefaultTextStyle(x => x.LineHeight(1.5f))
.Text(text =>
{
if (paragraph.Inline != null)
{
RenderInlines(text, paragraph.Inline);
}
});
}
private void RenderCodeBlock(ColumnDescriptor column, CodeBlock code)
{
var codeText = string.Join("\n", code.Lines);
column.Item()
.Background(Colors.Grey.Lighten3)
.Padding(10)
.Text(codeText)
.FontFamily("Courier New")
.FontSize(10);
}
private void RenderList(ColumnDescriptor column, ListBlock list)
{
int itemNumber = 1;
foreach (var item in list)
{
if (item is ListItemBlock listItem)
{
column.Item().Row(row =>
{
// Bullet or number
row.ConstantItem(30).Text(list.IsOrdered ? $"{itemNumber}." : "•").FontSize(12);
// Content
row.RelativeItem().Column(itemColumn =>
{
foreach (var block in listItem)
{
RenderBlock(itemColumn, block);
}
});
});
if (list.IsOrdered)
itemNumber++;
}
}
}
private void RenderQuote(ColumnDescriptor column, QuoteBlock quote)
{
column.Item()
.BorderLeft(4)
.BorderColor(Colors.Blue.Lighten2)
.PaddingLeft(15)
.Column(quoteColumn =>
{
foreach (var block in quote)
{
RenderBlock(quoteColumn, block);
}
});
}
private void RenderInlines(TextDescriptor text, ContainerInline? inline)
{
if (inline == null) return;
foreach (var child in inline)
{
switch (child)
{
case LiteralInline literal:
text.Span(literal.Content.ToString());
break;
case EmphasisInline emphasis:
var emphasisText = GetInlineText(emphasis);
if (emphasis.DelimiterCount == 2) // Bold
text.Span(emphasisText).Bold();
else // Italic
text.Span(emphasisText).Italic();
break;
case CodeInline code:
text.Span(code.Content)
.FontFamily("Courier New")
.FontSize(10)
.BackgroundColor(Colors.Grey.Lighten3);
break;
case LinkInline link:
var linkText = GetInlineText(link);
var url = link.Url ?? "";
text.Hyperlink(linkText, url)
.FontColor(Colors.Blue.Medium)
.Underline();
break;
case LineBreakInline:
text.Span("\n");
break;
}
}
}
private string GetInlineText(ContainerInline container)
{
var result = "";
foreach (var child in container)
{
if (child is LiteralInline literal)
result += literal.Content.ToString();
else if (child is ContainerInline containerChild)
result += GetInlineText(containerChild);
}
return result;
}
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using MarkdownEditor.Models;
namespace MarkdownEditor.Services;
/// <summary>
/// Service for persisting and restoring application session state.
/// Saves open tabs, file paths, and unsaved content to a JSON file in the app data directory.
/// </summary>
public class SessionService : ISessionService
{
private readonly string _sessionFilePath;
/// <summary>
/// Initializes a new instance of the <see cref="SessionService"/> class.
/// </summary>
public SessionService()
{
// Store session file in app data directory
var appDataPath = GetAppDataDirectory();
Directory.CreateDirectory(appDataPath);
_sessionFilePath = Path.Combine(appDataPath, "session.json");
}
/// <summary>
/// Gets the application data directory for storing session files.
/// </summary>
/// <returns>The path to the app data directory.</returns>
private static string GetAppDataDirectory()
{
var appName = "OrmentiaMarkus";
// Use LocalApplicationData for cross-platform compatibility
// On Windows: %LOCALAPPDATA%
// On macOS: ~/Library/Application Support
// On Linux: ~/.local/share
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(appDataPath, appName);
}
/// <inheritdoc/>
public async Task SaveSessionAsync(IEnumerable<SessionTabInfo> tabs, int selectedTabIndex)
{
try
{
var sessionData = new SessionData
{
Tabs = tabs.ToList(),
SelectedTabIndex = selectedTabIndex
};
var json = JsonSerializer.Serialize(sessionData, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(_sessionFilePath, json);
}
catch
{
// Fail silently - session restoration is a convenience feature
}
}
/// <inheritdoc/>
public async Task<SessionData?> LoadSessionAsync()
{
try
{
if (!File.Exists(_sessionFilePath))
{
return null;
}
var json = await File.ReadAllTextAsync(_sessionFilePath);
var sessionData = JsonSerializer.Deserialize<SessionData>(json);
return sessionData;
}
catch
{
return null;
}
}
/// <inheritdoc/>
public async Task ClearSessionAsync()
{
try
{
if (File.Exists(_sessionFilePath))
{
File.Delete(_sessionFilePath);
}
}
catch
{
// Error clearing session - fail silently
}
await Task.CompletedTask;
}
}

37
src/ViewLocator.cs Normal file
View File

@@ -0,0 +1,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using MarkdownEditor.ViewModels;
namespace MarkdownEditor;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}

View File

@@ -0,0 +1,560 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarkdownEditor.Constants;
using MarkdownEditor.Models;
using MarkdownEditor.Services;
namespace MarkdownEditor.ViewModels;
/// <summary>
/// Main view model for the application window.
/// Manages the collection of markdown document tabs and file operations.
/// </summary>
public partial class MainWindowViewModel : ViewModelBase
{
[ObservableProperty]
private ObservableCollection<MarkdownTabViewModel> _tabs = new();
[ObservableProperty]
private MarkdownTabViewModel? _selectedTab;
[ObservableProperty]
private bool _isDarkMode = true;
[ObservableProperty]
private string _textSize = "Small"; // Small, Medium, Large
private readonly IPdfExportService _pdfExportService;
private readonly ISessionService _sessionService;
private bool _isRestoringSession = false;
private readonly bool _hasInitialFile;
/// <summary>
/// Gets or sets the storage provider for file dialogs.
/// Set by the view when the window is opened.
/// </summary>
public IStorageProvider? StorageProvider { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
/// </summary>
/// <param name="pdfExportService">Optional PDF export service for dependency injection.</param>
/// <param name="sessionService">Optional session service for dependency injection.</param>
/// <param name="initialFilePath">Optional file path to open on startup (takes precedence over session restore).</param>
public MainWindowViewModel(IPdfExportService? pdfExportService = null, ISessionService? sessionService = null, string? initialFilePath = null)
{
_pdfExportService = pdfExportService ?? new PdfExportService();
_sessionService = sessionService ?? new SessionService();
// Track if we have an initial file (takes precedence over session restore)
_hasInitialFile = !string.IsNullOrEmpty(initialFilePath) && System.IO.File.Exists(initialFilePath);
if (_hasInitialFile)
{
var newTab = new MarkdownTabViewModel();
newTab.LoadFromFile(initialFilePath!);
Tabs.Add(newTab);
SelectedTab = newTab;
}
else
{
// Will restore session after StorageProvider is set
// For now, add a default tab as fallback (will be replaced if session exists)
AddNewTab();
}
}
/// <summary>
/// Creates and adds a new empty tab.
/// </summary>
[RelayCommand]
private void AddNewTab()
{
var newTab = new MarkdownTabViewModel
{
Title = $"Tab {Tabs.Count + 1}"
};
Tabs.Add(newTab);
SelectedTab = newTab;
}
/// <summary>
/// Closes the specified tab.
/// Maintains at least one open tab and updates selection appropriately.
/// </summary>
/// <param name="tab">The tab to close.</param>
[RelayCommand]
private void CloseTab(MarkdownTabViewModel tab)
{
if (tab == null)
{
return;
}
if (Tabs.Count > AppConstants.Layout.MinTabsToAllowClose)
{
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
if (SelectedTab == tab)
{
SelectedTab = Tabs[Math.Max(0, index - 1)];
}
}
}
/// <summary>
/// Opens a markdown file from disk in a new or existing tab.
/// </summary>
[RelayCommand]
private async Task OpenFile()
{
if (StorageProvider == null)
{
return;
}
try
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = AppConstants.Messages.OpenFileDialogTitle,
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType(AppConstants.Files.MarkdownFileTypeName)
{
Patterns = new[] { AppConstants.Files.MarkdownExtension, AppConstants.Files.MarkdownExtensionAlt }
},
FilePickerFileTypes.TextPlain,
FilePickerFileTypes.All
}
});
if (files.Count > 0)
{
var file = files[0];
var path = file.Path.LocalPath;
// Check if file is already open
var existingTab = Tabs.FirstOrDefault(t => t.FilePath == path);
if (existingTab != null)
{
SelectedTab = existingTab;
return;
}
// Create new tab and load file
var newTab = new MarkdownTabViewModel();
newTab.LoadFromFile(path);
Tabs.Add(newTab);
SelectedTab = newTab;
}
}
catch
{
// Error opening file - fail silently
}
}
/// <summary>
/// Saves the currently selected tab's document.
/// Prompts for a file location if not previously saved.
/// </summary>
[RelayCommand]
private async Task SaveFile()
{
if (SelectedTab == null || StorageProvider == null)
{
return;
}
try
{
if (string.IsNullOrEmpty(SelectedTab.FilePath))
{
await SaveFileAs();
}
else
{
SelectedTab.SaveToFile(SelectedTab.FilePath);
}
}
catch
{
// Error saving file - fail silently
}
}
/// <summary>
/// Prompts the user to save the currently selected tab's document to a new location.
/// </summary>
[RelayCommand]
private async Task SaveFileAs()
{
if (SelectedTab == null || StorageProvider == null)
{
return;
}
try
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = AppConstants.Messages.SaveFileDialogTitle,
SuggestedFileName = string.IsNullOrEmpty(SelectedTab.FilePath)
? AppConstants.Files.DefaultFileName
: System.IO.Path.GetFileName(SelectedTab.FilePath),
FileTypeChoices = new[]
{
new FilePickerFileType(AppConstants.Files.MarkdownFileTypeName)
{
Patterns = new[] { AppConstants.Files.MarkdownExtension, AppConstants.Files.MarkdownExtensionAlt }
},
FilePickerFileTypes.TextPlain
}
});
if (file != null)
{
SelectedTab.SaveToFile(file.Path.LocalPath);
}
}
catch
{
// Error saving file - fail silently
}
}
/// <summary>
/// Exports the currently selected tab's markdown content to PDF.
/// </summary>
[RelayCommand]
private async Task ExportToPdf()
{
if (SelectedTab == null || StorageProvider == null)
{
return;
}
try
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export to PDF",
SuggestedFileName = string.IsNullOrEmpty(SelectedTab.FilePath)
? "document.pdf"
: System.IO.Path.GetFileNameWithoutExtension(SelectedTab.FilePath) + ".pdf",
FileTypeChoices = new[]
{
new FilePickerFileType("PDF Document")
{
Patterns = new[] { "*.pdf" }
}
}
});
if (file != null)
{
var title = string.IsNullOrEmpty(SelectedTab.Title)
? "Markdown Document"
: SelectedTab.Title;
await _pdfExportService.ExportToPdfAsync(
SelectedTab.MarkdownContent,
file.Path.LocalPath,
title);
}
}
catch
{
// Error exporting to PDF - fail silently
}
}
/// <summary>
/// Toggles between dark and light mode.
/// </summary>
[RelayCommand]
private void ToggleTheme()
{
IsDarkMode = !IsDarkMode;
}
/// <summary>
/// Sets the text size to Small.
/// </summary>
[RelayCommand]
private void SetTextSizeSmall()
{
TextSize = "Small";
}
/// <summary>
/// Sets the text size to Medium.
/// </summary>
[RelayCommand]
private void SetTextSizeMedium()
{
TextSize = "Medium";
}
/// <summary>
/// Sets the text size to Large.
/// </summary>
[RelayCommand]
private void SetTextSizeLarge()
{
TextSize = "Large";
}
// Computed properties for theme colors
public string BackgroundColor => IsDarkMode ? "#1E1E1E" : "#FFFFFF";
public string SecondaryBackgroundColor => IsDarkMode ? "#252526" : "#F3F3F3";
public string TertiaryBackgroundColor => IsDarkMode ? "#2D2D2D" : "#E8E8E8";
public string BorderColor => IsDarkMode ? "#3E3E42" : "#CCCCCC";
public string ForegroundColor => IsDarkMode ? "#D4D4D4" : "#333333";
public string SecondaryForegroundColor => IsDarkMode ? "#CCCCCC" : "#666666";
public string ActiveBackgroundColor => IsDarkMode ? "#505050" : "#D0D0D0";
public string HoverBackgroundColor => IsDarkMode ? "#3E3E42" : "#E0E0E0";
public string LineNumberColor => IsDarkMode ? "#858585" : "#999999";
// Computed properties for font sizes
public double EditorFontSize => TextSize switch
{
"Small" => 14,
"Medium" => 16,
"Large" => 18,
_ => 14
};
public double ContentFontSize => TextSize switch
{
"Small" => 14,
"Medium" => 16,
"Large" => 18,
_ => 14
};
partial void OnIsDarkModeChanged(bool value)
{
// Notify all color properties have changed
OnPropertyChanged(nameof(BackgroundColor));
OnPropertyChanged(nameof(SecondaryBackgroundColor));
OnPropertyChanged(nameof(TertiaryBackgroundColor));
OnPropertyChanged(nameof(BorderColor));
OnPropertyChanged(nameof(ForegroundColor));
OnPropertyChanged(nameof(SecondaryForegroundColor));
OnPropertyChanged(nameof(ActiveBackgroundColor));
OnPropertyChanged(nameof(HoverBackgroundColor));
OnPropertyChanged(nameof(LineNumberColor));
}
partial void OnTextSizeChanged(string value)
{
// Notify all font size properties have changed
OnPropertyChanged(nameof(EditorFontSize));
OnPropertyChanged(nameof(ContentFontSize));
}
/// <summary>
/// Restores the previous session (open files and tabs).
/// Should be called after StorageProvider is set.
/// </summary>
public async Task RestoreSessionAsync()
{
// Don't restore if we opened a specific file (command line argument)
if (_hasInitialFile || _isRestoringSession)
{
return;
}
_isRestoringSession = true;
try
{
var sessionData = await _sessionService.LoadSessionAsync();
if (sessionData == null || sessionData.Tabs.Count == 0)
{
// No session to restore, keep the default tab
return;
}
// Clear existing tabs
Tabs.Clear();
// Restore each tab from the session
foreach (var sessionTab in sessionData.Tabs)
{
var tab = new MarkdownTabViewModel();
if (!string.IsNullOrEmpty(sessionTab.FilePath) && System.IO.File.Exists(sessionTab.FilePath))
{
// File exists
tab.FilePath = sessionTab.FilePath;
if (sessionTab.IsDirty && !string.IsNullOrEmpty(sessionTab.Content))
{
// File was dirty - restore the unsaved content (don't load from disk)
tab.MarkdownContent = sessionTab.Content;
tab.IsDirty = true;
tab.Title = System.IO.Path.GetFileName(sessionTab.FilePath);
}
else
{
// File wasn't dirty - load from disk (may have been modified externally)
tab.LoadFromFile(sessionTab.FilePath);
}
}
else if (!string.IsNullOrEmpty(sessionTab.Content))
{
// Unsaved file or file no longer exists - restore content
tab.MarkdownContent = sessionTab.Content;
tab.Title = string.IsNullOrEmpty(sessionTab.Title)
? AppConstants.Messages.DefaultTabTitle
: sessionTab.Title;
tab.IsDirty = sessionTab.IsDirty;
}
else
{
// Empty tab
tab.Title = string.IsNullOrEmpty(sessionTab.Title)
? AppConstants.Messages.DefaultTabTitle
: sessionTab.Title;
}
Tabs.Add(tab);
}
// Restore selected tab
if (sessionData.SelectedTabIndex >= 0 && sessionData.SelectedTabIndex < Tabs.Count)
{
SelectedTab = Tabs[sessionData.SelectedTabIndex];
}
else if (Tabs.Count > 0)
{
SelectedTab = Tabs[0];
}
}
catch
{
// If restoration fails, keep the default tab
}
finally
{
_isRestoringSession = false;
}
}
/// <summary>
/// Saves the current session state (open files and tabs).
/// Should be called when the window is closing.
/// </summary>
public async Task SaveSessionAsync()
{
try
{
var sessionTabs = Tabs.Select(tab => new SessionTabInfo
{
FilePath = tab.FilePath,
Content = tab.MarkdownContent,
Title = tab.Title,
IsDirty = tab.IsDirty
}).ToList();
var selectedIndex = SelectedTab != null ? Tabs.IndexOf(SelectedTab) : 0;
if (selectedIndex < 0)
{
selectedIndex = 0;
}
await _sessionService.SaveSessionAsync(sessionTabs, selectedIndex);
}
catch
{
// Fail silently - session saving is a convenience feature
}
}
/// <summary>
/// Checks if there are any tabs with unsaved changes.
/// </summary>
/// <returns>True if any tab has unsaved changes, false otherwise.</returns>
public bool HasUnsavedChanges()
{
return Tabs.Any(tab => tab.IsDirty);
}
/// <summary>
/// Gets the count of tabs with unsaved changes.
/// </summary>
/// <returns>The number of tabs with unsaved changes.</returns>
public int GetUnsavedChangesCount()
{
return Tabs.Count(tab => tab.IsDirty);
}
/// <summary>
/// Saves all tabs with unsaved changes.
/// Prompts for file location for unsaved files without a path.
/// </summary>
/// <returns>True if all files were saved successfully, false if user cancelled or error occurred.</returns>
public async Task<bool> SaveAllUnsavedChangesAsync()
{
if (StorageProvider == null)
{
return false;
}
var dirtyTabs = Tabs.Where(tab => tab.IsDirty).ToList();
foreach (var tab in dirtyTabs)
{
try
{
if (string.IsNullOrEmpty(tab.FilePath))
{
// File hasn't been saved yet - prompt for location
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = AppConstants.Messages.SaveFileDialogTitle,
SuggestedFileName = AppConstants.Files.DefaultFileName,
FileTypeChoices = new[]
{
new FilePickerFileType(AppConstants.Files.MarkdownFileTypeName)
{
Patterns = new[] { AppConstants.Files.MarkdownExtension, AppConstants.Files.MarkdownExtensionAlt }
},
FilePickerFileTypes.TextPlain
}
});
if (file == null)
{
// User cancelled - stop saving
return false;
}
tab.SaveToFile(file.Path.LocalPath);
}
else
{
// File has a path - save directly
tab.SaveToFile(tab.FilePath);
}
}
catch
{
// Continue with other files even if one fails
}
}
return true;
}
}

View File

@@ -0,0 +1,296 @@
using System;
using AvaloniaEdit.Document;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarkdownEditor.Constants;
using MarkdownEditor.Models;
using MarkdownEditor.Services;
namespace MarkdownEditor.ViewModels;
/// <summary>
/// View model for a single markdown document tab.
/// Manages document content, editing state, and formatting operations.
/// </summary>
public partial class MarkdownTabViewModel : ViewModelBase
{
private readonly IFileService _fileService;
private readonly IMarkdownFormattingService _formattingService;
[ObservableProperty]
private string _title = AppConstants.Messages.DefaultTabTitle;
private readonly TextDocument _document;
[ObservableProperty]
private bool _isPrettyView = false;
[ObservableProperty]
private string? _filePath;
[ObservableProperty]
private bool _isDirty = false;
/// <summary>
/// Gets the text selection used for formatting operations.
/// </summary>
public TextSelection Selection { get; } = new();
/// <summary>
/// Initializes a new instance of the <see cref="MarkdownTabViewModel"/> class.
/// </summary>
/// <param name="fileService">Service for file operations. If null, uses default implementation.</param>
/// <param name="formattingService">Service for markdown formatting. If null, uses default implementation.</param>
public MarkdownTabViewModel(IFileService? fileService = null, IMarkdownFormattingService? formattingService = null)
{
_fileService = fileService ?? new FileService();
_formattingService = formattingService ?? new MarkdownFormattingService();
_document = new TextDocument(AppConstants.Messages.DefaultEditorContent);
_document.TextChanged += (s, e) =>
{
IsDirty = true;
OnPropertyChanged(nameof(SafeMarkdownContent));
};
}
/// <summary>
/// Gets the underlying text document.
/// </summary>
public TextDocument Document => _document;
/// <summary>
/// Gets or sets the markdown content of the document.
/// </summary>
public string MarkdownContent
{
get => _document.Text;
set
{
if (_document.Text != value)
{
_document.Text = value;
OnPropertyChanged(nameof(SafeMarkdownContent));
}
}
}
/// <summary>
/// Gets the markdown content safely, with error handling.
/// </summary>
public string SafeMarkdownContent
{
get
{
try
{
var content = _document.Text;
if (string.IsNullOrWhiteSpace(content))
return string.Empty;
return content;
}
catch
{
return AppConstants.Messages.ErrorRenderingMarkdown;
}
}
}
/// <summary>
/// Gets the view mode text for display.
/// </summary>
public string ViewModeText => IsPrettyView ? "Pretty" : "Raw";
/// <summary>
/// Gets the display title with dirty indicator.
/// </summary>
public string DisplayTitle => IsDirty ? $"{Title} *" : Title;
partial void OnTitleChanged(string value)
{
OnPropertyChanged(nameof(DisplayTitle));
}
partial void OnIsDirtyChanged(bool value)
{
OnPropertyChanged(nameof(DisplayTitle));
}
/// <summary>
/// Loads a markdown file from disk.
/// </summary>
/// <param name="path">The file path to load.</param>
public async void LoadFromFile(string path)
{
try
{
FilePath = path;
var content = await _fileService.ReadFileAsync(path);
_document.Text = content;
Title = _fileService.GetFileName(path);
IsDirty = false;
OnPropertyChanged(nameof(SafeMarkdownContent));
}
catch
{
// Error loading file - fail silently
}
}
/// <summary>
/// Saves the document to disk.
/// </summary>
/// <param name="path">The file path to save to.</param>
public async void SaveToFile(string path)
{
try
{
FilePath = path;
await _fileService.WriteFileAsync(path, _document.Text);
Title = _fileService.GetFileName(path);
IsDirty = false;
}
catch
{
// Error saving file - fail silently
}
}
// Formatting commands using the formatting service
[RelayCommand]
private void FormatBold()
{
Selection.CopyFrom(_formattingService.WrapSelection(
_document, Selection, AppConstants.Markdown.Bold, AppConstants.Markdown.Bold));
}
[RelayCommand]
private void FormatItalic()
{
Selection.CopyFrom(_formattingService.WrapSelection(
_document, Selection, AppConstants.Markdown.Italic, AppConstants.Markdown.Italic));
}
[RelayCommand]
private void FormatHeading1()
{
Selection.CopyFrom(_formattingService.AddLinePrefix(
_document, Selection, AppConstants.Markdown.Heading1Prefix));
}
[RelayCommand]
private void FormatHeading2()
{
Selection.CopyFrom(_formattingService.AddLinePrefix(
_document, Selection, AppConstants.Markdown.Heading2Prefix));
}
[RelayCommand]
private void FormatHeading3()
{
Selection.CopyFrom(_formattingService.AddLinePrefix(
_document, Selection, AppConstants.Markdown.Heading3Prefix));
}
[RelayCommand]
private void FormatBulletList()
{
Selection.CopyFrom(_formattingService.AddLinePrefix(
_document, Selection, AppConstants.Markdown.BulletPrefix));
}
[RelayCommand]
private void FormatNumberedList()
{
Selection.CopyFrom(_formattingService.AddLinePrefix(
_document, Selection, AppConstants.Markdown.NumberedPrefix));
}
[RelayCommand]
private void FormatCode()
{
Selection.CopyFrom(_formattingService.WrapSelection(
_document, Selection, AppConstants.Markdown.InlineCode, AppConstants.Markdown.InlineCode));
}
[RelayCommand]
private void FormatCodeBlock()
{
Selection.CopyFrom(_formattingService.WrapSelection(
_document, Selection, AppConstants.Markdown.CodeBlockStart, AppConstants.Markdown.CodeBlockEnd));
}
[RelayCommand]
private void FormatQuote()
{
Selection.CopyFrom(_formattingService.AddLinePrefix(
_document, Selection, AppConstants.Markdown.QuotePrefix));
}
[RelayCommand]
private void SetRawView()
{
IsPrettyView = false;
}
[RelayCommand]
private void SetPrettyView()
{
IsPrettyView = true;
}
/// <summary>
/// Inserts a hyperlink at the current selection.
/// </summary>
/// <param name="url">The URL to link to.</param>
public void InsertHyperlink(string url)
{
var selectedText = _document.GetText(Selection.Start, Selection.End - Selection.Start);
var linkText = string.IsNullOrWhiteSpace(selectedText) ? "link text" : selectedText;
var markdown = $"[{linkText}]({url})";
_document.Replace(Selection.Start, Selection.End - Selection.Start, markdown);
// Update selection to highlight the link text for easy editing
Selection.Start = Selection.Start;
Selection.End = Selection.Start + linkText.Length + 2; // +2 for the brackets
}
/// <summary>
/// Inserts an image at the current selection.
/// </summary>
/// <param name="url">The image URL.</param>
public void InsertImage(string url)
{
var selectedText = _document.GetText(Selection.Start, Selection.End - Selection.Start);
var altText = string.IsNullOrWhiteSpace(selectedText) ? "image" : selectedText;
var markdown = $"![{altText}]({url})";
_document.Replace(Selection.Start, Selection.End - Selection.Start, markdown);
// Update selection to highlight the alt text for easy editing
Selection.Start = Selection.Start + 2; // Skip "!["
Selection.End = Selection.Start + altText.Length;
}
// Legacy properties for backward compatibility
/// <summary>
/// Gets or sets the selection start position.
/// </summary>
public int SelectionStart
{
get => Selection.Start;
set => Selection.Start = value;
}
/// <summary>
/// Gets or sets the selection end position.
/// </summary>
public int SelectionEnd
{
get => Selection.End;
set => Selection.End = value;
}
}

View File

@@ -0,0 +1,11 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace MarkdownEditor.ViewModels;
/// <summary>
/// Base class for all view models in the application.
/// Provides observable property change notification through CommunityToolkit.Mvvm.
/// </summary>
public abstract class ViewModelBase : ObservableObject
{
}

View File

@@ -0,0 +1,78 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="500"
x:Class="MarkdownEditor.Views.AboutWindow"
Title="About Markus"
Width="600" Height="500"
WindowStartupLocation="CenterOwner"
CanResize="False"
Background="#1E1E1E">
<Grid>
<StackPanel VerticalAlignment="Top"
HorizontalAlignment="Center"
Spacing="20"
Margin="0,40,0,40">
<!-- Logo -->
<Image Source="/Assets/markus-logo.png"
Width="200" Height="200"
HorizontalAlignment="Center"/>
<!-- App Name -->
<TextBlock Text="Markus"
FontSize="48"
FontWeight="Light"
Foreground="White"
HorizontalAlignment="Center"/>
<!-- Version -->
<TextBlock Text="v1.0.0"
FontSize="18"
Foreground="#CCCCCC"
HorizontalAlignment="Center"/>
<!-- Description -->
<TextBlock Text="A Simple Markdown Editor"
FontSize="14"
Foreground="#CCCCCC"
HorizontalAlignment="Center"
Margin="0,10,0,0"/>
<!-- Copyright -->
<TextBlock Text="© 2025 Ormentia. All rights reserved."
FontSize="12"
Foreground="#888888"
HorizontalAlignment="Center"
Margin="0,10,0,0"/>
<!-- Ormentia Credit with Logo -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
Margin="0,8,0,0">
<TextBlock Text="By "
FontSize="10"
Foreground="#666666"
VerticalAlignment="Center"
Margin="0,0,4,0"/>
<Button Background="Transparent"
BorderThickness="0"
Padding="0"
Click="OrmentiaLink_Click"
Cursor="Hand"
VerticalAlignment="Center">
<Image Source="/Assets/ormentia-text.png"
Height="12"
VerticalAlignment="Center"/>
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="Opacity" Value="0.7"/>
</Style>
</Button.Styles>
</Button>
</StackPanel>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,62 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace MarkdownEditor.Views;
/// <summary>
/// About window that displays application information.
/// </summary>
public partial class AboutWindow : Window
{
/// <summary>
/// Initializes a new instance of the AboutWindow class.
/// </summary>
public AboutWindow()
{
InitializeComponent();
}
/// <summary>
/// Handles the close button click event.
/// </summary>
private void CloseButton_Click(object? sender, RoutedEventArgs e)
{
Close();
}
/// <summary>
/// Handles the Ormentia link click event to open the website.
/// </summary>
private void OrmentiaLink_Click(object? sender, RoutedEventArgs e)
{
OpenUrl("https://ormentia.com");
}
/// <summary>
/// Opens a URL in the default browser.
/// </summary>
private static void OpenUrl(string url)
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Process.Start("xdg-open", url);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", url);
}
}
catch
{
// Silently fail if we can't open the URL
}
}
}

487
src/Views/MainWindow.axaml Normal file
View File

@@ -0,0 +1,487 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MarkdownEditor.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:MarkdownEditor.Views"
xmlns:ae="using:AvaloniaEdit"
xmlns:i="https://github.com/projektanker/icons.avalonia"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800"
x:Class="MarkdownEditor.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/markus-logo.png"
Title="Markus - A Simple Markdown Editor"
Width="1200" Height="800">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Window.KeyBindings>
<KeyBinding Gesture="Cmd+O" Command="{Binding OpenFileCommand}"/>
<KeyBinding Gesture="Cmd+S" Command="{Binding SaveFileCommand}"/>
<KeyBinding Gesture="Cmd+Shift+S" Command="{Binding SaveFileAsCommand}"/>
<KeyBinding Gesture="Cmd+T" Command="{Binding AddNewTabCommand}"/>
</Window.KeyBindings>
<NativeMenu.Menu>
<NativeMenu>
<NativeMenuItem Header="_File">
<NativeMenu>
<NativeMenuItem Header="_New Tab" Command="{Binding AddNewTabCommand}" Gesture="Cmd+T"/>
<NativeMenuItem Header="_Open..." Command="{Binding OpenFileCommand}" Gesture="Cmd+O"/>
<NativeMenuItemSeparator/>
<NativeMenuItem Header="_Save" Command="{Binding SaveFileCommand}" Gesture="Cmd+S"/>
<NativeMenuItem Header="Save _As..." Command="{Binding SaveFileAsCommand}" Gesture="Cmd+Shift+S"/>
<NativeMenuItemSeparator/>
<NativeMenuItem Header="Export to PDF..." Command="{Binding ExportToPdfCommand}" Gesture="Cmd+E"/>
</NativeMenu>
</NativeMenuItem>
<NativeMenuItem Header="_Edit">
<NativeMenu>
<NativeMenuItem Header="Cu_t" Gesture="Cmd+X"/>
<NativeMenuItem Header="_Copy" Gesture="Cmd+C"/>
<NativeMenuItem Header="_Paste" Gesture="Cmd+V"/>
<NativeMenuItemSeparator/>
<NativeMenuItem Header="Select _All" Gesture="Cmd+A"/>
</NativeMenu>
</NativeMenuItem>
<NativeMenuItem Header="_View">
<NativeMenu>
<NativeMenuItem Header="Toggle Raw/Pretty" Gesture="Cmd+E"/>
</NativeMenu>
</NativeMenuItem>
</NativeMenu>
</NativeMenu.Menu>
<DockPanel Background="{Binding BackgroundColor}">
<!-- Tab control with custom tab strip -->
<TabControl ItemsSource="{Binding Tabs}"
SelectedItem="{Binding SelectedTab}"
Margin="0"
x:Name="MainTabControl">
<TabControl.Styles>
<!-- Sparkle animation for Pretty mode -->
<Style Selector="Button.active i|Icon#SparkleIcon">
<Style.Animations>
<Animation Duration="0:0:0.33" FillMode="Forward">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="0"/>
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="1"/>
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<!-- Modern toolbar button style -->
<Style Selector="Button.toolbar-btn">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ForegroundColor}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="8,4"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="FontSize" Value="13"/>
</Style>
<!-- Compact tab styling -->
<Style Selector="TabItem">
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="10,4"/>
<Setter Property="MinHeight" Value="30"/>
<Setter Property="Foreground" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ForegroundColor}"/>
</Style>
<Style Selector="TabControl">
<Setter Property="Template">
<ControlTemplate>
<DockPanel>
<!-- Custom tab strip with logo, tabs, and controls -->
<Grid DockPanel.Dock="Top" Background="{Binding SecondaryBackgroundColor}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Left: Logo -->
<Image Grid.Column="0"
Source="/Assets/markus-logo.png"
Width="28" Height="28"
Margin="8,2,8,2"
VerticalAlignment="Center"
ToolTip.Tip="Markus - A Simple Markdown Editor"/>
<!-- Center: Tabs with scrolling -->
<StackPanel Grid.Column="1" Orientation="Horizontal">
<ItemsPresenter Name="PART_ItemsPresenter"
ItemsPanel="{TemplateBinding ItemsPanel}"/>
<Button Content="+"
Command="{Binding AddNewTabCommand}"
Padding="8,2"
Margin="2,0,0,0"
VerticalAlignment="Center"
Background="Transparent"
Foreground="{Binding ForegroundColor}"
FontSize="14"
ToolTip.Tip="New Tab (Cmd+T)"/>
</StackPanel>
<!-- Right: Theme, Text Size, and Color controls -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="12" Margin="12,0,8,0">
<!-- Theme toggle -->
<Border BorderBrush="{Binding BorderColor}"
BorderThickness="1"
CornerRadius="4"
Background="{Binding SecondaryBackgroundColor}">
<StackPanel Orientation="Horizontal" Spacing="0">
<!-- Light mode button -->
<Button Command="{Binding ToggleThemeCommand}"
Padding="8,4"
CornerRadius="4,0,0,4"
BorderThickness="0"
Focusable="False"
ToolTip.Tip="Light Mode"
Classes.active="{Binding !IsDarkMode}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{Binding SecondaryForegroundColor}"/>
</Style>
<Style Selector="Button.active">
<Setter Property="Background" Value="{Binding ActiveBackgroundColor}"/>
<Setter Property="Foreground" Value="{Binding ForegroundColor}"/>
</Style>
</Button.Styles>
<i:Icon Value="fa-solid fa-sun" FontSize="16"/>
</Button>
<!-- Dark mode button -->
<Button Command="{Binding ToggleThemeCommand}"
Padding="8,4"
CornerRadius="0,4,4,0"
BorderThickness="0"
Focusable="False"
ToolTip.Tip="Dark Mode"
Classes.active="{Binding IsDarkMode}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{Binding SecondaryForegroundColor}"/>
</Style>
<Style Selector="Button.active">
<Setter Property="Background" Value="{Binding ActiveBackgroundColor}"/>
<Setter Property="Foreground" Value="{Binding ForegroundColor}"/>
</Style>
</Button.Styles>
<i:Icon Value="fa-solid fa-moon" FontSize="16"/>
</Button>
</StackPanel>
</Border>
<!-- Text size toggle -->
<Border BorderBrush="{Binding BorderColor}"
BorderThickness="1"
CornerRadius="4"
Background="{Binding SecondaryBackgroundColor}">
<StackPanel Orientation="Horizontal" Spacing="0">
<Button Content="S"
Command="{Binding SetTextSizeSmallCommand}"
Padding="8,4"
CornerRadius="4,0,0,4"
BorderThickness="0"
Focusable="False"
FontSize="13"
ToolTip.Tip="Small Text"
Classes.active="{Binding TextSize, Converter={x:Static ObjectConverters.Equal}, ConverterParameter=Small}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{Binding SecondaryForegroundColor}"/>
</Style>
<Style Selector="Button.active">
<Setter Property="Background" Value="{Binding ActiveBackgroundColor}"/>
<Setter Property="Foreground" Value="{Binding ForegroundColor}"/>
</Style>
</Button.Styles>
</Button>
<Button Content="M"
Command="{Binding SetTextSizeMediumCommand}"
Padding="8,4"
CornerRadius="0"
BorderThickness="0"
Focusable="False"
FontSize="13"
ToolTip.Tip="Medium Text"
Classes.active="{Binding TextSize, Converter={x:Static ObjectConverters.Equal}, ConverterParameter=Medium}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{Binding SecondaryForegroundColor}"/>
</Style>
<Style Selector="Button.active">
<Setter Property="Background" Value="{Binding ActiveBackgroundColor}"/>
<Setter Property="Foreground" Value="{Binding ForegroundColor}"/>
</Style>
</Button.Styles>
</Button>
<Button Content="L"
Command="{Binding SetTextSizeLargeCommand}"
Padding="8,4"
CornerRadius="0,4,4,0"
BorderThickness="0"
Focusable="False"
FontSize="13"
ToolTip.Tip="Large Text"
Classes.active="{Binding TextSize, Converter={x:Static ObjectConverters.Equal}, ConverterParameter=Large}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{Binding SecondaryForegroundColor}"/>
</Style>
<Style Selector="Button.active">
<Setter Property="Background" Value="{Binding ActiveBackgroundColor}"/>
<Setter Property="Foreground" Value="{Binding ForegroundColor}"/>
</Style>
</Button.Styles>
</Button>
</StackPanel>
</Border>
<!-- Color picker (fake for now) -->
<Button Padding="8,4"
CornerRadius="4"
BorderThickness="1"
BorderBrush="{Binding BorderColor}"
Background="{Binding SecondaryBackgroundColor}"
Focusable="False"
ToolTip.Tip="Theme Color">
<Border Width="16" Height="16"
CornerRadius="2"
Background="#007ACC"/>
</Button>
</StackPanel>
</Grid>
<ContentPresenter Name="PART_SelectedContentHost"
Content="{TemplateBinding SelectedContent}"
ContentTemplate="{TemplateBinding SelectedContentTemplate}"
Margin="{TemplateBinding Padding}"/>
</DockPanel>
</ControlTemplate>
</Setter>
</Style>
</TabControl.Styles>
<TabControl.ItemTemplate>
<DataTemplate DataType="vm:MarkdownTabViewModel">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding DisplayTitle}"
VerticalAlignment="Center"
FontSize="13"
Foreground="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ForegroundColor}"/>
<Button Content="×"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
Padding="3,0"
FontSize="15"
Background="Transparent"
BorderThickness="0"
Foreground="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ForegroundColor}"
ToolTip.Tip="Close Tab"/>
</StackPanel>
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.ContentTemplate>
<DataTemplate DataType="vm:MarkdownTabViewModel">
<DockPanel>
<!-- View mode toggle and file actions -->
<Border DockPanel.Dock="Top"
Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).TertiaryBackgroundColor}"
BorderBrush="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BorderColor}"
BorderThickness="0,0,0,1"
Padding="8,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Left side: View toggle and file actions -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
<!-- Raw/Pretty toggle -->
<Border BorderBrush="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BorderColor}"
BorderThickness="1"
CornerRadius="4"
Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).SecondaryBackgroundColor}">
<StackPanel Orientation="Horizontal" Spacing="0">
<!-- Raw button -->
<Button Command="{Binding SetRawViewCommand}"
Padding="8,4"
CornerRadius="4,0,0,4"
BorderThickness="0"
FontSize="13"
Focusable="False"
Classes.active="{Binding !IsPrettyView}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).SecondaryForegroundColor}"/>
</Style>
<Style Selector="Button.active">
<Setter Property="Background" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ActiveBackgroundColor}"/>
<Setter Property="Foreground" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ForegroundColor}"/>
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).HoverBackgroundColor}"/>
</Style>
<Style Selector="Button.active:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ActiveBackgroundColor}"/>
</Style>
</Button.Styles>
<TextBlock Text="Raw"/>
</Button>
<!-- Pretty button -->
<Button Command="{Binding SetPrettyViewCommand}"
Padding="8,4"
CornerRadius="0,4,4,0"
BorderThickness="0"
FontSize="13"
Focusable="False"
Classes.active="{Binding IsPrettyView}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).SecondaryForegroundColor}"/>
</Style>
<Style Selector="Button.active">
<Setter Property="Background" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ActiveBackgroundColor}"/>
<Setter Property="Foreground" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ForegroundColor}"/>
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).HoverBackgroundColor}"/>
</Style>
<Style Selector="Button.active:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ActiveBackgroundColor}"/>
</Style>
</Button.Styles>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="Pretty" VerticalAlignment="Center"/>
<i:Icon x:Name="SparkleIcon"
Value="fa-solid fa-wand-magic-sparkles"
VerticalAlignment="Center"
Opacity="0"
FontSize="14"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<!-- File action buttons -->
<Button Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).OpenFileCommand}"
Classes="toolbar-btn"
ToolTip.Tip="Open File (Cmd+O)"
Focusable="False">
<i:Icon Value="fa-solid fa-folder-open" FontSize="16"/>
</Button>
<Button Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).SaveFileCommand}"
Classes="toolbar-btn"
ToolTip.Tip="Save File (Cmd+S)"
Focusable="False">
<i:Icon Value="fa-solid fa-floppy-disk" FontSize="16"/>
</Button>
<Button Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ExportToPdfCommand}"
Classes="toolbar-btn"
ToolTip.Tip="Export to PDF (Cmd+E)"
Focusable="False">
<i:Icon Value="fa-solid fa-file-pdf" FontSize="16"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Formatting toolbar -->
<Border DockPanel.Dock="Top"
Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).SecondaryBackgroundColor}"
BorderBrush="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BorderColor}"
BorderThickness="0,0,0,1"
Padding="8,4"
IsVisible="{Binding !IsPrettyView}">
<WrapPanel>
<Button Content="B" Command="{Binding FormatBoldCommand}"
Classes="toolbar-btn" FontWeight="Bold" ToolTip.Tip="Bold (Cmd+B)"
Focusable="False"/>
<Button Content="I" Command="{Binding FormatItalicCommand}"
Classes="toolbar-btn" FontStyle="Italic" ToolTip.Tip="Italic (Cmd+I)"
Focusable="False"/>
<Separator Width="1" Height="20" Margin="4,0" Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BorderColor}"/>
<Button Content="H1" Command="{Binding FormatHeading1Command}"
Classes="toolbar-btn" FontWeight="SemiBold" ToolTip.Tip="Heading 1"
Focusable="False"/>
<Button Content="H2" Command="{Binding FormatHeading2Command}"
Classes="toolbar-btn" FontWeight="SemiBold" ToolTip.Tip="Heading 2"
Focusable="False"/>
<Button Content="H3" Command="{Binding FormatHeading3Command}"
Classes="toolbar-btn" FontWeight="SemiBold" ToolTip.Tip="Heading 3"
Focusable="False"/>
<Separator Width="1" Height="20" Margin="4,0" Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BorderColor}"/>
<Button Content="• List" Command="{Binding FormatBulletListCommand}"
Classes="toolbar-btn" ToolTip.Tip="Bullet List"
Focusable="False"/>
<Button Content="1. List" Command="{Binding FormatNumberedListCommand}"
Classes="toolbar-btn" ToolTip.Tip="Numbered List"
Focusable="False"/>
<Separator Width="1" Height="20" Margin="4,0" Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BorderColor}"/>
<Button Content="&lt;/&gt;" Command="{Binding FormatCodeCommand}"
Classes="toolbar-btn" FontFamily="Consolas,Courier New,monospace" ToolTip.Tip="Inline Code"
Focusable="False"/>
<Button Content="{}{ }" Command="{Binding FormatCodeBlockCommand}"
Classes="toolbar-btn" FontFamily="Consolas,Courier New,monospace" ToolTip.Tip="Code Block"
Focusable="False"/>
<Button Content="&quot;" Command="{Binding FormatQuoteCommand}"
Classes="toolbar-btn" ToolTip.Tip="Quote"
Focusable="False"/>
<Separator Width="1" Height="20" Margin="4,0" Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BorderColor}"/>
<Button Content="🔗" Click="OnInsertHyperlink"
Classes="toolbar-btn" ToolTip.Tip="Insert Link"
Focusable="False"
Tag="{Binding}"/>
<Button Content="🖼" Click="OnInsertImage"
Classes="toolbar-btn" ToolTip.Tip="Insert Image"
Focusable="False"
Tag="{Binding}"/>
</WrapPanel>
</Border>
<!-- Content area - fills remaining DockPanel space -->
<Grid>
<!-- Raw editor view with AvaloniaEdit -->
<ae:TextEditor x:Name="EditorTextBox"
views:TextEditorHelper.Document="{Binding Document}"
FontFamily="Consolas,Courier New,monospace"
FontSize="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).EditorFontSize}"
ShowLineNumbers="True"
IsVisible="{Binding !IsPrettyView}"
Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BackgroundColor}"
Foreground="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ForegroundColor}"
LineNumbersForeground="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).LineNumberColor}"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"/>
<!-- Pretty markdown viewer -->
<views:MarkdownViewer Markdown="{Binding SafeMarkdownContent}"
IsVisible="{Binding IsPrettyView}"
FontSize="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ContentFontSize}"
Foreground="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ForegroundColor}"
Background="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).BackgroundColor}"/>
</Grid>
</DockPanel>
</DataTemplate>
</TabControl.ContentTemplate>
</TabControl>
</DockPanel>
</Window>

View File

@@ -0,0 +1,204 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using AvaloniaEdit;
using MarkdownEditor.Constants;
using MarkdownEditor.ViewModels;
namespace MarkdownEditor.Views;
/// <summary>
/// Main application window.
/// Handles initialization and coordination between the view and view model.
/// </summary>
public partial class MainWindow : Window
{
private bool _isClosingConfirmed = false;
/// <summary>
/// Initializes a new instance of the <see cref="MainWindow"/> class.
/// </summary>
public MainWindow()
{
InitializeComponent();
// Subscribe to pointer pressed to capture selection before command executes
this.AddHandler(Button.PointerPressedEvent, OnButtonPointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel);
}
/// <inheritdoc/>
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
if (DataContext is MainWindowViewModel viewModel)
{
viewModel.StorageProvider = StorageProvider;
// Restore session after StorageProvider is set
_ = viewModel.RestoreSessionAsync();
}
}
/// <inheritdoc/>
protected override async void OnClosing(WindowClosingEventArgs e)
{
if (DataContext is not MainWindowViewModel viewModel)
{
base.OnClosing(e);
return;
}
// If we've already confirmed the close (from a previous call), just save session and close
if (_isClosingConfirmed)
{
await viewModel.SaveSessionAsync();
base.OnClosing(e);
return;
}
// Check for unsaved changes
if (viewModel.HasUnsavedChanges())
{
// Cancel the close event to prevent immediate closing
e.Cancel = true;
// Show save confirmation dialog
var unsavedCount = viewModel.GetUnsavedChangesCount();
var message = unsavedCount == 1
? AppConstants.Messages.SaveChangesBeforeClose
: string.Format(AppConstants.Messages.SaveChangesBeforeCloseMultiple, unsavedCount);
var dialog = new SaveConfirmationDialog(message);
await dialog.ShowDialog<bool>(this);
var result = dialog.Result;
switch (result)
{
case SaveConfirmationResult.Save:
// User wants to save - save all unsaved changes
var saved = await viewModel.SaveAllUnsavedChangesAsync();
if (saved)
{
// All files saved successfully - mark as confirmed and close
_isClosingConfirmed = true;
await viewModel.SaveSessionAsync();
Close();
}
// If save was cancelled, keep the window open (e.Cancel is already true)
// Don't call base.OnClosing since we're cancelling
return;
case SaveConfirmationResult.DontSave:
// User doesn't want to save - mark as confirmed and close
_isClosingConfirmed = true;
await viewModel.SaveSessionAsync();
Close();
// Don't call base.OnClosing here - Close() will trigger OnClosing again
return;
case SaveConfirmationResult.Cancel:
default:
// User cancelled - keep the window open (e.Cancel is already true)
// Don't call base.OnClosing since we're cancelling
return;
}
}
else
{
// No unsaved changes - just save session and close normally
await viewModel.SaveSessionAsync();
}
base.OnClosing(e);
}
/// <summary>
/// Handles pointer pressed events to capture text selection before formatting commands execute.
/// This is necessary because toolbar buttons take focus, losing the text selection.
/// </summary>
private void OnButtonPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
// Walk up the visual tree to find the Button
Button? button = null;
if (e.Source is Control sourceControl)
{
button = sourceControl.GetVisualAncestors().OfType<Button>().FirstOrDefault();
}
// Only process toolbar buttons
if (button != null && button.Classes.Contains("toolbar-btn"))
{
// Find the TextEditor and update selection in ViewModel before command executes
if (DataContext is MainWindowViewModel mainViewModel && mainViewModel.SelectedTab != null)
{
var textEditor = this.GetVisualDescendants().OfType<TextEditor>()
.FirstOrDefault(te => te.IsVisible);
if (textEditor != null)
{
mainViewModel.SelectedTab.SelectionStart = textEditor.SelectionStart;
mainViewModel.SelectedTab.SelectionEnd = textEditor.SelectionStart + textEditor.SelectionLength;
}
}
}
}
/// <summary>
/// Handles the Insert Hyperlink button click.
/// </summary>
private async void OnInsertHyperlink(object? sender, RoutedEventArgs e)
{
if (sender is Button button && button.Tag is MarkdownTabViewModel tabViewModel)
{
CaptureSelection();
var dialog = new UrlInputDialog("Insert Hyperlink", "Enter the URL for the link:");
var result = await dialog.ShowDialog<bool>(this);
if (dialog.IsConfirmed && !string.IsNullOrWhiteSpace(dialog.Url))
{
tabViewModel.InsertHyperlink(dialog.Url);
}
}
}
/// <summary>
/// Handles the Insert Image button click.
/// </summary>
private async void OnInsertImage(object? sender, RoutedEventArgs e)
{
if (sender is Button button && button.Tag is MarkdownTabViewModel tabViewModel)
{
CaptureSelection();
var dialog = new UrlInputDialog("Insert Image", "Enter the URL for the image:");
var result = await dialog.ShowDialog<bool>(this);
if (dialog.IsConfirmed && !string.IsNullOrWhiteSpace(dialog.Url))
{
tabViewModel.InsertImage(dialog.Url);
}
}
}
/// <summary>
/// Captures the current text selection from the visible text editor.
/// </summary>
private void CaptureSelection()
{
if (DataContext is MainWindowViewModel mainViewModel && mainViewModel.SelectedTab != null)
{
var textEditor = this.GetVisualDescendants().OfType<TextEditor>()
.FirstOrDefault(te => te.IsVisible);
if (textEditor != null)
{
mainViewModel.SelectedTab.SelectionStart = textEditor.SelectionStart;
mainViewModel.SelectedTab.SelectionEnd = textEditor.SelectionStart + textEditor.SelectionLength;
}
}
}
}

114
src/Views/MarkdownViewer.cs Normal file
View File

@@ -0,0 +1,114 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Markdig;
using MarkdownEditor.Views.Renderers;
namespace MarkdownEditor.Views;
/// <summary>
/// A control that renders markdown text into formatted Avalonia UI elements.
/// Uses a modular renderer system for extensibility and maintainability.
/// </summary>
public class MarkdownViewer : UserControl
{
/// <summary>
/// Defines the Markdown property for binding.
/// </summary>
public static readonly StyledProperty<string?> MarkdownProperty =
AvaloniaProperty.Register<MarkdownViewer, string?>(nameof(Markdown));
/// <summary>
/// Gets or sets the markdown content to render.
/// </summary>
public string? Markdown
{
get => GetValue(MarkdownProperty);
set => SetValue(MarkdownProperty, value);
}
private readonly StackPanel _container;
private readonly ScrollViewer _scrollViewer;
private readonly MarkdownPipeline _pipeline;
private MarkdownRendererCoordinator? _rendererCoordinator;
/// <summary>
/// Initializes a new instance of the <see cref="MarkdownViewer"/> class.
/// </summary>
public MarkdownViewer()
{
_container = new StackPanel { Margin = new Thickness(10) };
_scrollViewer = new ScrollViewer
{
Content = _container,
HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
};
Content = _scrollViewer;
_pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
}
/// <inheritdoc/>
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == MarkdownProperty ||
change.Property == ForegroundProperty ||
change.Property == BackgroundProperty ||
change.Property == FontSizeProperty)
{
RenderMarkdown();
}
}
/// <summary>
/// Renders the current markdown content into the view.
/// </summary>
private void RenderMarkdown()
{
_container.Children.Clear();
if (string.IsNullOrWhiteSpace(Markdown))
return;
try
{
// Determine if we're in dark mode based on background brightness
bool isDarkMode = true;
if (Background is SolidColorBrush bgBrush)
{
var color = bgBrush.Color;
var brightness = (color.R * 299 + color.G * 587 + color.B * 114) / 1000;
isDarkMode = brightness < 128;
}
// Calculate font scale factor (base size is 14)
double fontScale = FontSize / 14.0;
// Create coordinator with theme-aware colors and font scale
_rendererCoordinator = new MarkdownRendererCoordinator(isDarkMode, fontScale);
var document = Markdig.Markdown.Parse(Markdown, _pipeline);
var controls = _rendererCoordinator.RenderBlocks(document);
foreach (var control in controls)
{
_container.Children.Add(control);
}
}
catch (Exception ex)
{
_container.Children.Add(new TextBlock
{
Text = $"Error rendering markdown: {ex.Message}",
Foreground = Brushes.Red,
TextWrapping = TextWrapping.Wrap
});
}
}
}

View File

@@ -0,0 +1,60 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Markdig.Syntax;
using MarkdownEditor.Constants;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Renders markdown code blocks (both fenced and indented).
/// </summary>
public class CodeBlockRenderer : IMarkdownBlockRenderer
{
private readonly bool _isDarkMode;
private readonly double _fontScale;
/// <summary>
/// Initializes a new instance of the <see cref="CodeBlockRenderer"/> class.
/// </summary>
/// <param name="isDarkMode">Whether the current theme is dark mode.</param>
/// <param name="fontScale">Font scale factor for text sizing.</param>
public CodeBlockRenderer(bool isDarkMode = true, double fontScale = 1.0)
{
_isDarkMode = isDarkMode;
_fontScale = fontScale;
}
/// <inheritdoc/>
public bool CanRender(Block block) => block is CodeBlock;
/// <inheritdoc/>
public Control? Render(Block block)
{
if (block is not CodeBlock code)
{
return null;
}
var text = code is FencedCodeBlock fenced
? fenced.Lines.ToString()
: code.Lines.ToString();
return new Border
{
Background = new SolidColorBrush(Color.Parse(_isDarkMode ? "#2D2D2D" : "#F0F0F0")),
Padding = new Thickness(10),
Margin = new Thickness(0, 5, 0, 5),
CornerRadius = new CornerRadius(4),
Child = new TextBlock
{
Text = text,
FontFamily = new FontFamily("Consolas,Courier New,monospace"),
FontSize = 14 * _fontScale,
Foreground = new SolidColorBrush(Color.Parse(_isDarkMode ? "#E8E8E8" : "#333333")),
TextWrapping = TextWrapping.Wrap
}
};
}
}

View File

@@ -0,0 +1,59 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Markdig.Syntax;
using MarkdownEditor.Constants;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Renders markdown heading blocks (H1-H6).
/// </summary>
public class HeadingRenderer : IMarkdownBlockRenderer
{
private readonly double _fontScale;
/// <summary>
/// Initializes a new instance of the <see cref="HeadingRenderer"/> class.
/// </summary>
/// <param name="fontScale">Font scale factor for text sizing.</param>
public HeadingRenderer(double fontScale = 1.0)
{
_fontScale = fontScale;
}
/// <inheritdoc/>
public bool CanRender(Block block) => block is HeadingBlock;
/// <inheritdoc/>
public Control? Render(Block block)
{
if (block is not HeadingBlock heading)
{
return null;
}
var text = MarkdownTextHelper.ExtractText(heading.Inline);
var fontSize = GetFontSizeForLevel(heading.Level) * _fontScale;
return new TextBlock
{
Text = text,
FontSize = fontSize,
FontWeight = FontWeight.Bold,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 10, 0, 5)
};
}
private static int GetFontSizeForLevel(int level) => level switch
{
1 => AppConstants.FontSizes.Heading1,
2 => AppConstants.FontSizes.Heading2,
3 => AppConstants.FontSizes.Heading3,
4 => AppConstants.FontSizes.Heading4,
5 => AppConstants.FontSizes.Heading5,
_ => AppConstants.FontSizes.Heading6
};
}

View File

@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Markdig.Syntax;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Interface for rendering markdown blocks into Avalonia controls.
/// Allows for modular rendering of different markdown block types.
/// </summary>
public interface IMarkdownBlockRenderer
{
/// <summary>
/// Determines if this renderer can handle the given block type.
/// </summary>
/// <param name="block">The markdown block to check.</param>
/// <returns>True if this renderer can handle the block, false otherwise.</returns>
bool CanRender(Block block);
/// <summary>
/// Renders the markdown block into an Avalonia control.
/// </summary>
/// <param name="block">The markdown block to render.</param>
/// <returns>The rendered Avalonia control, or null if rendering fails.</returns>
Control? Render(Block block);
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Markdig.Syntax.Inlines;
using MarkdownEditor.Constants;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Renders markdown inline elements (bold, italic, links, code, images, etc.).
/// </summary>
public class InlineRenderer
{
private readonly bool _isDarkMode;
private readonly double _fontScale;
/// <summary>
/// Initializes a new instance of the <see cref="InlineRenderer"/> class.
/// </summary>
/// <param name="isDarkMode">Whether the current theme is dark mode.</param>
/// <param name="fontScale">Font scale factor for text sizing.</param>
public InlineRenderer(bool isDarkMode = true, double fontScale = 1.0)
{
_isDarkMode = isDarkMode;
_fontScale = fontScale;
}
/// <summary>
/// Builds inline elements from markdown and adds them to the Avalonia inline collection.
/// </summary>
/// <param name="inline">The markdown container inline to process.</param>
/// <param name="inlines">The Avalonia inline collection to add to.</param>
public void BuildInlines(ContainerInline? inline, InlineCollection inlines)
{
if (inline == null) return;
foreach (var child in inline)
{
switch (child)
{
case LiteralInline literal:
inlines.Add(new Run(literal.Content.ToString()));
break;
case CodeInline code:
inlines.Add(CreateCodeInline(code.Content));
break;
case LineBreakInline:
inlines.Add(new LineBreak());
break;
case EmphasisInline emphasis:
inlines.Add(CreateEmphasisInline(emphasis));
break;
case LinkInline link:
AddLinkInline(link, inlines);
break;
case ContainerInline container:
BuildInlines(container, inlines);
break;
}
}
}
private Run CreateCodeInline(string content)
{
return new Run(content)
{
FontFamily = new FontFamily("Consolas,Courier New,monospace"),
Background = new SolidColorBrush(Color.Parse(_isDarkMode ? "#2D2D2D" : "#F0F0F0")),
Foreground = new SolidColorBrush(Color.Parse(_isDarkMode ? "#E8E8E8" : "#333333"))
};
}
private Run CreateEmphasisInline(EmphasisInline emphasis)
{
var text = MarkdownTextHelper.ExtractText(emphasis);
var run = new Run(text);
if (emphasis.DelimiterCount == 2)
{
run.FontWeight = FontWeight.Bold;
}
else
{
run.FontStyle = FontStyle.Italic;
}
return run;
}
private void AddLinkInline(LinkInline link, InlineCollection inlines)
{
if (link.IsImage)
{
AddImageInline(link, inlines);
}
else
{
AddHyperlinkInline(link, inlines);
}
}
private void AddImageInline(LinkInline link, InlineCollection inlines)
{
if (link.Url != null && (link.Url.StartsWith("http://") || link.Url.StartsWith("https://")))
{
// Remote image - load asynchronously
var image = new Image
{
Stretch = Stretch.Uniform,
MaxWidth = AppConstants.Layout.ImageMaxWidth,
Margin = new Thickness(0, 5, 0, 5)
};
if (!string.IsNullOrEmpty(link.Title))
{
ToolTip.SetTip(image, link.Title);
}
LoadImageAsync(image, link.Url);
inlines.Add(new InlineUIContainer { Child = image });
}
else
{
// Local images - show placeholder
inlines.Add(new InlineUIContainer { Child = CreateLocalImagePlaceholder(link) });
}
}
private Border CreateLocalImagePlaceholder(LinkInline link)
{
var linkText = MarkdownTextHelper.ExtractText(link);
var stackPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 6
};
// Add image icon indicator
// Note: Font Awesome icons work great in XAML, but for code-behind rendering
// we use a simple text indicator. The main UI icons (toolbar, theme toggle)
// are already using Font Awesome via XAML.
var iconText = new TextBlock
{
Text = "🖼",
Foreground = new SolidColorBrush(Color.Parse(AppConstants.Colors.Accent)),
FontSize = 14,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0)
};
// Add text
var textBlock = new TextBlock
{
Text = string.Format(AppConstants.Messages.LocalImagePlaceholder, linkText),
Foreground = new SolidColorBrush(Color.Parse(AppConstants.Colors.Accent)),
FontStyle = FontStyle.Italic,
VerticalAlignment = VerticalAlignment.Center
};
stackPanel.Children.Add(iconText);
stackPanel.Children.Add(textBlock);
var placeholder = new Border
{
Background = new SolidColorBrush(Color.Parse(_isDarkMode ? "#2D2D2D" : "#F0F0F0")),
BorderBrush = new SolidColorBrush(Color.Parse(AppConstants.Colors.Accent)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(8, 4),
Margin = new Thickness(0, 5, 0, 5),
Child = stackPanel
};
if (!string.IsNullOrEmpty(link.Title))
{
ToolTip.SetTip(placeholder, $"{link.Title}\nPath: {link.Url}");
}
else if (!string.IsNullOrEmpty(link.Url))
{
ToolTip.SetTip(placeholder, $"Local image: {link.Url}");
}
return placeholder;
}
private void AddHyperlinkInline(LinkInline link, InlineCollection inlines)
{
var linkText = new TextBlock
{
Text = MarkdownTextHelper.ExtractText(link),
Foreground = new SolidColorBrush(Color.Parse(_isDarkMode ? "#8AB4F8" : "#1A73E8")),
TextDecorations = TextDecorations.Underline,
Cursor = new Cursor(StandardCursorType.Hand)
};
linkText.PointerPressed += (s, e) =>
{
try
{
if (!string.IsNullOrEmpty(link.Url))
{
Process.Start(new ProcessStartInfo(link.Url) { UseShellExecute = true });
}
}
catch
{
// Silently fail if link cannot be opened
}
};
inlines.Add(new InlineUIContainer { Child = linkText });
}
private static async void LoadImageAsync(Image image, string url)
{
try
{
using var httpClient = new System.Net.Http.HttpClient();
var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync();
using var memoryStream = new System.IO.MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
image.Source = new Avalonia.Media.Imaging.Bitmap(memoryStream);
}
catch
{
// Failed to load image - fail silently
}
}
}

View File

@@ -0,0 +1,76 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Markdig.Syntax;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Renders markdown list blocks (ordered and unordered).
/// </summary>
public class ListRenderer : IMarkdownBlockRenderer
{
private readonly MarkdownRendererCoordinator _coordinator;
/// <summary>
/// Initializes a new instance of the <see cref="ListRenderer"/> class.
/// </summary>
/// <param name="coordinator">The coordinator for rendering nested blocks.</param>
public ListRenderer(MarkdownRendererCoordinator coordinator)
{
_coordinator = coordinator;
}
/// <inheritdoc/>
public bool CanRender(Block block) => block is ListBlock;
/// <inheritdoc/>
public Control? Render(Block block)
{
if (block is not ListBlock list)
{
return null;
}
var panel = new StackPanel { Margin = new Thickness(20, 5, 0, 5) };
int index = 1;
foreach (var item in list)
{
if (item is ListItemBlock listItem)
{
var itemPanel = new StackPanel();
foreach (var childBlock in listItem)
{
var control = _coordinator.RenderBlock(childBlock);
if (control != null)
{
itemPanel.Children.Add(control);
}
}
var bullet = list.IsOrdered ? $"{index}. " : "• ";
var itemControl = new DockPanel
{
Children =
{
new TextBlock
{
Text = bullet,
[DockPanel.DockProperty] = Dock.Left,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 0, 5, 0)
},
itemPanel
}
};
panel.Children.Add(itemControl);
index++;
}
}
return panel;
}
}

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using Avalonia.Controls;
using Markdig.Syntax;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Coordinates multiple markdown block renderers to render a complete markdown document.
/// Uses a chain of responsibility pattern to delegate rendering to appropriate renderers.
/// </summary>
public class MarkdownRendererCoordinator
{
private readonly List<IMarkdownBlockRenderer> _renderers;
/// <summary>
/// Initializes a new instance of the <see cref="MarkdownRendererCoordinator"/> class.
/// </summary>
/// <param name="isDarkMode">Whether the current theme is dark mode.</param>
/// <param name="fontScale">Font scale factor for text sizing (1.0 = 14px base).</param>
public MarkdownRendererCoordinator(bool isDarkMode = true, double fontScale = 1.0)
{
var inlineRenderer = new InlineRenderer(isDarkMode, fontScale);
_renderers = new List<IMarkdownBlockRenderer>
{
new HeadingRenderer(fontScale),
new CodeBlockRenderer(isDarkMode, fontScale),
new ParagraphRenderer(inlineRenderer),
new ThematicBreakRenderer(isDarkMode),
new ListRenderer(this), // Pass coordinator for nested rendering
new QuoteRenderer(this), // Pass coordinator for nested rendering
new TableRenderer(this, isDarkMode, fontScale) // Pass coordinator for nested rendering
};
}
/// <summary>
/// Renders a markdown block by delegating to the appropriate renderer.
/// </summary>
/// <param name="block">The markdown block to render.</param>
/// <returns>The rendered Avalonia control, or null if no renderer can handle the block.</returns>
public Control? RenderBlock(Block block)
{
foreach (var renderer in _renderers)
{
if (renderer.CanRender(block))
{
return renderer.Render(block);
}
}
return null;
}
/// <summary>
/// Renders multiple markdown blocks into a collection of controls.
/// </summary>
/// <param name="blocks">The markdown blocks to render.</param>
/// <returns>A collection of rendered controls.</returns>
public IEnumerable<Control> RenderBlocks(IEnumerable<Block> blocks)
{
var controls = new List<Control>();
foreach (var block in blocks)
{
var control = RenderBlock(block);
if (control != null)
{
controls.Add(control);
}
}
return controls;
}
}

View File

@@ -0,0 +1,34 @@
using Markdig.Syntax.Inlines;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Helper class for extracting plain text from markdown inline elements.
/// </summary>
public static class MarkdownTextHelper
{
/// <summary>
/// Extracts plain text from a markdown inline container.
/// </summary>
/// <param name="inline">The container inline to extract text from.</param>
/// <returns>The extracted plain text.</returns>
public static string ExtractText(ContainerInline? inline)
{
if (inline == null) return string.Empty;
var result = "";
foreach (var child in inline)
{
if (child is LiteralInline literal)
{
result += literal.Content.ToString();
}
else if (child is ContainerInline container)
{
result += ExtractText(container);
}
}
return result;
}
}

View File

@@ -0,0 +1,49 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Markdig.Syntax;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Renders markdown paragraph blocks with inline formatting.
/// </summary>
public class ParagraphRenderer : IMarkdownBlockRenderer
{
private readonly InlineRenderer _inlineRenderer;
/// <summary>
/// Initializes a new instance of the <see cref="ParagraphRenderer"/> class.
/// </summary>
/// <param name="inlineRenderer">The inline renderer for formatting inline elements.</param>
public ParagraphRenderer(InlineRenderer inlineRenderer)
{
_inlineRenderer = inlineRenderer;
}
/// <inheritdoc/>
public bool CanRender(Block block) => block is ParagraphBlock;
/// <inheritdoc/>
public Control? Render(Block block)
{
if (block is not ParagraphBlock paragraph)
{
return null;
}
var textBlock = new TextBlock
{
Margin = new Thickness(0, 5, 0, 5),
TextWrapping = TextWrapping.Wrap
};
if (textBlock.Inlines != null)
{
_inlineRenderer.BuildInlines(paragraph.Inline, textBlock.Inlines);
}
return textBlock;
}
}

View File

@@ -0,0 +1,57 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Markdig.Syntax;
using MarkdownEditor.Constants;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Renders markdown quote blocks.
/// </summary>
public class QuoteRenderer : IMarkdownBlockRenderer
{
private readonly MarkdownRendererCoordinator _coordinator;
/// <summary>
/// Initializes a new instance of the <see cref="QuoteRenderer"/> class.
/// </summary>
/// <param name="coordinator">The coordinator for rendering nested blocks.</param>
public QuoteRenderer(MarkdownRendererCoordinator coordinator)
{
_coordinator = coordinator;
}
/// <inheritdoc/>
public bool CanRender(Block block) => block is QuoteBlock;
/// <inheritdoc/>
public Control? Render(Block block)
{
if (block is not QuoteBlock quote)
{
return null;
}
var panel = new StackPanel();
foreach (var childBlock in quote)
{
var control = _coordinator.RenderBlock(childBlock);
if (control != null)
{
panel.Children.Add(control);
}
}
return new Border
{
BorderBrush = new SolidColorBrush(Color.Parse(AppConstants.Colors.Accent)),
BorderThickness = new Thickness(3, 0, 0, 0),
Padding = new Thickness(10, 0, 0, 0),
Margin = new Thickness(0, 5, 0, 5),
Child = panel
};
}
}

View File

@@ -0,0 +1,109 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using MarkdownEditor.Constants;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Renders markdown table blocks.
/// </summary>
public class TableRenderer : IMarkdownBlockRenderer
{
private readonly MarkdownRendererCoordinator _coordinator;
private readonly bool _isDarkMode;
private readonly double _fontScale;
/// <summary>
/// Initializes a new instance of the <see cref="TableRenderer"/> class.
/// </summary>
/// <param name="coordinator">The coordinator for rendering nested blocks.</param>
/// <param name="isDarkMode">Whether the current theme is dark mode.</param>
/// <param name="fontScale">Font scale factor for text sizing.</param>
public TableRenderer(MarkdownRendererCoordinator coordinator, bool isDarkMode = true, double fontScale = 1.0)
{
_coordinator = coordinator;
_isDarkMode = isDarkMode;
_fontScale = fontScale;
}
/// <inheritdoc/>
public bool CanRender(Block block) => block is Table;
/// <inheritdoc/>
public Control? Render(Block block)
{
if (block is not Table table)
{
return null;
}
var grid = new Grid
{
Margin = new Thickness(0, 5, 0, 5)
};
// Add column definitions
if (table.ColumnDefinitions != null)
{
foreach (var _ in table.ColumnDefinitions)
{
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Star });
}
}
int rowIndex = 0;
foreach (var row in table)
{
if (row is TableRow tableRow)
{
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
int colIndex = 0;
foreach (var cell in tableRow)
{
if (cell is TableCell tableCell)
{
var cellContent = new StackPanel();
foreach (var childBlock in tableCell)
{
var control = _coordinator.RenderBlock(childBlock);
if (control != null)
{
cellContent.Children.Add(control);
}
}
var border = new Border
{
BorderBrush = new SolidColorBrush(Color.Parse(_isDarkMode ? "#555555" : "#CCCCCC")),
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 4),
Background = rowIndex == 0
? new SolidColorBrush(Color.Parse(_isDarkMode ? "#2D2D2D" : "#F0F0F0"))
: Brushes.Transparent,
Child = cellContent,
[Grid.RowProperty] = rowIndex,
[Grid.ColumnProperty] = colIndex
};
grid.Children.Add(border);
colIndex++;
}
}
rowIndex++;
}
}
return new Border
{
BorderBrush = new SolidColorBrush(Color.Parse(_isDarkMode ? "#555555" : "#CCCCCC")),
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 5, 0, 5),
Child = grid
};
}
}

View File

@@ -0,0 +1,44 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Markdig.Syntax;
namespace MarkdownEditor.Views.Renderers;
/// <summary>
/// Renders markdown thematic breaks (horizontal rules).
/// </summary>
public class ThematicBreakRenderer : IMarkdownBlockRenderer
{
private readonly bool _isDarkMode;
/// <summary>
/// Initializes a new instance of the <see cref="ThematicBreakRenderer"/> class.
/// </summary>
/// <param name="isDarkMode">Whether the current theme is dark mode.</param>
public ThematicBreakRenderer(bool isDarkMode = true)
{
_isDarkMode = isDarkMode;
}
/// <inheritdoc/>
public bool CanRender(Block block) => block is ThematicBreakBlock;
/// <inheritdoc/>
public Control? Render(Block block)
{
if (block is not ThematicBreakBlock)
{
return null;
}
return new Border
{
BorderBrush = new SolidColorBrush(Color.Parse(_isDarkMode ? "#555555" : "#CCCCCC")),
BorderThickness = new Thickness(0, 1, 0, 0),
Margin = new Thickness(0, 10, 0, 10),
Height = 1
};
}
}

View File

@@ -0,0 +1,120 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MarkdownEditor.Views.SaveConfirmationDialog"
Title="Save Changes?"
Width="480" Height="220"
CanResize="False"
WindowStartupLocation="CenterOwner"
Background="#1E1E1E"
BorderBrush="#3E3E42"
BorderThickness="1">
<Grid Margin="30">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Icon and Message -->
<StackPanel Grid.Row="0"
Orientation="Horizontal"
Spacing="20"
Margin="0,0,0,25">
<!-- Warning Icon -->
<Border Width="48"
Height="48"
Background="#4A90E2"
CornerRadius="24"
VerticalAlignment="Top"
Margin="0,5,0,0">
<TextBlock Text="⚠"
FontSize="28"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- Message Text -->
<TextBlock x:Name="MessageText"
Grid.Row="0"
Foreground="#D4D4D4"
FontSize="15"
FontWeight="Normal"
TextWrapping="Wrap"
VerticalAlignment="Center"
LineHeight="22"/>
</StackPanel>
<!-- Buttons -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="12"
Margin="0,20,0,0">
<!-- Don't Save Button -->
<Button Content="Don't Save"
Click="OnDontSave"
Padding="20,10"
MinWidth="110"
Background="#2D2D2D"
Foreground="#CCCCCC"
BorderBrush="#3E3E42"
BorderThickness="1"
CornerRadius="4"
Cursor="Hand">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="Background" Value="#3E3E42"/>
</Style>
<Style Selector="Button:pressed">
<Setter Property="Background" Value="#505050"/>
</Style>
</Button.Styles>
</Button>
<!-- Cancel Button -->
<Button Content="Cancel"
Click="OnCancel"
Padding="20,10"
MinWidth="110"
Background="#2D2D2D"
Foreground="#CCCCCC"
BorderBrush="#3E3E42"
BorderThickness="1"
CornerRadius="4"
Cursor="Hand">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="Background" Value="#3E3E42"/>
</Style>
<Style Selector="Button:pressed">
<Setter Property="Background" Value="#505050"/>
</Style>
</Button.Styles>
</Button>
<!-- Save Button (Primary) -->
<Button Content="Save"
Click="OnSave"
Padding="20,10"
MinWidth="110"
Background="#4A90E2"
Foreground="White"
BorderThickness="0"
CornerRadius="4"
FontWeight="SemiBold"
IsDefault="True"
Cursor="Hand">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="Background" Value="#5BA0F2"/>
</Style>
<Style Selector="Button:pressed">
<Setter Property="Background" Value="#3A80D2"/>
</Style>
</Button.Styles>
</Button>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,68 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace MarkdownEditor.Views;
/// <summary>
/// Dialog for confirming whether to save unsaved changes before closing.
/// </summary>
public partial class SaveConfirmationDialog : Window
{
/// <summary>
/// Gets the user's choice: Save, Don't Save, or Cancel.
/// </summary>
public SaveConfirmationResult Result { get; private set; } = SaveConfirmationResult.Cancel;
public SaveConfirmationDialog()
{
InitializeComponent();
}
public SaveConfirmationDialog(string message) : this()
{
if (MessageText != null)
{
MessageText.Text = message;
}
}
private void OnSave(object? sender, RoutedEventArgs e)
{
Result = SaveConfirmationResult.Save;
Close();
}
private void OnDontSave(object? sender, RoutedEventArgs e)
{
Result = SaveConfirmationResult.DontSave;
Close();
}
private void OnCancel(object? sender, RoutedEventArgs e)
{
Result = SaveConfirmationResult.Cancel;
Close();
}
}
/// <summary>
/// Result of the save confirmation dialog.
/// </summary>
public enum SaveConfirmationResult
{
/// <summary>
/// User chose to save changes.
/// </summary>
Save,
/// <summary>
/// User chose not to save changes.
/// </summary>
DontSave,
/// <summary>
/// User cancelled the close operation.
/// </summary>
Cancel
}

View File

@@ -0,0 +1,54 @@
using Avalonia;
using AvaloniaEdit;
using AvaloniaEdit.Document;
namespace MarkdownEditor.Views;
/// <summary>
/// Helper class for binding AvaloniaEdit TextDocument to MVVM view models.
/// Provides an attached property to enable two-way binding of the Document property.
/// </summary>
public static class TextEditorHelper
{
/// <summary>
/// Attached property for binding a TextDocument to a TextEditor control.
/// </summary>
public static readonly AttachedProperty<TextDocument?> DocumentProperty =
AvaloniaProperty.RegisterAttached<TextEditor, TextDocument?>(
"Document",
typeof(TextEditorHelper));
/// <summary>
/// Gets the document attached to the specified TextEditor.
/// </summary>
/// <param name="editor">The TextEditor control.</param>
/// <returns>The attached TextDocument, or null if none is attached.</returns>
public static TextDocument? GetDocument(TextEditor editor)
{
return editor.GetValue(DocumentProperty);
}
/// <summary>
/// Sets the document to attach to the specified TextEditor.
/// </summary>
/// <param name="editor">The TextEditor control.</param>
/// <param name="value">The TextDocument to attach.</param>
public static void SetDocument(TextEditor editor, TextDocument? value)
{
editor.SetValue(DocumentProperty, value);
}
static TextEditorHelper()
{
DocumentProperty.Changed.AddClassHandler<TextEditor>(OnDocumentChanged);
}
private static void OnDocumentChanged(TextEditor editor, AvaloniaPropertyChangedEventArgs e)
{
if (e.NewValue is TextDocument document)
{
editor.Document = document;
}
}
}

View File

@@ -0,0 +1,39 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MarkdownEditor.Views.UrlInputDialog"
Width="400" Height="180"
CanResize="False"
WindowStartupLocation="CenterOwner"
Background="#2D2D2D">
<StackPanel Margin="20" Spacing="15">
<TextBlock x:Name="PromptText"
Foreground="#CCCCCC"
FontSize="14"/>
<TextBox x:Name="UrlTextBox"
Watermark="Enter URL..."
FontSize="14"
Padding="8"/>
<TextBlock x:Name="ErrorMessage"
Text="Please enter a valid URL"
Foreground="#FF6B6B"
FontSize="12"
IsVisible="False"/>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="10">
<Button Content="Cancel"
Click="OnCancel"
Padding="15,8"
MinWidth="80"/>
<Button Content="OK"
Click="OnOk"
Padding="15,8"
MinWidth="80"
IsDefault="True"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,86 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace MarkdownEditor.Views;
/// <summary>
/// Dialog for inputting and validating URLs.
/// </summary>
public partial class UrlInputDialog : Window
{
public string Url { get; set; } = string.Empty;
public bool IsConfirmed { get; private set; }
public UrlInputDialog()
{
InitializeComponent();
}
public UrlInputDialog(string title, string prompt) : this()
{
Title = title;
if (PromptText != null)
{
PromptText.Text = prompt;
}
}
private void OnOk(object? sender, RoutedEventArgs e)
{
var urlText = UrlTextBox?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(urlText))
{
ShowError();
return;
}
// Basic URL validation
if (!IsValidUrl(urlText))
{
ShowError();
return;
}
Url = urlText;
IsConfirmed = true;
Close();
}
private void OnCancel(object? sender, RoutedEventArgs e)
{
IsConfirmed = false;
Close();
}
private void ShowError()
{
if (ErrorMessage != null)
{
ErrorMessage.IsVisible = true;
}
}
private bool IsValidUrl(string url)
{
// Allow relative URLs (starting with /)
if (url.StartsWith("/"))
return true;
// Allow URLs with common protocols
if (url.StartsWith("http://") || url.StartsWith("https://") ||
url.StartsWith("ftp://") || url.StartsWith("file://"))
{
return Uri.TryCreate(url, UriKind.Absolute, out _);
}
// Allow URLs without protocol (assume https)
if (url.Contains("."))
{
return Uri.TryCreate("https://" + url, UriKind.Absolute, out _);
}
return false;
}
}

18
src/app.manifest Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="MarkdownEditor.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

97
src/deploy/README.md Normal file
View File

@@ -0,0 +1,97 @@
# Deployment Scripts
This folder contains scripts to build release packages for different platforms.
## Prerequisites
- .NET 10.0 SDK installed
- For macOS builds: Run on macOS
- For Linux builds: Can be run on Linux or macOS
- For Windows builds: Run on Windows with PowerShell
- For Windows icon creation: Python 3 with Pillow (`pip3 install Pillow`)
## Usage
### macOS
```bash
./deploy/build-macos.sh
```
Creates a complete `.app` bundle with:
- Proper macOS application structure
- Application icon embedded
- Info.plist with metadata
- Ready for immediate distribution or installation to /Applications
Automatically detects your Mac architecture (Intel or Apple Silicon).
**Output:**
- Apple Silicon: `./bin/Release/osx-arm64/OrmentiaMarkus.app`
- Intel: `./bin/Release/osx-x64/OrmentiaMarkus.app`
### Windows
```powershell
.\deploy\build-windows.ps1
```
Creates a self-contained `.exe` file with:
- Embedded application icon
- All dependencies bundled
- Single-file executable ready for distribution
**Output:** `.\bin\Release\win-x64\OrmentiaMarkus.exe`
**Note:** Icon file (`Assets/AppIcon.ico`) must be created first. Run `bash deploy/create-windows-icon.sh` from macOS/Linux.
### Linux
```bash
./deploy/build-linux.sh
```
Creates a complete application package with:
- Executable binary
- .desktop file for menu integration
- Application icon
- Launcher script
- Installation README
**Output:** `./bin/Release/linux-x64/OrmentiaMarkus/` folder (ready to archive and distribute)
## Helper Scripts
### Create Windows Icon
```bash
./deploy/create-windows-icon.sh
```
Converts PNG icons to Windows .ico format. Requires Python 3 with Pillow library.
## Build Options
All scripts build with the following options:
- **Self-contained**: Includes the .NET runtime (no need to install .NET on target system)
- **Release configuration**: Optimized for production
- **Platform-specific packaging**: Proper application bundles for each OS
## What Gets Built
| Platform | Output Format | Icon | Ready to Distribute |
|----------|---------------|------|---------------------|
| macOS | `.app` bundle | ✓ | ✓ |
| Windows | `.exe` file | ✓ | ✓ |
| Linux | App folder | ✓ | ✓ (archive it) |
## Distribution
- **macOS**: Distribute the `.app` file (optionally create a DMG)
- **Windows**: Distribute the `.exe` file (optionally create an installer)
- **Linux**: Archive the folder as `.tar.gz` or create an AppImage
## Cross-Platform Building
Note: While you can use `dotnet publish` to cross-compile, the packaging scripts must run on their target OS to create proper platform-specific bundles.

93
src/deploy/build-linux.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
# Build script for Linux release binary and create application package
set -e
echo "Building Markus for Linux..."
# Navigate to project root
cd "$(dirname "$0")/.."
# Clean previous builds
echo "Cleaning previous builds..."
dotnet clean MarkdownEditor.csproj -c Release
# Build for Linux x64
echo "Building for Linux x64..."
dotnet publish MarkdownEditor.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-o ./bin/Release/linux-x64/publish
# Create application directory structure
APP_DIR="./bin/Release/linux-x64/OrmentiaMarkus"
echo "Creating application package..."
rm -rf "$APP_DIR"
mkdir -p "$APP_DIR"
mkdir -p "$APP_DIR/bin"
mkdir -p "$APP_DIR/share/applications"
mkdir -p "$APP_DIR/share/icons/hicolor/256x256/apps"
# Copy executable
cp ./bin/Release/linux-x64/publish/OrmentiaMarkus "$APP_DIR/bin/"
chmod +x "$APP_DIR/bin/OrmentiaMarkus"
# Copy icon
if [ -f "Assets/AppIcon.iconset/icon_256x256.png" ]; then
cp "Assets/AppIcon.iconset/icon_256x256.png" "$APP_DIR/share/icons/hicolor/256x256/apps/ormentia-markus.png"
fi
# Create .desktop file
cat > "$APP_DIR/share/applications/ormentia-markus.desktop" << EOF
[Desktop Entry]
Version=1.0
Type=Application
Name=Markus
Comment=A Simple Markdown Editor
Exec=OrmentiaMarkus
Icon=ormentia-markus
Categories=Office;TextEditor;Utility;
Terminal=false
StartupNotify=true
EOF
# Create launcher script
cat > "$APP_DIR/OrmentiaMarkus" << 'EOF'
#!/bin/bash
# Launcher script for Markus
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/bin/OrmentiaMarkus" "$@"
EOF
chmod +x "$APP_DIR/OrmentiaMarkus"
# Create README
cat > "$APP_DIR/README.txt" << EOF
Markus - A Simple Markdown Editor
==================================
Installation:
1. Extract this archive to your preferred location (e.g., /opt or ~/Applications)
2. Run ./OrmentiaMarkus to launch the application
3. (Optional) Copy share/applications/ormentia-markus.desktop to ~/.local/share/applications/
for desktop menu integration
© 2025 Ormentia. All rights reserved.
https://ormentia.com
EOF
# Clean up publish directory
rm -rf ./bin/Release/linux-x64/publish
echo ""
echo "✓ Build completed successfully!"
echo "Application package: ./bin/Release/linux-x64/OrmentiaMarkus/"
echo ""
echo "You can now:"
echo " 1. Run ./bin/Release/linux-x64/OrmentiaMarkus/OrmentiaMarkus"
echo " 2. Archive and distribute the OrmentiaMarkus folder"
echo " 3. Install to /opt or ~/Applications"
echo ""

110
src/deploy/build-macos.sh Executable file
View File

@@ -0,0 +1,110 @@
#!/bin/bash
# Build script for macOS release binary and create .app bundle
set -e
echo "Building Markus for macOS..."
# Navigate to project root
cd "$(dirname "$0")/.."
# Clean previous builds
echo "Cleaning previous builds..."
dotnet clean MarkdownEditor.csproj -c Release
# Determine architecture
ARCH=$(uname -m)
if [ "$ARCH" = "arm64" ]; then
RUNTIME="osx-arm64"
echo "Building for Apple Silicon (ARM64)..."
else
RUNTIME="osx-x64"
echo "Building for Intel (x64)..."
fi
# Build for macOS (not as single file - we need dependencies accessible)
echo "Building application..."
dotnet publish MarkdownEditor.csproj \
-c Release \
-r $RUNTIME \
--self-contained true \
-o ./bin/Release/$RUNTIME/publish
# Create .app bundle structure
APP_NAME="OrmentiaMarkus.app"
APP_PATH="./bin/Release/$RUNTIME/$APP_NAME"
CONTENTS_PATH="$APP_PATH/Contents"
MACOS_PATH="$CONTENTS_PATH/MacOS"
RESOURCES_PATH="$CONTENTS_PATH/Resources"
echo "Creating .app bundle structure..."
rm -rf "$APP_PATH"
mkdir -p "$MACOS_PATH"
mkdir -p "$RESOURCES_PATH"
# Copy all files from publish to MacOS
echo "Copying application files..."
cp -R ./bin/Release/$RUNTIME/publish/* "$MACOS_PATH/"
# Make the executable actually executable
chmod +x "$MACOS_PATH/OrmentiaMarkus"
# Copy icon
echo "Copying application icon..."
if [ -f "./Assets/AppIcon.icns" ]; then
cp "./Assets/AppIcon.icns" "$RESOURCES_PATH/"
else
echo "Warning: AppIcon.icns not found, app will use default icon"
fi
# Create Info.plist
echo "Creating Info.plist..."
cat > "$CONTENTS_PATH/Info.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Markus</string>
<key>CFBundleDisplayName</key>
<string>Markus</string>
<key>CFBundleIdentifier</key>
<string>com.ormentia.markus</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>MRKS</string>
<key>CFBundleExecutable</key>
<string>OrmentiaMarkus</string>
<key>CFBundleIconFile</key>
<string>AppIcon.icns</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2025 Ormentia. All rights reserved.</string>
</dict>
</plist>
EOF
# Clean up publish directory
rm -rf ./bin/Release/$RUNTIME/publish
echo ""
echo "✓ Build completed successfully!"
echo "Application bundle: ./bin/Release/$RUNTIME/$APP_NAME"
echo ""
echo "You can now:"
echo " 1. Double-click the app to run it"
echo " 2. Drag it to /Applications folder"
echo " 3. Distribute the .app bundle"
echo ""
# Open Finder to the build location
open ./bin/Release/$RUNTIME

View File

@@ -0,0 +1,43 @@
# Build script for Windows release binary
$ErrorActionPreference = "Stop"
Write-Host "Building Markus for Windows..." -ForegroundColor Cyan
# Navigate to project root
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location "$scriptPath\.."
# Ensure Windows icon exists
if (-not (Test-Path "Assets\AppIcon.ico")) {
Write-Host "Warning: AppIcon.ico not found. Application will use default icon." -ForegroundColor Yellow
Write-Host "Run 'bash deploy/create-windows-icon.sh' on macOS/Linux to create the icon." -ForegroundColor Yellow
}
# Clean previous builds
Write-Host "Cleaning previous builds..." -ForegroundColor Yellow
dotnet clean MarkdownEditor.csproj -c Release
# Build for Windows x64
Write-Host "Building for Windows x64..." -ForegroundColor Yellow
dotnet publish MarkdownEditor.csproj `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-o .\bin\Release\win-x64
Write-Host ""
Write-Host "✓ Build completed successfully!" -ForegroundColor Green
Write-Host "Application: .\bin\Release\win-x64\OrmentiaMarkus.exe" -ForegroundColor Green
Write-Host ""
Write-Host "You can now:" -ForegroundColor Cyan
Write-Host " 1. Run the .exe file directly" -ForegroundColor Cyan
Write-Host " 2. Copy it to Program Files" -ForegroundColor Cyan
Write-Host " 3. Distribute the .exe file" -ForegroundColor Cyan
Write-Host ""
# Open File Explorer to the build location
Invoke-Item .\bin\Release\win-x64

View File

@@ -0,0 +1,47 @@
#!/bin/bash
# Create Windows .ico file from PNG icons
set -e
cd "$(dirname "$0")/.."
# Check if we have the icon source
if [ ! -f "Assets/AppIcon.iconset/icon_256x256.png" ]; then
echo "Error: icon_256x256.png not found"
exit 1
fi
echo "Creating Windows .ico file..."
# Use Python with PIL if available, otherwise manual creation
if command -v python3 &> /dev/null; then
python3 << 'EOF'
try:
from PIL import Image
import os
# Create ico with multiple sizes
img_256 = Image.open("Assets/AppIcon.iconset/icon_256x256.png")
img_128 = Image.open("Assets/AppIcon.iconset/icon_128x128.png")
img_64 = img_256.resize((64, 64), Image.Resampling.LANCZOS)
img_48 = img_256.resize((48, 48), Image.Resampling.LANCZOS)
img_32 = Image.open("Assets/AppIcon.iconset/icon_32x32.png")
img_16 = Image.open("Assets/AppIcon.iconset/icon_16x16.png")
img_256.save("Assets/AppIcon.ico",
format='ICO',
sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
append_images=[img_16, img_32, img_48, img_64, img_128])
print("✓ Created Assets/AppIcon.ico")
except ImportError:
print("Warning: PIL/Pillow not available. Please install with: pip3 install Pillow")
print("Or manually create AppIcon.ico and place it in Assets/ folder")
exit(1)
EOF
else
echo "Warning: Python3 not available. Cannot create .ico file."
echo "Please manually create AppIcon.ico and place it in Assets/ folder"
exit 1
fi

24
src/markdown-editor.sln Normal file
View File

@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarkdownEditor", "MarkdownEditor.csproj", "{B3D4B5C3-6E23-DA6D-AC50-ED5D72CFFABC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B3D4B5C3-6E23-DA6D-AC50-ED5D72CFFABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3D4B5C3-6E23-DA6D-AC50-ED5D72CFFABC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3D4B5C3-6E23-DA6D-AC50-ED5D72CFFABC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3D4B5C3-6E23-DA6D-AC50-ED5D72CFFABC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AA14ABBA-8913-48A1-89C4-30FA0EE218A5}
EndGlobalSection
EndGlobal