commit 7562a4ce14d73409d0e6377998e710e699cbd67f Author: shump Date: Thu Jan 15 11:11:42 2026 -0600 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..588846b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..169e9a6 --- /dev/null +++ b/LICENSE @@ -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 . + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + 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 . + +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 +. + + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b62d916 --- /dev/null +++ b/README.md @@ -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. diff --git a/src/App.axaml b/src/App.axaml new file mode 100644 index 0000000..497ab9b --- /dev/null +++ b/src/App.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/App.axaml.cs b/src/App.axaml.cs new file mode 100644 index 0000000..c98d7b3 --- /dev/null +++ b/src/App.axaml.cs @@ -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; + +/// +/// Main application class. +/// Handles application initialization and lifetime management. +/// +public partial class App : Application +{ + /// + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + SetupNativeMenu(); + } + + /// + /// Sets up the native macOS menu with About dialog. + /// + 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") + }); + } + } + + /// + 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(); + } + + /// + /// Disables Avalonia's built-in data annotation validation to prevent conflicts with CommunityToolkit. + /// + private void DisableAvaloniaDataAnnotationValidation() + { + // Get an array of plugins to remove + var dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + + // remove each entry found + foreach (var plugin in dataValidationPluginsToRemove) + { + BindingPlugins.DataValidators.Remove(plugin); + } + } + + /// + /// Handles the About Markus menu item click event. + /// + private void AboutMarkus_Click(object? sender, EventArgs e) + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && + desktop.MainWindow != null) + { + var aboutWindow = new AboutWindow(); + aboutWindow.ShowDialog(desktop.MainWindow); + } + } +} \ No newline at end of file diff --git a/src/Assets/AppIcon.icns b/src/Assets/AppIcon.icns new file mode 100644 index 0000000..ec6e895 Binary files /dev/null and b/src/Assets/AppIcon.icns differ diff --git a/src/Assets/AppIcon.ico b/src/Assets/AppIcon.ico new file mode 100644 index 0000000..992652e Binary files /dev/null and b/src/Assets/AppIcon.ico differ diff --git a/src/Assets/AppIcon.iconset/icon_128x128.png b/src/Assets/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000..e0a7aa9 Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_128x128.png differ diff --git a/src/Assets/AppIcon.iconset/icon_128x128@2x.png b/src/Assets/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..e206c5b Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/src/Assets/AppIcon.iconset/icon_16x16.png b/src/Assets/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000..6c1f8aa Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_16x16.png differ diff --git a/src/Assets/AppIcon.iconset/icon_16x16@2x.png b/src/Assets/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..f74df61 Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/src/Assets/AppIcon.iconset/icon_256x256.png b/src/Assets/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000..e206c5b Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_256x256.png differ diff --git a/src/Assets/AppIcon.iconset/icon_256x256@2x.png b/src/Assets/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..05cc434 Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/src/Assets/AppIcon.iconset/icon_32x32.png b/src/Assets/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000..f74df61 Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_32x32.png differ diff --git a/src/Assets/AppIcon.iconset/icon_32x32@2x.png b/src/Assets/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..06f31bf Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/src/Assets/AppIcon.iconset/icon_512x512.png b/src/Assets/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000..05cc434 Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_512x512.png differ diff --git a/src/Assets/AppIcon.iconset/icon_512x512@2x.png b/src/Assets/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..ca21d1e Binary files /dev/null and b/src/Assets/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/src/Assets/markus-logo.png b/src/Assets/markus-logo.png new file mode 100644 index 0000000..ca21d1e Binary files /dev/null and b/src/Assets/markus-logo.png differ diff --git a/src/Assets/ormentia-text.png b/src/Assets/ormentia-text.png new file mode 100644 index 0000000..3d842d9 Binary files /dev/null and b/src/Assets/ormentia-text.png differ diff --git a/src/Constants/AppConstants.cs b/src/Constants/AppConstants.cs new file mode 100644 index 0000000..cefa83e --- /dev/null +++ b/src/Constants/AppConstants.cs @@ -0,0 +1,100 @@ +namespace MarkdownEditor.Constants; + +/// +/// Application-wide constant values for configuration and styling. +/// Centralizes magic strings and numbers to improve maintainability. +/// +public static class AppConstants +{ + /// + /// UI color constants for consistent theming. + /// + 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"; + } + + /// + /// Font size constants for typography consistency. + /// + 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; + } + + /// + /// Markdown formatting symbols. + /// + 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"; + } + + /// + /// File-related constants. + /// + 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"; + } + + /// + /// Default UI text and messages. + /// + 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?"; + } + + /// + /// Window and layout dimensions. + /// + public static class Layout + { + public const int DefaultWindowWidth = 1200; + public const int DefaultWindowHeight = 800; + public const int ImageMaxWidth = 600; + public const int MinTabsToAllowClose = 1; + } +} + diff --git a/src/Info.plist b/src/Info.plist new file mode 100644 index 0000000..5e0faec --- /dev/null +++ b/src/Info.plist @@ -0,0 +1,63 @@ + + + + + CFBundleName + Ormentia Markus + CFBundleDisplayName + Ormentia Markus + CFBundleIdentifier + com.ormentia.markus + CFBundleVersion + 1.0.0 + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleExecutable + OrmentiaMarkus + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + CFBundleShortVersionString + 1.0.0 + NSHumanReadableCopyright + © 2025 Ormentia. All rights reserved. + CFBundleGetInfoString + Markus 1.0.0 - A Simple Markdown Editor + CFBundleIconFile + AppIcon + CFBundleDocumentTypes + + + CFBundleTypeName + Markdown Document + CFBundleTypeRole + Editor + CFBundleTypeIconFile + AppIcon + LSItemContentTypes + + net.daringfireball.markdown + + LSHandlerRank + Alternate + + + CFBundleTypeName + Plain Text Document + CFBundleTypeRole + Editor + CFBundleTypeExtensions + + md + markdown + mdown + + LSHandlerRank + Alternate + + + + diff --git a/src/MarkdownEditor.csproj b/src/MarkdownEditor.csproj new file mode 100644 index 0000000..55a6a56 --- /dev/null +++ b/src/MarkdownEditor.csproj @@ -0,0 +1,48 @@ + + + WinExe + net10.0 + enable + true + app.manifest + true + Ormentia Markus + OrmentiaMarkus + Ormentia Markus + Assets\AppIcon.ico + Ormentia Markus + Ormentia Markus + com.ormentia.markus + 1.0.0 + 1.0.0 + + + + + + + + + + PreserveNewest + + + + + + + + + + + + None + All + + + + + + + + diff --git a/src/Models/SessionData.cs b/src/Models/SessionData.cs new file mode 100644 index 0000000..7bfe8d4 --- /dev/null +++ b/src/Models/SessionData.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace MarkdownEditor.Models; + +/// +/// Represents a complete application session state. +/// +public class SessionData +{ + /// + /// Gets or sets the list of tabs that were open in the session. + /// + public List Tabs { get; set; } = new(); + + /// + /// Gets or sets the index of the tab that was selected when the session was saved. + /// + public int SelectedTabIndex { get; set; } +} diff --git a/src/Models/SessionTabInfo.cs b/src/Models/SessionTabInfo.cs new file mode 100644 index 0000000..70a2025 --- /dev/null +++ b/src/Models/SessionTabInfo.cs @@ -0,0 +1,31 @@ +namespace MarkdownEditor.Models; + +/// +/// Represents information about a tab in a saved session. +/// Includes file path (if saved) and content (for unsaved files). +/// +public class SessionTabInfo +{ + /// + /// Gets or sets the file path if the tab represents a saved file. + /// Null if the tab is an unsaved document. + /// + public string? FilePath { get; set; } + + /// + /// 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. + /// + public string Content { get; set; } = string.Empty; + + /// + /// Gets or sets the display title of the tab. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether this tab has unsaved changes. + /// + public bool IsDirty { get; set; } +} diff --git a/src/Models/TabInfo.cs b/src/Models/TabInfo.cs new file mode 100644 index 0000000..52066b2 --- /dev/null +++ b/src/Models/TabInfo.cs @@ -0,0 +1,30 @@ +namespace MarkdownEditor.Models; + +/// +/// Represents metadata about a markdown document tab. +/// This model encapsulates the state of a document independently from the view model. +/// +public class TabInfo +{ + /// + /// Gets or sets the display title of the tab. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the file system path of the document, if it has been saved. + /// Null indicates an unsaved document. + /// + public string? FilePath { get; set; } + + /// + /// Gets or sets a value indicating whether the document has unsaved changes. + /// + public bool IsDirty { get; set; } + + /// + /// Gets the display title with dirty indicator. + /// + public string DisplayTitle => IsDirty ? $"{Title} *" : Title; +} + diff --git a/src/Models/TextSelection.cs b/src/Models/TextSelection.cs new file mode 100644 index 0000000..2421e21 --- /dev/null +++ b/src/Models/TextSelection.cs @@ -0,0 +1,74 @@ +namespace MarkdownEditor.Models; + +/// +/// Represents a text selection range within a document. +/// Used for formatting operations on selected text. +/// +public class TextSelection +{ + /// + /// Gets or sets the starting position of the selection. + /// + public int Start { get; set; } + + /// + /// Gets or sets the ending position of the selection. + /// + public int End { get; set; } + + /// + /// Gets the length of the selection. + /// + public int Length => System.Math.Max(0, End - Start); + + /// + /// Initializes a new instance of the class. + /// + public TextSelection() + { + Start = 0; + End = 0; + } + + /// + /// Initializes a new instance of the class with specified range. + /// + /// The start position. + /// The end position. + public TextSelection(int start, int end) + { + Start = start; + End = end; + } + + /// + /// Validates that the selection is within the given text bounds. + /// + /// The maximum text length. + /// True if the selection is valid, false otherwise. + public bool IsValid(int textLength) + { + return Start >= 0 && End <= textLength && Start <= End; + } + + /// + /// Normalizes the selection to ensure it's within valid bounds. + /// + /// The maximum text length. + 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)); + } + + /// + /// Copies values from another text selection. + /// + /// The selection to copy from. + public void CopyFrom(TextSelection other) + { + Start = other.Start; + End = other.End; + } +} + diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..b275aa3 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,35 @@ +using Avalonia; +using System; +using Projektanker.Icons.Avalonia; +using Projektanker.Icons.Avalonia.FontAwesome; + +namespace MarkdownEditor; + +/// +/// Application entry point. +/// Configures and starts the Avalonia application. +/// +sealed class Program +{ + /// + /// Application entry point. + /// + /// Command line arguments. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + /// + /// Builds and configures the Avalonia application. + /// This method is also used by the visual designer. + /// + /// Configured AppBuilder instance. + public static AppBuilder BuildAvaloniaApp() + { + IconProvider.Current.Register(); + return AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} diff --git a/src/Services/FileService.cs b/src/Services/FileService.cs new file mode 100644 index 0000000..76a7e88 --- /dev/null +++ b/src/Services/FileService.cs @@ -0,0 +1,51 @@ +using System.IO; +using System.Threading.Tasks; + +namespace MarkdownEditor.Services; + +/// +/// Default implementation of using System.IO. +/// Provides file system operations for reading and writing markdown files. +/// +public class FileService : IFileService +{ + /// + public async Task 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); + } + + /// + 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); + } + + /// + public bool FileExists(string path) + { + return !string.IsNullOrWhiteSpace(path) && File.Exists(path); + } + + /// + public string GetFileName(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + return Path.GetFileName(path); + } +} + diff --git a/src/Services/IFileService.cs b/src/Services/IFileService.cs new file mode 100644 index 0000000..48d3632 --- /dev/null +++ b/src/Services/IFileService.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; + +namespace MarkdownEditor.Services; + +/// +/// Interface for file system operations. +/// Abstracts file I/O to improve testability and separation of concerns. +/// +public interface IFileService +{ + /// + /// Reads the entire contents of a file as text. + /// + /// The file path to read from. + /// The file contents as a string. + /// Thrown when the file cannot be read. + Task ReadFileAsync(string path); + + /// + /// Writes text content to a file, creating or overwriting as necessary. + /// + /// The file path to write to. + /// The text content to write. + /// Thrown when the file cannot be written. + Task WriteFileAsync(string path, string content); + + /// + /// Checks if a file exists at the specified path. + /// + /// The file path to check. + /// True if the file exists, false otherwise. + bool FileExists(string path); + + /// + /// Gets the file name from a full path. + /// + /// The full file path. + /// The file name without directory information. + string GetFileName(string path); +} + diff --git a/src/Services/IMarkdownFormattingService.cs b/src/Services/IMarkdownFormattingService.cs new file mode 100644 index 0000000..556cf7b --- /dev/null +++ b/src/Services/IMarkdownFormattingService.cs @@ -0,0 +1,39 @@ +using AvaloniaEdit.Document; +using MarkdownEditor.Models; + +namespace MarkdownEditor.Services; + +/// +/// Interface for markdown text formatting operations. +/// Handles insertion of markdown syntax around selected text or at cursor positions. +/// +public interface IMarkdownFormattingService +{ + /// + /// Wraps the selected text with before and after strings (e.g., ** for bold). + /// + /// The text document to modify. + /// The current text selection. + /// The string to insert before the selection. + /// The string to insert after the selection. + /// The new text selection after the operation. + TextSelection WrapSelection(TextDocument document, TextSelection selection, string before, string after); + + /// + /// Adds a prefix to the beginning of the current line (e.g., # for heading). + /// + /// The text document to modify. + /// The current text selection to determine the line. + /// The prefix string to add to the line. + /// The new text selection after the operation. + TextSelection AddLinePrefix(TextDocument document, TextSelection selection, string prefix); + + /// + /// Gets the selected text from the document. + /// + /// The text document. + /// The text selection range. + /// The selected text, or empty string if selection is invalid. + string GetSelectedText(TextDocument document, TextSelection selection); +} + diff --git a/src/Services/IPdfExportService.cs b/src/Services/IPdfExportService.cs new file mode 100644 index 0000000..96374d1 --- /dev/null +++ b/src/Services/IPdfExportService.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace MarkdownEditor.Services; + +/// +/// Service interface for exporting markdown content to PDF. +/// +public interface IPdfExportService +{ + /// + /// Exports markdown content to a PDF file. + /// + /// The markdown content to export. + /// The file path where the PDF should be saved. + /// Optional document title. + Task ExportToPdfAsync(string markdownContent, string filePath, string? title = null); +} diff --git a/src/Services/ISessionService.cs b/src/Services/ISessionService.cs new file mode 100644 index 0000000..64d4280 --- /dev/null +++ b/src/Services/ISessionService.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MarkdownEditor.Models; + +namespace MarkdownEditor.Services; + +/// +/// Interface for session persistence operations. +/// Handles saving and restoring application session state (open files, tabs, etc.). +/// +public interface ISessionService +{ + /// + /// Saves the current session state (open tabs with their file paths and unsaved content). + /// + /// Collection of tab information including file paths and content. + /// Index of the currently selected tab. + /// Task representing the async operation. + Task SaveSessionAsync(IEnumerable tabs, int selectedTabIndex); + + /// + /// Loads the previously saved session state. + /// + /// The saved session data, or null if no session exists or loading fails. + Task LoadSessionAsync(); + + /// + /// Clears the saved session data. + /// + /// Task representing the async operation. + Task ClearSessionAsync(); +} diff --git a/src/Services/MarkdownFormattingService.cs b/src/Services/MarkdownFormattingService.cs new file mode 100644 index 0000000..020a1fc --- /dev/null +++ b/src/Services/MarkdownFormattingService.cs @@ -0,0 +1,109 @@ +using System; +using AvaloniaEdit.Document; +using MarkdownEditor.Constants; +using MarkdownEditor.Models; + +namespace MarkdownEditor.Services; + +/// +/// Default implementation of . +/// Provides markdown formatting operations on text documents. +/// +public class MarkdownFormattingService : IMarkdownFormattingService +{ + /// + 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); + } + + /// + 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); + } + + /// + 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; + } +} + diff --git a/src/Services/PdfExportService.cs b/src/Services/PdfExportService.cs new file mode 100644 index 0000000..970178a --- /dev/null +++ b/src/Services/PdfExportService.cs @@ -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; + +/// +/// Service for exporting markdown content to PDF format. +/// +public class PdfExportService : IPdfExportService +{ + /// + 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; + } +} diff --git a/src/Services/SessionService.cs b/src/Services/SessionService.cs new file mode 100644 index 0000000..b535bd8 --- /dev/null +++ b/src/Services/SessionService.cs @@ -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; + +/// +/// 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. +/// +public class SessionService : ISessionService +{ + private readonly string _sessionFilePath; + + /// + /// Initializes a new instance of the class. + /// + public SessionService() + { + // Store session file in app data directory + var appDataPath = GetAppDataDirectory(); + Directory.CreateDirectory(appDataPath); + _sessionFilePath = Path.Combine(appDataPath, "session.json"); + } + + /// + /// Gets the application data directory for storing session files. + /// + /// The path to the app data directory. + 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); + } + + /// + public async Task SaveSessionAsync(IEnumerable 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 + } + } + + /// + public async Task LoadSessionAsync() + { + try + { + if (!File.Exists(_sessionFilePath)) + { + return null; + } + + var json = await File.ReadAllTextAsync(_sessionFilePath); + var sessionData = JsonSerializer.Deserialize(json); + + return sessionData; + } + catch + { + return null; + } + } + + /// + public async Task ClearSessionAsync() + { + try + { + if (File.Exists(_sessionFilePath)) + { + File.Delete(_sessionFilePath); + } + } + catch + { + // Error clearing session - fail silently + } + + await Task.CompletedTask; + } +} diff --git a/src/ViewLocator.cs b/src/ViewLocator.cs new file mode 100644 index 0000000..f8eca8a --- /dev/null +++ b/src/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using MarkdownEditor.ViewModels; + +namespace MarkdownEditor; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[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; + } +} diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..d8c687e --- /dev/null +++ b/src/ViewModels/MainWindowViewModel.cs @@ -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; + +/// +/// Main view model for the application window. +/// Manages the collection of markdown document tabs and file operations. +/// +public partial class MainWindowViewModel : ViewModelBase +{ + [ObservableProperty] + private ObservableCollection _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; + + /// + /// Gets or sets the storage provider for file dialogs. + /// Set by the view when the window is opened. + /// + public IStorageProvider? StorageProvider { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// Optional PDF export service for dependency injection. + /// Optional session service for dependency injection. + /// Optional file path to open on startup (takes precedence over session restore). + 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(); + } + } + + /// + /// Creates and adds a new empty tab. + /// + [RelayCommand] + private void AddNewTab() + { + var newTab = new MarkdownTabViewModel + { + Title = $"Tab {Tabs.Count + 1}" + }; + Tabs.Add(newTab); + SelectedTab = newTab; + } + + /// + /// Closes the specified tab. + /// Maintains at least one open tab and updates selection appropriately. + /// + /// The tab to close. + [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)]; + } + } + } + + /// + /// Opens a markdown file from disk in a new or existing tab. + /// + [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 + } + } + + /// + /// Saves the currently selected tab's document. + /// Prompts for a file location if not previously saved. + /// + [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 + } + } + + /// + /// Prompts the user to save the currently selected tab's document to a new location. + /// + [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 + } + } + + /// + /// Exports the currently selected tab's markdown content to PDF. + /// + [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 + } + } + + /// + /// Toggles between dark and light mode. + /// + [RelayCommand] + private void ToggleTheme() + { + IsDarkMode = !IsDarkMode; + } + + /// + /// Sets the text size to Small. + /// + [RelayCommand] + private void SetTextSizeSmall() + { + TextSize = "Small"; + } + + /// + /// Sets the text size to Medium. + /// + [RelayCommand] + private void SetTextSizeMedium() + { + TextSize = "Medium"; + } + + /// + /// Sets the text size to Large. + /// + [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)); + } + + /// + /// Restores the previous session (open files and tabs). + /// Should be called after StorageProvider is set. + /// + 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; + } + } + + /// + /// Saves the current session state (open files and tabs). + /// Should be called when the window is closing. + /// + 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 + } + } + + /// + /// Checks if there are any tabs with unsaved changes. + /// + /// True if any tab has unsaved changes, false otherwise. + public bool HasUnsavedChanges() + { + return Tabs.Any(tab => tab.IsDirty); + } + + /// + /// Gets the count of tabs with unsaved changes. + /// + /// The number of tabs with unsaved changes. + public int GetUnsavedChangesCount() + { + return Tabs.Count(tab => tab.IsDirty); + } + + /// + /// Saves all tabs with unsaved changes. + /// Prompts for file location for unsaved files without a path. + /// + /// True if all files were saved successfully, false if user cancelled or error occurred. + public async Task 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; + } +} diff --git a/src/ViewModels/MarkdownTabViewModel.cs b/src/ViewModels/MarkdownTabViewModel.cs new file mode 100644 index 0000000..c3959b6 --- /dev/null +++ b/src/ViewModels/MarkdownTabViewModel.cs @@ -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; + +/// +/// View model for a single markdown document tab. +/// Manages document content, editing state, and formatting operations. +/// +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; + + /// + /// Gets the text selection used for formatting operations. + /// + public TextSelection Selection { get; } = new(); + + /// + /// Initializes a new instance of the class. + /// + /// Service for file operations. If null, uses default implementation. + /// Service for markdown formatting. If null, uses default implementation. + 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)); + }; + } + + /// + /// Gets the underlying text document. + /// + public TextDocument Document => _document; + + /// + /// Gets or sets the markdown content of the document. + /// + public string MarkdownContent + { + get => _document.Text; + set + { + if (_document.Text != value) + { + _document.Text = value; + OnPropertyChanged(nameof(SafeMarkdownContent)); + } + } + } + + /// + /// Gets the markdown content safely, with error handling. + /// + public string SafeMarkdownContent + { + get + { + try + { + var content = _document.Text; + if (string.IsNullOrWhiteSpace(content)) + return string.Empty; + + return content; + } + catch + { + return AppConstants.Messages.ErrorRenderingMarkdown; + } + } + } + + /// + /// Gets the view mode text for display. + /// + public string ViewModeText => IsPrettyView ? "Pretty" : "Raw"; + + /// + /// Gets the display title with dirty indicator. + /// + public string DisplayTitle => IsDirty ? $"{Title} *" : Title; + + partial void OnTitleChanged(string value) + { + OnPropertyChanged(nameof(DisplayTitle)); + } + + partial void OnIsDirtyChanged(bool value) + { + OnPropertyChanged(nameof(DisplayTitle)); + } + + /// + /// Loads a markdown file from disk. + /// + /// The file path to load. + 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 + } + } + + /// + /// Saves the document to disk. + /// + /// The file path to save to. + 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; + } + + /// + /// Inserts a hyperlink at the current selection. + /// + /// The URL to link to. + 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 + } + + /// + /// Inserts an image at the current selection. + /// + /// The image URL. + 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 + /// + /// Gets or sets the selection start position. + /// + public int SelectionStart + { + get => Selection.Start; + set => Selection.Start = value; + } + + /// + /// Gets or sets the selection end position. + /// + public int SelectionEnd + { + get => Selection.End; + set => Selection.End = value; + } +} diff --git a/src/ViewModels/ViewModelBase.cs b/src/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..835056e --- /dev/null +++ b/src/ViewModels/ViewModelBase.cs @@ -0,0 +1,11 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace MarkdownEditor.ViewModels; + +/// +/// Base class for all view models in the application. +/// Provides observable property change notification through CommunityToolkit.Mvvm. +/// +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/src/Views/AboutWindow.axaml b/src/Views/AboutWindow.axaml new file mode 100644 index 0000000..4bfec6e --- /dev/null +++ b/src/Views/AboutWindow.axaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AboutWindow.axaml.cs b/src/Views/AboutWindow.axaml.cs new file mode 100644 index 0000000..c17ed29 --- /dev/null +++ b/src/Views/AboutWindow.axaml.cs @@ -0,0 +1,62 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace MarkdownEditor.Views; + +/// +/// About window that displays application information. +/// +public partial class AboutWindow : Window +{ + /// + /// Initializes a new instance of the AboutWindow class. + /// + public AboutWindow() + { + InitializeComponent(); + } + + /// + /// Handles the close button click event. + /// + private void CloseButton_Click(object? sender, RoutedEventArgs e) + { + Close(); + } + + /// + /// Handles the Ormentia link click event to open the website. + /// + private void OrmentiaLink_Click(object? sender, RoutedEventArgs e) + { + OpenUrl("https://ormentia.com"); + } + + /// + /// Opens a URL in the default browser. + /// + 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 + } + } +} diff --git a/src/Views/MainWindow.axaml b/src/Views/MainWindow.axaml new file mode 100644 index 0000000..b8e36e8 --- /dev/null +++ b/src/Views/MainWindow.axaml @@ -0,0 +1,487 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/SaveConfirmationDialog.axaml.cs b/src/Views/SaveConfirmationDialog.axaml.cs new file mode 100644 index 0000000..d469726 --- /dev/null +++ b/src/Views/SaveConfirmationDialog.axaml.cs @@ -0,0 +1,68 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace MarkdownEditor.Views; + +/// +/// Dialog for confirming whether to save unsaved changes before closing. +/// +public partial class SaveConfirmationDialog : Window +{ + /// + /// Gets the user's choice: Save, Don't Save, or Cancel. + /// + 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(); + } +} + +/// +/// Result of the save confirmation dialog. +/// +public enum SaveConfirmationResult +{ + /// + /// User chose to save changes. + /// + Save, + + /// + /// User chose not to save changes. + /// + DontSave, + + /// + /// User cancelled the close operation. + /// + Cancel +} diff --git a/src/Views/TextEditorHelper.cs b/src/Views/TextEditorHelper.cs new file mode 100644 index 0000000..1243374 --- /dev/null +++ b/src/Views/TextEditorHelper.cs @@ -0,0 +1,54 @@ +using Avalonia; +using AvaloniaEdit; +using AvaloniaEdit.Document; + +namespace MarkdownEditor.Views; + +/// +/// Helper class for binding AvaloniaEdit TextDocument to MVVM view models. +/// Provides an attached property to enable two-way binding of the Document property. +/// +public static class TextEditorHelper +{ + /// + /// Attached property for binding a TextDocument to a TextEditor control. + /// + public static readonly AttachedProperty DocumentProperty = + AvaloniaProperty.RegisterAttached( + "Document", + typeof(TextEditorHelper)); + + /// + /// Gets the document attached to the specified TextEditor. + /// + /// The TextEditor control. + /// The attached TextDocument, or null if none is attached. + public static TextDocument? GetDocument(TextEditor editor) + { + return editor.GetValue(DocumentProperty); + } + + /// + /// Sets the document to attach to the specified TextEditor. + /// + /// The TextEditor control. + /// The TextDocument to attach. + public static void SetDocument(TextEditor editor, TextDocument? value) + { + editor.SetValue(DocumentProperty, value); + } + + static TextEditorHelper() + { + DocumentProperty.Changed.AddClassHandler(OnDocumentChanged); + } + + private static void OnDocumentChanged(TextEditor editor, AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue is TextDocument document) + { + editor.Document = document; + } + } +} + diff --git a/src/Views/UrlInputDialog.axaml b/src/Views/UrlInputDialog.axaml new file mode 100644 index 0000000..24dd51d --- /dev/null +++ b/src/Views/UrlInputDialog.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + +