feat(reader): complete configuration and Windows integration

Add cancel-safe native Settings pages, configurable typography/CSS/themes,
file registration and activation, theme-aware source and proportional layout,
toolbar/footer/editor guidance and open-source credits. Preserve attributed
Darin Kelkhoff document styles and literal passive rendering. Retain verbatim
vendor notices with scoped newline/whitespace preservation; secret, security
and other hooks remain enabled.

Validation: zero-warning Release build, 582 core tests with 100% measured line
coverage, 25 Windows adapter tests, formatting and self-contained publish pass.
Current native/distributed acceptance and source-hover diagnosis remain pending;
no production release-readiness claim.

Refs #1, #6, #11, #14, #16, #17, #18, #19, #20, #21, #23, #24, #25, #26, #27, #28

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
James MaesandCodex committed 2026-10-11 13:40:39 -05:00
1 parent 841798c750
commit 0eb3a0efbd
136 files changed
+23604 -324

No files matched your search

+3
View File
@@ -4,3 +4,6 @@
*.ico binary
*.cmd text eol=crlf
*.bat text eol=crlf
# Retain verbatim vendor license/notice bytes, including original whitespace.
THIRD-PARTY-LICENSES/** -text whitespace=-trailing-space,-blank-at-eof
+3
View File
@@ -24,9 +24,12 @@ repos:
- id: check-toml
- id: detect-private-key
- id: end-of-file-fixer
exclude: ^THIRD-PARTY-LICENSES/
- id: mixed-line-ending
exclude: ^THIRD-PARTY-LICENSES/
args: [--fix=lf]
- id: trailing-whitespace
exclude: ^THIRD-PARTY-LICENSES/
args: [--markdown-linebreak-ext=md]
- id: no-commit-to-branch
args: [--branch, main]
+28 -6
View File
@@ -1,6 +1,6 @@
# MarkdownViewr
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The reader, live reload, built-in themes/editing, frontmatter, outline/source/find, named editors and basic font zoom pass native/published and both installed channels on `2639e2d`. Native print cancellation and real default/themed PDF output/state cases pass; full print acceptance and broader layout comparisons remain pending. The full port, broader parity and signed releases remain in progress.
> In development: a Windows counterpart to [Darin Kelkhoff's macOS markdownViewr](https://github.com/darinkelkhoff/markdownViewr) ([his website](https://kelkhoff.com/)), built with AppKit. The reader, source/find/navigation/editor features and full printing pass hosted acceptance on `bf4fcdc`, including both installed update channels. Document-window acceptance and the new renderer, typography, shared appearance and theme-management controls remain in progress. Full reference parity and signed production releases are not complete.
A Windows Markdown viewer with live reload, themes and document navigation.
@@ -13,6 +13,8 @@ A Windows Markdown viewer with live reload, themes and document navigation.
| **Standards** | Adopting [KofTwentyTwo standards](https://github.com/KofTwentyTwo/standards) (pre-v1.0); built from the KofTwentyTwo Windows app template |
| **Latest release** | [Releases](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest) |
Settings → Advanced contains the startup splash toggle, splash duration (500–30000 milliseconds) and startup update checks. Changes save immediately and apply on the next launch. The native toolbar keeps Print visible, aligns controls in a compact row and groups reading commands.
## Install
No Windows release has been published. The following describes the planned release artifacts.
@@ -29,23 +31,31 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
## Usage
- **File → Open / Ctrl+O:** choose a Markdown document. A literal file path can also be supplied when launching the executable.
- **Settings → Advanced → Choose default file types:** an installed copy opens Windows Default Apps so you can choose MarkdownViewr for `.md`, `.markdown`, `.mdown` or `.mkd`. `.txt` is optional. Windows 11 23H2+ opens this installation's app page; older versions open general Default Apps. Portable previews show installation guidance. Registration/routing tests pass locally; actual installed selection and Explorer acceptance remain pending.
- **Source highlighting:** the native read-only source view uses document-theme accents adjusted for shell light/dark contrast. Windows high contrast takes precedence. Copy preserves visible original text/newline spelling without rich-text clipboard data; find/fold/reload share the committed snapshot. New native acceptance is prepared, not yet run.
- **Live reload:** saves automatically refresh the document, including replacement saves. Deletion keeps the last version visible until the file is restored. The relative reading position is preserved.
- **File → Reload:** reread the current file manually. Local images stay within the document folder; remote images and authored scripts/styles are blocked.
- **File → Print / Ctrl+P:** choose a separate print theme, images/backgrounds, standard or matching font zoom, content width, borders and page padding. **Print...** opens Windows print UI; **Save PDF...** asks for a PDF destination. Cancel leaves the reading view unchanged. Implementation and core validation are complete; native output acceptance is pending.
- **File → Print / Ctrl+P:** choose a separate print theme, images/backgrounds, standard or matching font zoom, content width, borders and page padding. **Print...** opens Windows print UI; **Save PDF...** asks for a PDF destination. Cancel leaves the reading view unchanged. The earlier five hosted print cases passed in native, published and both installed builds. The owner-reported missing system dialog and PDF behavior still need current-build reproduction; wider options, readable-file verification and opt-in PDF/folder actions are implemented locally. Final production signatures remain unverified.
- **Document toolbar:** TOC and H1–H6 depth appear on the left; source, minus/percentage/plus zoom, theme and external editor follow in the Mac reference order. **Print** is a primary toolbar command; **Theme** uses the Color icon. Native overflow keeps commands accessible in narrow windows. Buttons share View/File handlers; depth is disabled when TOC is hidden, and editors launch directly or offer named choices. New toolbar/shared-editor acceptance is pending.
- **Reading zoom:** use the minus/percentage/plus toolbar or **View → Zoom in / Zoom out / Actual size**. `Ctrl++` (also `Ctrl+=`), `Ctrl+-` and `Ctrl+0` change only the reading window's font scale, bounded to 30–500%. Reload and theme changes retain it; the percentage button resets to 100%. Glyph/key/reload acceptance passes in native, published and installed stable/dev builds on `2639e2d`; broader theme/find/collapse combinations remain pending.
- **View → Markdown extensions:** independently enable highlight (`==text==`), superscript (`^text^`), subscript (`~text~`) and underline (`++text++`). All start enabled; code stays literal. Choices are saved and applied to reloads.
- **File → Settings:** open the resizable Windows settings window with Appearance, Document themes, Editors and Advanced navigation. Content-width changes save immediately across readers; **Apply custom CSS** saves the advanced CSS draft. Closing discards unapplied CSS. Theme/editor dialogs use the settings window as their owner and keep their own Save/Cancel. Current desktop acceptance is pending.
- **View → Document theme:** choose one of Darin's 20 built-in palettes. The saved document palette is independent of the Windows shell theme; changing it retains the reading position.
- **View → Edit document theme:** edit colors, code font/size and custom CSS. Keep its name to override a built-in, or enter a new name to create a custom theme. Invalid input keeps the editor open. **Restore built-in themes** removes built-in edits after confirmation and keeps separately named custom themes.
- **File → Settings → Themes → Edit current theme:** edit all palette colors, body/heading/code fonts, body/code sizes, heading levels 1–6, line height and custom CSS. Keep its name to override a built-in, or enter a new name to create a custom theme. Invalid input keeps the editor open. **Restore built-in themes** restores and enables the original palettes, keeping edited built-ins as uniquely named custom copies.
- **Settings → Themes → Manage themes:** reorder, enable/disable, delete, create, copy or edit choices in a copied draft, including disabled themes. **Use selected** chooses and enables a default. Save updates all open readers; Cancel discards changes across editor/picker dialogs. Built-in deletion is restorable. **Next/Previous document theme** and `Ctrl+Shift+Down/Up` cycle enabled choices in saved order when reading has focus. New management acceptance is pending.
- **Settings → Themes → Import / Export:** import multiple JSON files, choosing **Overwrite**, **Keep both** or **Skip** for each conflict. Compatible newer schemas require **Import anyway** confirmation. Invalid/oversized files are rejected independently. Export any visible theme, including disabled choices, to the selected JSON destination; version and metadata are retained. The manager can also import/export its copied draft. New transfer acceptance is pending.
- **View → Table of contents:** show the heading sidebar, choose levels 1–6, and drag its divider to resize. Select a heading to jump there; collapse a selected section or all sections, then expand when ready. Matching section state survives reloads and document theme changes.
- **File → Configure editor / Open in editor:** save named installed `.exe` editors, add/remove or move them in the list, and choose whether each opens the document or its folder. Save commits the list; Cancel discards changes. One editor has a direct named action; several offer a submenu in saved order. A missing editor or document reports a status message; the viewer remains open. Named list/folder/cancel/removal acceptance passes in native, published and both installed channels on `2639e2d`.
- **Settings → Editors / File → Open in editor:** save named installed `.exe` editors, add/remove or move them in the list, and choose whether each opens the document or its folder. Save commits and refreshes all open readers; Cancel discards changes. One editor has a direct named action; several offer a submenu in saved order. A missing editor or document reports a status message; the viewer remains open. Named list/folder/cancel/removal acceptance passes in native, published and both installed channels on `2639e2d`.
- **Edit → Find / Ctrl+F:** search the rendered document and literal source, with one occurrence count and wrapping **Next/Previous** controls. Enter moves forward, Shift+Enter moves back, and Escape closes find. Search temporarily reveals collapsed sections; closing it restores prior choices and retains explicit section commands made during search.
- **Reading keys:** `j/k` move a line; `Ctrl+D/U` move half a viewport; `gg/G` go to the beginning/end; `]]/[[` visit visible headings; `/` opens native find and `n/N` advance/reverse its matches. Escape closes find. Prefixes expire after 500 ms; typing in find/theme/editor controls retains normal behavior. Windows `Ctrl+F` opens find; `Ctrl+B` moves back a viewport. Physical-key acceptance has passed in hosted native, published and installed builds.
- **View → Markdown source:** show the literal, read-only snapshot beside the reader, including frontmatter and fenced code. Visibility and preferred divider width persist; saves refresh both surfaces. Scrolling follows the corresponding heading positions in either pane. Section collapse retains its literal heading and replaces the hidden source body with an ellipsis; nested choices remain independent. Find temporarily expands the source, then restores prior folds on close. Actual bidirectional heading/wheel/focus/fold/find/reload acceptance passes in native, published and both installed channels on `2639e2d`; raw-heading parity audit remains open.
- **View → Markdown source:** show the literal, read-only snapshot beside the reader, including frontmatter and fenced code. Visibility persists; every window and reopening starts at 50/50. Drag the divider left/right, or focus it and use Left/Right arrows; Home restores equal panes. The outline is excluded from the split, and saves refresh both surfaces. New proportional/keyboard layout acceptance is pending. Scrolling follows the corresponding heading positions in either pane. Section collapse retains its literal heading and replaces the hidden source body with an ellipsis; nested choices remain independent. Find temporarily expands the source, then restores prior folds on close. Actual bidirectional heading/wheel/focus/fold/find/reload acceptance passes in native, published and both installed channels on `2639e2d`; raw-heading parity audit remains open.
- **View → Frontmatter:** hide leading YAML by default, show an escaped metadata table, or render the block as Markdown. The mode is saved and applied to reloads.
- **View:** choose the light, dark, or system shell theme; turn the startup splash and the
startup update check on or off. Choices are saved immediately.
- **Help → Activity log:** a live view of the app's log, with an errors-only filter and a
shortcut to the log folder.
- **Help → Check for updates** and **Help → About**.
- **Help → View on GitHub / Submit an issue… / Community support…:** source, bug/feature templates and Discussions for the Windows application.
- **Help → Check for updates** and **Help → About**; About → Contributions credits Darin's original Mac app with repository, website and license links; Components links every restored application dependency, with development/test tools acknowledged separately.
Settings and logs live in `%LOCALAPPDATA%\markdownviewr-windows`. Set the data-folder environment
variable the app reports in its log (the app id in capitals, plus `_DATA_DIR`) to use a
@@ -106,3 +116,15 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
[MIT](LICENSE). Copyright (c) 2026 James Maes.
This Windows application is based on [Darin Kelkhoff's macOS markdownViewr](https://github.com/darinkelkhoff/markdownViewr). Visit [Darin's website](https://kelkhoff.com/). The original MIT copyright/license and adapted resource notices are included in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), distributed with the app.
The current review build uses native Settings pages for long theme/editor forms,
with Back, Save and Cancel. Contents and Source start off in new windows unless
changed under Settings → Advanced; toolbar toggles affect the current reader.
Contents and Print have icon/text labels, with depth selection retained in the
Contents pane. The footer shows the loaded file's modification time, marks failed
reloads/stale files and offers Open folder. Disabled editor commands explain their
configuration state. These owner-review changes have passing local build/core/
adapter/style gates; current native/distributed acceptance remains pending.
@@ -0,0 +1,27 @@
Copyright (C) Microsoft Corporation. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* The name of Microsoft Corporation, or the names of its contributors
may not be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,85 @@
NOTICES AND INFORMATION
Do Not Translate or Localize
This software incorporates material from third parties. Microsoft makes certain
open source code available at https://3rdpartysource.microsoft.com, or you may
send a check or money order for US $5.00, including the product name, the open
source component name, and version number, to:
Source Code Compliance Team
Microsoft Corporation
One Microsoft Way
Redmond, WA 98052
USA
Notwithstanding any other terms, you may reverse engineer this software to the
extent required to debug changes to any libraries licensed under the GNU Lesser
General Public License.
----------------------------------------------------------------
Antlr3.Runtime 3.5.2-rc1 - BSD 3-Clause
[The "BSD license"]
Copyright (c) 2011 The ANTLR Project
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---------------------------------------------------------
---------------------------------------------------------
StringTemplate4 4.0.9-rc1 - BSD 3-Clause
[The "BSD license"]
Copyright (c) 2011 The ANTLR Project
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---------------------------------------------------------
@@ -0,0 +1,51 @@
MICROSOFT SOFTWARE LICENSE TERMS
Microsoft Windows ML Runtime
________________________________________
IF YOU LIVE IN (OR ARE A BUSINESS WITH A PRINCIPAL PLACE OF BUSINESS IN) THE UNITED STATES, PLEASE READ THE “BINDING ARBITRATION AND CLASS ACTION WAIVER” SECTION BELOW. IT AFFECTS HOW DISPUTES ARE RESOLVED.
________________________________________
These license terms are an agreement between you and Microsoft Corporation (or one of its affiliates). They apply to the software named above and any Microsoft services or software updates (except to the extent such services or updates are accompanied by new or additional terms, in which case those different terms apply prospectively and do not alter your or Microsoft’s rights relating to pre-updated software or services). IF YOU COMPLY WITH THESE LICENSE TERMS, YOU HAVE THE RIGHTS BELOW. BY USING THE SOFTWARE, YOU ACCEPT THESE TERMS.
1. INSTALLATION AND USE RIGHTS.
a) General. Subject to the terms of this agreement, you may install and use any number of copies of the software to develop and test your applications, solely for use on Windows. When building Generative AI applications follow the guidelines in https://learn.microsoft.com/windows/ai/rai.
b) Included Microsoft Applications. The software may include other Microsoft applications. These license terms apply to those included applications, if any, unless other license terms are provided with the other Microsoft applications.
c) Microsoft Platforms. The software may include components from Microsoft Windows. These components are governed by separate agreements and their own product support policies, as described in the license terms found in the installation directory for that component or in the “Licenses” folder accompanying the software.
d) Third Party Materials. The software may use or include source code, object code, or data available under an open source or open data license (“Open Source Materials”) or a proprietary license from a third party (with Open Source Materials, collectively, “Third Party Materials”) that are licensed under the specific license terms applicable to the Third Party Materials (an “Third Party License Agreement”). To the extent that any Third Party License Agreement requires that the corresponding Third Party Materials be licensed under terms that are different from the terms set forth in this Agreement, then such Third Party Materials are licensed under the terms of that Third Party License Agreement and the other terms and conditions in this Agreement will not apply with respect to those Third Party Materials. You (a) acknowledge that any such Third Party License Agreement is solely between Licensee and the applicable licensor of the Third Party Materials, and (b) will comply with the terms of any such Third Party License Agreement. Without limitation to the foregoing, the licenses referenced on this website (https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers) are deemed to be “Third Party Materials.”
2. DATA.
a) Data Collection. The software may collect information about you and your use of the software, and send that to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may opt-out of many of these scenarios, but not all, as described in the product documentation. There are also some features in the software that may enable you to collect data from users of your applications. If you use these features to enable data collection in your applications, you must comply with applicable law, including providing appropriate notices to users of your applications. You can learn more about data collection and use in the help documentation and the privacy statement at https://aka.ms/privacy. Your use of the software operates as your consent to these practices.
b) Processing of Personal Data. To the extent Microsoft is a processor or subprocessor of personal data in connection with the software, Microsoft makes the commitments in the European Union General Data Protection Regulation Terms of the Online Services Terms to all customers effective May 25, 2018, at https://docs.microsoft.com/en-us/legal/gdpr.
3. DISTRIBUTABLE CODE. The software may contain code you are permitted to distribute (i.e. make available for third parties) in applications you develop, as described in this Section.
a) Distribution Rights. The code and test files described below are distributable if included with the software.
i. Any files that are binplaced with your application by the WindowsAppSDK NuGet package are, by definition, permitted to be redistributed. This applies to both framework package dependent and self-contained deployments.
ii. Image Library. You may copy and distribute images, graphics, and animations in the Image Library as described in the software documentation; and
iii. Third Party Distribution. You may permit distributors of your applications to copy and distribute any of this distributable code you elect to distribute with your applications.
b) Distribution Requirements. For any code you distribute, you must:
i. add significant primary functionality to it in your applications;
ii. require distributors and external end users to agree to terms that protect it and Microsoft at least as much as this agreement; and
iii. indemnify, defend, and hold harmless Microsoft from any claims, including attorneys’ fees, related to the distribution or use of your applications, except to the extent that any claim is based solely on the unmodified distributable code.
c) Distribution Restrictions. You may not:
i. use Microsoft’s trademarks or trade dress in your application in any way that suggests your application comes from or is endorsed by Microsoft; or
ii. modify or distribute the source code of any distributable code so that any part of it becomes subject to any license that requires that the distributable code, any other part of the software, or any of Microsoft’s other intellectual property be disclosed or distributed in source code form, or that others have the right to modify it.
4. SCOPE OF LICENSE. The software is licensed, not sold. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you will not (and have no right to):
a) work around any technical limitations in the software that only allow you to use it in certain ways;
b) reverse engineer, decompile or disassemble the software, or otherwise attempt to derive the source code for the software, except and to the extent required by third party licensing terms governing use of certain open source components that may be included in the software;
c) remove, minimize, block, or modify any notices of Microsoft or its suppliers in the software;
d) use the software in any way that is against the law or to create or propagate malware; or
e) share, publish, distribute, or lease the software (except for any distributable code, subject to the terms above), provide the software as a stand-alone offering for others to use, or transfer the software or this agreement to any third party.
5. EXPORT RESTRICTIONS. You must comply with all domestic and international export laws and regulations that apply to the software, which include restrictions on destinations, end users, and end use. For further information on export restrictions, visit https://aka.ms/exporting.
6. SUPPORT SERVICES. Microsoft is not obligated under this agreement to provide any support services for the software. Any support provided is “as is”, “with all faults”, and without warranty of any kind.
7. UPDATES. The software may periodically check for updates, and download and install them for you. You may obtain updates only from Microsoft or authorized sources. Microsoft may need to update your system to provide you with updates. You agree to receive these automatic updates without any additional notice. Updates may not include or support all existing software features, services, or peripheral devices.
8. BINDING ARBITRATION AND CLASS ACTION WAIVER. This Section applies if you live in (or, if a business, your principal place of business is in) the United States. If you and Microsoft have a dispute, you and Microsoft agree to try for 60 days to resolve it informally. If you and Microsoft can’t, you and Microsoft agree to binding individual arbitration before the American Arbitration Association under the Federal Arbitration Act (“FAA”), and not to sue in court in front of a judge or jury. Instead, a neutral arbitrator will decide. Class action lawsuits, class-wide arbitrations, private attorney-general actions, and any other proceeding where someone acts in a representative capacity are not allowed; nor is combining individual proceedings without the consent of all parties. The complete Arbitration Agreement contains more terms and is at https://aka.ms/arb-agreement-4. You and Microsoft agree to these terms.
9. ENTIRE AGREEMENT. This agreement, and any other terms Microsoft may provide for supplements, updates, or third-party applications, is the entire agreement for the software.
10. APPLICABLE LAW AND PLACE TO RESOLVE DISPUTES. If you acquired the software in the United States or Canada, the laws of the state or province where you live (or, if a business, where your principal place of business is located) govern the interpretation of this agreement, claims for its breach, and all other claims (including consumer protection, unfair competition, and tort claims), regardless of conflict of laws principles, except that the FAA governs everything related to arbitration. If you acquired the software in any other country, its laws apply, except that the FAA governs everything related to arbitration. If U.S. federal jurisdiction exists, you and Microsoft consent to exclusive jurisdiction and venue in the federal court in King County, Washington for all disputes heard in court (excluding arbitration). If not, you and Microsoft consent to exclusive jurisdiction and venue in the Superior Court of King County, Washington for all disputes heard in court (excluding arbitration).
11. CONSUMER RIGHTS; REGIONAL VARIATIONS. This agreement describes certain legal rights. You may have other rights, including consumer rights, under the laws of your state or country. Separate and apart from your relationship with Microsoft, you may also have rights with respect to the party from which you acquired the software. This agreement does not change those other rights if the laws of your state or country do not permit it to do so. For example, if you acquired the software in one of the below regions, or mandatory country law applies, then the following provisions apply to you:
a) Australia. You have statutory guarantees under the Australian Consumer Law and nothing in this agreement is intended to affect those rights.
b) Canada. If you acquired this software in Canada, you may stop receiving updates by turning off the automatic update feature, disconnecting your device from the Internet (if and when you re-connect to the Internet, however, the software will resume checking for and installing updates), or uninstalling the software. The product documentation, if any, may also specify how to turn off updates for your specific device or software.
c) Germany and Austria.
i. Warranty. The properly licensed software will perform substantially as described in any Microsoft materials that accompany the software. However, Microsoft gives no contractual guarantee in relation to the licensed software.
ii. Limitation of Liability. In case of intentional conduct, gross negligence, claims based on the Product Liability Act, as well as, in case of death or personal or physical injury, Microsoft is liable according to the statutory law.
Subject to the foregoing clause ii., Microsoft will only be liable for slight negligence if Microsoft is in breach of such material contractual obligations, the fulfillment of which facilitate the due performance of this agreement, the breach of which would endanger the purpose of this agreement and the compliance with which a party may constantly trust in (so-called "cardinal obligations"). In other cases of slight negligence, Microsoft will not be liable for slight negligence.
12. DISCLAIMER OF WARRANTY. THE SOFTWARE IS LICENSED “AS IS.” YOU BEAR THE RISK OF USING IT. MICROSOFT GIVES NO EXPRESS WARRANTIES, GUARANTEES, OR CONDITIONS. TO THE EXTENT PERMITTED UNDER APPLICABLE LAWS, MICROSOFT EXCLUDES ALL IMPLIED WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
13. LIMITATION ON AND EXCLUSION OF DAMAGES. IF YOU HAVE ANY BASIS FOR RECOVERING DAMAGES DESPITE THE PRECEDING DISCLAIMER OF WARRANTY, YOU CAN RECOVER FROM MICROSOFT AND ITS SUPPLIERS ONLY DIRECT DAMAGES UP TO U.S. $5.00. YOU CANNOT RECOVER ANY OTHER DAMAGES, INCLUDING CONSEQUENTIAL, LOST PROFITS, SPECIAL, INDIRECT, OR INCIDENTAL DAMAGES.
This limitation applies to (a) anything related to the software, services, content (including code) on third party Internet sites, or third party applications; and (b) claims for breach of contract, warranty, guarantee, or condition; strict liability, negligence, or other tort; or any other claim; in each case to the extent permitted by applicable law.
It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your state, province, or country may not allow the exclusion or limitation of incidental, consequential, or other damages.
@@ -0,0 +1,92 @@
MICROSOFT SOFTWARE LICENSE TERMS
MICROSOFT WINDOWS APP SDK
________________________________________
IF YOU LIVE IN (OR ARE A BUSINESS WITH A PRINCIPAL PLACE OF BUSINESS IN) THE UNITED STATES, PLEASE READ THE “BINDING ARBITRATION AND CLASS ACTION WAIVER” SECTION BELOW. IT AFFECTS HOW DISPUTES ARE RESOLVED.
________________________________________
These license terms are an agreement between you and Microsoft Corporation (or one of its affiliates). They apply to the software named above and any Microsoft services or software updates (except to the extent such services or updates are accompanied by new or additional terms, in which case those different terms apply prospectively and do not alter your or Microsoft’s rights relating to pre-updated software or services). IF YOU COMPLY WITH THESE LICENSE TERMS, YOU HAVE THE RIGHTS BELOW. BY USING THE SOFTWARE, YOU ACCEPT THESE TERMS.
1. INSTALLATION AND USE RIGHTS.
a) General. Subject to the terms of this agreement, you may install and use any number of copies of the software to develop and test your applications, solely for use on Windows. When building Generative AI applications follow the guidelines in https://learn.microsoft.com/windows/ai/rai.
b) Included Microsoft Applications. The software may include other Microsoft applications. These license terms apply to those included applications, if any, unless other license terms are provided with the other Microsoft applications.
c) Microsoft Platforms. The software may include components from Microsoft Windows. These components are governed by separate agreements and their own product support policies, as described in the license terms found in the installation directory for that component or in the “Licenses” folder accompanying the software.
2. DATA.
a) Data Collection. The software may collect information about you and your use of the software, and send that to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may opt-out of many of these scenarios, but not all, as described in the product documentation. There are also some features in the software that may enable you to collect data from users of your applications. If you use these features to enable data collection in your applications, you must comply with applicable law, including providing appropriate notices to users of your applications. You can learn more about data collection and use in the help documentation and the privacy statement at https://aka.ms/privacy. Your use of the software operates as your consent to these practices.
b) Processing of Personal Data. To the extent Microsoft is a processor or subprocessor of personal data in connection with the software, Microsoft makes the commitments in the European Union General Data Protection Regulation Terms of the Online Services Terms to all customers effective May 25, 2018, at https://docs.microsoft.com/en-us/legal/gdpr.
3. DISTRIBUTABLE CODE. The software may contain code you are permitted to distribute (i.e. make available for third parties) in applications you develop, as described in this Section.
a) Distribution Rights. The code and test files described below are distributable if included with the software.
i. Any files that are binplaced with your application by the WindowsAppSDK NuGet package are, by definition, permitted to be redistributed. This applies to both framework package dependent and self-contained deployments.
ii. Image Library. You may copy and distribute images, graphics, and animations in the Image Library as described in the software documentation; and
iii. Third Party Distribution. You may permit distributors of your applications to copy and distribute any of this distributable code you elect to distribute with your applications.
b) Distribution Requirements. For any code you distribute, you must:
i. add significant primary functionality to it in your applications;
ii. require distributors and external end users to agree to terms that protect it and Microsoft at least as much as this agreement; and
iii. indemnify, defend, and hold harmless Microsoft from any claims, including attorneys’ fees, related to the distribution or use of your applications, except to the extent that any claim is based solely on the unmodified distributable code.
c) Distribution Restrictions. You may not:
i. use Microsoft’s trademarks or trade dress in your application in any way that suggests your application comes from or is endorsed by Microsoft; or
ii. modify or distribute the source code of any distributable code so that any part of it becomes subject to any license that requires that the distributable code, any other part of the software, or any of Microsoft’s other intellectual property be disclosed or distributed in source code form, or that others have the right to modify it.
4. SCOPE OF LICENSE. The software is licensed, not sold. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you will not (and have no right to):
a) work around any technical limitations in the software that only allow you to use it in certain ways;
b) reverse engineer, decompile or disassemble the software, or otherwise attempt to derive the source code for the software, except and to the extent required by third party licensing terms governing use of certain open source components that may be included in the software;
c) remove, minimize, block, or modify any notices of Microsoft or its suppliers in the software;
d) use the software in any way that is against the law or to create or propagate malware; or
e) share, publish, distribute, or lease the software (except for any distributable code, subject to the terms above), provide the software as a stand-alone offering for others to use, or transfer the software or this agreement to any third party.
5. EXPORT RESTRICTIONS. You must comply with all domestic and international export laws and regulations that apply to the software, which include restrictions on destinations, end users, and end use. For further information on export restrictions, visit https://aka.ms/exporting.
6. SUPPORT SERVICES. Microsoft is not obligated under this agreement to provide any support services for the software. Any support provided is “as is”, “with all faults”, and without warranty of any kind.
7. UPDATES. The software may periodically check for updates, and download and install them for you. You may obtain updates only from Microsoft or authorized sources. Microsoft may need to update your system to provide you with updates. You agree to receive these automatic updates without any additional notice. Updates may not include or support all existing software features, services, or peripheral devices.
8. BINDING ARBITRATION AND CLASS ACTION WAIVER. This Section applies if you live in (or, if a business, your principal place of business is in) the United States. If you and Microsoft have a dispute, you and Microsoft agree to try for 60 days to resolve it informally. If you and Microsoft can’t, you and Microsoft agree to binding individual arbitration before the American Arbitration Association under the Federal Arbitration Act (“FAA”), and not to sue in court in front of a judge or jury. Instead, a neutral arbitrator will decide. Class action lawsuits, class-wide arbitrations, private attorney-general actions, and any other proceeding where someone acts in a representative capacity are not allowed; nor is combining individual proceedings without the consent of all parties. The complete Arbitration Agreement contains more terms and is at https://aka.ms/arb-agreement-4. You and Microsoft agree to these terms.
9. ENTIRE AGREEMENT. This agreement, and any other terms Microsoft may provide for supplements, updates, or third-party applications, is the entire agreement for the software.
10. APPLICABLE LAW AND PLACE TO RESOLVE DISPUTES. If you acquired the software in the United States or Canada, the laws of the state or province where you live (or, if a business, where your principal place of business is located) govern the interpretation of this agreement, claims for its breach, and all other claims (including consumer protection, unfair competition, and tort claims), regardless of conflict of laws principles, except that the FAA governs everything related to arbitration. If you acquired the software in any other country, its laws apply, except that the FAA governs everything related to arbitration. If U.S. federal jurisdiction exists, you and Microsoft consent to exclusive jurisdiction and venue in the federal court in King County, Washington for all disputes heard in court (excluding arbitration). If not, you and Microsoft consent to exclusive jurisdiction and venue in the Superior Court of King County, Washington for all disputes heard in court (excluding arbitration).
11. CONSUMER RIGHTS; REGIONAL VARIATIONS. This agreement describes certain legal rights. You may have other rights, including consumer rights, under the laws of your state or country. Separate and apart from your relationship with Microsoft, you may also have rights with respect to the party from which you acquired the software. This agreement does not change those other rights if the laws of your state or country do not permit it to do so. For example, if you acquired the software in one of the below regions, or mandatory country law applies, then the following provisions apply to you:
a) Australia. You have statutory guarantees under the Australian Consumer Law and nothing in this agreement is intended to affect those rights.
b) Canada. If you acquired this software in Canada, you may stop receiving updates by turning off the automatic update feature, disconnecting your device from the Internet (if and when you re-connect to the Internet, however, the software will resume checking for and installing updates), or uninstalling the software. The product documentation, if any, may also specify how to turn off updates for your specific device or software.
c) Germany and Austria.
i. Warranty. The properly licensed software will perform substantially as described in any Microsoft materials that accompany the software. However, Microsoft gives no contractual guarantee in relation to the licensed software.
ii. Limitation of Liability. In case of intentional conduct, gross negligence, claims based on the Product Liability Act, as well as, in case of death or personal or physical injury, Microsoft is liable according to the statutory law.
Subject to the foregoing clause ii., Microsoft will only be liable for slight negligence if Microsoft is in breach of such material contractual obligations, the fulfillment of which facilitate the due performance of this agreement, the breach of which would endanger the purpose of this agreement and the compliance with which a party may constantly trust in (so-called "cardinal obligations"). In other cases of slight negligence, Microsoft will not be liable for slight negligence.
12. DISCLAIMER OF WARRANTY. THE SOFTWARE IS LICENSED “AS IS.” YOU BEAR THE RISK OF USING IT. MICROSOFT GIVES NO EXPRESS WARRANTIES, GUARANTEES, OR CONDITIONS. TO THE EXTENT PERMITTED UNDER APPLICABLE LAWS, MICROSOFT EXCLUDES ALL IMPLIED WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
13. LIMITATION ON AND EXCLUSION OF DAMAGES. IF YOU HAVE ANY BASIS FOR RECOVERING DAMAGES DESPITE THE PRECEDING DISCLAIMER OF WARRANTY, YOU CAN RECOVER FROM MICROSOFT AND ITS SUPPLIERS ONLY DIRECT DAMAGES UP TO U.S. $5.00. YOU CANNOT RECOVER ANY OTHER DAMAGES, INCLUDING CONSEQUENTIAL, LOST PROFITS, SPECIAL, INDIRECT, OR INCIDENTAL DAMAGES.
This limitation applies to (a) anything related to the software, services, content (including code) on third party Internet sites, or third party applications; and (b) claims for breach of contract, warranty, guarantee, or condition; strict liability, negligence, or other tort; or any other claim; in each case to the extent permitted by applicable law.
It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your state, province, or country may not allow the exclusion or limitation of incidental, consequential, or other damages.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
MICROSOFT SOFTWARE LICENSE TERMS
1. MICROSOFT WINDOWS APP SDK
2. WINDOWS MACHINE LEARNING (ML)
________________________________________
IF YOU LIVE IN (OR ARE A BUSINESS WITH A PRINCIPAL PLACE OF BUSINESS IN) THE UNITED STATES, PLEASE READ THE “BINDING ARBITRATION AND CLASS ACTION WAIVER” SECTION BELOW. IT AFFECTS HOW DISPUTES ARE RESOLVED.
________________________________________
These license terms are an agreement between you and Microsoft Corporation (or one of its affiliates). They apply to the software named above and any Microsoft services or software updates (except to the extent such services or updates are accompanied by new or additional terms, in which case those different terms apply prospectively and do not alter your or Microsoft’s rights relating to pre-updated software or services). IF YOU COMPLY WITH THESE LICENSE TERMS, YOU HAVE THE RIGHTS BELOW. BY USING THE SOFTWARE, YOU ACCEPT THESE TERMS.
1. INSTALLATION AND USE RIGHTS.
a) General. Subject to the terms of this agreement, you may install and use any number of copies of the software to develop and test your applications, solely for use on Windows. When building Generative AI applications follow the guidelines in https://learn.microsoft.com/windows/ai/rai.
b) Included Microsoft Applications. The software may include other Microsoft applications. These license terms apply to those included applications, if any, unless other license terms are provided with the other Microsoft applications.
c) Microsoft Platforms. The software may include components from Microsoft Windows. These components are governed by separate agreements and their own product support policies, as described in the license terms found in the installation directory for that component or in the “Licenses” folder accompanying the software.
d) Third Party Materials. The software may use or include source code, object code, or data available under an open source or open data license (“Open Source Materials”) or a proprietary license from a third party (with Open Source Materials, collectively, “Third Party Materials”) that are licensed under the specific license terms applicable to the Third Party Materials (an “Third Party License Agreement”). To the extent that any Third Party License Agreement requires that the corresponding Third Party Materials be licensed under terms that are different from the terms set forth in this Agreement, then such Third Party Materials are licensed under the terms of that Third Party License Agreement and the other terms and conditions in this Agreement will not apply with respect to those Third Party Materials. You (a) acknowledge that any such Third Party License Agreement is solely between Licensee and the applicable licensor of the Third Party Materials, and (b) will comply with the terms of any such Third Party License Agreement. Without limitation to the foregoing, the licenses referenced on this website (https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers) are deemed to be “Third Party Materials.”
2. DATA.
a) Data Collection. The software may collect information about you and your use of the software, and send that to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may opt-out of many of these scenarios, but not all, as described in the product documentation. There are also some features in the software that may enable you to collect data from users of your applications. If you use these features to enable data collection in your applications, you must comply with applicable law, including providing appropriate notices to users of your applications. You can learn more about data collection and use in the help documentation and the privacy statement at https://aka.ms/privacy. Your use of the software operates as your consent to these practices.
b) Processing of Personal Data. To the extent Microsoft is a processor or subprocessor of personal data in connection with the software, Microsoft makes the commitments in the European Union General Data Protection Regulation Terms of the Online Services Terms to all customers effective May 25, 2018, at https://docs.microsoft.com/en-us/legal/gdpr.
3. DISTRIBUTABLE CODE. The software may contain code you are permitted to distribute (i.e. make available for third parties) in applications you develop, as described in this Section.
a) Distribution Rights. The code and test files described below are distributable if included with the software.
i. Any files that are binplaced with your application by the WindowsAppSDK NuGet package are, by definition, permitted to be redistributed. This applies to both framework package dependent and self-contained deployments.
ii. Image Library. You may copy and distribute images, graphics, and animations in the Image Library as described in the software documentation; and
iii. Third Party Distribution. You may permit distributors of your applications to copy and distribute any of this distributable code you elect to distribute with your applications.
b) Distribution Requirements. For any code you distribute, you must:
i. add significant primary functionality to it in your applications;
ii. require distributors and external end users to agree to terms that protect it and Microsoft at least as much as this agreement; and
iii. indemnify, defend, and hold harmless Microsoft from any claims, including attorneys’ fees, related to the distribution or use of your applications, except to the extent that any claim is based solely on the unmodified distributable code.
c) Distribution Restrictions. You may not:
i. use Microsoft’s trademarks or trade dress in your application in any way that suggests your application comes from or is endorsed by Microsoft; or
ii. modify or distribute the source code of any distributable code so that any part of it becomes subject to any license that requires that the distributable code, any other part of the software, or any of Microsoft’s other intellectual property be disclosed or distributed in source code form, or that others have the right to modify it.
4. SCOPE OF LICENSE. The software is licensed, not sold. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you will not (and have no right to):
a) work around any technical limitations in the software that only allow you to use it in certain ways;
b) reverse engineer, decompile or disassemble the software, or otherwise attempt to derive the source code for the software, except and to the extent required by third party licensing terms governing use of certain open source components that may be included in the software;
c) remove, minimize, block, or modify any notices of Microsoft or its suppliers in the software;
d) use the software in any way that is against the law or to create or propagate malware; or
e) share, publish, distribute, or lease the software (except for any distributable code, subject to the terms above), provide the software as a stand-alone offering for others to use, or transfer the software or this agreement to any third party.
5. EXPORT RESTRICTIONS. You must comply with all domestic and international export laws and regulations that apply to the software, which include restrictions on destinations, end users, and end use. For further information on export restrictions, visit https://aka.ms/exporting.
6. SUPPORT SERVICES. Microsoft is not obligated under this agreement to provide any support services for the software. Any support provided is “as is”, “with all faults”, and without warranty of any kind.
7. UPDATES. The software may periodically check for updates, and download and install them for you. You may obtain updates only from Microsoft or authorized sources. Microsoft may need to update your system to provide you with updates. You agree to receive these automatic updates without any additional notice. Updates may not include or support all existing software features, services, or peripheral devices.
8. BINDING ARBITRATION AND CLASS ACTION WAIVER. This Section applies if you live in (or, if a business, your principal place of business is in) the United States. If you and Microsoft have a dispute, you and Microsoft agree to try for 60 days to resolve it informally. If you and Microsoft can’t, you and Microsoft agree to binding individual arbitration before the American Arbitration Association under the Federal Arbitration Act (“FAA”), and not to sue in court in front of a judge or jury. Instead, a neutral arbitrator will decide. Class action lawsuits, class-wide arbitrations, private attorney-general actions, and any other proceeding where someone acts in a representative capacity are not allowed; nor is combining individual proceedings without the consent of all parties. The complete Arbitration Agreement contains more terms and is at https://aka.ms/arb-agreement-4. You and Microsoft agree to these terms.
9. ENTIRE AGREEMENT. This agreement, and any other terms Microsoft may provide for supplements, updates, or third-party applications, is the entire agreement for the software.
10. APPLICABLE LAW AND PLACE TO RESOLVE DISPUTES. If you acquired the software in the United States or Canada, the laws of the state or province where you live (or, if a business, where your principal place of business is located) govern the interpretation of this agreement, claims for its breach, and all other claims (including consumer protection, unfair competition, and tort claims), regardless of conflict of laws principles, except that the FAA governs everything related to arbitration. If you acquired the software in any other country, its laws apply, except that the FAA governs everything related to arbitration. If U.S. federal jurisdiction exists, you and Microsoft consent to exclusive jurisdiction and venue in the federal court in King County, Washington for all disputes heard in court (excluding arbitration). If not, you and Microsoft consent to exclusive jurisdiction and venue in the Superior Court of King County, Washington for all disputes heard in court (excluding arbitration).
11. CONSUMER RIGHTS; REGIONAL VARIATIONS. This agreement describes certain legal rights. You may have other rights, including consumer rights, under the laws of your state or country. Separate and apart from your relationship with Microsoft, you may also have rights with respect to the party from which you acquired the software. This agreement does not change those other rights if the laws of your state or country do not permit it to do so. For example, if you acquired the software in one of the below regions, or mandatory country law applies, then the following provisions apply to you:
a) Australia. You have statutory guarantees under the Australian Consumer Law and nothing in this agreement is intended to affect those rights.
b) Canada. If you acquired this software in Canada, you may stop receiving updates by turning off the automatic update feature, disconnecting your device from the Internet (if and when you re-connect to the Internet, however, the software will resume checking for and installing updates), or uninstalling the software. The product documentation, if any, may also specify how to turn off updates for your specific device or software.
c) Germany and Austria.
i. Warranty. The properly licensed software will perform substantially as described in any Microsoft materials that accompany the software. However, Microsoft gives no contractual guarantee in relation to the licensed software.
ii. Limitation of Liability. In case of intentional conduct, gross negligence, claims based on the Product Liability Act, as well as, in case of death or personal or physical injury, Microsoft is liable according to the statutory law.
Subject to the foregoing clause ii., Microsoft will only be liable for slight negligence if Microsoft is in breach of such material contractual obligations, the fulfillment of which facilitate the due performance of this agreement, the breach of which would endanger the purpose of this agreement and the compliance with which a party may constantly trust in (so-called "cardinal obligations"). In other cases of slight negligence, Microsoft will not be liable for slight negligence.
12. DISCLAIMER OF WARRANTY. THE SOFTWARE IS LICENSED “AS IS.” YOU BEAR THE RISK OF USING IT. MICROSOFT GIVES NO EXPRESS WARRANTIES, GUARANTEES, OR CONDITIONS. TO THE EXTENT PERMITTED UNDER APPLICABLE LAWS, MICROSOFT EXCLUDES ALL IMPLIED WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
13. LIMITATION ON AND EXCLUSION OF DAMAGES. IF YOU HAVE ANY BASIS FOR RECOVERING DAMAGES DESPITE THE PRECEDING DISCLAIMER OF WARRANTY, YOU CAN RECOVER FROM MICROSOFT AND ITS SUPPLIERS ONLY DIRECT DAMAGES UP TO U.S. $5.00. YOU CANNOT RECOVER ANY OTHER DAMAGES, INCLUDING CONSEQUENTIAL, LOST PROFITS, SPECIAL, INDIRECT, OR INCIDENTAL DAMAGES.
This limitation applies to (a) anything related to the software, services, content (including code) on third party Internet sites, or third party applications; and (b) claims for breach of contract, warranty, guarantee, or condition; strict liability, negligence, or other tort; or any other claim; in each case to the extent permitted by applicable law.
It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your state, province, or country may not allow the exclusion or limitation of incidental, consequential, or other damages.
@@ -0,0 +1,51 @@
NOTICES AND INFORMATION
Do Not Translate or Localize
This software incorporates material from third parties.
Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com,
or you may send a check or money order for US $5.00, including the product name,
the open source component name, platform, and version number, to:
Source Code Compliance Team
Microsoft Corporation
One Microsoft Way
Redmond, WA 98052
USA
Notwithstanding any other terms, you may reverse engineer this software to the extent
required to debug changes to any libraries licensed under the GNU Lesser General Public License.
---------------------------------------------------------
Newtonsoft.Json 13.0.1 - MIT
Copyright James Newton-King 2008
Copyright (c) 2007 James Newton-King
Copyright (c) James Newton-King 2008
Copyright James Newton-King 2008 Json.NET
The MIT License (MIT)
Copyright (c) 2007 James Newton-King
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---------------------------------------------------------
@@ -0,0 +1,19 @@
NOTICES AND INFORMATION
Do Not Translate or Localize
This software incorporates material from third parties. Microsoft makes certain
open source code available at https://3rdpartysource.microsoft.com, or you may
send a check or money order for US $5.00, including the product name, the open
source component name, and version number, to:
Source Code Compliance Team
Microsoft Corporation
One Microsoft Way
Redmond, WA 98052
USA
Notwithstanding any other terms, you may reverse engineer this software to the
extent required to debug changes to any libraries licensed under the GNU Lesser
General Public License.
-----
@@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large. Load diff
+109 -2
View File
@@ -1,6 +1,6 @@
# Third-party rendering libraries
# Third-party notices
These notices accompany the rendering libraries shipped with MarkdownViewr.
These notices accompany the libraries and platform components used by MarkdownViewr.
## Markdig 1.4.0
@@ -110,6 +110,11 @@ SOFTWARE.
## markdownViewr resources, frontmatter and navigation behavior
This Windows application is based on **Darin Kelkhoff's macOS markdownViewr**.
Original project: [darinkelkhoff/markdownViewr](https://github.com/darinkelkhoff/markdownViewr).
Author website: [kelkhoff.com](https://kelkhoff.com/). Windows issues and support
belong to the Windows repository; this attribution does not imply endorsement.
Per-window reading zoom bounds/steps and native minus/percentage/plus behavior are
adapted from ContentView.swift and MarkdownViewrApp.swift. Font-size scaling is
adapted from ThemeManager.swift; original theme JSON and line height stay unchanged.
@@ -145,3 +150,105 @@ The print defaults, Clean Printing palette and print-media layout rules in
MIT-licensed markdownViewr (`6d08bc9`), with original/adaptation copyright headers
retained. Actual PDF acceptance uses the built-in Windows PDF API; no additional
PDF renderer/parser package is shipped.
## Complete application and tooling inventory
About → Components links each direct/transitive application dependency.
Development/test tools are acknowledged separately and are not described as
shipped application libraries. Microsoft SDK packages include their own binary
license terms; they are not all labeled MIT. Inventory below was checked against
restored package assets and packaged license metadata.
| Component | Version | Scope | Project | License |
| --- | --- | --- | --- | --- |
| AngleSharp.Css | 1.0.2 | Application | [Project](https://github.com/AngleSharp/AngleSharp.Css) | [MIT](https://licenses.nuget.org/MIT) |
| AngleSharp | 1.7.2 | Application | [Project](https://github.com/AngleSharp/AngleSharp) | [MIT](https://licenses.nuget.org/MIT) |
| HtmlSanitizer | 9.2.1039 | Application | [Project](https://github.com/mganss/HtmlSanitizer) | [MIT](https://licenses.nuget.org/MIT) |
| KofTwentyTwo.AppKit.Updates | 0.1.0 | Application | [Project](https://github.com/KofTwentyTwo/AppKit) | [MIT](https://licenses.nuget.org/MIT) |
| KofTwentyTwo.AppKit.WinUI | 0.1.0 | Application | [Project](https://github.com/KofTwentyTwo/AppKit) | [MIT](https://licenses.nuget.org/MIT) |
| KofTwentyTwo.AppKit | 0.1.0 | Application | [Project](https://github.com/KofTwentyTwo/AppKit) | [MIT](https://licenses.nuget.org/MIT) |
| Markdig | 1.4.0 | Application | [Project](https://github.com/xoofx/markdig) | [BSD-2-Clause](https://licenses.nuget.org/BSD-2-Clause) |
| Meziantou.Analyzer | 3.0.296 | Development | [Project](https://github.com/meziantou/Meziantou.Analyzer) | [MIT](https://licenses.nuget.org/MIT) |
| Microsoft.Web.WebView2 | 1.0.3719.77 | Application | [Project](https://aka.ms/webview) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.Web.WebView2/1.0.3719.77/License) |
| Microsoft.Windows.AI.MachineLearning | 2.1.74 | Application | [Project](https://github.com/microsoft/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.Windows.AI.MachineLearning/2.1.74/License) |
| Microsoft.Windows.SDK.BuildTools.MSIX | 1.7.251221100 | Development | [Project](https://aka.ms/WinSDKProjectURL) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.Windows.SDK.BuildTools.MSIX/1.7.251221100/License) |
| Microsoft.Windows.SDK.BuildTools | 10.0.28000.2705 | Development | [Project](https://aka.ms/WinSDKProjectURL) | [Package license](https://www.nuget.org/packages/Microsoft.Windows.SDK.BuildTools/10.0.28000.2705/License) |
| Microsoft.WindowsAppSDK.AI | 2.5.5 | Application | [Project](https://github.com/microsoft/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.AI/2.5.5/License) |
| Microsoft.WindowsAppSDK.Base | 2.0.4 | Application | [Project](https://github.com/microsoft/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Base/2.0.4/License) |
| Microsoft.WindowsAppSDK.DWrite | 2.1.0 | Application | [Project](https://aka.ms/WindowsAppSDK) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.DWrite/2.1.0/License) |
| Microsoft.WindowsAppSDK.Foundation | 2.3.12 | Application | [Project](https://aka.ms/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Foundation/2.3.12/License) |
| Microsoft.WindowsAppSDK.InteractiveExperiences | 2.1.9 | Application | [Project](https://aka.ms/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.InteractiveExperiences/2.1.9/License) |
| Microsoft.WindowsAppSDK.ML | 2.1.94 | Application | [Project](https://github.com/microsoft/windowsml) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.ML/2.1.94/License) |
| Microsoft.WindowsAppSDK.Runtime | 2.5.1 | Application | [Project](https://github.com/microsoft/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Runtime/2.5.1/License) |
| Microsoft.WindowsAppSDK.Search | 2.5.5 | Application | [Project](https://github.com/microsoft/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Search/2.5.5/License) |
| Microsoft.WindowsAppSDK.Widgets | 2.0.5 | Application | [Project](https://github.com/microsoft/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Widgets/2.0.5/License) |
| Microsoft.WindowsAppSDK.WinUI | 2.3.9 | Application | [Project](https://github.com/microsoft/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.WinUI/2.3.9/License) |
| Microsoft.WindowsAppSDK | 2.5.1 | Application | [Project](https://github.com/microsoft/windowsappsdk) | [Microsoft package license](https://www.nuget.org/packages/Microsoft.WindowsAppSDK/2.5.1/License) |
| System.Numerics.Tensors | 9.0.0 | Application | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| Velopack | 1.2.161 | Application | [Project](https://github.com/velopack/velopack) | [MIT](https://licenses.nuget.org/MIT) |
| Microsoft.CodeCoverage | 18.10.1 | Development | [Project](https://github.com/microsoft/vstest) | [MIT](https://licenses.nuget.org/MIT) |
| Microsoft.NET.Test.Sdk | 18.10.1 | Development | [Project](https://github.com/microsoft/vstest) | [MIT](https://licenses.nuget.org/MIT) |
| Microsoft.TestPlatform.ObjectModel | 18.10.1 | Development | [Project](https://github.com/microsoft/vstest) | [MIT](https://licenses.nuget.org/MIT) |
| Microsoft.TestPlatform.TestHost | 18.10.1 | Development | [Project](https://github.com/microsoft/vstest) | [MIT](https://licenses.nuget.org/MIT) |
| coverlet.collector | 10.1.0 | Development | [Project](https://github.com/coverlet-coverage/coverlet) | [MIT](https://licenses.nuget.org/MIT) |
| xunit.abstractions | 2.0.3 | Development | [Project](https://github.com/xunit/xunit) | [Package license](https://www.nuget.org/packages/xunit.abstractions/2.0.3/License) |
| xunit.analyzers | 1.18.0 | Development | [Project](https://github.com/xunit/xunit.analyzers) | [Apache-2.0](https://licenses.nuget.org/Apache-2.0) |
| xunit.assert | 2.9.3 | Development | [Project](https://github.com/xunit/xunit) | [Apache-2.0](https://licenses.nuget.org/Apache-2.0) |
| xunit.core | 2.9.3 | Development | [Project](https://github.com/xunit/xunit) | [Apache-2.0](https://licenses.nuget.org/Apache-2.0) |
| xunit.extensibility.core | 2.9.3 | Development | [Project](https://github.com/xunit/xunit) | [Apache-2.0](https://licenses.nuget.org/Apache-2.0) |
| xunit.extensibility.execution | 2.9.3 | Development | [Project](https://github.com/xunit/xunit) | [Apache-2.0](https://licenses.nuget.org/Apache-2.0) |
| xunit.runner.visualstudio | 4.0.0 | Development | [Project](https://github.com/xunit/visualstudio.xunit) | [Apache-2.0](https://licenses.nuget.org/Apache-2.0) |
| xunit | 2.9.3 | Development | [Project](https://github.com/xunit/xunit) | [Apache-2.0](https://licenses.nuget.org/Apache-2.0) |
| FlaUI.Core | 5.0.0 | Development | [Project](https://github.com/FlaUI/FlaUI) | [MIT](https://www.nuget.org/packages/FlaUI.Core/5.0.0/License) |
| FlaUI.UIA3 | 5.0.0 | Development | [Project](https://github.com/FlaUI/FlaUI) | [MIT](https://www.nuget.org/packages/FlaUI.UIA3/5.0.0/License) |
| Interop.UIAutomationClient | 10.19041.0 | Development | [Project](https://github.com/Roemer/UIAutomation-Interop) | [MIT](https://www.nuget.org/packages/Interop.UIAutomationClient/10.19041.0/License) |
| Microsoft.Win32.SystemEvents | 8.0.0 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| System.CodeDom | 8.0.0 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| System.Configuration.ConfigurationManager | 8.0.1 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| System.Diagnostics.EventLog | 8.0.1 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| System.Diagnostics.PerformanceCounter | 8.0.1 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| System.Drawing.Common | 8.0.10 | Development | [Project](https://github.com/dotnet/winforms) | [MIT](https://licenses.nuget.org/MIT) |
| System.Management | 8.0.0 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| System.Security.Cryptography.ProtectedData | 8.0.0 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| System.Security.Permissions | 8.0.0 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| System.Windows.Extensions | 8.0.0 | Development | [Project](https://github.com/dotnet/runtime) | [MIT](https://licenses.nuget.org/MIT) |
| .NET Runtime | 10 | Application platform | [Project](https://github.com/dotnet/runtime) | [MIT](https://github.com/dotnet/runtime/blob/main/LICENSE.TXT), [third-party notices](https://github.com/dotnet/runtime/blob/main/THIRD-PARTY-NOTICES.TXT) |
Additional packaged license and notice files are retained verbatim under
`THIRD-PARTY-LICENSES/` and copied to `ThirdPartyLicenses/` in application output.
Identical texts shared by multiple SDK packages are stored once.
- Microsoft.Web.WebView2 1.0.3719.77: [Microsoft.Web.WebView2-1.0.3719.77-LICENSE.txt](THIRD-PARTY-LICENSES/Microsoft.Web.WebView2-1.0.3719.77-LICENSE.txt)
- Microsoft.Web.WebView2 1.0.3719.77: [Microsoft.Web.WebView2-1.0.3719.77-NOTICE.txt](THIRD-PARTY-LICENSES/Microsoft.Web.WebView2-1.0.3719.77-NOTICE.txt)
- Microsoft.Windows.AI.MachineLearning 2.1.74: [Microsoft.Windows.AI.MachineLearning-2.1.74-ThirdPartyNotices.txt](THIRD-PARTY-LICENSES/Microsoft.Windows.AI.MachineLearning-2.1.74-ThirdPartyNotices.txt)
- Microsoft.Windows.AI.MachineLearning 2.1.74: [Microsoft.Windows.AI.MachineLearning-2.1.74-license.txt](THIRD-PARTY-LICENSES/Microsoft.Windows.AI.MachineLearning-2.1.74-license.txt)
- Microsoft.WindowsAppSDK 2.5.1: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK 2.5.1: [Microsoft.WindowsAppSDK.Base-2.0.4-NOTICE.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.Base-2.0.4-NOTICE.txt)
- Microsoft.WindowsAppSDK.AI 2.5.5: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.Base 2.0.4: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.Base 2.0.4: [Microsoft.WindowsAppSDK.Base-2.0.4-NOTICE.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.Base-2.0.4-NOTICE.txt)
- Microsoft.WindowsAppSDK.DWrite 2.1.0: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.Foundation 2.3.12: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.InteractiveExperiences 2.1.9: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.ML 2.1.94: [Microsoft.WindowsAppSDK.ML-2.1.94-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.ML-2.1.94-license.txt)
- Microsoft.WindowsAppSDK.Runtime 2.5.1: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.Runtime 2.5.1: [Microsoft.WindowsAppSDK.Base-2.0.4-NOTICE.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.Base-2.0.4-NOTICE.txt)
- Microsoft.WindowsAppSDK.Search 2.5.5: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.Widgets 2.0.5: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.WinUI 2.3.9: [Microsoft.WindowsAppSDK.AI-2.5.5-license.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.AI-2.5.5-license.txt)
- Microsoft.WindowsAppSDK.WinUI 2.3.9: [Microsoft.WindowsAppSDK.WinUI-2.3.9-NOTICE.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.WinUI-2.3.9-NOTICE.txt)
- Microsoft.WindowsAppSDK.WinUI 2.3.9: [Microsoft.WindowsAppSDK.WinUI-2.3.9-tools-NOTICE.txt](THIRD-PARTY-LICENSES/Microsoft.WindowsAppSDK.WinUI-2.3.9-tools-NOTICE.txt)
- System.Numerics.Tensors 9.0.0: [System.Numerics.Tensors-9.0.0-LICENSE.TXT](THIRD-PARTY-LICENSES/System.Numerics.Tensors-9.0.0-LICENSE.TXT)
- System.Numerics.Tensors 9.0.0: [System.Numerics.Tensors-9.0.0-THIRD-PARTY-NOTICES.TXT](THIRD-PARTY-LICENSES/System.Numerics.Tensors-9.0.0-THIRD-PARTY-NOTICES.TXT)
## Reference document stylesheet and containers
The embedded `Themes/Document.css` and document container/navigation adaptations
are based on Darin Kelkhoff's MIT-licensed markdownViewr at commit
`6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021`. The original document rules, variable
aliases and `#content` / `#content-inner` structure are retained with Windows
sanitization, scrolling and print integration. Source files retain Darin's
copyright and MIT identifiers; the full original MIT notice above accompanies
the application. Original project: <https://github.com/darinkelkhoff/markdownViewr>.
Author website: <https://kelkhoff.com/>.
+14 -3
View File
@@ -6,8 +6,18 @@ The reader implements passive GFM, bounded local images, save/replacement/deleti
Full `bf4fcdc` / CI38128311242 passes54 native/published and53 installed stable/dev tests, zero skips: all5 print cases, real font/padding/width/border/background/image output and preserved source/find/reading state. Twenty-four generated PDFs and both actual AppKit apply/restart probes are retained; fixtures remain unsigned. Multiple native document windows, modern multiple-file picking, bounded private recent files and sibling-safe settings merging are implemented locally with376 core tests/100% measured lines; hosted lifecycle acceptance remains pending. Windows associations/activation and the remaining reference features below are still required. This is not a completed port.
Windows file registration and activation are now implemented locally (ADR-0005):
installed channel/executable identities, preserved defaults, SDK primary selection
and current-user bounded literal-path delivery. Nine real isolated boundary tests
pass, and the corrected entry point publishes/packs with all 543 published files verified
byte-for-byte in both archives. Real Explorer/installed lifecycle and two prepared
secondary-process/profile/duplicate/watcher cases remain unverified; this does
not complete the port or establish production signatures.
## Reference and feature scope
Credit the original macOS app and Darin Kelkhoff in About and public documentation, linking to [his repository](https://github.com/darinkelkhoff/markdownViewr) and [website](https://kelkhoff.com/); retain distributed MIT notices. Windows issues/support must remain in the Windows repository.
Reference source inspected at commit `6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021`. Preserve Darin's MIT copyright/license when adapting his rendering resources or other code. macOS SwiftUI/WebKit and Sparkle integrations require Windows equivalents.
| Area | Required Windows behavior |
@@ -17,13 +27,14 @@ Reference source inspected at commit `6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021`.
| Reload | Watch saves, including atomic replacements; debounce changes, preserve scroll, report deletion/recovery |
| Themes | 20 built-ins; editable body/heading/code fonts, sizes and palette; theme/global CSS; content width; theme order, enable/disable/delete/restore and cycling; JSON import/export with overwrite/keep-both/skip conflicts; persistent settings |
| Navigation | Resizable table of contents, depth control, heading jumps, collapsible sections and per-window font zoom (30–500%, Ctrl+plus/minus/0) |
| Source | Optional source pane, synchronized headings/scroll and collapse state |
| Source | Optional source pane, synchronized headings/scroll and collapse state; theme-aware syntax highlighting; draggable split opening at50/50 (#16/#17) |
| Find | Match counts, next/previous matches, search in both panes and vim-style shortcuts |
| Keys | j/k, Ctrl+d/u, gg/G, ]]/[[, /, n/N; Windows Ctrl-based equivalents for native commands |
| Frontmatter | Hide YAML, display metadata or render it as Markdown |
| Editor | Configurable external editor with safe literal file arguments |
| Print | Native printing, theme/layout options and restoration of screen state afterwards |
| Foundation | AppKit About, activity log, theme/settings, crash handling, stable/dev updates, tests and release controls |
| Print | Native printing, theme/layout options, readable PDF export with optional PDF/folder opening, and restoration of screen state afterwards (#22/#23) |
| Toolbar/settings | Mac toolbar action parity (#18), a dedicated native Settings dialog for theme/appearance configuration (#21) |
| Foundation | AppKit About with original-author credit, repository/issues/community Help links, activity log, crash handling, stable/dev updates, tests and release controls |
## Proposed implementation for discussion
+436 -1
View File
@@ -2,15 +2,450 @@
Updated: 2026-10-11
## Current owner review launch - 2026-10-11
Owner-requested latest self-contained build is open from
artifacts/owner-1.0-review-publish/MarkdownViewr.Windows.exe, PID55472,
with title Review-1.0.md — MarkdownViewr. Separate persistent review profile:
artifacts/owner-1.0-review-profile. Launch metadata is retained in
artifacts/owner-1.0-review-launch.json. Do not overwrite this publish output or
interrupt the owner preview while reviewing. Initial process/title/Responding
readbacks succeeded; no physical desktop input or visual acceptance performed.
Current local gates: zero-warning Release build,582 core tests,100% measured
core line coverage,25 adapter tests and full formatting verification pass.
Default-file registration has five passing isolated registry tests in
artifacts/release-review-adapters/release-review-adapters.trx. Install/update/
uninstall hooks and Settings → Advanced → Choose default file types are
implemented. User chooses defaults in Windows; portable previews do not register.
Actual installed chooser, Explorer double-click and lifecycle acceptance remain
pending under issue #14. Source-hover regression and signed release acceptance
remain open; latest review launch does not establish release readiness.
## Active work and authority
Build the full Windows counterpart to Darin's markdownViewr with published
AppKit 0.1.0. Identity: MarkdownViewr / MarkdownViewr.Windows /
markdownviewr-windows. PRODUCT.md remains the full scope. Topic feat/windows-viewer,
draft PR #3. Owner authorizes signed/DCO commits, pushes, configuration and tickets.
No merges, tags/releases, new Azure sign-ins or fabricated human review.
The owner now authorizes completion and stable1.0/dev publication following gclo.
Preserve review/security/signing gates; no fabricated human review or new Azure sign-ins.
Hosted Windows UI tests are authorized; no current local desktop reservation.
## Active 1.0 and dev release work - 2026-10-11
The owner cleared the prior goal and explicitly resumed work to finish and release
stable1.0 and dev versions following gclo. The paused queue below is historical,
not a current stop instruction. Full PRODUCT scope and owner feedback remain required.
Plan: (1) fix owner regressions and UI requirements; (2) pass core/build/style plus
actual current native/published/installed stable/dev acceptance; (3) verify and
configure repository-specific Azure OIDC/signing and gclo-style isolated editions;
(4) complete review/checks, signed commits/push and publish verified releases.
Commit authentication retry succeeded. Commit 841798c is signed and DCO-signed;
it preserves the original four-file native-window correction and passed hooks.
The remaining implementation is being committed after local verification.
Implemented locally: removed toolbar depth duplication, labeled Contents/Print;
explicit Contents/Source new-window defaults independent of local toggles;
open-source-only About view with full license audit catalog preserved; accurate
disabled-editor native tooltip; loaded-handle timestamp/stale/folder footer.
Long theme/editor forms now use native Settings pages, Back and fixed Save/Cancel;
theme management uses adaptive list/detail layout and preserves copied draft loops,
import/export/newer-schema conflicts and validation. First full build passed with
zero warnings/errors. New default-persistence tests and native hover regression
are prepared; source highlighting itself is deliberately not changed before a
real red-capable pointer test runs.
gclo's existing release environment confirms tenant4ebeb0ad-5782-487a-a1b4-5080255774cf,
kof22signing/releases/EastUS and publisher James Maes, but these are reference
configuration, not actual Azure resource verification for this repository.
MarkdownViewr release variables are absent. Current Azure CLI has no login; no
new sign-in initiated. Official desktop-tool initialization reports missing
native pipe; no local GUI fallback/input performed. Hosted testing follows push.
## Historical paused owner review - 2026-10-11
Historical checkpoint only: the owner paused work at this point and subsequently
resumed the full release goal recorded above. The following text records that pause.
Earlier signed-commit/push authorization remains bounded by the pending
1Password-ready reply. The original staged four-file patch remains82+/10-.
New tickets: #26 right footer timestamp/stale indicator/Open folder; #27 source
highlighting disappears permanently after pointer hover (owner report, not yet
reproduced); #28 disabled Open in editor hover explanation; #29 gclo-family icon
redesign. Existing #18 now requires removing only the toolbar H1-H6 dropdown and
consistent Contents/Print icon+text labels. #21 records owner rejection of the
crowded inner modals: propose adaptive Settings list/detail pages, brief native
confirmations, preserved theme/CSS drafts and native pickers. Add explicit new-window
Contents/Source defaults, both off; current-window toggles must not rewrite them.
Issue #24 now calls for actual shipped open-source credits/project/license links only
in Components, with Darin in Contributions and all required notices preserved.
These requirements are queued, not implemented. Source unit/color coverage does
not disprove the owner-reported pointer regression. Do not claim an accepted
toolbar/settings layout or full application completion.
## Reference document CSS checkpoint - 2026-10-11
Before the pause, the document-only stylesheet was ported from Darin's pinned
6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021 template, retaining MIT headers.
Embedded Themes/Document.css and DocumentPage provide #content/#content-inner;
legacy custom variables remain alongside original aliases. Reading width applies
to the inner container after custom CSS. Browser scrolling/navigation/source
coordinates now use #content. Sanitization retains only bounded code-language
and fixed document classes; metadata keeps accessible headers. Print resets
scrolling containers and applies page padding once.
Completed local gates: zero-warning Release build,580/580 core tests,100% measured
core line coverage, formatting and self-contained win-x64 publish. Evidence:
artifacts/reference-css-verified/reference-css-verified.trx and
artifacts/reference-css-verified/e1f564a9-bb6e-4628-8d9a-a2e1fa5c0df6/coverage.cobertura.xml.
Ten new style/page/security regressions pass. Two existing actual-paint UI cases
were adapted to the original document container; neither was run. UI discovery
remains93 cases (69 desktop/24 adapter), expected92 per installed channel.
Review-reference-css.md was launched from artifacts/reference-css-publish using
its separate review profile (launch recorded as119988). Initial startup/document
opening was observed before pause; this is not full visual/native acceptance.
The fixture deliberately forced Source on and does not define production defaults.
No current native/distributed/install/signature or hosted-CI acceptance exists for
this CSS work. Global/per-theme selector parity for native TOC/source, raw HTML/
heading fidelity and actual layout/paint remain open. Update the CSS attribution
inventory and threat model when implementation resumes. Code remains local and
unstaged; no new commit, push, merge, tag, release or Azure sign-in.
## Default-file Settings action - 2026-10-11
Issue #14 follow-up is implemented locally: Settings → Advanced has Choose default
file types and optional-.txt guidance. Only actual installed copies with an
unchanged owned command/RegisteredApplications value enable it. Native click
revalidates ownership. Windows 11 23H2+ opens the exact registeredAppUser page;
earlier versions use general Default Apps and .md instructions. Portable previews
show install/repair guidance. Launch failure gets manual instructions and
content-free diagnostics; defaults are still selected by the Windows user.
Separate-output full Release build passes with zero warnings/errors at
artifacts/default-files-build/bin. Eleven real isolated registry/activation tests
pass (two new route/ownership/default-preservation cases);570 core tests pass and
100% measured core line coverage gate passes. Formatting passes. A native page
acceptance case compiles, not run;93 UI-project cases are discovered only
(69 desktop/24 adapter; expected92 per installed channel). Actual installed
chooser/selection/Explorer and hook lifecycle remain unverified. No owner preview replaced or new local desktop
input, sign-in, commit/push or publication. Original staged four files82+/10-
remain unchanged; the existing signing-authentication reply is still pending.
Historical default-file checkpoint preceded the local document CSS port recorded above.
Full PRODUCT scope remains active, including current native/distributed acceptance,
Print/PDF, reference fidelity, standards and signed production artifacts.
## Windows activation and registration - 2026-10-11
Issue #14 has local implementation: Velopack install/update/uninstall callbacks
advertise md/markdown/mdown/mkd/txt under HKCU with quoted stable current-exe
commands, Default Apps capabilities and a REG_NONE AllowSilentDefaultTakeOver
marker. Channel/executable-specific ProgIDs, unchanged-command checks and owned
cleanup preserve other installs, viewers and user defaults. No real registration
or installer was executed locally; only UUID-subtree registry fixtures were used.
SDK AppInstance chooses a primary per executable/profile. Sender-canonicalized
paths travel through a current-user-only generated-schema pipe to the native UI
queue.128-path/1MiB/10second/16-pending bounds; unknown/null/relative/oversized
payloads rejected; no command protocol. Initial/duplicate/empty/error requests
reuse native reader paths; foreground rights are delegated by the launcher.
Invalid startup notification survives browser initialization. Existing pending
startup path duplication detection is retained.
Actual adapter tests initially8/9: InvalidDataException derives SystemException,
so an oversized frame escaped the original catch and stopped the listener. The
explicit catch fix preserves all assertions; final9/9 pass, including malformed
recovery and idempotent shutdown/rebinding. Red/fixed/final TRXs are retained under
artifacts/activation-boundaries. No diagnostic probes remain.
Fresh full separate-output Release build/publish pass;570 core tests/100% measured
lines, formatting and docs lint pass.90 UI-project cases discovered only
(68 desktop/22 adapter; expected89 per installed channel). Two new real native
secondary-process/relative-Unicode/profile/duplicate/watcher cases compile, not
run. Controlled desktop/distributed/Explorer/installed acceptance remains needed.
Velopack1.2.161 verified the corrected Program.Main Build/Run callback chain and
packed a local unsigned stable fixture in artifacts/activation-package. Both
portable/full payloads retain all543 published files byte-for-byte;11 notice files
match source bytes. Publish evidence and unsigned-only flag are retained. No
installation, actual hook lifecycle, signed-artifact claim or publication occurred.
Owner preview103040 remains Review-toolbar-splash.md using the earlier preview
output/profile; it has not been replaced by activation-build. Original staged
four files82+/10- remain unchanged; new work is unstaged. Existing1Password ready
reply still pending; no signing retry/push/new Azure/merge/tag/release. Full
PRODUCT requirements, native UI, Print/PDF, original CSS/render/activation parity,
standards and exact production signatures remain open.
## Splash and toolbar owner preview - 2026-10-11
Owner screenshot follow-up #18: reduce toolbar height, align TOC/depth/Print
and reading controls, remove the wide gap and group commands. Native CommandBar
now uses a48-DIP row with consistent40-DIP fixed controls and natural left
alignment; Print remains fixed while reading commands retain dynamic overflow.
Actual geometry assertions are prepared; native acceptance is still pending.
New #25: reused the published AppKit branded splash already connected to initial
launch. Settings → Advanced now has gclo-style Show splash/duration controls
(500–30000ms,250ms steps) and startup update checks, moved out of View. Checked
saves update only startup fields, broadcast to all reader baselines and preserve
other preferences/CSS drafts. Existing saved durations remain unchanged.
A real shared-preference/native-controls regression is prepared, not executed.
Fresh separate-output Release build: zero warnings/errors.570 core tests pass,
100% measured lines; format/docs lint pass.79 UI-project cases discovered only
(66 desktop/13 filesystem; expected78 per installed channel). No local input
acceptance ran. Computer Use passive observation was attempted, but its native
pipe was unavailable; no screenshot or input action occurred.
Owner preview opened successfully as Review-toolbar-splash.md — MarkdownViewr,
process103040, with source visible and a5000ms splash in its separate
artifacts/splash-toolbar-profile. Startup update checks are disabled in that
review profile. Other owner windows were preserved. Output:
artifacts/splash-toolbar-review/bin/x64/Release/net10.0-windows10.0.19041.0/
MarkdownViewr.Windows.exe. Evidence: artifacts/splash-toolbar-core/
53b3f3ce-c62e-40f9-afac-cfcf1a2ed905/coverage.cobertura.xml and
artifacts/splash-toolbar-ui-discovery.log. Actual toolbar, Settings and splash
visual/duration/distribution proof remains pending; launch logs prove only
startup/document opening. Signed commits/push await the existing1Password
ready reply; no signing retry, merge, tag, release or Azure change occurred.
## Latest owner review changes - 2026-10-11
Owner feedback is implemented locally: #24 About has Contributions below the
AppKit header/license, with Darin's repository/site/license links. Components
individually links 23 application/platform and 29 development dependencies;
independent tests compare every package/version with actual restored assets.
Eleven distinct packaged SDK/WebView/tensor license/notice files are retained
verbatim and match copied review-output bytes. Microsoft binary license terms
are identified separately from MIT components.
Issue #21 replaces the rejected modal Pivot dialog with a separate resizable WinUI
Settings window: adaptive left NavigationView, native pages/controls and owned
theme/editor dialogs/pickers. Routine width preferences save immediately;
advanced CSS has Apply, and closing discards unsubmitted CSS. Shared saved
appearance refreshes open Settings baselines without overwriting its CSS draft.
Issue #18 Print stays in the fixed toolbar area even when other commands overflow;
Theme uses the native Color glyph rather than Highlight.
Issue #16 is implemented locally: native read-only RichEditBox with literal SetText,
passive parser character spans, contrast-adjusted document accents, Windows
light/dark/high-contrast updates, stale parse/paint guards and exact original
visible source copying with no rich clipboard formats. Native Text/Value
accessibility, find/fold/reload and existing scroll behavior remain test targets.
Latest evidence: full separate-output Release build zero warnings/errors;
570 core tests,100% measured lines;78 UI-project cases discovered only
(65 desktop/13 filesystem, expected77 per installed channel). Native source
color/selection/clipboard and standard Settings acceptance cases compile; no
local desktop automation ran. Initial review startup failures were diagnosed:
unsupported HighContrastChanged registration now uses UISettings color changes;
read-only RichEdit character formatting uses a synchronous writable guard that
restores read-only mode before returning/yielding. The corrected review opened
Review-updated.md successfully, most recently in process 94448. Owner windows
were preserved; source color/clipboard acceptance is still pending. Final native and
distributed acceptance, missing system Print/PDF handoff, original CSS/activation,
standards and production signature proof remain required. Original four staged
corrections are unchanged; signed commit/push still awaits the pending1Password
ready reply. No signing retry, merge, tag, release or Azure changes occurred.
Review output: artifacts/source-highlight-fixed/bin/x64/Release/
net10.0-windows10.0.19041.0/MarkdownViewr.Windows.exe.
Evidence: artifacts/source-highlight-about-core/38ccc305-1af2-4456-9380-a7420b4850f0/
coverage.cobertura.xml, source-highlight-ui-discovery.log,
about-component-inventory.json and review-license-verification.json.
Earlier sections describe historical implementations and acceptance milestones.
## Owner build handoff - 2026-10-11
The owner requested another build to try. A fresh full Release build passed
with zero warnings/errors, then opened README.md in a new responsive process
(PID 107716). The older Debug window showing AGENTS.md (PID 91712) was preserved.
This verifies build and launch only; no automated desktop input or acceptance
ran. Settings, source split, toolbar, attribution/Help and Print/PDF options are
available for owner review. Source highlighting has a compiling core draft but
is not connected to the native source control yet. The previous 538-test/100%
coverage result predates that draft; new highlighting tests remain required.
Signing/push and runtime Print/PDF verification remain pending.
## Owner requests and local implementation - 2026-10-11
Every current owner request is tracked: #16 source syntax highlighting, #17
50/50 draggable source split, #18 Mac toolbar parity, #19 Darin attribution,
plus #20 gclo Help/defaults, #21 dedicated Settings, #22 system print/sizing,
and #23 PDF verification/open actions; #6 holds automatic reload/fallback requirements.
Source highlighting, the reworked Settings window, source split, toolbar and linked About credits are implemented locally; current native acceptance remains pending.
Superseded first #21 implementation: File → Settings with native Appearance, Themes and
Editors tabs. Width/global CSS edit a copied draft; explicit Save merges against
the latest store and broadcasts to open readers. Theme/editor actions retain
separate saves and return to the same parent draft; no overlapping dialogs.
Removed six theme/appearance editing entries from View and moved editor
configuration into Settings. Everyday reading controls remain in View. Native
menu tests now use actual Settings tabs; a new parent/child cancellation case
is prepared. Keyboard entry uses File/Settings access keys.
Locally implemented #17: source opens at50/50 in fresh windows and when reopened,
excluding the outline/dividers. Drag changes proportional widths without the
old600px cap. Left/Right resize the focused divider; Home resets equal columns,
with a visible keyboard focus border. Ratio is window-local, not persisted;
legacy SourceWidth remains readable for older settings.21 layout regressions
cover narrow/large windows, drag direction, both bounds and invalid inputs.
The real physical-resize case now checks actual equal columns, growth, keyboard
reset/resize and reopening rather than the obsolete saved pixel preference.
Latest gates: zero-warning full Release build;538 core tests,100% measured lines;
format verification passes. Headless UI-project discovery finds75 cases
(62 desktop and13 filesystem; installed expectation74). New native Settings/
source/toolbar acceptance has not run. Original staged four-file signing
correction remains unchanged; existing1Password authentication and local GUI
reservation replies are still pending. The older Debug window predates these changes; the owner now has a fresh Release window.
Evidence: artifacts/settings-source-core and toolbar-ui-discovery.log.
Locally implemented #18: native CommandBar preserves the reference order: TOC,
H1–H6 depth, flexible space, source, minus/percentage/plus zoom, enabled theme
picker and editor. Print is in native More overflow. Narrow layouts use the
built-in dynamic overflow; accessible labels/tooltips/toggle state remain tied
to existing handlers. Depth is disabled with hidden TOC; zero existing editors
is disabled, one existing editor launches directly by stable identity, multiple
existing editors offer ordered file/folder choices. Menu/theme broadcasts and
active-document changes refresh toolbar state.
Editor saves now merge against their copied baseline and check persistence
before applying. An explicit save updates each open reader and its saved
baseline; later local saves cannot restore a stale editor list. Four actual
UI cases cover reading/menu synchronization, file/folder/direct editor process
arguments and Print cancel, narrow overflow, and sibling editor launch/stale
source save. These compile/discover only; no local input ran. Build/format pass;
core is unchanged since the measured538-test/100% coverage gate.
Locally implemented #19/#20: AppInfo description/component credit, accessible
About links to the original Mac repository and verified [kelkhoff.com](https://kelkhoff.com/),
README/product/distributed notices. Help matches gclo main198a56213b: repository,
issue chooser and Community support. These use the Windows repository, never
Darin's support destinations. App/starter Discussions enabled and read back true.
AppKit#28 and windows-app-template#3 track shared defaults in isolated worktrees:
AppKit/artifacts/help-menu (feat/help-menu from main839fb72) and
AppKit/artifacts/starter-help-menu (feat/help-menu from onboarding758e773).
Both WinUI/WPF samples and starter use existing APIs; no package version changes.
Print options now use a responsive wider layout and opt-in Open PDF/Open folder
checkboxes. PDF export loads the generated file through Windows' PDF API and
requires pages before reporting success or starting either native action. Handler
failures remain independent and do not misreport successful creation. Print-phase
logging distinguishes preparation/selection/export and records runtime version
without private paths/content. The missing system-dialog root cause remains
unproven; earlier hosted success does not resolve the owner's report. #22/#23
remain open. GUI reproduction reservation and PDF-outcome questions are pending;
no desktop input ran. The owner is still using the earlier Debug build; these
changes compile in separate Release output and are not in the running process.
Live-reload native filesystem tests pass6/6 without an app/desktop launch. A failed
refresh now explicitly shows the retained previous snapshot and File → Reload.
A real prolonged-lock/automatic-recovery acceptance case compiles but has not run.
Current UI count becomes70 native/published and69 per installed channel; all
new actual About/print/fallback assertions still require execution.
App517 core tests/100% measured lines; Release full build zero warnings/errors.
Shared default builds pass; AppKit115 unit tests with100% core/updates lines,
starter20 core +14 CLI tests/100% core lines,56 Pester tests and changed-script
all-rule two-pass analysis pass. All three formats and the final app build pass; nine-doc lint passes.
Fresh headless discovery confirms70 cases; no actual desktop acceptance ran. The actual packed
starter generated both changed-owner CLI and GUI-only apps with correct Help URLs;
locked restore/build and20 core tests pass for each, plus14 CLI tests in the CLI
variant. No generated desktop UI was run. Evidence lives in app artifacts/
owner-followups-core and starter `artifacts/help-template-*` / `help-generated-*`.
Original four staged corrections remain unchanged (82 additions/10 deletions).
All new changes remain uncommitted/unpushed while the existing1Password signing
question awaits authentication. Do not bypass signed/DCO commits or rerun signing
until the owner confirms readiness. Azure remains deferred; no release/merge.
## Requested local launch - 2026-10-11
Owner requested a build and interactive launch. The full solution rebuilt with
zero warnings/errors; the Debug x64 executable opened a responsive native
MarkdownViewr window (PID91712). No automated mouse/keyboard tests ran.
This launch does not provide new feature or distributed-artifact acceptance.
## Current local renderer/theme milestones and signing dependency
Pushed head remains 1cfc07f. Four window-test corrections remain staged and
validated; 1Password signing authentication is still required after two failed
attempts. No bypass, corrected commit/push or fresh hosted result exists.
Independent unstaged work adds four enabled-by-default Markdown extensions and
native View toggles using the existing Markdig parser. Immutable options are
captured before asynchronous reads; separate saved fields merge sibling changes
individually. Passive mark/ins markup passes the existing sanitizer; selected
palette styles apply to highlights/underlines. Parser AST normalization preserves
literal code/newline boundaries and reference no-whitespace caret/tilde semantics
without rewriting original UTF-16 source positions.
All native typography controls now expose body/heading/code font families,
body/code and h1–h6 sizes, and line height. ReaderTheme.Edit creates a copied JSON
schema, preserves unrelated metadata, rejects unknown fields/nonfinite or invalid
sizes, and applies the existing font/CSS validation. Invalid drafts and cancellation
leave originals unchanged.18 focused theme cases pass. Existing automation IDs
remain; a new hosted-only case saves all fields, requires real heading/body/code
glyph growth and verifies cancellation preserves the accepted theme. It compiles,
not executed. Actual new desktop acceptance remains required.
Reader-wide appearance now provides native limit/width/global-CSS drafts. Defaults
match the reference (enabled,1000px); stored corruption is repaired, invalid drafts
remain open, and cancellation preserves settings. Global CSS precedes per-theme
CSS, with enabled width applied last and scaled by reading zoom. Screen-only styles
cannot change print layout. Saves merge against the latest store and broadcast to
all open readers; revision guards supersede older asynchronous updates and update
sibling save baselines.22 focused regressions cover bounds/defaults, CSS cascade,
HTML embedding, separate print output, source-generated JSON and field merging.
Theme management now provides a copied native list for ordering, enable/disable,
delete and restoration. Enabled choices alone populate reading menus/cycling;
Ctrl+Shift+Down/Up requires owned reading focus. Deleting a built-in removes its
user override and records a restorable tombstone. Reference restoration keeps
edited built-ins as uniquely named custom copies, updating saved selection/order,
then restores/enables originals. Theme commits merge unchanged fields from the
latest store and broadcast catalog/selection to open readers with save baselines
and asynchronous revision guards.23 focused cases cover order, wrapping, empty
lists, corruption, copied arrays, deletion, restoration collisions/metadata,
JSON defaults and sibling merges. No dependency/version changes.
Theme transfer now uses owned multiple-file JSON selection, serial bounded reads,
per-conflict overwrite/keep-both/skip and explicit compatible newer-schema consent.
Known schema/font/color/size/CSS/depth checks remain enforced. Strict Parse still
rejects newer versions; compatible saved/import parsing retains their original
version/metadata. Unconfirmed newer imports cannot be persisted. Export includes
disabled choices and writes exact validated JSON to the user's destination.
Invalid files do not prevent other batch entries from importing. Errors log only
exception kind/HRESULT, without paths/content. Manager New/Copy/Edit/Use selected
and import/export finish their secondary dialogs before reopening the copied
manager draft; only Save themes commits it. Rename collisions stay in the editor,
and owner closure stops reopening. No dependencies or workflow changes.
Local517 core tests/100% measured lines, zero-warning full solution build,
format and four-doc lint pass.
Latest coverage: artifacts/theme-transfer-core;46 focused transfer regressions
include all20 actual bundled JSON round-trips, bounds/read position, BOM/Unicode,
invalid input/cancellation, schema consent/security, conflict/version/metadata
retention, copied drafts and safe Windows filename suggestions. Earlier extension/
typography/appearance/list checkpoints retain32/18/22/23 focused cases.
Headless discovery (correct AnyCPU test-project mapping) finds69 UI cases; evidence:
artifacts/theme-transfer-ui-discovery.log. No app/desktop input was executed.
Twelve new runtime cases compile but have not run: six prior renderer/typography/
appearance/list cases plus six transfer/cross-dialog/cancel cases. Existing restoration
also requires retained override JSON/paint and explicit original selection.
Original staged window correction alone expects57 native/published and56 per
installed channel. All independent local milestones require69 native/published
and68 per installed channel. No new desktop/PDF/current-head hosted proof exists.
Remaining CSS-variable/DOM-selector fidelity, raw-HTML extension/source-heading
edges, local link/render behavior, Windows activation and standards/signatures
are not complete.
## Printing baseline verified; expanded geometry investigation
Native File/Print/Ctrl+P, copied themes/layout preferences and explicit native PDF
+67 -3
View File
@@ -46,10 +46,74 @@
- [x] Verify all5 print cases in full bf4fcdc / CI38128311242: native/published54, installed stable/dev53 each, zero failures/skips;24 actual PDFs/48 PNGs/TRXs retained, update apply/restart passes both channels.
- [x] Implement native independent document windows, multiple-file picker/all reference extensions and bounded private recent files/clear; source-generated sibling-safe preference merging. Local376 core/100% lines and zero-warning build pass.
- [ ] Verify actual hosted Ctrl+N/Ctrl+W, two-window zoom/watching, actual multiple-file picker/cancel, recent duplicate/missing/clear/stale-save and File/Exit acceptance.
- [ ] Implement and verify Windows file associations/cross-process activation with stable/dev identity boundaries.
- [ ] Complete reference theme order/enable/delete/restore/cycling, JSON import/export conflicts, body/heading typography, global CSS and content width.
- [ ] Implement and verify configurable highlight/superscript/subscript/underline and remaining image/link/render fidelity.
- [x] Implement Windows file registrations and cross-process activation with channel/install/profile identities, quoted commands and preserved defaults;9 actual isolated adapter tests pass.
- [ ] Verify Explorer Open With, installed registration/update/uninstall and actual secondary-app/profile/duplicate/watcher behavior in all distributed builds (issue #14).
- [x] #14: Implement Settings → Advanced → Choose default file types. Read-only install ownership checks target this exact registered application on Windows 11 23H2+; older versions open general Default Apps. Portable previews show installation guidance; `.txt` remains optional. Eleven isolated registration/activation tests pass.
- [ ] #14: Verify the actual installed Settings action, Windows Default Apps selection and Explorer double-click for Markdown types; confirm optional `.txt` remains unchanged, stable/dev identities and older-Windows guidance. New native page case compiles, not yet run.
- [x] Expose all body/heading/code typography controls, including heading levels1–6 and line height, with copied core edit validation.18 focused theme regressions; combined426 core tests/100% lines, zero-warning full build/format pass. Unsigned worktree only.
- [ ] Verify real hosted complete typography save/heading-body-code glyph growth/cancel acceptance; new case compiles, not run.
- [x] Implement shared global CSS and bounded persisted content width, copied cancel-safe drafts, sibling broadcasts and separate print styles.22 focused regressions; combined448 core/100% measured lines, zero-warning build/format pass. Unsigned worktree only.
- [ ] Verify real hosted width wrapping/global CSS/theme changes/PDF isolation/cancel and two-reader broadcast/stale local save; two new cases compile, not run.
- [x] Implement copied native theme order/enable/delete/restore and shared selection, saved menu order and guarded Ctrl+Shift+Up/Down cycling. Restoration preserves edits as collision-safe custom copies.23 focused cases; combined471 core tests/100% measured lines, zero-warning build/format pass; local worktree only.
- [ ] Verify actual hosted menu order/disabled choices/physical cycle/focus/cancel, sibling deletion/restoration and updated retained-override restoration case. Two new cases compile, not run.
- [x] Implement bounded multi-file JSON import, explicit overwrite/keep-both/skip/newer-schema choices, metadata/version-preserving disabled-theme export and native manager New/Copy/Edit/Use selected with copied cross-dialog drafts.46 focused regressions; combined517 core/100% measured lines and zero-warning full build pass; local worktree only.
- [ ] Verify all six new hosted conflict/batch/future/disabled-export/new-copy-edit-cancel cases in native, published and installed builds; headless discovery finds69 total cases, runtime not executed.
- [ ] Audit original global/per-theme CSS variable/DOM selector compatibility; retain full reference stylesheet behavior.
- [x] Implement independent saved highlight/superscript/subscript/underline options and native controls; existing parser/sanitizer, exact source offsets, code/newline/whitespace guards and theme palette. Local408 core tests/100% lines and full build/format pass; unsigned worktree only.
- [ ] Verify hosted native extension glyphs/paint/toggles/persistence/source/reload in all distributed builds; one real acceptance case is prepared, not run.
- [ ] Audit remaining extension edge cases against raw authored HTML and complete local image/link/render fidelity.
- [x] Verify the all-rule two-pass PSScriptAnalyzer mitigation on scripts 38104988748 and 38105483489; closes #5 only on reviewed merge.
- [ ] Verify hosted checks on each actual implementation head.
- [ ] Finish standards inventory and reviewed/published AppKit/signing API adoption when available.
- [ ] Verify actual signed stable/dev installers and exact shipped bytes before release publication.
## Owner follow-ups - 2026-10-11
- [ ] #22: Fix the system print handoff and widen print options.
- [ ] #23: Diagnose PDF export and offer success-only open PDF/folder actions. Readable-output verification and options implemented locally; runtime acceptance pending.
- [ ] #19: Credit Darin Kelkhoff's original Mac app in About and documentation, with repository/site links. Implemented locally; actual About acceptance pending.
- [ ] #20: Match gclo Help commands and carry defaults into AppKit samples and the starter. Implemented locally; builds/generation/setup tests pass, native acceptance/signing/push pending.
- [x] #21: Replace owner-rejected modal tabs with a standard resizable WinUI Settings window, left navigation, native controls, immediate checked width preferences and explicit CSS apply. Theme/editor dialogs and pickers belong to Settings.
- [ ] #21: Verify native window resizing/navigation, immediate preferences, CSS apply/discard and owned theme/editor/picker dialogs in distributed builds.
- [x] #16: Implement native passive source highlighting, document/shell colors, high contrast and exact plain copying.570 core tests and100% measured lines pass.
- [ ] #16: Run real native color/mode/palette/selection/clipboard and existing find/fold/scroll/reload acceptance in distributed builds.
- [x] #17: Implement50/50 source opening/reopening, proportional mouse resizing and focused Left/Right/Home controls;21 core layout regressions pass.
- [ ] #17: Verify actual mouse/keyboard/outline/DPI/window-resize/reopening geometry in distributed builds.
- [x] #18: Implement reference-order native toolbar with dynamic overflow, shared reading handlers, H1–H6, enabled themes, valid editor/direct/choices and primary Print; Theme uses the native Color icon. Broadcast saved editor choices to all readers; build/format pass.
- [ ] #18: Verify four prepared actual toolbar reading/menu/editor/Print/narrow/sibling cases in native, published and both installed builds.
- [ ] #6: Verify automatic external-save refresh or a clear stale/reload notification in the current build. Native watcher6/6 passes; explicit fallback/new locked-save UI case implemented, actual acceptance pending.
- [x] #24: Move Darin attribution into About Contributions; link23 runtime/platform and29 development entries verified against restored package metadata; preserve11 distinct packaged license/notice texts.
- [ ] #24: Verify actual About scrolling, keyboard-accessible project/license links and Contributions placement in distributed builds.
- [x] #25: Add gclo-style native Advanced splash toggle/duration (500–30000 ms, 250 ms steps), startup update preference, checked saves and sibling broadcasts using the published AppKit splash.
- [ ] #25: Verify actual startup duration/enabled/disabled and shared preference persistence in distributed acceptance.
- [ ] #18: Review the tightened toolbar row, control alignment and grouped commands against the owner screenshot; geometry assertions are prepared.
## Active 1.0 release queue - 2026-10-11
Full stable1.0 and isolated development releases are authorized. Local implementation
checks below do not substitute for current native/distributed acceptance.
- [ ] #27: Reproduce and fix source token colors disappearing on pointer hover; verify focus/selection/scroll/reload/theme transitions in the actual native control.
- [x] #18: Remove the duplicate toolbar depth selector and label Contents/Print; retain TOC depth.
- [ ] #18: Verify current toolbar alignment, overflow and narrow-window behavior.
- [x] #21: Add independent new-window Contents/Source defaults, both off; core merge/copy regressions pass.
- [ ] #21: Verify persisted defaults and unchanged existing windows through actual Settings input.
- [x] #21: Move long theme/editor forms into native Settings pages with Back/Save/Cancel; preserve copied drafts/import conflicts/native pickers.
- [ ] #21: Verify the current Settings pages, draft cancellation/validation and imports at normal/narrow sizes.
- [x] #26: Add loaded-handle modification time, stale/reload status and native Open folder; real metadata adapter regression passes.
- [ ] #26: Verify actual footer changes during locked/replaced/missing files and independent windows.
- [x] #28: Add accessible/native delayed disabled-editor explanation with the actual unavailable reason and Settings guidance.
- [ ] #28: Verify actual disabled hover, mouse exit, state changes and keyboard accessibility.
- [ ] #29: Design/review a distinct gclo-family MarkdownViewr icon and integrate the accepted artwork through existing Windows brand generation.
- [x] #24: Limit Components to shipped open-source credits/project/license links; preserve full audit catalog and notices, with Darin in Contributions.
- [ ] #24: Verify current native About layout, Contributions and all credit links.
- [x] Port pinned document CSS/container/scroll/print semantics locally. Current combined582 core tests/100% lines,25 adapter tests,zero-warning Release/format/publish pass.
- [ ] Audit complete CSS selector/raw-HTML/source/TOC parity and run current actual paint/native/distributed acceptance. Attribution/threat-model updated; local port is not full parity proof.
- [ ] Pass hosted CI on the actual current implementation; retain native/published/installed TRXs, screenshots and real PDFs.
- [ ] Implement gclo-style isolated stable/dev IDs, titles, data, installs and feeds; verify side-by-side behavior.
- [ ] Adopt the reviewed/published owned-file signing builder; verify actual Azure resource/profile/publisher and repository-bound OIDC/profile-signer permission.
- [ ] Obtain genuine current-head maintainer review acknowledgement, complete required checks and merge.
- [ ] Publish signed stable1.0/dev releases with verification of exact distributed artifacts, timestamp/publisher/checksums and installed update acceptance.
@@ -25,8 +25,10 @@ Use the current App SDK FileOpenPicker(AppWindow.Id) and PickMultipleFilesAsync,
including all five reference extensions. Reuse a blank window, focus an already
open canonical path within this process, or create a new document window.
Cancellation leaves readers intact; failed opens retain any existing reader.
Windows registration and cross-process activation redirection remain separate
required work; launching an executable with literal paths is not association proof.
Windows registration and cross-process activation now have local implementation
and isolated boundary evidence in ADR-0005. Explorer and installed lifecycle
acceptance remain required; literal process arguments alone are not association
proof.
Store at most20 successful absolute paths in the private AppKit settings file,
deduplicated with Windows path comparison. Update only after a parsed snapshot
@@ -41,7 +43,8 @@ Separate tracked XAML windows reuse existing document adapters and native lifeti
handling. Tabs would require additional detach/reattach state and do not supply the
reference's independent windows. Creating AppWindow directly does not attach XAML
content. Current duplication detection covers one process; OS activation and
stable/dev identity boundaries still need implementation and separate acceptance.
stable/dev identity boundaries have local implementation/isolated tests in
ADR-0005; actual application and installed acceptance remains pending.
376 core tests pass with100% measured lines, including bounded/corrupt history,
case deduplication and sibling-safe settings merging. Require hosted real two-window
@@ -0,0 +1,85 @@
# ADR-0005: Windows file registration and activation
**Status:** Implemented locally; isolated boundary tests pass; native/installed acceptance pending
**Date:** 2026-10-11
**Decider:** James Maes, repository maintainer
## Context
The Windows counterpart must open Markdown/plain-text files from Explorer and
subsequent launches without replacing existing readers. Stable, development,
portable previews and isolated test profiles must retain their identity boundaries.
Relative arguments must keep the launching process's working-directory meaning.
## Decision
Use Velopack install/update/uninstall fast callbacks to register only installed
copies. The registered executable is the installation's stable
`current/MarkdownViewr.Windows.exe`. Advertise `.md`, `.markdown`, `.mdown`, `.mkd`
and `.txt` through per-user ProgIDs, OpenWithProgids and Default Apps capabilities.
Quote the executable and literal `%1` argument. Set `AllowSilentDefaultTakeOver`
as REG_NONE; never write extension defaults, UserChoice or machine-wide keys.
A ProgID identifies channel and canonical executable path. Uninstall first
checks that its command remains unchanged, then removes only owned entries.
Another channel, another installation and deliberate user retargeting survive.
Builds and portable copies do not register file types.
Use Windows App SDK AppInstance to choose the primary for the executable and
profile directories. SDK rich file activation and ordinary command-line paths
become the same fixed native document request. Resolve relative paths before
crossing processes; the public SDK launch envelope cannot carry sender CWD.
Deliver requests through a current-user-only .NET named pipe. Accept only a
source-generated bounded schema: at most128 absolute paths, a1MiB frame and an
optional fixed invalid-path notification. Reject unknown fields; no command or
script operation exists. Connection/read/acknowledgement waits are bounded to
10seconds, with at most16 requests queued before XAML attaches its dispatcher.
Malformed/disconnected peers cannot stop later valid requests. App shutdown
cancels and disposes the listener. No dependencies or generic RPC layer are added.
The foreground launcher permits the primary to focus an existing document.
Dispatch all window creation on the native UI thread; duplicate paths include
pending initial loads and preserve watcher/source/find/zoom state. Empty launch
focuses an existing reader. An invalid startup path gets a fixed native message;
logs contain only failure kind/HRESULT.
## User-selected default file types
Settings → Advanced exposes a native Choose default file types button. Read-only
checks require this installed executable's unchanged command and the exact owned
RegisteredApplications value. Portable/unregistered previews instead show install
or repair guidance and cannot launch the selection action. Nothing selects or
reclaims a default, and `.txt` is explicitly optional.
Windows 11 23H2+ uses `ms-settings:defaultapps?registeredAppUser=` plus the
URI-escaped registered value name (the installation/channel ProgID, not a display
name). Earlier builds use general Default Apps with instructions to search `.md`;
this conservatively includes Windows 11 builds predating the supported deep link.
The native action revalidates ownership at click time, handles launch failure with
manual OS guidance and logs only error kind/HRESULT. Document content has no route
to invoke it. Actual Windows chooser/selection and Explorer acceptance are pending.
## Evidence and remaining acceptance
Eleven actual isolated registry/pipe/path tests pass, including two Default Apps
routing/ownership cases retained in `artifacts/default-files-boundaries/`. They cover command/icon
quoting, all five extensions/capabilities, REG_NONE marker, preserved defaults,
channel/installation cleanup, retarget protection, sender-relative Unicode,
profile identities, startup queuing, acknowledgements, malformed recovery and
idempotent cancellation/rebinding. Red/fixed TRXs are retained under
`artifacts/activation-boundaries/`. These do not prove Explorer or XAML behavior.
Two real secondary-app/profile/duplicate/watcher cases compile but have not run.
Require controlled native/published/installed acceptance, actual install/update/
uninstall readbacks and Explorer Open With before closing issue #14. Production
signatures and release publication remain separate requirements.
## References
- [Microsoft: App SDK single-instance lifecycle](https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/applifecycle/applifecycle-single-instance)
- [Microsoft: Rich activation](https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/applifecycle/applifecycle-rich-activation)
- [Microsoft: Launch Default Apps settings](https://learn.microsoft.com/en-us/windows/apps/develop/launch/launch-default-apps-settings)
- [Microsoft: File association ProgIDs](https://learn.microsoft.com/en-us/windows/win32/shell/fa-progids)
- [Microsoft SDK activation implementation](https://github.com/microsoft/WindowsAppSDK/blob/main/dev/AppLifecycle/AppInstance.cpp)
- [Velopack application hooks](https://docs.velopack.io/reference/csharp/Velopack.VelopackApp)
+189 -1
View File
@@ -24,7 +24,7 @@ physical directory. It does not authorize native commands or remote resource acc
| Markdown / raw HTML | Explicit tag/attribute/scheme allowlist; disabled checkbox inputs; no authored scripts, CSS, IDs or classes |
| Native print/PDF | Deliberate File/Print/Ctrl+P; ready passive document only; copied bounded settings; Windows destination picker; JSON-serialized CSS text in host-owned print stylesheet; same navigation checked after preparation; no authored command/output path; no document/path logs |
| File change notifications | Directory subscription scoped to selected path; 350 ms quiet-period debounce; replacement/deletion recovery; bounded transient retries; subscriptions released on window close |
| Theme JSON / CSS | Native editor and saved user overrides use the same bounded validation; corrupt theme settings are discarded before startup; restoration keeps separately named custom themes; embedded original palettes; bounded schema-version-one parser; validated hex colors and finite sizes; code-point escaped font names; less-than signs escaped before style embedding; no network capability |
| Theme JSON / CSS | Native editor and saved user overrides use the same bounded validation; corrupt theme settings are discarded before startup; restoration keeps separately named custom themes; embedded original palettes; bounded strict version-one parsing and validated compatible saved/import schemas with explicit newer-import consent; validated hex colors and finite sizes; code-point escaped font names; less-than signs escaped before style embedding; no network capability |
| Browser viewport | Fixed origin-checked native query; validate two finite fractions; invariant numeric restore only; authored scripts/messages stay disabled |
| Source pane | Bounded immutable snapshot plus literal native fold projection; original UTF-16 find positions map to visible native CR text; HTML/CSS/script text never enters a renderer or command interpreter; hidden pane removed from accessibility surface |
| Find | Bounded literal query; pure source positions and native WebView2 finder, with duplicate browser UI suppressed; no query enters executable code or activity logs. Serialized queries and page/navigation checks reject stale completions; existing numeric section operations reveal/restore content |
@@ -180,3 +180,191 @@ and prevents an older window snapshot from restoring cleared paths. Core tests
cover corruption, bounds, ordering, independent arrays and stale-window merging.
Actual hosted lifecycle/clear/reload tests are pending; association/activation
registration has not been implemented or represented as verified. See ADR-0004.
## Configurable Markdown extensions
Only deliberate native menu commands change the four saved rendering preferences;
document content cannot modify them. Each asynchronous read captures immutable
options. Existing Markdig parsing generates passive mark/sup/sub/ins nodes and
preserves original source offsets. Reference caret/tilde whitespace and protected
code/newline boundaries remain literal. The sanitizer still removes authored
styles, identity/event attributes and unsafe links; mark/ins grant no native or
network access. Host-generated theme CSS uses validated palette variables.
Separate saved fields preserve sibling preference edits. Core tests cover all16
option combinations, code/escapes, hostile attributes, original source provenance
and old JSON defaults. Actual native repaint/toggle/reload acceptance is prepared
for hosted runners and remains unverified. Raw authored-HTML extension edge cases
remain part of the reference audit; no broader renderer-parity claim is made.
## Complete native typography editing
All font families/sizes and line height use native controls backed by a copied
core theme edit. Known field names, finite/ranged numbers, bounded escaped font
strings and hex colors pass the existing renderer validation before saving.
Unknown fields and invalid drafts are rejected; unrelated schema metadata is
preserved. No document content can invoke these edits. Custom CSS still cannot
close its style element, and existing CSP/resource limits apply. Core tests prove
invalid input leaves originals intact, successful edits retain all fields, and
hostile font/CSS strings preserve the HTML boundary. Actual glyph/save/cancel
acceptance compiles for hosted runners and is not yet verified.
## Shared reading appearance
Only deliberate native settings commands save global CSS and content width.
Copied drafts validate finite200–4000px widths and a128Ki-character CSS bound;
corrupt persisted values fall back safely. Settings are merged against the latest
store before broadcasting to open readers. Revision checks reject obsolete
asynchronous application; updating sibling baselines prevents later local saves
from restoring an older global preference.
Global CSS is escaped for HTML style embedding and remains in screen-only media.
Per-theme CSS retains precedence; an enabled width limit is applied last, scaled
with reading zoom. Print CSS uses its existing independent theme/layout path.
No new script, host object, message bridge or remote-resource permission is enabled;
existing CSP and denied network requests still apply.448 core tests/100% measured
lines pass. Two new hosted cases compile but have not run; actual sibling paint,
wrapping and PDF isolation are not yet declared verified.
## Shared theme management
Only owned native manager/menu/cycling commands change saved theme lists or the
active palette. Copied drafts retain cancellation; names are bounded, deduplicated
and reject control characters. Disabled themes remain editable while disappearing
from reading menus. Built-in deletion only removes settings overrides and records
a restorable name; shipped resources and document files are untouched. Restoring
originals preserves edited JSON/metadata as uniquely named custom copies.
Latest-store field merging and reader save baselines preserve unrelated sibling
preferences. Catalog/selection broadcasts and browser revision checks discard
obsolete asynchronous theme applications. Cycling uses only enabled saved order
and exact Ctrl+Shift arrows with owned reading focus; text controls retain input.
Existing schema/font/CSS validation, screen/print separation, CSP and resource
denial remain enforced.471 core tests/100% measured lines pass. Native menu/paint/
keyboard/sibling and revised restoration acceptance compile but remain unexecuted.
## Native theme transfer and copied manager dialogs
Theme JSON enters only through deliberate owned native file pickers. Serial reads
allocate at most the128Ki-character bound plus one before schema/depth validation;
UTF-8 and recognized BOM encodings retain Unicode. Invalid files fail independently
within a batch. Compatible newer schemas still validate all known colors/fonts/
sizes/CSS and require explicit native consent before import persistence. Strict
version-one parsing remains available. Saved approved versions and unknown metadata
survive rename/edit/restoration/export without enabling new rendering privileges.
Overwrite replaces only the named user override; keep-both reserves unique names
across the batch; skip/cancel leaves saved data intact. Native new/copy/edit/default
and transfer actions use copied settings across successive dialogs, with manager
Save required for persistence. A renamed editor cannot silently overwrite another
existing palette. Export writes exact validated JSON only to the user's chosen
native destination, including disabled choices. Suggested names exclude Windows
path characters and ordinary reserved device names. Errors log exception kind/
HRESULT only, never selected paths or JSON. Closing the owner stops reopening.
517 core tests/100% measured lines and zero-warning build pass.69 desktop cases
are discovered headlessly; six new actual picker/choice/export/draft/cancel cases compile
but have not run. Runtime proof remains pending. Existing CSP, resource denial and
screen/print separation remain in force; original CSS-selector compatibility is
still a required audit.
## Public Help links and post-export actions
Help destinations are fixed application metadata: the Windows repository, its issue
chooser and Discussions. Original Mac/author links appear separately in About; no
authored document path, content or credential enters these URLs. Native browser
launches require deliberate menu/link actions; the renderer capability boundary
is unchanged. Starter setup now requires/readbacks Discussions for support.
Optional PDF/folder opening uses only the destination deliberately chosen in the
owned save picker. The native exporter must succeed and Windows' PDF loader must
read pages before success/actions. Cancellation/failure cannot launch them. The
PDF and folder actions are independent; missing native handlers report an action
failure while retaining the completed export status. Logs retain phase/runtime
or exception kind/HRESULT, never private PDF/document paths or content. New actual
print/PDF/credit acceptance is still pending; the owner's handoff report remains
unresolved despite earlier hosted PDF proof.
## Native highlighted source and linked component credits
Source is committed as literal text in a read-only RichEditBox; only native
character formats are applied. No RTF/HTML source loading or authored URL
attributes are used. Explicit Default input scope disables auto-URL detection.
Parser classification runs off-thread with snapshot/cancellation guards; native
paint batches discard stale snapshot/palette generations. Selected document
accents are contrast-adjusted for shell mode; high contrast uses system colors.
Copy is a deliberate native action and emits only visible original plain text,
including Unicode and original line breaks. Folding never copies hidden bodies.
Clipboard failures report a content-free status; closing cancels background work.
Core spans/copy/contrast are verified; native accessibility/colors/clipboard and
scroll/find/fold/reload acceptance remain pending.
About project/license links are curated from restored package metadata, never
from authored documents. Microsoft binary-package license terms are identified
separately from MIT libraries. Verbatim SDK/WebView/tensor notices are copied as
ordinary application data, independent of the XAML resource index. Native Settings
owns its theme/editor dialogs and pickers; routine checked layout saves preserve
unsubmitted CSS drafts and update reader/save baselines. UI acceptance remains
required for the new settings window and exact distributed artifacts.
## Windows file registration and process activation
Installed Velopack callbacks advertise five fixed file extensions under HKCU.
Commands quote the stable current executable and literal `%1`; no shell script
is involved. Channel/executable-specific identities and command ownership checks
bound uninstall cleanup. REG_NONE `AllowSilentDefaultTakeOver` prevents silent
selection as a default. Extension defaults, UserChoice, HKLM, other registrations
and portable/development-build registration are outside these operations.
The App SDK chooses a primary per executable/profile directory. The activation
pipe permits only the current Windows user; it is an additional native ingress
for literal file paths, never browser content or native command execution. Paths
are canonicalized at the sender. The generated schema rejects unknown fields,
relative/control-containing/null/overlong paths and more than128 paths; frames
are bounded to1MiB, connection/read waits to10seconds and pre-XAML queuing to16
requests. Normal document size/resource/sanitizer boundaries still apply.
Malformed/disconnected requests are rejected and the listener continues; owned
shutdown cancels the pipe. Fixed notifications and failure kind/HRESULT logging
do not expose paths or payloads. UI-dispatched duplicate activation preserves
existing readers, including pending initial loads. Nine actual isolated adapter
tests pass; native app, Explorer and installed lifecycle acceptance is pending.
See ADR-0005 and issue #14. Same-user processes can deliberately request document
opening; this is not an authorization boundary against an already compromised
account. No IPC operation executes arbitrary commands, loads plug-ins or changes
settings, credentials or updater feeds.
The default-file Settings button is a deliberate native user action. It reads the
owned installed registration again before launching a fixed `ms-settings` URI;
the query contains only the fixed channel/executable-derived registered value
name. No document path/content can select the target or invoke this action.
Portable/unregistered previews cannot initiate it. Selection remains in Windows,
with no writes to UserChoice/defaults and `.txt` explicitly optional. Eleven
isolated adapter tests now pass; OS chooser behavior still needs acceptance.
## Reference CSS, Settings drafts and document status
The attributed reference stylesheet is an embedded application resource. Rendered
Markdown remains sanitized before DocumentPage inserts it into #content-inner;
titles are HTML-encoded. Only fixed document classes and bounded code-language
classes survive sanitization. Theme/global CSS uses existing validation and
blocked-resource policies; it cannot close the style element, add native commands
or fetch unauthorized remote/local resources. Reading/print container changes do
not extend renderer message capabilities. Native TOC/source remain separate
controls; full reference selector parity still requires actual acceptance.
Long theme/editor drafts live inside the native Settings window. Back, Cancel and
window closure discard them. Validation gates explicit Save, manager child edits
remain inside its copied draft, and native file pickers/short confirmations retain
Settings ownership. A display-state change never grants document content access
to these native operations.
Footer metadata belongs to the same open handle used for the loaded snapshot.
Watcher notices, failed reloads and bounded metadata polling mark stale content;
only a successful new snapshot clears that indicator. The explicit Open folder
button uses StorageFolder and Launcher.LaunchFolderAsync with a literal document
parent directory; it does not construct shell commands. Diagnostics omit authored
content/paths. The disabled-editor tooltip reports native configuration state,
and its timer and footer polling stop on window closure. Native status/hover/
Explorer acceptance remains pending; passing pure or adapter tests is insufficient.
@@ -0,0 +1,9 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core;
/// <summary>A verified dependency credit with fixed official project and license destinations, never document-supplied links.</summary>
public sealed record AboutComponent(string Name, string Version, string License, string ProjectUrl, string LicenseUrl);
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core;
/// <summary>Dependency credits verified against restored assets/nuspec metadata; keep them current when package locks change.</summary>
public static class AboutComponents
{
/// <summary>Direct/transitive application dependencies, including the self-contained runtime and Microsoft SDK binary licenses.</summary>
public static IReadOnlyList<AboutComponent> Runtime { get; } = Array.AsReadOnly<AboutComponent>(
[
new("AngleSharp.Css", "1.0.2", "MIT", "https://github.com/AngleSharp/AngleSharp.Css", "https://licenses.nuget.org/MIT"),
new("AngleSharp", "1.7.2", "MIT", "https://github.com/AngleSharp/AngleSharp", "https://licenses.nuget.org/MIT"),
new("HtmlSanitizer", "9.2.1039", "MIT", "https://github.com/mganss/HtmlSanitizer", "https://licenses.nuget.org/MIT"),
new("KofTwentyTwo.AppKit.Updates", "0.1.0", "MIT", "https://github.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"),
new("KofTwentyTwo.AppKit.WinUI", "0.1.0", "MIT", "https://github.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"),
new("KofTwentyTwo.AppKit", "0.1.0", "MIT", "https://github.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"),
new("Markdig", "1.4.0", "BSD-2-Clause", "https://github.com/xoofx/markdig", "https://licenses.nuget.org/BSD-2-Clause"),
new("Microsoft.Web.WebView2", "1.0.3719.77", "Microsoft package license", "https://aka.ms/webview", "https://www.nuget.org/packages/Microsoft.Web.WebView2/1.0.3719.77/License"),
new("Microsoft.Windows.AI.MachineLearning", "2.1.74", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.Windows.AI.MachineLearning/2.1.74/License"),
new("Microsoft.WindowsAppSDK.AI", "2.5.5", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.AI/2.5.5/License"),
new("Microsoft.WindowsAppSDK.Base", "2.0.4", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Base/2.0.4/License"),
new("Microsoft.WindowsAppSDK.DWrite", "2.1.0", "Microsoft package license", "https://aka.ms/WindowsAppSDK", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.DWrite/2.1.0/License"),
new("Microsoft.WindowsAppSDK.Foundation", "2.3.12", "Microsoft package license", "https://aka.ms/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Foundation/2.3.12/License"),
new("Microsoft.WindowsAppSDK.InteractiveExperiences", "2.1.9", "Microsoft package license", "https://aka.ms/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.InteractiveExperiences/2.1.9/License"),
new("Microsoft.WindowsAppSDK.ML", "2.1.94", "Microsoft package license", "https://github.com/microsoft/windowsml", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.ML/2.1.94/License"),
new("Microsoft.WindowsAppSDK.Runtime", "2.5.1", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Runtime/2.5.1/License"),
new("Microsoft.WindowsAppSDK.Search", "2.5.5", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Search/2.5.5/License"),
new("Microsoft.WindowsAppSDK.Widgets", "2.0.5", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.Widgets/2.0.5/License"),
new("Microsoft.WindowsAppSDK.WinUI", "2.3.9", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK.WinUI/2.3.9/License"),
new("Microsoft.WindowsAppSDK", "2.5.1", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.WindowsAppSDK/2.5.1/License"),
new("System.Numerics.Tensors", "9.0.0", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("Velopack", "1.2.161", "MIT", "https://github.com/velopack/velopack", "https://licenses.nuget.org/MIT"),
new(".NET Runtime", "10", "MIT and third-party notices", "https://github.com/dotnet/runtime", "https://github.com/dotnet/runtime/blob/main/LICENSE.TXT"),
]);
/// <summary>User-facing acknowledgements include shipped open-source libraries; the full inventory remains available for license audits.</summary>
public static IReadOnlyList<AboutComponent> OpenSource { get; } = Array.AsReadOnly(Runtime.Where(component => !component.License.Contains("Microsoft package license", StringComparison.Ordinal)).ToArray());
/// <summary>Build, analysis and test dependencies remain in the audit inventory rather than the user-facing About dialog.</summary>
public static IReadOnlyList<AboutComponent> Development { get; } = Array.AsReadOnly<AboutComponent>(
[
new("Meziantou.Analyzer", "3.0.296", "MIT", "https://github.com/meziantou/Meziantou.Analyzer", "https://licenses.nuget.org/MIT"),
new("Microsoft.Windows.SDK.BuildTools.MSIX", "1.7.251221100", "Microsoft package license", "https://aka.ms/WinSDKProjectURL", "https://www.nuget.org/packages/Microsoft.Windows.SDK.BuildTools.MSIX/1.7.251221100/License"),
new("Microsoft.Windows.SDK.BuildTools", "10.0.28000.2705", "Package license", "https://aka.ms/WinSDKProjectURL", "https://www.nuget.org/packages/Microsoft.Windows.SDK.BuildTools/10.0.28000.2705/License"),
new("Microsoft.CodeCoverage", "18.10.1", "MIT", "https://github.com/microsoft/vstest", "https://licenses.nuget.org/MIT"),
new("Microsoft.NET.Test.Sdk", "18.10.1", "MIT", "https://github.com/microsoft/vstest", "https://licenses.nuget.org/MIT"),
new("Microsoft.TestPlatform.ObjectModel", "18.10.1", "MIT", "https://github.com/microsoft/vstest", "https://licenses.nuget.org/MIT"),
new("Microsoft.TestPlatform.TestHost", "18.10.1", "MIT", "https://github.com/microsoft/vstest", "https://licenses.nuget.org/MIT"),
new("coverlet.collector", "10.1.0", "MIT", "https://github.com/coverlet-coverage/coverlet", "https://licenses.nuget.org/MIT"),
new("xunit.abstractions", "2.0.3", "Package license", "https://github.com/xunit/xunit", "https://www.nuget.org/packages/xunit.abstractions/2.0.3/License"),
new("xunit.analyzers", "1.18.0", "Apache-2.0", "https://github.com/xunit/xunit.analyzers", "https://licenses.nuget.org/Apache-2.0"),
new("xunit.assert", "2.9.3", "Apache-2.0", "https://github.com/xunit/xunit", "https://licenses.nuget.org/Apache-2.0"),
new("xunit.core", "2.9.3", "Apache-2.0", "https://github.com/xunit/xunit", "https://licenses.nuget.org/Apache-2.0"),
new("xunit.extensibility.core", "2.9.3", "Apache-2.0", "https://github.com/xunit/xunit", "https://licenses.nuget.org/Apache-2.0"),
new("xunit.extensibility.execution", "2.9.3", "Apache-2.0", "https://github.com/xunit/xunit", "https://licenses.nuget.org/Apache-2.0"),
new("xunit.runner.visualstudio", "4.0.0", "Apache-2.0", "https://github.com/xunit/visualstudio.xunit", "https://licenses.nuget.org/Apache-2.0"),
new("xunit", "2.9.3", "Apache-2.0", "https://github.com/xunit/xunit", "https://licenses.nuget.org/Apache-2.0"),
new("FlaUI.Core", "5.0.0", "MIT", "https://github.com/FlaUI/FlaUI", "https://www.nuget.org/packages/FlaUI.Core/5.0.0/License"),
new("FlaUI.UIA3", "5.0.0", "MIT", "https://github.com/FlaUI/FlaUI", "https://www.nuget.org/packages/FlaUI.UIA3/5.0.0/License"),
new("Interop.UIAutomationClient", "10.19041.0", "MIT", "https://github.com/Roemer/UIAutomation-Interop", "https://www.nuget.org/packages/Interop.UIAutomationClient/10.19041.0/License"),
new("Microsoft.Win32.SystemEvents", "8.0.0", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("System.CodeDom", "8.0.0", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("System.Configuration.ConfigurationManager", "8.0.1", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("System.Diagnostics.EventLog", "8.0.1", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("System.Diagnostics.PerformanceCounter", "8.0.1", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("System.Drawing.Common", "8.0.10", "MIT", "https://github.com/dotnet/winforms", "https://licenses.nuget.org/MIT"),
new("System.Management", "8.0.0", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("System.Security.Cryptography.ProtectedData", "8.0.0", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("System.Security.Permissions", "8.0.0", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
new("System.Windows.Extensions", "8.0.0", "MIT", "https://github.com/dotnet/runtime", "https://licenses.nuget.org/MIT"),
]);
}
+21 -9
View File
@@ -35,15 +35,7 @@ public static class AppIdentity
IconSvgPath = "Assets/Brand/app-icon.svg",
IconIcoPath = "Assets/Brand/app.ico",
},
Attributions =
[
new("KofTwentyTwo.AppKit", "MIT License"),
new("Markdig", "BSD-2-Clause License"),
new("HtmlSanitizer / AngleSharp / AngleSharp.Css", "MIT License"),
new("Velopack", "MIT License"),
new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"),
new(".NET Runtime", "MIT License"),
],
Attributions = AboutComponents.Runtime.Select(component => new Attribution(component.Name, component.License)).ToArray(),
};
@@ -53,6 +45,26 @@ public static class AppIdentity
/// <summary>Darin Kelkhoff's original macOS viewer, credited without changing Windows support destinations.</summary>
public static Uri OriginalRepositoryUrl { get; } = new("https://github.com/darinkelkhoff/markdownViewr");
/// <summary>The original author's website, verified against his GitHub profile.</summary>
public static Uri OriginalAuthorWebsite { get; } = new("https://kelkhoff.com/");
/// <summary>The app's bug/feature templates, independent of any upstream project credited in About.</summary>
public static Uri IssuesUrl { get; } = new(Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + "/issues/new/choose");
/// <summary>Community questions and tips in the app's own repository.</summary>
public static Uri SupportUrl { get; } = new(Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + "/discussions");
/// <summary>Loads and saves <see cref="AppSettings"/> at <see cref="AppPaths.SettingsFile"/>.</summary>
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
+81 -3
View File
@@ -22,6 +22,21 @@ public sealed class AppSettings : ShellSettings
/// <summary>Visible theme order, including disabled choices managed separately from their JSON data.</summary>
public string[] ThemeOrder { get; set; } = [];
/// <summary>Disabled choices remain editable but are omitted from reading menus and cycling.</summary>
public string[] DisabledThemes { get; set; } = [];
/// <summary>Hidden bundled themes can be restored without damaging shipped resources.</summary>
public string[] DeletedBuiltInThemes { get; set; } = [];
/// <summary>The frontmatter display mode, saved independently of document contents.</summary>
public string Frontmatter { get; set; } = "Hide";
@@ -32,7 +47,64 @@ public sealed class AppSettings : ShellSettings
public Documents.FrontmatterMode FrontmatterKind => Enum.TryParse(Frontmatter, out Documents.FrontmatterMode mode) && Enum.IsDefined(mode) ? mode : Documents.FrontmatterMode.Hide;
/// <summary>Shows the native heading sidebar when requested.</summary>
/// <summary>Renders double-equals highlights; disabled syntax remains literal.</summary>
public bool RenderHighlight { get; set; } = true;
/// <summary>Renders single-caret superscripts independently of other Markdown extensions.</summary>
public bool RenderSuperscript { get; set; } = true;
/// <summary>Renders single-tilde subscripts while double-tilde GFM strikethrough remains enabled.</summary>
public bool RenderSubscript { get; set; } = true;
/// <summary>Renders double-plus inserted/underlined content.</summary>
public bool RenderUnderline { get; set; } = true;
/// <summary>An immutable snapshot captures this window's options before asynchronous parsing begins.</summary>
[JsonIgnore]
public Documents.MarkdownExtensions MarkdownExtensions => new(RenderHighlight, RenderSuperscript, RenderSubscript, RenderUnderline);
/// <summary>Limits reader content width, independently of print layout.</summary>
public bool ContentWidthEnabled { get; set; } = true;
/// <summary>The reference's 1000-pixel default scales with reading zoom.</summary>
public double ContentWidthPx { get; set; } = 1000;
/// <summary>Native user CSS shared across themes; saved separately from each theme's own CSS.</summary>
public string GlobalCss { get; set; } = "";
/// <summary>An immutable window snapshot keeps asynchronous style changes independent of saved theme data.</summary>
[JsonIgnore]
public Themes.ReadingAppearance ReadingAppearance => new(ContentWidthEnabled, ContentWidthPx, GlobalCss);
/// <summary>New document windows start with Contents hidden unless explicitly enabled in Settings.</summary>
public bool ShowOutlineByDefault { get; set; }
/// <summary>New document windows start with Source hidden unless explicitly enabled in Settings.</summary>
public bool ShowSourceByDefault { get; set; }
/// <summary>Shows the native heading sidebar in this window without changing the selected startup default.</summary>
public bool ShowOutline { get; set; }
@@ -51,7 +123,7 @@ public sealed class AppSettings : ShellSettings
/// <summary>Last source pane width in device-independent pixels.</summary>
/// <summary>Legacy fixed-width preference retained for older settings; current source opens at50/50 and resizes within each window.</summary>
public double SourceWidth { get; set; } = 320;
@@ -80,6 +152,9 @@ public sealed class AppSettings : ShellSettings
public override void Sanitize()
{
base.Sanitize();
Themes.ReadingAppearance appearance = ReadingAppearance.Sanitize();
ContentWidthPx = appearance.WidthPixels;
GlobalCss = appearance.GlobalCss;
RecentFiles = Documents.RecentDocuments.Sanitize(RecentFiles);
Print = (Print ?? new Printing.PrintOptions()).Sanitize();
ExternalEditor = Editors.EditorLaunch.IsExecutablePath(ExternalEditor) ? ExternalEditor : "";
@@ -98,6 +173,9 @@ public sealed class AppSettings : ShellSettings
DocumentTheme = "Catppuccin Mocha";
}
UserThemes = (UserThemes ?? []).Where(IsValidTheme).ToArray();
ThemeOrder = Themes.ThemeManagement.SanitizeNames(ThemeOrder);
DisabledThemes = Themes.ThemeManagement.SanitizeNames(DisabledThemes);
DeletedBuiltInThemes = Themes.ThemeManagement.SanitizeNames(DeletedBuiltInThemes);
}
@@ -111,7 +189,7 @@ public sealed class AppSettings : ShellSettings
}
try
{
_ = Themes.ReaderTheme.Parse(json);
_ = Themes.ReaderTheme.ParseCompatible(json);
return true;
}
catch(JsonException)
@@ -34,7 +34,7 @@ internal static class DocumentFrontmatter
/// <summary>Metadata is display text: split only at the first colon and escape every value before creating table markup.</summary>
public static string RenderMetadata(string metadata)
{
var html = new StringBuilder("<table><caption>Document metadata</caption>");
var html = new StringBuilder("<div class=\"frontmatter\"><table><caption>Document metadata</caption>");
foreach(string line in metadata.Split('\n'))
{
string text = line.Trim();
@@ -46,16 +46,16 @@ internal static class DocumentFrontmatter
html.Append("<tr>");
if(colon >= 0)
{
html.Append("<th>").Append(WebUtility.HtmlEncode(PlainValue(text[..colon]))).Append("</th>");
html.Append("<td>").Append(WebUtility.HtmlEncode(PlainValue(text[(colon + 1)..]))).Append("</td>");
html.Append("<th class=\"fm-key\">").Append(WebUtility.HtmlEncode(PlainValue(text[..colon]))).Append("</th>");
html.Append("<td class=\"fm-value\">").Append(WebUtility.HtmlEncode(PlainValue(text[(colon + 1)..]))).Append("</td>");
}
else
{
html.Append("<td colspan=\"2\">").Append(WebUtility.HtmlEncode(PlainValue(text))).Append("</td>");
html.Append("<td class=\"fm-value\" colspan=\"2\">").Append(WebUtility.HtmlEncode(PlainValue(text))).Append("</td>");
}
html.Append("</tr>");
}
return html.Append("</table>").ToString();
return html.Append("</table></div>").ToString();
}
@@ -19,11 +19,11 @@ public sealed class DocumentOutline
/// <summary>Sanitizes first; authored identifiers cannot impersonate the generated navigation targets.</summary>
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide)
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide, MarkdownExtensions? extensions = null)
{
var sources = new DocumentSourceMap(markdown);
var sourceHeadings = new List<DocumentSourceHeading>();
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().RenderWithSources(markdown, frontmatter, sources));
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer(extensions).RenderWithSources(markdown, frontmatter, sources));
IElement body = document.Body!;
var fragments = new Dictionary<string, string>(StringComparer.Ordinal);
foreach(IElement element in body.QuerySelectorAll("h1,h2,h3,h4,h5,h6"))
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using MarkdownViewr.Windows.Core.Themes;
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Preserves the reference rendered-document containers; TOC/source remain native Windows controls.</summary>
public static class DocumentPage
{
/// <summary>Wraps already-sanitized renderer output; title and theme CSS cannot break their HTML boundaries.</summary>
public static string Build(string sanitizedHtml, string title, ReaderTheme theme, ReadingAppearance appearance, double zoomScale)
{
ArgumentNullException.ThrowIfNull(sanitizedHtml);
ArgumentNullException.ThrowIfNull(title);
ArgumentNullException.ThrowIfNull(theme);
ArgumentNullException.ThrowIfNull(appearance);
return $"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{WebUtility.HtmlEncode(title)}</title><style media="screen">{theme.GetReadingStyles(appearance, zoomScale)}</style></head><body><div id="content"><div id="content-inner">{sanitizedHtml}</div></div></body></html>""";
}
}
@@ -23,6 +23,8 @@ public sealed class DocumentSourceText
private (int Start, int End)[] _ranges = [];
private int[] _rangeStarts = [];
private int[] _removed = [0];
private string _copyText = "";
private int[] _copyBreaks = [];
@@ -124,6 +126,7 @@ public sealed class DocumentSourceText
}
text.Append(_native, cursor, _native.Length - cursor);
Text = text.ToString();
BuildCopyText(ranges);
var headings = new List<DocumentSourceHeading>();
foreach(DocumentSourceHeading heading in _originalHeadings)
{
@@ -160,6 +163,56 @@ public sealed class DocumentSourceText
/// <summary>Indexes copied visible original chunks separately from native paragraph coordinates.</summary>
private void BuildCopyText(List<(int Start, int End)> ranges)
{
var copy = new StringBuilder();
int cursor = 0;
foreach((int start, int end) in ranges)
{
copy.Append(_source, cursor, start - cursor).Append(Ellipsis);
cursor = end;
}
copy.Append(_source, cursor, _source.Length - cursor);
_copyText = copy.ToString();
var copyBreaks = new List<int>();
for(int index = 0; index + 1 < _copyText.Length; index++)
{
if(_copyText[index] == '\r' && _copyText[index + 1] == '\n')
{
copyBreaks.Add(index + 1 - copyBreaks.Count);
index++;
}
}
_copyBreaks = copyBreaks.ToArray();
}
/// <summary>Copies visible literal source with its original newline spelling; folded bodies remain omitted.</summary>
public string GetCopyText(int start, int length)
{
ArgumentOutOfRangeException.ThrowIfNegative(start);
ArgumentOutOfRangeException.ThrowIfNegative(length);
if(start > Text.Length || length > Text.Length - start)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
int copyStart = ToCopy(start);
return _copyText.Substring(copyStart, ToCopy(start + length) - copyStart);
}
/// <summary>A native boundary after a CR includes the original LF of that CRLF, without altering lone line breaks.</summary>
private int ToCopy(int position)
{
int index = Array.BinarySearch(_copyBreaks, position);
return position + (index >= 0 ? index + 1 : ~index);
}
/// <summary>Each original CRLF loses its LF in WinUI text; lone CR/LF and Unicode retain their lengths.</summary>
private int ToNative(int position)
{
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Markdig.Extensions.EmphasisExtras;
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Independent reference Markdown extensions, enabled by default without altering the literal source.</summary>
/// <param name="Highlight">Renders double-equals text as a passive mark.</param>
/// <param name="Superscript">Renders single-caret text above the baseline.</param>
/// <param name="Subscript">Renders single-tilde text below the baseline, leaving double-tilde strikethrough available.</param>
/// <param name="Underline">Renders double-plus text as inserted/underlined content.</param>
public sealed record MarkdownExtensions(bool Highlight = true, bool Superscript = true, bool Subscript = true, bool Underline = true)
{
/// <summary>GFM strikethrough remains available regardless of the four independent extension preferences.</summary>
internal EmphasisExtraOptions EmphasisOptions
{
get
{
EmphasisExtraOptions options = EmphasisExtraOptions.Strikethrough;
if(Highlight)
{
options |= EmphasisExtraOptions.Marked;
}
if(Superscript)
{
options |= EmphasisExtraOptions.Superscript;
}
if(Subscript)
{
options |= EmphasisExtraOptions.Subscript;
}
if(Underline)
{
options |= EmphasisExtraOptions.Inserted;
}
return options;
}
}
}
@@ -3,11 +3,13 @@
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using Ganss.Xss;
using Markdig;
using Markdig.Extensions.EmphasisExtras;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace MarkdownViewr.Windows.Core.Documents;
@@ -18,31 +20,34 @@ public sealed class MarkdownRenderer
/// <summary>Bounds parser work before an opened document can consume unbounded memory.</summary>
public const int MaximumCharacters = 16 * 1024 * 1024;
private static readonly MarkdownPipeline s_pipeline = new MarkdownPipelineBuilder()
.UsePipeTables(new PipeTableOptions { UseGfmRules = true })
.UseTaskLists()
.UseEmphasisExtras(EmphasisExtraOptions.Strikethrough)
.UseAutoLinks()
.UsePreciseSourceLocation()
.Build();
private readonly MarkdownPipeline _pipeline;
private readonly HtmlSanitizer _sanitizer;
/// <summary>Allows document formatting without scripts, CSS, forms or native host capabilities.</summary>
public MarkdownRenderer()
public MarkdownRenderer(MarkdownExtensions? extensions = null)
{
MarkdownPipelineBuilder pipeline = new MarkdownPipelineBuilder()
.UsePipeTables(new PipeTableOptions { UseGfmRules = true })
.UseTaskLists()
.UseEmphasisExtras((extensions ?? new MarkdownExtensions()).EmphasisOptions)
.UseAutoLinks()
.UsePreciseSourceLocation();
pipeline.DocumentProcessed += PreserveLiteralExtensionBoundaries;
_pipeline = pipeline.Build();
_sanitizer = new HtmlSanitizer();
_sanitizer.AllowedTags.Clear();
_sanitizer.AllowedTags.UnionWith(["a", "abbr", "b", "blockquote", "br", "caption", "code", "dd", "del", "details", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "kbd", "li", "ol", "p", "pre", "s", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"]);
_sanitizer.AllowedTags.UnionWith(["a", "abbr", "b", "blockquote", "br", "caption", "code", "dd", "del", "details", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "ins", "kbd", "li", "mark", "ol", "p", "pre", "s", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"]);
_sanitizer.AllowedAttributes.Clear();
_sanitizer.AllowedAttributes.UnionWith(["href", "src", "alt", "title", "align", "colspan", "rowspan", "width", "height", "type", "checked", "disabled"]);
_sanitizer.AllowedAttributes.UnionWith(["href", "src", "alt", "title", "align", "colspan", "rowspan", "width", "height", "type", "checked", "disabled", "class"]);
_sanitizer.AllowedSchemes.Clear();
_sanitizer.AllowedSchemes.UnionWith(["http", "https", "mailto"]);
_sanitizer.AllowedCssProperties.Clear();
_sanitizer.AllowedAtRules.Clear();
_sanitizer.PostProcessNode += MakeInputsPassive;
_sanitizer.PostProcessNode += FilterStylingClasses;
}
@@ -84,15 +89,58 @@ public sealed class MarkdownRenderer
string prefix = !string.IsNullOrEmpty(metadata) ? frontmatter switch
{
FrontmatterMode.Metadata => DocumentFrontmatter.RenderMetadata(metadata),
FrontmatterMode.Markdown => sources is null ? Markdown.ToHtml("---\n" + metadata + "\n---", s_pipeline) : sources.Render("---\n" + metadata + "\n---", s_pipeline, markdown.IndexOf('\n') + 1 - 4),
FrontmatterMode.Markdown => sources is null ? Markdown.ToHtml("---\n" + metadata + "\n---", _pipeline) : sources.Render("---\n" + metadata + "\n---", _pipeline, markdown.IndexOf('\n') + 1 - 4),
_ => "",
} : "";
string html = sources is null ? Markdown.ToHtml(body, s_pipeline) : sources.Render(body, s_pipeline, markdown.Length - body.Length);
string html = sources is null ? Markdown.ToHtml(body, _pipeline) : sources.Render(body, _pipeline, markdown.Length - body.Length);
return _sanitizer.Sanitize(prefix + html);
}
/// <summary>Reference delimiters cannot span code/newlines; single caret/tilde pairs also cannot contain whitespace.</summary>
private static void PreserveLiteralExtensionBoundaries(MarkdownDocument document)
{
foreach(EmphasisInline emphasis in document.Descendants().OfType<EmphasisInline>().ToArray())
{
if(emphasis.DelimiterChar is not ('=' or '+' or '^' or '~') || (emphasis.DelimiterChar == '~' && emphasis.DelimiterCount == 2))
{
continue;
}
bool single = emphasis.DelimiterChar is '^' or '~';
if(emphasis.Descendants().Any(node => node is CodeInline or LineBreakInline || (single && node is LiteralInline literal && literal.Content.ToString().Any(char.IsWhiteSpace))))
{
string delimiter = new(emphasis.DelimiterChar, emphasis.DelimiterCount);
Inline last = emphasis.ReplaceBy(new LiteralInline(delimiter));
last.InsertAfter(new LiteralInline(delimiter));
}
}
}
/// <summary>Retains only passive reference styling classes; native identifiers, handlers and arbitrary document classes remain unavailable.</summary>
private static void FilterStylingClasses(object? sender, PostProcessNodeEventArgs args)
{
if(args.Node is IElement element && element.GetAttribute("class") is { } classes)
{
string[] allowed = classes.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Where(value =>
value is "task-list" or "task-list-item" or "frontmatter" or "fm-key" or "fm-value" ||
value.StartsWith("language-", StringComparison.Ordinal) && value.Length is > 9 and <= 80 &&
value[9..].All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '+' or '#')).Distinct(StringComparer.Ordinal).ToArray();
if(allowed.Length == 0)
{
element.RemoveAttribute("class");
}
else
{
element.SetAttribute("class", string.Join(' ', allowed));
}
}
}
/// <summary>Task markers are display-only checkboxes; authored inputs cannot become active controls.</summary>
private static void MakeInputsPassive(object? sender, PostProcessNodeEventArgs args)
{
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Markdig;
using Markdig.Extensions.TaskLists;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Classifies literal UTF16 source spans with the existing parser; no token renders or executes document content.</summary>
public static class MarkdownSourceHighlighting
{
private static readonly MarkdownPipeline s_pipeline = new MarkdownPipelineBuilder().UsePipeTables().UseTaskLists().UseEmphasisExtras().UseAutoLinks().UseYamlFrontMatter().UsePreciseSourceLocation().Build();
/// <summary>Parser work is bounded by the supported document limit; cancellation discards stale classification during traversal.</summary>
public static IReadOnlyList<SourceToken> Parse(string source, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
if(source.Length > MarkdownRenderer.MaximumCharacters)
{
throw new ArgumentException("The source exceeds the document size limit.", nameof(source));
}
cancellationToken.ThrowIfCancellationRequested();
MarkdownDocument document = Markdown.Parse(source, s_pipeline);
var tokens = new List<SourceToken>();
foreach(MarkdownObject node in document.Descendants())
{
cancellationToken.ThrowIfCancellationRequested();
SourceTokenKind? kind = Kind(node);
if(kind is { } value && node.Span.Start >= 0 && node.Span.End >= node.Span.Start && node.Span.End < source.Length)
{
int length = node.Span.End - node.Span.Start + 1;
if(node is ListItemBlock)
{
length = ListMarkerLength(source.AsSpan(node.Span.Start, length));
}
if(length > 0)
{
tokens.Add(new SourceToken(node.Span.Start, length, value));
}
}
}
return tokens.AsReadOnly();
}
/// <summary>Preorder parent/child spans permit nested markup to refine a block's color without changing its literal characters.</summary>
private static SourceTokenKind? Kind(MarkdownObject node) => node switch
{
YamlFrontMatterBlock => SourceTokenKind.Frontmatter,
HeadingBlock => SourceTokenKind.Heading,
CodeBlock or CodeInline => SourceTokenKind.Code,
EmphasisInline emphasis when emphasis.DelimiterChar == '~' && emphasis.DelimiterCount == 2 => SourceTokenKind.Strikethrough,
EmphasisInline emphasis when emphasis.DelimiterCount == 2 => SourceTokenKind.Strong,
EmphasisInline => SourceTokenKind.Emphasis,
LinkInline { IsImage: true } => SourceTokenKind.Image,
LinkInline or AutolinkInline => SourceTokenKind.Link,
ListItemBlock or TaskList => SourceTokenKind.List,
QuoteBlock => SourceTokenKind.Quote,
HtmlBlock or HtmlInline => SourceTokenKind.Html,
_ => null,
};
/// <summary>Only a list marker is colored; ordinary list prose remains readable and child code/emphasis keeps its own style.</summary>
private static int ListMarkerLength(ReadOnlySpan<char> span)
{
int length = 0;
while(length < span.Length && char.IsAsciiDigit(span[length]))
{
length++;
}
// Markdig's valid ListItemBlock span begins at its bullet or ordered marker.
return length + 1;
}
}
@@ -0,0 +1,9 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Offsets are UTF16 positions into the exact supplied text, including all whitespace and line breaks.</summary>
public sealed record SourceToken(int Start, int Length, SourceTokenKind Kind);
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Semantic source colors remain independent of HTML/CSS and native capabilities.</summary>
public enum SourceTokenKind
{
Heading,
Code,
Emphasis,
Strong,
Strikethrough,
Link,
Image,
List,
Quote,
Frontmatter,
Html,
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Layout;
/// <summary>Allocates the space shared only by the document and source, preserving usable columns without a fixed maximum source width.</summary>
public static class SourcePaneLayout
{
/// <summary>A narrow window keeps the two panes equal; larger windows reserve160 source pixels and200 document pixels.</summary>
public static double Fraction(double available, double preferred = 0.5)
{
if(!double.IsFinite(available) || available <= 0)
{
return 0.5;
}
if(!double.IsFinite(preferred))
{
preferred = 0.5;
}
double minimum = Math.Min(160, available / 2) / available;
double maximum = 1 - Math.Min(200, available / 2) / available;
return Math.Clamp(preferred, minimum, maximum);
}
/// <summary>Dragging right reduces the source; invalid input cannot hide either reading surface.</summary>
public static double Resize(double available, double sourceWidth, double horizontalChange)
{
if(!double.IsFinite(available) || available <= 0 || !double.IsFinite(sourceWidth) || !double.IsFinite(horizontalChange))
{
return 0.5;
}
return Fraction(available, (sourceWidth - horizontalChange) / available);
}
}
@@ -25,6 +25,7 @@
<ItemGroup>
<EmbeddedResource Include="Themes/BuiltIn/*.json" />
<EmbeddedResource Include="Themes/Document.css" LogicalName="MarkdownViewr.Windows.Core.Themes.Document.css" />
</ItemGroup>
</Project>
@@ -28,4 +28,6 @@ public enum ReaderCommand
ZoomOut,
ZoomReset,
Print,
NextTheme,
PreviousTheme,
}
@@ -72,6 +72,10 @@ public sealed class ReaderKeyboard
{
return key.Character == '-' ? ReaderCommand.ZoomOut : ReaderCommand.ZoomReset;
}
if(readingFocused && key.Shift && key.Character is '\u2193' or '\u2191')
{
return key.Character == '\u2193' ? ReaderCommand.NextTheme : ReaderCommand.PreviousTheme;
}
if(key.Shift)
{
return ReaderCommand.None;
@@ -45,9 +45,11 @@ public static class PrintStyles
"Clean Printing" => s_clean,
_ => themes.FirstOrDefault(candidate => string.Equals(candidate.Name, options.Theme, StringComparison.Ordinal)) ?? s_clean,
};
var css = new StringBuilder(theme?.GetStyles(options.MatchScreenZoom ? readingZoom : 1) ?? "");
var css = new StringBuilder("#content-inner{max-width:900px;margin:0 auto;}" + (theme?.GetStyles(options.MatchScreenZoom ? readingZoom : 1) ?? ""));
css.Append("""
html,body{height:auto!important;overflow:visible!important;display:block!important;}
html,body,#content{height:auto!important;overflow:visible!important;display:block!important;}
#content{width:100%!important;max-width:none!important;margin:0!important;padding:0!important;}
#content-inner{padding:0!important;}
[data-mv-body][hidden]{display:block!important;}
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important;}
h1,h2,h3,h4,h5,h6{break-after:avoid!important;}
@@ -59,7 +61,7 @@ public static class PrintStyles
css.Append("body{padding:").Append(options.PaddingPixels.ToString("R", CultureInfo.InvariantCulture)).Append("px!important;}");
if(options.FullPage)
{
css.Append("body{max-width:none!important;margin:0!important;}");
css.Append("#content-inner{max-width:none!important;margin:0!important;}");
}
if(!options.Backgrounds)
{
@@ -0,0 +1,260 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
* Document rules adapted from markdownViewr template.html at
* 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021.
* TOC/source/resizers are native WinUI controls; print rules are owned by PrintStyles.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text) 30%, var(--bg));
border: 3px solid var(--bg);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--text) 45%, var(--bg));
}
::-webkit-scrollbar-corner {
background: var(--bg);
}
html {
height: 100%;
overflow: hidden;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--body-font);
font-size: var(--base-font-size);
line-height: var(--line-height);
padding: 0;
-webkit-font-smoothing: antialiased;
display: flex;
align-items: flex-start;
height: 100vh;
overflow: hidden;
}
#content {
flex: 1;
min-width: 0;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
}
#content-inner {
padding: 32px 48px;
}
h1, h2, h3, h4, h5, h6 { font-family: var(--heading-font); font-weight: 600; cursor: pointer; user-select: none; scroll-margin-top: 1rem; }
h1 .collapse-arrow, h2 .collapse-arrow, h3 .collapse-arrow,
h4 .collapse-arrow, h5 .collapse-arrow, h6 .collapse-arrow {
display: inline-block;
width: 0.6em;
font-size: 0.6em;
margin-right: 10px;
transition: transform 0.15s ease;
opacity: 0.4;
vertical-align: middle;
}
h1:hover .collapse-arrow, h2:hover .collapse-arrow, h3:hover .collapse-arrow,
h4:hover .collapse-arrow, h5:hover .collapse-arrow, h6:hover .collapse-arrow {
opacity: 0.8;
}
h1.collapsed .collapse-arrow, h2.collapsed .collapse-arrow, h3.collapsed .collapse-arrow,
h4.collapsed .collapse-arrow, h5.collapsed .collapse-arrow, h6.collapsed .collapse-arrow {
transform: rotate(-90deg);
}
h1 { color: var(--h1); font-size: var(--h1-size); margin: 24px 0 16px 0; padding-bottom: 8px; border-bottom: 1px solid color-mix(in srgb, var(--text) 20%, transparent); }
h2 { color: var(--h2); font-size: var(--h2-size); margin: 20px 0 12px 0; padding-bottom: 6px; border-bottom: 1px solid color-mix(in srgb, var(--text) 10%, transparent); }
h3 { color: var(--h3); font-size: var(--h3-size); margin: 18px 0 10px 0; }
h4 { color: var(--h4); font-size: var(--h4-size); margin: 16px 0 8px 0; }
h5 { color: var(--h5); font-size: var(--h5-size); margin: 14px 0 6px 0; }
h6 { color: var(--h6); font-size: var(--h6-size); margin: 14px 0 6px 0; }
h1:first-child, h2:first-child, h3:first-child { margin-top: 0; }
p {
margin: 0 0 16px 0;
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
pre {
background: var(--code-bg);
border-radius: 8px;
padding: 16px;
margin: 0 0 16px 0;
overflow-x: auto;
}
pre code {
font-family: var(--code-font);
font-size: var(--code-font-size);
color: var(--code-text);
background: none;
padding: 0;
border-radius: 0;
}
code {
font-family: var(--code-font);
font-size: 0.9em;
background: var(--code-bg);
color: var(--code-text);
padding: 2px 6px;
border-radius: 4px;
}
blockquote {
border-left: 3px solid var(--blockquote-border);
padding: 8px 16px;
margin: 0 0 16px 0;
background: color-mix(in srgb, var(--blockquote-bg) 50%, transparent);
border-radius: 0 8px 8px 0;
}
blockquote blockquote {
background: color-mix(in srgb, var(--blockquote-bg) 75%, transparent);
}
blockquote blockquote blockquote {
background: var(--blockquote-bg);
}
blockquote p:last-child {
margin-bottom: 0;
}
ul, ol {
padding-left: 24px;
margin: 0 0 16px 0;
}
li {
margin-bottom: 4px;
}
li.task-list-item {
list-style: none;
}
li.task-list-item > input[type="checkbox"] {
margin: 0 0.4em 0.15em -1.4em;
vertical-align: middle;
}
li > ul, li > ol {
margin-top: 4px;
margin-bottom: 4px;
}
hr {
border: none;
border-top: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
margin: 24px 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0 0 16px 0;
}
th, td {
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
padding: 8px 12px;
text-align: left;
}
th {
background: var(--code-bg);
font-weight: 600;
}
img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
del {
opacity: 0.6;
}
mark {
background-color: var(--highlight-bg);
color: var(--highlight-text);
border-radius: 2px;
padding: 0 2px;
}
.frontmatter {
margin: 0 0 24px 0;
padding: 12px 16px;
background: var(--blockquote-bg);
border-radius: 8px;
border-left: 3px solid var(--blockquote-border);
font-size: 0.85em;
}
.frontmatter table {
width: auto;
border: none;
margin: 0;
}
.frontmatter td {
border: none;
padding: 2px 12px 2px 0;
vertical-align: top;
}
.frontmatter .fm-key {
color: var(--link);
font-weight: 600;
white-space: nowrap;
}
/* Generated collapse sections retain the spacing of the original flat document. */
#content-inner section > h1 { margin-top: 24px; }
#content-inner section > h2 { margin-top: 20px; }
#content-inner section > h3 { margin-top: 18px; }
#content-inner > section:first-child > h1,
#content-inner > section:first-child > h2,
#content-inner > section:first-child > h3 { margin-top: 0; }
/* Windows keeps accessible metadata row headings and literal overflow safe. */
#content-inner { overflow-wrap: anywhere; }
.frontmatter th { border: none; padding: 2px 12px 2px 0; vertical-align: top; background: none; }
kbd { font-family: var(--code-font); color: var(--code-text); }
ins { text-decoration: underline; }
::selection { background: var(--highlight-bg); color: var(--highlight-text); }
@@ -7,6 +7,7 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace MarkdownViewr.Windows.Core.Themes;
@@ -14,18 +15,21 @@ namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Validates Darin's version-one theme format before generating passive document CSS.</summary>
public sealed class ReaderTheme
{
private const int MaximumCharacters = 128 * 1024;
/// <summary>Bounds schema input before a native file read allocates the complete JSON text.</summary>
public const int MaximumCharacters = 128 * 1024;
private static readonly string[] s_colors = ["background", "text", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "link", "codeBackground", "codeText", "blockquoteBorder", "blockquoteBackground", "highlightBackground", "highlightText"];
private static readonly string[] s_sizes = ["baseFontSize", "h1Size", "h2Size", "h3Size", "h4Size", "h5Size", "h6Size", "codeFontSize", "lineHeight"];
private static readonly string s_baseStyles = LoadBaseStyles();
/// <summary>Retains only the validated display name and generated CSS.</summary>
private ReaderTheme(string name, string styles, string json)
private ReaderTheme(string name, string styles, string json, int schemaVersion)
{
Name = name;
Styles = styles;
Json = json;
SchemaVersion = schemaVersion;
}
@@ -45,6 +49,65 @@ public sealed class ReaderTheme
/// <summary>A compatible newer schema requires deliberate native confirmation before import, while its version is retained on export.</summary>
public int SchemaVersion { get; }
/// <summary>Copies a validated theme under another display name without dropping metadata or changing its schema version.</summary>
public ReaderTheme Rename(string name)
{
JsonNode source = JsonNode.Parse(Json)!;
source["name"] = name;
return ParseCompatible(source.ToJsonString());
}
/// <summary>Edits a copy of the original schema, preserving unrelated metadata and rejecting invalid controls before persistence.</summary>
public ReaderTheme Edit(string name, IReadOnlyDictionary<string, string> fonts, IReadOnlyDictionary<string, double> sizes, IReadOnlyDictionary<string, string> colors, string customCss)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(fonts);
ArgumentNullException.ThrowIfNull(sizes);
ArgumentNullException.ThrowIfNull(colors);
ArgumentNullException.ThrowIfNull(customCss);
JsonNode source = JsonNode.Parse(Json)!;
source["name"] = name.Trim();
source["customCSS"] = customCss;
foreach((string key, string value) in fonts)
{
if(key is not ("body" or "heading" or "code"))
{
throw new ArgumentException("Unknown theme font field.", nameof(fonts));
}
source["fonts"]![key] = value?.Trim();
}
foreach((string key, double value) in sizes)
{
if(!s_sizes.Contains(key, StringComparer.Ordinal))
{
throw new ArgumentException("Unknown theme size field.", nameof(sizes));
}
if(!double.IsFinite(value))
{
throw new JsonException("Enter finite font sizes between 6 and 96, and line height between 0.5 and 4.");
}
source["sizes"]![key] = value;
}
foreach((string key, string value) in colors)
{
if(!s_colors.Contains(key, StringComparer.Ordinal))
{
throw new ArgumentException("Unknown theme color field.", nameof(colors));
}
source["colors"]![key] = value?.Trim();
}
return ParseCompatible(source.ToJsonString());
}
/// <summary>Scales only validated font sizes; colors, fonts, line height and custom CSS retain their original semantics.</summary>
public string GetStyles(double zoomScale)
{
@@ -58,8 +121,33 @@ public sealed class ReaderTheme
/// <summary>Applies reference global/theme/width precedence only to reading styles, leaving print styles and theme JSON independent.</summary>
public string GetReadingStyles(ReadingAppearance appearance, double zoomScale)
{
ArgumentNullException.ThrowIfNull(appearance);
appearance.Validate();
if(!double.IsFinite(zoomScale) || zoomScale is < 0.3 or > 5)
{
throw new ArgumentOutOfRangeException(nameof(zoomScale));
}
using var document = JsonDocument.Parse(Json);
return BuildStyles(document.RootElement, zoomScale, appearance);
}
/// <summary>Accepts the original schema, including themes without an explicit version number.</summary>
public static ReaderTheme Parse(string json)
public static ReaderTheme Parse(string json) => ParseSchema(json, allowNewer: false);
/// <summary>Validates every known field of saved themes or pending imports; native import still requires explicit newer-version confirmation.</summary>
public static ReaderTheme ParseCompatible(string json) => ParseSchema(json, allowNewer: true);
/// <summary>Compatibility never bypasses JSON depth, field, font, color, size or CSS checks.</summary>
private static ReaderTheme ParseSchema(string json, bool allowNewer)
{
ArgumentNullException.ThrowIfNull(json);
if(json.Length > MaximumCharacters)
@@ -69,9 +157,10 @@ public sealed class ReaderTheme
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
JsonElement root = document.RootElement;
RequireObject(root);
if(root.TryGetProperty("schemaVersion", out JsonElement version) && (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out int number) || number != 1))
int schemaVersion = 1;
if(root.TryGetProperty("schemaVersion", out JsonElement version) && (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out schemaVersion) || schemaVersion < 1 || !allowNewer && schemaVersion != 1))
{
throw new JsonException("Only theme schema version 1 is supported.");
throw new JsonException("The theme schema version is invalid or unsupported.");
}
string name = ReadString(root, "name");
if(string.IsNullOrWhiteSpace(name) || name.Length > 80 || name.Any(char.IsControl))
@@ -79,13 +168,13 @@ public sealed class ReaderTheme
throw new JsonException("Theme names must contain 1–80 printable characters.");
}
string styles = BuildStyles(root);
return new ReaderTheme(name, styles, json);
return new ReaderTheme(name, styles, json, schemaVersion);
}
/// <summary>Constructs variables only from validated colors, finite sizes and escaped font families.</summary>
private static string BuildStyles(JsonElement root, double zoomScale = 1)
private static string BuildStyles(JsonElement root, double zoomScale = 1, ReadingAppearance? appearance = null)
{
var styles = new StringBuilder(":root {");
JsonElement colors = ReadObject(root, "colors");
@@ -104,12 +193,25 @@ public sealed class ReaderTheme
{
styles.Append("--").Append(field).Append("Font:").Append(FontFamily(ReadString(fonts, field))).Append(';');
}
styles.Append('}').Append(BaseStyles);
styles.Append("--bg:var(--background);--h1:var(--heading1);--h2:var(--heading2);--h3:var(--heading3);--h4:var(--heading4);--h5:var(--heading5);--h6:var(--heading6);");
styles.Append("--code-bg:var(--codeBackground);--code-text:var(--codeText);--blockquote-border:var(--blockquoteBorder);--blockquote-bg:var(--blockquoteBackground);");
styles.Append("--highlight-bg:var(--highlightBackground);--highlight-text:var(--highlightText);--body-font:var(--bodyFont);--heading-font:var(--headingFont);--code-font:var(--codeFont);");
styles.Append("--base-font-size:var(--baseFontSize);--h1-size:var(--h1Size);--h2-size:var(--h2Size);--h3-size:var(--h3Size);--h4-size:var(--h4Size);--h5-size:var(--h5Size);--h6-size:var(--h6Size);");
styles.Append("--code-font-size:var(--codeFontSize);--line-height:var(--lineHeight);--zoom:").Append(zoomScale.ToString("R", CultureInfo.InvariantCulture));
styles.Append(";}").Append(s_baseStyles);
if(appearance is not null)
{
styles.Append(appearance.GlobalCss.Replace("<", "\\3c ", StringComparison.Ordinal));
}
if(root.TryGetProperty("customCSS", out _))
{
// CSS can style the page, but it cannot close the HTML style element.
styles.Append(ReadString(root, "customCSS").Replace("<", "\\3c ", StringComparison.Ordinal));
}
if(appearance?.LimitWidth == true)
{
styles.Append("#content-inner{max-width:calc(").Append(appearance.WidthPixels.ToString("R", CultureInfo.InvariantCulture)).Append("px * var(--zoom));margin:auto;}");
}
return styles.ToString();
}
@@ -187,22 +289,11 @@ public sealed class ReaderTheme
private const string BaseStyles = """
html { background: var(--background); color: var(--text); }
body { max-width: 900px; margin: 0 auto; padding: 32px; overflow-wrap: anywhere;
font: var(--baseFontSize)/var(--lineHeight) var(--bodyFont); }
h1,h2,h3,h4,h5,h6 { font-family: var(--headingFont); line-height: 1.25; margin-top: 1.5em; }
h1 { color: var(--heading1); font-size: var(--h1Size); }
h2 { color: var(--heading2); font-size: var(--h2Size); }
h3 { color: var(--heading3); font-size: var(--h3Size); }
h4 { color: var(--heading4); font-size: var(--h4Size); }
h5 { color: var(--heading5); font-size: var(--h5Size); }
h6 { color: var(--heading6); font-size: var(--h6Size); }
pre { padding: 16px; overflow: auto; background: var(--codeBackground); border-radius: 6px; }
code,kbd { color: var(--codeText); font: var(--codeFontSize)/var(--lineHeight) var(--codeFont); }
table { border-collapse: collapse; } th,td { border: 1px solid var(--blockquoteBorder); padding: 6px 12px; }
blockquote { margin-left: 0; padding: 8px 20px; border-left: 4px solid var(--blockquoteBorder); background: var(--blockquoteBackground); }
img { max-width: 100%; height: auto; } a { color: var(--link); }
::selection { background: var(--highlightBackground); color: var(--highlightText); }
""";
/// <summary>Embeds the pinned reference document rules without network access or a runtime asset-path dependency.</summary>
private static string LoadBaseStyles()
{
using Stream stream = typeof(ReaderTheme).Assembly.GetManifestResourceStream("MarkdownViewr.Windows.Core.Themes.Document.css")!;
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Reader-wide layout and CSS remain independent of saved theme data and print preferences.</summary>
/// <param name="LimitWidth">Whether the reader applies a finite content width, enabled by default in the reference.</param>
/// <param name="WidthPixels">Maximum content width before reading zoom is applied.</param>
/// <param name="GlobalCss">Native user CSS applied before per-theme CSS.</param>
public sealed record ReadingAppearance(bool LimitWidth = true, double WidthPixels = 1000, string GlobalCss = "")
{
/// <summary>Bounds native CSS input and persisted layout without allocating unbounded style output.</summary>
public const int MaximumCssCharacters = 128 * 1024;
/// <summary>Repairs corrupt saved values while retaining the user's valid preferences.</summary>
public ReadingAppearance Sanitize() => this with
{
WidthPixels = double.IsFinite(WidthPixels) && WidthPixels > 0 ? Math.Clamp(WidthPixels, 200, 4000) : 1000,
GlobalCss = GlobalCss is null || GlobalCss.Length > MaximumCssCharacters ? "" : GlobalCss,
};
/// <summary>Invalid native drafts stay open; repairing stored values must not silently accept an invalid edit.</summary>
public void Validate()
{
if(!double.IsFinite(WidthPixels) || WidthPixels is < 200 or > 4000)
{
throw new InvalidOperationException("Enter a content width between 200 and 4000 pixels.");
}
if(GlobalCss is null)
{
throw new InvalidOperationException("Global CSS cannot be null.");
}
if(GlobalCss.Length > MaximumCssCharacters)
{
throw new InvalidOperationException("Global CSS exceeds the 128 Ki-character limit.");
}
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text.Json;
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Combines document accent colors with Windows light/dark source backgrounds and readable foreground contrast.</summary>
public sealed class SourcePalette
{
private readonly Dictionary<SourceTokenKind, string> _colors = [];
/// <summary>Validated document colors are adjusted only for the source's native background, never written back to theme data.</summary>
public SourcePalette(ReaderTheme theme, bool dark)
{
ArgumentNullException.ThrowIfNull(theme);
Background = dark ? "#1E1E1E" : "#FFFFFF";
Foreground = dark ? "#D4D4D4" : "#1F1F1F";
using var json = JsonDocument.Parse(theme.Json);
JsonElement colors = json.RootElement.GetProperty("colors");
foreach(SourceTokenKind kind in Enum.GetValues<SourceTokenKind>())
{
string key = kind switch
{
SourceTokenKind.Heading => "heading1",
SourceTokenKind.Emphasis => "heading3",
SourceTokenKind.Strong => "heading2",
SourceTokenKind.Strikethrough => "heading6",
SourceTokenKind.Image => "heading4",
SourceTokenKind.List => "heading5",
SourceTokenKind.Quote or SourceTokenKind.Html => "blockquoteBorder",
SourceTokenKind.Code or SourceTokenKind.Frontmatter => "codeText",
_ => "link",
};
_colors.Add(kind, Readable(colors.GetProperty(key).GetString()!));
}
}
/// <summary>Shell mode defines the source background independently of the reader's document palette.</summary>
public string Background { get; }
/// <summary>Ordinary source remains readable when a document palette was authored for a different light/dark mode.</summary>
public string Foreground { get; }
/// <summary>Only known source kinds can request native colors.</summary>
public string Color(SourceTokenKind kind) => _colors[kind];
/// <summary>WCAG luminance measures native foreground/background contrast, including custom document palettes.</summary>
public static double Contrast(string foreground, string background)
{
double first = Luminance(foreground);
double second = Luminance(background);
return (Math.Max(first, second) + 0.05) / (Math.Min(first, second) + 0.05);
}
/// <summary>Bounded blending retains the document hue while ensuring at least4.5:1 contrast against the native background.</summary>
private string Readable(string color)
{
string candidate = color;
int step = 0;
while(Contrast(candidate, Background) < 4.5)
{
// At step10 the known foreground guarantees contrast, even for a matching-background custom accent.
double amount = ++step / 10d;
byte[] channels = new byte[3];
for(int channel = 0; channel < 3; channel++)
{
double original = Channel(color, channel);
double target = Channel(Foreground, channel);
channels[channel] = (byte)Math.Round(original + (target - original) * amount);
}
candidate = FormattableString.Invariant($"#{channels[0]:X2}{channels[1]:X2}{channels[2]:X2}");
}
return candidate;
}
/// <summary>Theme validation already restricts input to RGB hex colors; sRGB channels are converted to linear light.</summary>
private static double Luminance(string color)
{
double value = 0;
double[] weights = [0.2126, 0.7152, 0.0722];
for(int channel = 0; channel < 3; channel++)
{
double component = Channel(color, channel) / 255d;
double linear = component <= 0.04045 ? component / 12.92 : Math.Pow((component + 0.055) / 1.055, 2.4);
value += linear * weights[channel];
}
return value;
}
/// <summary>Extracts only validated numeric RGB channels; authored CSS and font names are not interpreted.</summary>
private static byte Channel(string color, int channel) => byte.Parse(color.AsSpan(1 + channel * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Deliberate native choices match the reference theme import conflict behavior.</summary>
public enum ThemeImportResolution
{
Overwrite,
KeepBoth,
Skip,
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text;
using System.Text.Json;
namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Reads only bounded theme JSON; exports retain the validated original schema and metadata.</summary>
public static class ThemeJson
{
/// <summary>The caller owns the selected stream; an oversized/invalid file never reaches saved settings.</summary>
public static async Task<ReaderTheme> ReadAsync(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new StreamReader(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true);
char[] text = new char[ReaderTheme.MaximumCharacters + 1];
int count = await reader.ReadBlockAsync(text.AsMemory(), cancellationToken).ConfigureAwait(false);
if(count > ReaderTheme.MaximumCharacters)
{
throw new JsonException("The theme exceeds the 128 Ki-character limit.");
}
return ReaderTheme.ParseCompatible(new string(text, 0, count));
}
/// <summary>Suggests the reference's lowercase hyphenated JSON name without Windows path characters or reserved device names.</summary>
public static string SuggestedFileName(ReaderTheme theme)
{
ArgumentNullException.ThrowIfNull(theme);
string stem = string.Concat(theme.Name.Select(character => "<>:\"/\\|?*".Contains(character, StringComparison.Ordinal) ? '_' : character))
.Replace(' ', '-').ToLowerInvariant().Trim('.', '-');
if(stem.Length == 0)
{
stem = "theme";
}
string device = stem.Split('.')[0];
if(device is "con" or "prn" or "aux" or "nul" || device.Length == 4 && (device.StartsWith("com", StringComparison.Ordinal) || device.StartsWith("lpt", StringComparison.Ordinal)) && device[3] is >= '1' and <= '9')
{
stem = "theme-" + stem;
}
return stem + ".json";
}
}
@@ -0,0 +1,231 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Copied native theme-list drafts retain reference order, disabled choices and restorable built-ins.</summary>
public static class ThemeManagement
{
/// <summary>Corrupt list names cannot create unbounded menus or change display identities.</summary>
public static string[] SanitizeNames(IEnumerable<string?>? names) => (names ?? [])
.Where(name => !string.IsNullOrWhiteSpace(name) && name.Length <= 80 && !name.Any(char.IsControl))
.Select(name => name!).Distinct(StringComparer.Ordinal).Take(512).ToArray();
/// <summary>Disabled choices remain in the manager, while deleted built-ins stay hidden until restoration.</summary>
public static IReadOnlyList<ReaderTheme> All(AppSettings settings)
{
IReadOnlyList<ReaderTheme> combined = ThemeCatalog.Combine(settings.UserThemes.Select(ReaderTheme.ParseCompatible));
return combined.Where(theme => !settings.DeletedBuiltInThemes.Contains(theme.Name, StringComparer.Ordinal))
.OrderBy(theme => Array.IndexOf(settings.ThemeOrder, theme.Name) is int index && index >= 0 ? index : int.MaxValue)
.ThenBy(theme => theme.Name, StringComparer.Ordinal).ToArray();
}
/// <summary>Reading menus and cycling use only enabled choices in the saved order.</summary>
public static IReadOnlyList<ReaderTheme> Enabled(AppSettings settings) => All(settings)
.Where(theme => !settings.DisabledThemes.Contains(theme.Name, StringComparer.Ordinal)).ToArray();
/// <summary>Removing an active choice falls back to the first enabled theme; an empty list retains a safe bundled palette.</summary>
public static ReaderTheme Active(AppSettings settings)
{
IReadOnlyList<ReaderTheme> enabled = Enabled(settings);
return enabled.FirstOrDefault(theme => string.Equals(theme.Name, settings.DocumentTheme, StringComparison.Ordinal))
?? (enabled.Count > 0 ? enabled[0] : ThemeCatalog.Find(null));
}
/// <summary>Previous/next wrap over enabled choices; an empty list has no cycle action.</summary>
public static ReaderTheme? Cycle(AppSettings settings, bool forward)
{
ReaderTheme[] enabled = Enabled(settings).ToArray();
if(enabled.Length == 0)
{
return null;
}
int index = Array.FindIndex(enabled, theme => string.Equals(theme.Name, settings.DocumentTheme, StringComparison.Ordinal));
int current = Math.Max(0, index);
return enabled[(current + (forward ? 1 : enabled.Length - 1)) % enabled.Length];
}
/// <summary>Moves one visible choice without mutating the caller's saved arrays; disabled themes retain their place.</summary>
public static AppSettings Move(AppSettings settings, string name, bool up)
{
AppSettings draft = SettingsChanges.Copy(settings);
string[] order = All(settings).Select(theme => theme.Name).ToArray();
int index = Array.IndexOf(order, name);
int next = index + (up ? -1 : 1);
if(index >= 0 && next >= 0 && next < order.Length)
{
(order[index], order[next]) = (order[next], order[index]);
draft.ThemeOrder = order;
}
return draft;
}
/// <summary>Enabled changes retain theme data and allow an intentionally empty reading menu.</summary>
public static AppSettings SetEnabled(AppSettings settings, string name, bool enabled)
{
AppSettings draft = SettingsChanges.Copy(settings);
if(All(settings).Any(theme => string.Equals(theme.Name, name, StringComparison.Ordinal)))
{
draft.DisabledThemes = enabled ? draft.DisabledThemes.Where(item => !string.Equals(item, name, StringComparison.Ordinal)).ToArray()
: draft.DisabledThemes.Append(name).Distinct(StringComparer.Ordinal).ToArray();
draft.DocumentTheme = Active(draft).Name;
}
return draft;
}
/// <summary>Deletes user data and hides a same-named built-in; restoration never requires deleting files outside settings.</summary>
public static AppSettings Delete(AppSettings settings, string name)
{
AppSettings draft = SettingsChanges.Copy(settings);
draft.UserThemes = draft.UserThemes.Where(json => !string.Equals(ReaderTheme.ParseCompatible(json).Name, name, StringComparison.Ordinal)).ToArray();
draft.DisabledThemes = draft.DisabledThemes.Where(item => !string.Equals(item, name, StringComparison.Ordinal)).ToArray();
draft.ThemeOrder = draft.ThemeOrder.Where(item => !string.Equals(item, name, StringComparison.Ordinal)).ToArray();
if(ThemeCatalog.BuiltIn.Any(theme => string.Equals(theme.Name, name, StringComparison.Ordinal)))
{
draft.DeletedBuiltInThemes = draft.DeletedBuiltInThemes.Append(name).Distinct(StringComparer.Ordinal).ToArray();
}
draft.DocumentTheme = Active(draft).Name;
return draft;
}
/// <summary>Reference restoration keeps built-in edits as uniquely named custom copies, restores originals and enables every choice.</summary>
public static AppSettings RestoreBuiltIns(AppSettings settings)
{
AppSettings draft = SettingsChanges.Copy(settings);
var builtIns = ThemeCatalog.BuiltIn.Select(theme => theme.Name).ToHashSet(StringComparer.Ordinal);
ReaderTheme[] users = settings.UserThemes.Select(ReaderTheme.ParseCompatible).GroupBy(theme => theme.Name, StringComparer.Ordinal).Select(group => group.Last()).ToArray();
var taken = builtIns.Concat(users.Select(theme => theme.Name)).ToHashSet(StringComparer.Ordinal);
var renamed = new Dictionary<string, string>(StringComparer.Ordinal);
draft.UserThemes = users.Select(theme =>
{
if(!builtIns.Contains(theme.Name))
{
return theme.Json;
}
string name = UniqueName(theme.Name, taken);
taken.Add(name);
renamed[theme.Name] = name;
return theme.Rename(name).Json;
}).ToArray();
draft.DocumentTheme = renamed.GetValueOrDefault(draft.DocumentTheme, draft.DocumentTheme);
draft.ThemeOrder = draft.ThemeOrder.Select(name => renamed.GetValueOrDefault(name, name)).ToArray();
draft.DisabledThemes = [];
draft.DeletedBuiltInThemes = [];
return draft;
}
/// <summary>Imports a validated copy with explicit conflict semantics; newer schemas cannot be persisted without deliberate confirmation.</summary>
public static AppSettings Import(AppSettings settings, ReaderTheme theme, ThemeImportResolution resolution = ThemeImportResolution.KeepBoth, bool approveNewerSchema = false)
{
ArgumentNullException.ThrowIfNull(theme);
if(!Enum.IsDefined(resolution))
{
throw new ArgumentOutOfRangeException(nameof(resolution));
}
if(theme.SchemaVersion > 1 && !approveNewerSchema)
{
throw new InvalidOperationException("Confirm importing this newer theme schema before saving it.");
}
AppSettings draft = SettingsChanges.Copy(settings);
var names = All(settings).Select(item => item.Name).ToHashSet(StringComparer.Ordinal);
if(names.Contains(theme.Name))
{
if(resolution == ThemeImportResolution.Skip)
{
return draft;
}
if(resolution == ThemeImportResolution.KeepBoth)
{
theme = theme.Rename(UniqueName(theme.Name, names));
}
}
draft.UserThemes = draft.UserThemes.Where(json => !string.Equals(ReaderTheme.ParseCompatible(json).Name, theme.Name, StringComparison.Ordinal)).Append(theme.Json).ToArray();
draft.DeletedBuiltInThemes = draft.DeletedBuiltInThemes.Where(name => !string.Equals(name, theme.Name, StringComparison.Ordinal)).ToArray();
return draft;
}
/// <summary>Editing in place may override its built-in; renaming cannot silently replace a different existing theme.</summary>
public static void ValidateEditName(string originalName, string editedName, IEnumerable<string> existingNames)
{
ArgumentNullException.ThrowIfNull(existingNames);
if(!string.Equals(originalName, editedName, StringComparison.Ordinal) && existingNames.Contains(editedName, StringComparer.Ordinal))
{
throw new System.Text.Json.JsonException("A theme with this name already exists. Choose another name.");
}
}
/// <summary>Saves a copied editor draft and deliberately selects/enables it without hiding another theme under a renamed collision.</summary>
public static AppSettings SaveEdited(AppSettings settings, ReaderTheme original, ReaderTheme edited)
{
ArgumentNullException.ThrowIfNull(original);
ArgumentNullException.ThrowIfNull(edited);
ValidateEditName(original.Name, edited.Name, All(settings).Select(theme => theme.Name));
AppSettings draft = Import(settings, edited, ThemeImportResolution.Overwrite, approveNewerSchema: original.SchemaVersion == edited.SchemaVersion && original.SchemaVersion > 1);
draft.DocumentTheme = edited.Name;
draft.DisabledThemes = draft.DisabledThemes.Where(name => !string.Equals(name, edited.Name, StringComparison.Ordinal)).ToArray();
return draft;
}
/// <summary>A new native draft starts from the safe default palette and a name independent of existing choices.</summary>
public static ReaderTheme NewTheme(AppSettings settings)
=> ThemeCatalog.Find(null).Rename(UniqueName("New theme", All(settings).Select(theme => theme.Name).ToHashSet(StringComparer.Ordinal)));
/// <summary>Copy preserves a selected theme, including a disabled choice, with a unique bounded display name.</summary>
public static ReaderTheme CopyTheme(AppSettings settings, ReaderTheme theme)
{
ArgumentNullException.ThrowIfNull(theme);
string name = theme.Name[..Math.Min(theme.Name.Length, 75)] + " Copy";
return theme.Rename(UniqueName(name, All(settings).Select(item => item.Name).ToHashSet(StringComparer.Ordinal)));
}
/// <summary>Bounded names support deterministic keep-both suffixes without losing original theme fields.</summary>
private static string UniqueName(string name, HashSet<string> taken)
{
if(!taken.Contains(name))
{
return name;
}
for(int number = 2; ; number++)
{
string suffix = " (" + number.ToString(System.Globalization.CultureInfo.InvariantCulture) + ")";
string candidate = name[..Math.Min(name.Length, 80 - suffix.Length)] + suffix;
if(!taken.Contains(candidate))
{
return candidate;
}
}
}
}
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Buffers.Binary;
using System.IO.Pipes;
using System.Text.Json;
namespace MarkdownViewr.Windows;
/// <summary>Bounded current-user IPC delivers sender-canonicalized document paths to the SDK's chosen primary instance.</summary>
internal sealed class ActivationInbox : IDisposable
{
private readonly Lock _gate = new();
private readonly Queue<ActivationRequest> _pending = new();
private readonly CancellationTokenSource _closed = new();
private readonly NamedPipeServerStream _pipe;
private readonly Action<Exception> _report;
private readonly Task _listener;
private Func<ActivationRequest, bool>? _dispatch;
private int _disposed;
/// <summary>The primary owns the pipe before XAML starts; only the same Windows user can connect.</summary>
internal ActivationInbox(string key, Action<Exception> report)
{
_report = report;
_pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
_listener = ListenAsync();
}
/// <summary>The owned listener completes on disposal; tests and shutdown diagnostics can observe failures instead of losing a task.</summary>
internal Task Completion => _listener;
/// <summary>Attaches the native UI dispatcher after launch, delivering bounded requests received while XAML initialized.</summary>
internal void Attach(Func<ActivationRequest, bool> dispatch)
{
lock(_gate)
{
_dispatch = dispatch;
while(_pending.TryDequeue(out ActivationRequest? request))
{
if(!dispatch(request))
{
_report(new IOException("The reader could not accept activation."));
}
}
}
}
/// <summary>Sends one fixed request with a bounded connection/acknowledgement wait and preserves the real server acknowledgement.</summary>
internal static async Task<bool> SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
{
request.Validate();
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
if(payload.Length > ActivationRequest.MaximumPayloadBytes)
{
throw new ArgumentException("The activation payload is too large.", nameof(request));
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
await client.ConnectAsync(timeout.Token);
byte[] length = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
await client.WriteAsync(length, timeout.Token);
await client.WriteAsync(payload, timeout.Token);
await client.FlushAsync(timeout.Token);
byte[] response = new byte[1];
await client.ReadExactlyAsync(response, timeout.Token);
return response[0] == 1;
}
/// <summary>A disconnected or malformed peer cannot stop later activations; each request has a bounded read deadline.</summary>
private async Task ListenAsync()
{
while(!_closed.IsCancellationRequested)
{
try
{
await _pipe.WaitForConnectionAsync(_closed.Token);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
byte[] length = new byte[4];
await _pipe.ReadExactlyAsync(length, timeout.Token);
int size = BinaryPrimitives.ReadInt32LittleEndian(length);
if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
{
throw new InvalidDataException("Invalid activation length.");
}
byte[] payload = new byte[size];
await _pipe.ReadExactlyAsync(payload, timeout.Token);
ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
request.Validate();
bool accepted;
lock(_gate)
{
accepted = _dispatch is null ? QueueBeforeLaunch(request) : _dispatch(request);
}
await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, timeout.Token);
await _pipe.FlushAsync(timeout.Token);
}
catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or ArgumentException or OperationCanceledException or ObjectDisposedException)
{
if(!_closed.IsCancellationRequested)
{
_report(exception);
}
}
finally
{
if(!_closed.IsCancellationRequested && _pipe.IsConnected)
{
_pipe.Disconnect();
}
}
}
}
/// <summary>A startup burst can queue at most16 requests; the peer is told to retry if the native reader is busy.</summary>
private bool QueueBeforeLaunch(ActivationRequest request)
{
if(_pending.Count >= 16)
{
return false;
}
_pending.Enqueue(request);
return true;
}
/// <summary>Application shutdown cancels pending connections and releases the owned current-user pipe.</summary>
public void Dispose()
{
if(Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_closed.Cancel();
_pipe.Dispose();
_closed.Dispose();
}
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
namespace MarkdownViewr.Windows;
/// <summary>Activation JSON is a fixed generated schema with unknown fields rejected, rather than a general command protocol.</summary>
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
[JsonSerializable(typeof(ActivationRequest))]
internal sealed partial class ActivationJsonContext : JsonSerializerContext;
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text;
namespace MarkdownViewr.Windows;
/// <summary>Only bounded literal absolute document paths cross the current-user activation pipe; no native commands are accepted.</summary>
internal sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
{
internal const int MaximumPaths = 128;
internal const int MaximumPayloadBytes = 1024 * 1024;
/// <summary>Canonicalizes at the sender, preserving relative-path meaning even when the existing reader has a different working directory.</summary>
internal static ActivationRequest Create(IEnumerable<string> paths, string workingDirectory)
{
string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
if(candidates.Length > MaximumPaths)
{
throw new ArgumentException("Too many document paths.", nameof(paths));
}
var request = new ActivationRequest(candidates.Select(path => Path.GetFullPath(path, workingDirectory)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
request.Validate();
return request;
}
/// <summary>Malformed, oversized or relative pipe input cannot become an unbounded launch or be interpreted using the receiver's directory.</summary>
internal void Validate()
{
if(Paths is null || Paths.Length > MaximumPaths || Paths.Any(path => string.IsNullOrEmpty(path) || path.Length > 32767 || !Path.IsPathFullyQualified(path) || path.Any(char.IsControl)))
{
throw new InvalidDataException("Invalid document activation paths.");
}
}
/// <summary>Profile and executable directories define distinct stable/dev, development-build and review/test instance identities.</summary>
internal static string InstanceKey(string executableDirectory, string dataDirectory)
{
string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows;
/// <summary>Deliberate OS/process activation opens independent readers, or focuses an already-open document, on the native UI thread.</summary>
public partial class App
{
/// <summary>Empty launch focuses an existing reader; canonical duplicate paths preserve its watcher, find, source and zoom state.</summary>
private void OpenActivation(ActivationRequest request)
{
if(request.Paths.Length == 0)
{
if(_windows.Count == 0)
{
NewDocumentWindow();
}
else
{
_windows[^1].Activate();
}
}
else
{
foreach(string path in request.Paths)
{
NewDocumentWindow(path);
}
}
if(request.HasInvalidPath)
{
_windows[^1].ReportActivationFailure();
}
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows;
/// <summary>Reader-wide native appearance changes reach each open document on the existing UI thread.</summary>
public partial class App
{
private ulong _appearanceRevision;
/// <summary>A newer native save supersedes an older asynchronous broadcast without reopening or replacing any document.</summary>
internal async Task ApplyReadingAppearanceAsync(Core.Themes.ReadingAppearance appearance)
{
ulong revision = ++_appearanceRevision;
foreach(MainWindow window in _windows.ToArray())
{
if(revision != _appearanceRevision)
{
return;
}
await window.ApplyReadingAppearanceAsync(appearance);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows;
/// <summary>Explicit editor saves reach every open reader on the existing native UI thread.</summary>
public partial class App
{
/// <summary>Each window receives a separate settings copy; no background callback can mutate another reader's editor array.</summary>
internal void ApplyEditorSettings(Core.Editors.EditorConfiguration[] choices)
{
foreach(MainWindow window in _windows.ToArray())
{
window.ApplyEditorSettings(choices);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows;
/// <summary>Startup preferences are shared by independent reader windows without changing an in-progress launch.</summary>
public partial class App
{
/// <summary>Updates each native reader and its saved baseline only after startup preferences were persisted successfully.</summary>
internal void ApplyStartupSettings(Core.AppSettings settings)
{
foreach(MainWindow window in _windows.ToArray())
{
window.ApplyStartupSettings(settings);
}
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows;
/// <summary>Native theme preference changes update every open reader on the existing UI thread.</summary>
public partial class App
{
private ulong _themeRevision;
/// <summary>Newer theme saves supersede older asynchronous broadcasts.</summary>
internal async Task ApplyThemeSettingsAsync(Core.AppSettings settings)
{
ulong revision = ++_themeRevision;
foreach(MainWindow window in _windows.ToArray())
{
if(revision != _themeRevision)
{
return;
}
await window.ApplyThemeSettingsAsync(settings);
}
}
}
+3 -12
View File
@@ -32,17 +32,8 @@ public partial class App : Application
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
Log.Info($"{AppIdentity.Info.DisplayName} started.");
string[] paths = Environment.GetCommandLineArgs().Skip(1).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if(paths.Length == 0)
{
NewDocumentWindow();
}
else
{
foreach(string path in paths)
{
NewDocumentWindow(path);
}
}
OpenActivation(Program.InitialRequest);
var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
Program.Inbox?.Attach(request => dispatcher.TryEnqueue(() => OpenActivation(request)));
}
}
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>Extends published AppKit's About body with linked contributions and complete dependency credits.</summary>
internal static class AppAbout
{
/// <summary>Preserves AppKit's header/build identity and license, then adds contributions inside the same scrolling body.</summary>
public static AboutDialog Create(XamlRoot root)
{
var dialog = new AboutDialog(AppIdentity.Info with { Attributions = [] }) { XamlRoot = root };
var scroll = (ScrollViewer)dialog.Content;
var body = (StackPanel)scroll.Content;
body.MaxWidth = 580;
scroll.MaxHeight = Math.Max(160, Math.Min(460, root.Size.Height - 220));
dialog.Resources["ContentDialogMaxWidth"] = 680d;
body.Children.Add(Link("AboutApplicationLicense", "MarkdownViewr MIT license", AppIdentity.Info.RepositoryUrl!.AbsoluteUri + "/blob/main/LICENSE"));
body.Children.Add(Contributions());
body.Children.Add(Components("Components", "AboutComponents", AboutComponents.OpenSource));
return dialog;
}
/// <summary>Original application, themes and adapted code are credited below the normal app header, rather than as a separate top banner.</summary>
private static StackPanel Contributions()
{
StackPanel section = Section("Contributions", "AboutContributions");
section.Children.Add(new TextBlock { Text = "Based on Darin Kelkhoff's macOS markdownViewr. Original application, rendering resources and theme work: Copyright (c) 2026 Darin Kelkhoff; MIT License.", TextWrapping = TextWrapping.Wrap });
section.Children.Add(Link("AboutOriginalRepository", "Original Mac app by Darin Kelkhoff", AppIdentity.OriginalRepositoryUrl.AbsoluteUri));
section.Children.Add(Link("AboutOriginalAuthorWebsite", "Darin Kelkhoff's website — kelkhoff.com", AppIdentity.OriginalAuthorWebsite.AbsoluteUri));
section.Children.Add(Link("AboutOriginalLicense", "Original markdownViewr MIT license", AppIdentity.OriginalRepositoryUrl.AbsoluteUri + "/blob/main/LICENSE"));
return section;
}
/// <summary>Each package is identified separately with its locked version and both official project and license links.</summary>
private static StackPanel Components(string title, string identity, IReadOnlyList<AboutComponent> entries)
{
StackPanel section = Section(title, identity);
foreach(AboutComponent component in entries)
{
var entry = new StackPanel { Spacing = 2, Margin = new Thickness(0, 4, 0, 4) };
entry.Children.Add(Link("AboutProject:" + component.Name, component.Name + " " + component.Version, component.ProjectUrl));
entry.Children.Add(Link("AboutLicense:" + component.Name, "License: " + component.License, component.LicenseUrl));
section.Children.Add(entry);
}
return section;
}
/// <summary>Native heading and panel provide normal keyboard and screen-reader ordering within the scroll view.</summary>
private static StackPanel Section(string title, string identity)
{
var section = new StackPanel { Spacing = 4 };
AutomationProperties.SetAutomationId(section, identity);
AutomationProperties.SetName(section, title);
section.Children.Add(new TextBlock { Text = title, FontSize = 20, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
return section;
}
/// <summary>Only curated HTTPS destinations enter About; native hyperlink controls expose their names and Invoke behavior.</summary>
private static HyperlinkButton Link(string identity, string label, string destination)
{
var link = new HyperlinkButton { Content = new TextBlock { Text = label, TextWrapping = TextWrapping.Wrap }, NavigateUri = new Uri(destination), Padding = new Thickness(0), HorizontalAlignment = HorizontalAlignment.Left };
AutomationProperties.SetAutomationId(link, identity);
AutomationProperties.SetName(link, label);
return link;
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Runtime.InteropServices;
using Microsoft.Windows.AppLifecycle;
using Windows.ApplicationModel.Activation;
namespace MarkdownViewr.Windows;
/// <summary>SDK rich file activation and command-line paths share one canonical native document request.</summary>
internal static partial class DesktopActivation
{
/// <summary>Rich file payloads already identify local storage items; plain relative arguments are resolved at their launching process.</summary>
internal static ActivationRequest ReadInitialRequest()
{
try
{
AppActivationArguments activation = AppInstance.GetCurrent().GetActivatedEventArgs();
IEnumerable<string> paths = activation.Kind == ExtendedActivationKind.File && activation.Data is IFileActivatedEventArgs files
? files.Files.Select(item => item.Path)
: Environment.GetCommandLineArgs().Skip(1);
return ActivationRequest.Create(paths, Environment.CurrentDirectory);
}
catch(Exception exception) when(exception is ArgumentException or NotSupportedException or IOException or InvalidDataException or System.Runtime.InteropServices.COMException)
{
ReportFailure(exception);
return new ActivationRequest([], HasInvalidPath: true);
}
}
/// <summary>The foreground launcher permits the chosen primary to activate its existing document without simulating input.</summary>
internal static void AllowPrimaryForeground(uint processId) => AllowSetForegroundWindow(processId);
/// <summary>Logs activation failure kinds without document paths, JSON, profile identifiers or command-line content.</summary>
internal static void ReportFailure(Exception exception)
=> AppIdentity.CreateLog().Warning(FormattableString.Invariant($"Document activation failed ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
/// <summary>Windows still applies its foreground policy; failure to grant focus never prevents opening the document.</summary>
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool AllowSetForegroundWindow(uint processId);
}
@@ -32,7 +32,7 @@ internal sealed partial class DocumentBrowser
return Task.CompletedTask;
}
string operation = command is Core.Navigation.ReaderCommand.Top or Core.Navigation.ReaderCommand.Bottom ? "scrollTo" : "scrollBy";
return ExecuteDocumentScriptAsync("(()=>{const s=document.scrollingElement;if(!s)return;const c=getComputedStyle(document.body);let line=parseFloat(c.lineHeight);if(!Number.isFinite(line))line=parseFloat(c.fontSize)*1.5;if(!Number.isFinite(line)||line<=0)line=24;s." + operation + "({top:" + amount + ",behavior:'instant'});})()");
return ExecuteDocumentScriptAsync("(()=>{const s=document.getElementById('content');if(!s)return;const c=getComputedStyle(document.body);let line=parseFloat(c.lineHeight);if(!Number.isFinite(line))line=parseFloat(c.fontSize)*1.5;if(!Number.isFinite(line)||line<=0)line=24;s." + operation + "({top:" + amount + ",behavior:'instant'});})()");
}
@@ -44,12 +44,12 @@ internal sealed partial class DocumentBrowser
string update = forward ? "target=h;break;" : "target=h;";
return ExecuteDocumentScriptAsync(FormattableString.Invariant($$"""
(()=>{
const s=document.scrollingElement;if(!s)return;
const s=document.getElementById('content');if(!s)return;
const y=s.scrollTop;let target=null;
for(let i=0;i<{{_headings.Count}};i++){
const h=document.getElementById('mv-heading-'+i);
if(h&&h.offsetParent!==null){
const top=h.getBoundingClientRect().top+y;
const top=h.getBoundingClientRect().top-s.getBoundingClientRect().top+y;
if({{direction}}){ {{update}} }
}
}
@@ -18,8 +18,10 @@ internal sealed partial class DocumentBrowser
{
if(!await PreparePrintAsync(options, themes))
{
App.Log.Warning("System print preparation was rejected; the document may have changed or is not ready.");
return false;
}
App.Log.Info("Requesting the Windows system print dialog with WebView2 " + _view.CoreWebView2.Environment.BrowserVersionString + ".");
_view.CoreWebView2.ShowPrintUI(CoreWebView2PrintDialogKind.System);
return true;
}
@@ -43,7 +45,17 @@ internal sealed partial class DocumentBrowser
CoreWebView2PrintSettings settings = CreatePdfSettings(options.Backgrounds);
try
{
return await _view.CoreWebView2.PrintToPdfAsync(path, settings);
bool saved = await _view.CoreWebView2.PrintToPdfAsync(path, settings);
if(!saved)
{
App.Log.Warning("The WebView2 PDF export returned failure.");
return false;
}
global::Windows.Storage.StorageFile file = await global::Windows.Storage.StorageFile.GetFileFromPathAsync(path);
global::Windows.Data.Pdf.PdfDocument pdf = await global::Windows.Data.Pdf.PdfDocument.LoadFromFileAsync(file);
bool readable = pdf.PageCount > 0;
App.Log.Info(readable ? "The generated PDF was verified readable." : "The generated PDF contains no pages.");
return readable;
}
finally
{
@@ -16,11 +16,11 @@ internal sealed partial class DocumentBrowser
string? json = await ExecuteDocumentScriptAsync(FormattableString.Invariant($$"""
(()=>{
if(window.matchMedia('print').matches)return null;
const s=document.scrollingElement;if(!s)return null;
const s=document.getElementById('content');if(!s)return null;
const y=s.scrollTop;const headings=[];const collapsed=[];
for(let i=0;i<{{_headings.Count}};i++){
const h=document.getElementById('mv-heading-'+i);
if(h&&h.offsetParent!==null)headings.push([i,h.getBoundingClientRect().top+y]);
if(h&&h.offsetParent!==null)headings.push([i,h.getBoundingClientRect().top-s.getBoundingClientRect().top+y]);
const b=document.querySelector('[data-mv-body="'+i+'"]');
if(b&&b.hidden)collapsed.push(i);
}
@@ -39,6 +39,6 @@ internal sealed partial class DocumentBrowser
{
return Task.CompletedTask;
}
return ExecuteDocumentScriptAsync(FormattableString.Invariant($"window.scrollTo({{top:{top:R},behavior:'instant'}});"));
return ExecuteDocumentScriptAsync(FormattableString.Invariant($"document.getElementById('content')?.scrollTo({{top:{top:R},behavior:'instant'}});"));
}
}
+27 -7
View File
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
@@ -29,6 +28,9 @@ internal sealed partial class DocumentBrowser
private bool _restoreViewport;
private bool _closed;
private Core.Themes.ReaderTheme _theme;
private Core.Themes.ReadingAppearance _appearance;
private ulong _themeRevision;
private ulong _appearanceRevision;
private string _sanitizedHtml = "";
private string _title = "MarkdownViewr";
private double _zoomScale = 1;
@@ -38,11 +40,12 @@ internal sealed partial class DocumentBrowser
/// <summary>Attaches a native status callback; the document itself cannot invoke host commands.</summary>
public DocumentBrowser(WebView2 view, Action<string> status, Core.Themes.ReaderTheme theme)
public DocumentBrowser(WebView2 view, Action<string> status, Core.Themes.ReaderTheme theme, Core.Themes.ReadingAppearance? appearance = null)
{
_view = view;
_status = status;
_theme = theme;
_appearance = (appearance ?? new Core.Themes.ReadingAppearance()).Sanitize();
}
@@ -99,7 +102,7 @@ internal sealed partial class DocumentBrowser
_pageReady = false;
_restoreViewport = true;
_policy = new Core.Documents.DocumentResourcePolicy();
_html = Encoding.UTF8.GetBytes($"""<!doctype html><html lang="en"><head><meta charset="utf-8"><title>{WebUtility.HtmlEncode(title)}</title><style media="screen">{_theme.GetStyles(_zoomScale)}</style></head><body>{sanitizedHtml}</body></html>""");
_html = Encoding.UTF8.GetBytes(Core.Documents.DocumentPage.Build(sanitizedHtml, title, _theme, _appearance, _zoomScale));
_view.CoreWebView2.Navigate(_policy.DocumentUri.AbsoluteUri);
}
@@ -125,15 +128,32 @@ internal sealed partial class DocumentBrowser
/// <summary>Changes only the document appearance and preserves its current relative viewport.</summary>
public async Task ApplyThemeAsync(Core.Themes.ReaderTheme theme)
{
ulong revision = ++_themeRevision;
Core.Documents.DocumentViewport viewport = await CaptureViewportAsync();
_theme = theme;
if(!_closed)
if(!_closed && revision == _themeRevision)
{
_theme = theme;
Show(_files, _sanitizedHtml, _title, viewport, _headings);
}
}
/// <summary>Applies only native reader-wide preferences using the guarded viewport/collapse/find navigation path.</summary>
public async Task ApplyAppearanceAsync(Core.Themes.ReadingAppearance appearance)
{
ArgumentNullException.ThrowIfNull(appearance);
appearance.Validate();
ulong revision = ++_appearanceRevision;
Core.Documents.DocumentViewport viewport = await CaptureViewportAsync();
if(!_closed && revision == _appearanceRevision)
{
_appearance = appearance;
Show(_files, _sanitizedHtml, _title, viewport, _headings);
}
}
/// <summary>Reveals a heading's hidden ancestors before moving the actual reading viewport to it.</summary>
public Task JumpToHeadingAsync(int index)
{
@@ -213,7 +233,7 @@ internal sealed partial class DocumentBrowser
ulong navigation = _navigationId;
if(!_closed && _pageReady && IsCurrentDocument())
{
string result = await _view.CoreWebView2.ExecuteScriptAsync("[window.scrollX/Math.max(1,document.documentElement.scrollWidth-innerWidth),window.scrollY/Math.max(1,document.documentElement.scrollHeight-innerHeight)]");
string result = await _view.CoreWebView2.ExecuteScriptAsync("(()=>{const s=document.getElementById('content');return s?[s.scrollLeft/Math.max(1,s.scrollWidth-s.clientWidth),s.scrollTop/Math.max(1,s.scrollHeight-s.clientHeight)]:[0,0];})()");
if(!_closed && document == _policy.DocumentUri && navigation == _navigationId && IsCurrentDocument())
{
_viewport = Core.Documents.DocumentViewport.FromBrowserResult(result);
@@ -250,7 +270,7 @@ internal sealed partial class DocumentBrowser
{
string collapsed = JsonSerializer.Serialize(_collapsedSections);
await sender.ExecuteScriptAsync("for(const e of document.querySelectorAll('[data-mv-body]')) e.hidden=" + collapsed + ".includes(Number(e.dataset.mvBody));");
string script = FormattableString.Invariant($"if(location.href.split('#')[0] === '{_policy.DocumentUri.AbsoluteUri}') window.scrollTo({_viewport.HorizontalFraction:R}*Math.max(0,document.documentElement.scrollWidth-innerWidth),{_viewport.VerticalFraction:R}*Math.max(0,document.documentElement.scrollHeight-innerHeight));");
string script = FormattableString.Invariant($"if(location.href.split('#')[0] === '{_policy.DocumentUri.AbsoluteUri}') {{ const s=document.getElementById('content'); if(s) s.scrollTo({_viewport.HorizontalFraction:R}*Math.max(0,s.scrollWidth-s.clientWidth),{_viewport.VerticalFraction:R}*Math.max(0,s.scrollHeight-s.clientHeight)); }}");
await sender.ExecuteScriptAsync(script);
if(!_closed && args.NavigationId == _navigationId && IsCurrentDocument())
{
+14 -2
View File
@@ -20,11 +20,13 @@ internal sealed partial class DocumentFiles
/// <summary>Retains the selected directory and the physical root of the document actually opened.</summary>
private DocumentFiles(string path, string physicalPath, string markdown)
private DocumentFiles(string path, string physicalPath, string markdown, DateTime modifiedUtc, long byteLength)
{
_directory = Path.GetDirectoryName(path)!;
_physicalRoot = Path.GetDirectoryName(physicalPath)! + Path.DirectorySeparatorChar;
Markdown = markdown;
ModifiedUtc = modifiedUtc;
ByteLength = byteLength;
}
@@ -34,6 +36,16 @@ internal sealed partial class DocumentFiles
/// <summary>The modification timestamp of the opened file handle retained with this loaded snapshot.</summary>
public DateTime ModifiedUtc { get; }
/// <summary>The opened snapshot's byte length helps detect changes missed by directory notifications.</summary>
public long ByteLength { get; }
/// <summary>Loads at most the parser limit plus one character, using the same handle for identity and contents.</summary>
public static DocumentFiles Open(string path)
{
@@ -48,7 +60,7 @@ internal sealed partial class DocumentFiles
{
throw new IOException("This document exceeds the 16 Mi-character limit.");
}
return new DocumentFiles(fullPath, physicalPath, new string(buffer, 0, count));
return new DocumentFiles(fullPath, physicalPath, new string(buffer, 0, count), File.GetLastWriteTimeUtc(handle), RandomAccess.GetLength(handle));
}
@@ -74,7 +74,7 @@ internal sealed partial class DocumentKeyboard : IDisposable
_keys.Reset();
return false;
}
char character = nativeFind ? 'f' : message.WParam == 0x1B ? '\u001B' : control && !shift && message.WParam is 0x44 or 0x55 or 0x42
char character = control && shift && message.WParam is 0x26 or 0x28 ? message.WParam == 0x26 ? '\u2191' : '\u2193' : nativeFind ? 'f' : message.WParam == 0x1B ? '\u001B' : control && !shift && message.WParam is 0x44 or 0x55 or 0x42
? char.ToLowerInvariant((char)message.WParam)
: TranslateKey((uint)message.WParam, (uint)(((nuint)message.LParam >> 16) & 0xFF));
var key = new Core.Navigation.ReaderKey(character, control, shift, alt, windows, ((nuint)message.LParam & 0x40000000) != 0);
@@ -16,12 +16,11 @@ namespace MarkdownViewr.Windows;
internal sealed class EditorConfigurationDialog
{
private readonly List<EditorConfiguration> _editors;
private readonly ListBox _list = new() { Name = "EditorList", Height = 100 };
private readonly ListBox _list = new() { Name = "EditorList", Height = 220 };
private readonly TextBox _name = new() { Name = "EditorName", Header = "Editor name", MaxLength = 80 };
private readonly TextBox _path = new() { Name = "EditorPath", Header = "Editor executable path", MaxLength = 32767, MinWidth = 320 };
private readonly CheckBox _folder = new() { Name = "EditorFolder", Content = "Open the document's folder" };
private readonly TextBlock _error = new() { Name = "EditorError", TextWrapping = TextWrapping.Wrap };
private readonly ContentDialog _dialog;
private int _selected = -1;
private Guid _identity = Guid.NewGuid();
private bool _loading;
@@ -33,15 +32,6 @@ internal sealed class EditorConfigurationDialog
public EditorConfigurationDialog(IEnumerable<EditorConfiguration> editors)
{
_editors = editors.ToList();
_dialog = new ContentDialog
{
Title = "Configure editors",
Content = BuildContent(),
PrimaryButtonText = "Save",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Primary,
};
_dialog.PrimaryButtonClick += (_, args) => args.Cancel = !ApplyCurrent();
_list.SelectionChanged += (_, _) => ChangeSelection();
_name.TextChanged += (_, _) => _dirty |= !_loading;
_path.TextChanged += (_, _) => _dirty |= !_loading;
@@ -53,10 +43,9 @@ internal sealed class EditorConfigurationDialog
/// <summary>Shows the editor manager on the owning window and returns only explicitly committed choices.</summary>
public async Task<EditorConfiguration[]?> ShowAsync(XamlRoot root)
public async Task<EditorConfiguration[]?> ShowAsync(SettingsWindow host)
{
_dialog.XamlRoot = root;
return await _dialog.ShowAsync() == ContentDialogResult.Primary ? _editors.ToArray() : null;
return await host.ShowEditorAsync("Configure editors", BuildContent(), "Save", ApplyCurrent) == ContentDialogResult.Primary ? _editors.ToArray() : null;
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text;
using Microsoft.Win32;
namespace MarkdownViewr.Windows;
/// <summary>Per-user Open With registrations have channel/install ownership and never change Windows default selections.</summary>
internal sealed class FileTypeRegistration
{
private static readonly string[] s_extensions = [".md", ".markdown", ".mdown", ".mkd", ".txt"];
private readonly RegistryKey _user;
private readonly string _command;
private readonly string _capabilities;
private readonly string _name;
private readonly string _icon;
/// <summary>Quoted literal installed paths remain valid in folders containing spaces; each install/channel owns its own ProgID.</summary>
internal FileTypeRegistration(RegistryKey user, string executable, string channel)
{
ArgumentNullException.ThrowIfNull(user);
if(channel is not ("stable" or "dev") || !Path.IsPathFullyQualified(executable) || executable.Any(character => char.IsControl(character) || character == '"'))
{
throw new ArgumentException("A supported channel and absolute installed executable path are required.", nameof(executable));
}
executable = Path.GetFullPath(executable);
_user = user;
_name = string.Equals(channel, "dev", StringComparison.Ordinal) ? "MarkdownViewr (Development)" : "MarkdownViewr";
string identity = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(executable.ToUpperInvariant())))[..24];
ProgId = "KofTwentyTwo.MarkdownViewr." + channel + "." + identity;
_capabilities = "Software\\KofTwentyTwo\\MarkdownViewr\\FileAssociations\\" + ProgId + "\\Capabilities";
_command = "\"" + executable + "\" \"%1\"";
_icon = "\"" + executable + "\",0";
}
/// <summary>The stable owner identity is safe as a registry value/subkey name and does not include personal paths.</summary>
internal string ProgId { get; }
/// <summary>Returns the OS settings route only for this unchanged registration; choosing defaults remains a Windows user action.</summary>
internal Uri? GetDefaultAppsUri(bool supportsAppPage)
{
using RegistryKey? applications = _user.OpenSubKey("Software\\RegisteredApplications");
using RegistryKey? command = _user.OpenSubKey("Software\\Classes\\" + ProgId + "\\shell\\open\\command");
if(!string.Equals(applications?.GetValue(ProgId) as string, _capabilities, StringComparison.Ordinal) ||
!string.Equals(command?.GetValue("") as string, _command, StringComparison.Ordinal))
{
return null;
}
return new Uri(supportsAppPage ? "ms-settings:defaultapps?registeredAppUser=" + Uri.EscapeDataString(ProgId) : "ms-settings:defaultapps");
}
/// <summary>Advertises the viewer to Open With and Default Apps without setting extension defaults or touching UserChoice.</summary>
internal void Register()
{
using RegistryKey program = _user.CreateSubKey("Software\\Classes\\" + ProgId);
program.SetValue("", _name + " document");
program.SetValue("FriendlyAppName", _name);
program.SetValue("AllowSilentDefaultTakeOver", Array.Empty<byte>(), RegistryValueKind.None);
using(RegistryKey icon = program.CreateSubKey("DefaultIcon"))
{
icon.SetValue("", _icon);
}
using(RegistryKey open = program.CreateSubKey("shell\\open"))
{
open.SetValue("MultiSelectModel", "Document");
using RegistryKey command = open.CreateSubKey("command");
command.SetValue("", _command);
}
using RegistryKey capabilities = _user.CreateSubKey(_capabilities);
capabilities.SetValue("ApplicationName", _name);
capabilities.SetValue("ApplicationDescription", "Read Markdown and plain-text documents.");
capabilities.SetValue("ApplicationIcon", _icon);
using RegistryKey types = capabilities.CreateSubKey("FileAssociations");
foreach(string extension in s_extensions)
{
types.SetValue(extension, ProgId);
using RegistryKey openWith = _user.CreateSubKey("Software\\Classes\\" + extension + "\\OpenWithProgids");
openWith.SetValue(ProgId, "");
}
using RegistryKey applications = _user.CreateSubKey("Software\\RegisteredApplications");
applications.SetValue(ProgId, _capabilities);
}
/// <summary>Removes only an unchanged registration owned by this exact executable/channel; sibling and user entries survive.</summary>
internal void Unregister()
{
string programPath = "Software\\Classes\\" + ProgId;
using(RegistryKey? command = _user.OpenSubKey(programPath + "\\shell\\open\\command"))
{
if(!string.Equals(command?.GetValue("") as string, _command, StringComparison.Ordinal))
{
return;
}
}
foreach(string extension in s_extensions)
{
using RegistryKey? openWith = _user.OpenSubKey("Software\\Classes\\" + extension + "\\OpenWithProgids", writable: true);
openWith?.DeleteValue(ProgId, throwOnMissingValue: false);
}
using(RegistryKey? applications = _user.OpenSubKey("Software\\RegisteredApplications", writable: true))
{
if(string.Equals(applications?.GetValue(ProgId) as string, _capabilities, StringComparison.Ordinal))
{
applications!.DeleteValue(ProgId, throwOnMissingValue: false);
}
}
_user.DeleteSubKeyTree(programPath, throwOnMissingSubKey: false);
_user.DeleteSubKeyTree("Software\\KofTwentyTwo\\MarkdownViewr\\FileAssociations\\" + ProgId, throwOnMissingSubKey: false);
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Runtime.InteropServices;
using Microsoft.Win32;
using Velopack.Locators;
namespace MarkdownViewr.Windows;
/// <summary>Only real per-user installed builds register file types; builds and portable previews leave Windows associations alone.</summary>
internal static partial class InstalledFileTypes
{
/// <summary>Refreshes registrations at installation/update using the stable current directory rather than version folders.</summary>
internal static void Register() => Change(uninstall: false);
/// <summary>Uninstall removes only that installation/channel's unchanged registrations.</summary>
internal static void Unregister() => Change(uninstall: true);
/// <summary>Only an installed, unchanged registration can open its default-selection page; older Windows uses the general page.</summary>
internal static Uri? GetDefaultAppsUri()
{
FileTypeRegistration? registration = CurrentRegistration();
// Windows 11 23H2 guarantees the app-specific route. Earlier builds retain general Default Apps guidance.
return registration?.GetDefaultAppsUri(OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22631));
}
/// <summary>Velopack fast callbacks run before XAML; this path uses only the registry and bounded local metadata.</summary>
private static void Change(bool uninstall)
{
FileTypeRegistration? registration = CurrentRegistration();
if(registration is null)
{
return;
}
if(uninstall)
{
registration.Unregister();
}
else
{
registration.Register();
}
NotifyAssociationChanged(0x08000000, 0, IntPtr.Zero, IntPtr.Zero);
}
/// <summary>Derives channel and stable executable from the actual installer locator; portable copies never acquire registrations.</summary>
private static FileTypeRegistration? CurrentRegistration()
{
IVelopackLocator locator = VelopackLocator.Current;
if(locator.CurrentlyInstalledVersion is null || locator.IsPortable || string.IsNullOrEmpty(locator.RootAppDir))
{
return null;
}
string executable = Path.Combine(locator.RootAppDir, "current", "MarkdownViewr.Windows.exe");
if(!File.Exists(executable))
{
throw new IOException("The installed application executable was not found.");
}
return new FileTypeRegistration(Registry.CurrentUser, executable, locator.Channel ?? "stable");
}
/// <summary>Refreshes Explorer's cached associations after an owned registration changes.</summary>
[LibraryImport("shell32.dll", EntryPoint = "SHChangeNotify")]
private static partial void NotifyAssociationChanged(uint events, uint flags, IntPtr item1, IntPtr item2);
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows;
/// <summary>Applies deliberately saved reader-wide appearance independently of theme and print data.</summary>
public sealed partial class MainWindow
{
/// <summary>Accepts global preferences as the new baseline; later window-local saves cannot resurrect older appearance fields.</summary>
internal async Task ApplyReadingAppearanceAsync(Core.Themes.ReadingAppearance appearance)
{
if(_closed)
{
return;
}
_settings.ContentWidthEnabled = _lastSavedSettings.ContentWidthEnabled = appearance.LimitWidth;
_settings.ContentWidthPx = _lastSavedSettings.ContentWidthPx = appearance.WidthPixels;
_settings.GlobalCss = _lastSavedSettings.GlobalCss = appearance.GlobalCss;
_settingsWindow?.UpdateAppearance(appearance);
try
{
_browserInitialization ??= _browser.InitializeAsync();
await _browserInitialization;
await _browser.ApplyAppearanceAsync(appearance);
}
catch(System.Runtime.InteropServices.COMException exception)
{
if(!_closed)
{
StatusText.Text = "The reader appearance could not be displayed.";
App.Log.Error("The reader appearance could not be displayed.", exception);
}
}
}
}
@@ -14,6 +14,16 @@ public sealed partial class MainWindow
{
private string? _initialDocumentPath;
private Core.AppSettings _lastSavedSettings;
private bool _activationError;
/// <summary>Rejected startup payloads produce a fixed native message without disclosing a path in logs.</summary>
internal void ReportActivationFailure()
{
_activationError = true;
StatusText.Text = "One or more startup file paths could not be opened.";
}
+33 -10
View File
@@ -30,28 +30,51 @@ public sealed partial class MainWindow
item.Click += OpenEditor_Click;
OpenEditorChoices.Items.Add(item);
}
RefreshToolbarEditors();
}
/// <summary>Changes remain in the dialog's copied list until the user's validated Save.</summary>
private async void ConfigureEditor_Click(object sender, RoutedEventArgs e)
private async Task ConfigureEditorAsync()
{
var editor = new EditorConfigurationDialog(_settings.ConfiguredEditors);
Core.Editors.EditorConfiguration[]? choices = await editor.ShowAsync(Content.XamlRoot);
Core.AppSettings before = Core.SettingsChanges.Copy(_settings);
var editor = new EditorConfigurationDialog(before.ConfiguredEditors);
Core.Editors.EditorConfiguration[]? choices = await editor.ShowAsync(ConfigurationPage);
if(choices is null || _closed)
{
return;
}
_settings.ConfiguredEditors = choices;
_settings.ExternalEditor = choices.FirstOrDefault()?.Executable ?? "";
Save();
RefreshEditorCommands();
Core.AppSettings draft = Core.SettingsChanges.Copy(before);
draft.ConfiguredEditors = choices;
draft.ExternalEditor = choices.FirstOrDefault()?.Executable ?? "";
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
if(!AppIdentity.SettingsStore.Save(merged))
{
StatusText.Text = "Could not save editor configuration.";
return;
}
((App)Application.Current).ApplyEditorSettings(merged.ConfiguredEditors);
StatusText.Text = "Editors configured.";
}
/// <summary>A shared editor save updates both this reader and its save baseline before any later local preference change.</summary>
internal void ApplyEditorSettings(Core.Editors.EditorConfiguration[] choices)
{
if(_closed)
{
return;
}
_settings.ConfiguredEditors = choices.ToArray();
_lastSavedSettings.ConfiguredEditors = choices.ToArray();
_settings.ExternalEditor = _lastSavedSettings.ExternalEditor = choices.FirstOrDefault()?.Executable ?? "";
RefreshEditorCommands();
}
/// <summary>Launches only the selected executable and never interpolates Markdown or a shell command.</summary>
private void OpenEditor_Click(object sender, RoutedEventArgs e)
{
@@ -59,7 +82,7 @@ public sealed partial class MainWindow
{
return;
}
Core.Editors.EditorConfiguration? editor = sender is MenuFlyoutItem { Tag: Guid identity }
Core.Editors.EditorConfiguration? editor = sender is FrameworkElement { Tag: Guid identity }
? _settings.ConfiguredEditors.FirstOrDefault(choice => choice.Id == identity)
: _settings.ConfiguredEditors.FirstOrDefault();
if(!Core.Editors.EditorConfiguration.IsValid(editor))
@@ -68,7 +91,7 @@ public sealed partial class MainWindow
}
if(!File.Exists(editor!.Executable))
{
StatusText.Text = "The configured editor is missing. Use File → Configure editor to choose an installed executable.";
StatusText.Text = "The configured editor is missing. Use File → Settings → Editors to choose an installed executable.";
return;
}
if(!File.Exists(_documentPath))
@@ -84,7 +107,7 @@ public sealed partial class MainWindow
}
catch(Exception exception) when(exception is System.ComponentModel.Win32Exception or IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException)
{
StatusText.Text = "Could not start the configured editor. Use File → Configure editor to check its executable.";
StatusText.Text = "Could not start the configured editor. Use File → Settings → Editors to check its executable.";
App.Log.Warning("The configured editor could not be started.");
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>Owns native Markdown preferences; document content cannot change rendering options.</summary>
public sealed partial class MainWindow
{
/// <summary>Loads independent saved toggles without modifying a sibling window's rendering snapshot.</summary>
private void InitializeMarkdownExtensions()
{
HighlightExtension.IsChecked = _settings.RenderHighlight;
SuperscriptExtension.IsChecked = _settings.RenderSuperscript;
SubscriptExtension.IsChecked = _settings.RenderSubscript;
UnderlineExtension.IsChecked = _settings.RenderUnderline;
}
/// <summary>Persists one deliberately changed option and rerenders through the ordinary guarded reload path.</summary>
private async void MarkdownExtension_Click(object sender, RoutedEventArgs e)
{
var item = (ToggleMenuFlyoutItem)sender;
switch((string)item.Tag)
{
case "Highlight":
_settings.RenderHighlight = item.IsChecked;
break;
case "Superscript":
_settings.RenderSuperscript = item.IsChecked;
break;
case "Subscript":
_settings.RenderSubscript = item.IsChecked;
break;
case "Underline":
_settings.RenderUnderline = item.IsChecked;
break;
}
Save();
if(_documentPath is not null)
{
await OpenDocumentAsync(_documentPath, reload: true);
}
}
}
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>Snapshot metadata and stale state remain local to each reader; only a deliberate click opens Explorer.</summary>
public sealed partial class MainWindow
{
private readonly DispatcherTimer _footerPoll = new() { Interval = TimeSpan.FromSeconds(2) };
private DateTime _loadedModifiedUtc;
private long _loadedByteLength;
/// <summary>A light metadata poll covers missed directory notifications without reading or automatically reopening document content.</summary>
private void InitializeDocumentFooter()
{
_footerPoll.Tick += (_, _) => CheckDocumentFooter();
_footerPoll.Start();
}
/// <summary>Successful reads commit timestamp and length together with the displayed document; failed reloads retain this snapshot.</summary>
private void CommitDocumentFooter(DocumentFiles files)
{
_loadedModifiedUtc = files.ModifiedUtc;
_loadedByteLength = files.ByteLength;
FileModifiedText.Text = "Modified " + files.ModifiedUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
ToolTipService.SetToolTip(FileModifiedText, "Displayed version last modified: " + files.ModifiedUtc.ToLocalTime().ToString("F", CultureInfo.CurrentCulture));
FileModifiedText.Visibility = Visibility.Visible;
FileStaleButton.Visibility = Visibility.Collapsed;
OpenDocumentFolderButton.IsEnabled = true;
CheckDocumentFooter();
}
/// <summary>Watcher notifications and failed reloads expose an actionable stale indicator until a successful snapshot commit.</summary>
private void MarkDocumentStale()
{
if(_closed || _documentPath is null)
{
return;
}
bool exists = File.Exists(_documentPath);
FileStaleButton.Content = exists ? "Changes detected — reload" : "File unavailable — reload";
FileStaleButton.Visibility = Visibility.Visible;
RefreshEditorCommands();
}
/// <summary>Metadata changes mark the displayed copy stale; a later metadata match never clears an unsuccessful reload.</summary>
private void CheckDocumentFooter()
{
if(_closed || _documentPath is null)
{
return;
}
try
{
var file = new FileInfo(_documentPath);
if(!file.Exists || file.LastWriteTimeUtc != _loadedModifiedUtc || file.Length != _loadedByteLength)
{
MarkDocumentStale();
}
OpenDocumentFolderButton.IsEnabled = Directory.Exists(Path.GetDirectoryName(_documentPath));
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
MarkDocumentStale();
}
}
/// <summary>Launches a literal containing directory through the Windows folder API, with no shell arguments or authored commands.</summary>
private async void OpenDocumentFolder_Click(object sender, RoutedEventArgs e)
{
if(_closed || _documentPath is null)
{
return;
}
try
{
string directory = Path.GetDirectoryName(_documentPath)!;
global::Windows.Storage.StorageFolder folder = await global::Windows.Storage.StorageFolder.GetFolderFromPathAsync(directory);
if(!await global::Windows.System.Launcher.LaunchFolderAsync(folder) && !_closed)
{
StatusText.Text = "The document folder could not be opened.";
}
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or ArgumentException or System.Runtime.InteropServices.COMException)
{
if(!_closed)
{
StatusText.Text = "The document folder is unavailable.";
}
App.Log.Warning(FormattableString.Invariant($"The document folder could not be opened ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
namespace MarkdownViewr.Windows;
/// <summary>Opens only the app's fixed public Help destinations after deliberate native menu actions.</summary>
public sealed partial class MainWindow
{
/// <summary>Shows this Windows app's repository in the user's default browser.</summary>
private async void GitHub_Click(object sender, RoutedEventArgs args) => await OpenHelpPageAsync(AppIdentity.Info.RepositoryUrl!);
/// <summary>Offers the app's issue templates without sending private document data.</summary>
private async void SubmitIssue_Click(object sender, RoutedEventArgs args) => await OpenHelpPageAsync(AppIdentity.IssuesUrl);
/// <summary>Opens community questions and tips in this app's repository.</summary>
private async void CommunitySupport_Click(object sender, RoutedEventArgs args) => await OpenHelpPageAsync(AppIdentity.SupportUrl);
/// <summary>A missing browser or OS launch failure is visible; no document path/content is included.</summary>
private async Task OpenHelpPageAsync(Uri target)
{
try
{
if(!await global::Windows.System.Launcher.LaunchUriAsync(target))
{
StatusText.Text = "The link could not be opened. Check your default browser.";
}
}
catch(Exception exception) when(exception is System.Runtime.InteropServices.COMException or InvalidOperationException or ArgumentException)
{
StatusText.Text = "The link could not be opened. Check your default browser.";
App.Log.Warning(FormattableString.Invariant($"A Help link could not be opened ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
}
}
@@ -90,6 +90,10 @@ public sealed partial class MainWindow
case Core.Navigation.ReaderCommand.Print:
await PrintDocumentAsync();
break;
case Core.Navigation.ReaderCommand.NextTheme:
case Core.Navigation.ReaderCommand.PreviousTheme:
await CycleDocumentThemeAsync(command == Core.Navigation.ReaderCommand.NextTheme);
break;
case Core.Navigation.ReaderCommand.FocusFind:
OpenFind();
break;
@@ -34,6 +34,7 @@ public sealed partial class MainWindow
private void ApplyOutlineVisibility()
{
OutlinePanel.Visibility = _settings.ShowOutline ? Visibility.Visible : Visibility.Collapsed;
RefreshToolbarReadingState();
ApplyReadingWidths();
}
@@ -57,6 +58,7 @@ public sealed partial class MainWindow
return;
}
_settings.OutlineDepth = OutlineDepth.SelectedIndex + 1;
RefreshToolbarReadingState();
Save();
RefreshOutline();
}
+74 -15
View File
@@ -57,29 +57,19 @@ public sealed partial class MainWindow
{
return;
}
App.Log.Info(dialog.SaveAsPdf ? "Print options accepted for PDF export." : "Print options accepted for the Windows system dialog.");
_settings.Print = options;
Save();
if(dialog.SaveAsPdf)
{
var picker = new FileSavePicker(AppWindow.Id) { SuggestedFileName = Path.GetFileNameWithoutExtension(_documentPath), DefaultFileExtension = ".pdf" };
picker.FileTypeChoices.Add("PDF document", [".pdf"]);
PickFileResult? file = await picker.PickSaveFileAsync();
if(file is not null && !_closed)
{
StatusText.Text = "Saving PDF...";
bool saved = await _browser.SavePdfAsync(file.Path, options, _themes);
if(!_closed)
{
StatusText.Text = saved ? "PDF saved." : "The PDF could not be saved.";
}
}
await SavePrintPdfAsync(options, dialog);
}
else
{
bool opened = await _browser.ShowPrintUiAsync(options, _themes);
bool requested = await _browser.ShowPrintUiAsync(options, _themes);
if(!_closed)
{
StatusText.Text = opened ? "Use the Windows print dialog to print or cancel." : "The print dialog could not be opened.";
StatusText.Text = requested ? "Windows print dialog requested. Print or cancel in that dialog." : "The print dialog could not be opened.";
}
}
}
@@ -100,6 +90,75 @@ public sealed partial class MainWindow
/// <summary>Uses an owned destination picker and starts post-save actions only after readable-PDF verification.</summary>
private async Task SavePrintPdfAsync(Core.Printing.PrintOptions options, PrintOptionsDialog dialog)
{
var picker = new FileSavePicker(AppWindow.Id) { SuggestedFileName = Path.GetFileNameWithoutExtension(_documentPath), DefaultFileExtension = ".pdf" };
picker.FileTypeChoices.Add("PDF document", [".pdf"]);
PickFileResult? file = await picker.PickSaveFileAsync();
if(file is null)
{
App.Log.Info("PDF destination selection cancelled.");
}
if(file is not null && !_closed)
{
StatusText.Text = "Saving PDF...";
bool saved = await _browser.SavePdfAsync(file.Path, options, _themes);
if(!_closed)
{
StatusText.Text = saved ? "PDF saved." : "The PDF could not be saved.";
if(saved)
{
await OpenSavedPdfAsync(file.Path, dialog.OpenPdfAfterSave, dialog.OpenFolderAfterSave);
}
}
}
}
/// <summary>Opens only a successfully generated PDF and its selected folder, with independent native handler results.</summary>
private async Task OpenSavedPdfAsync(string path, bool openPdf, bool openFolder)
{
if(openPdf)
{
await OpenSavedPdfActionAsync(path, folder: false);
}
if(openFolder && !_closed)
{
await OpenSavedPdfActionAsync(path, folder: true);
}
}
/// <summary>One unavailable handler cannot suppress the other requested post-export action or misreport the completed export.</summary>
private async Task OpenSavedPdfActionAsync(string path, bool folder)
{
string error = folder ? "PDF saved, but its folder could not be opened." : "PDF saved, but it could not be opened. Check your default PDF viewer.";
try
{
global::Windows.Storage.StorageFile file = await global::Windows.Storage.StorageFile.GetFileFromPathAsync(path);
bool opened = folder
? await global::Windows.System.Launcher.LaunchFolderAsync(await file.GetParentAsync())
: await global::Windows.System.Launcher.LaunchFileAsync(file);
if(!opened && !_closed)
{
StatusText.Text = error;
}
}
catch(Exception exception) when(exception is System.Runtime.InteropServices.COMException or InvalidOperationException or IOException or UnauthorizedAccessException or ArgumentException)
{
if(!_closed)
{
StatusText.Text = error;
}
App.Log.Warning(FormattableString.Invariant($"A post-export action could not be completed ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
}
/// <summary>Busy and uncommitted documents cannot open multiple native print operations.</summary>
private void RefreshPrintCommand() => PrintMenuItem.IsEnabled = !_closed && !_printing && _documentPath is not null && _browser.IsReady;
private void RefreshPrintCommand() => PrintMenuItem.IsEnabled = ToolbarPrintButton.IsEnabled = !_closed && !_printing && _documentPath is not null && _browser.IsReady;
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
namespace MarkdownViewr.Windows;
/// <summary>Settings owns an ordinary native window and commits validated changes using the existing shared save paths.</summary>
public sealed partial class MainWindow
{
private SettingsWindow? _settingsWindow;
/// <summary>Configuration dialogs and pickers belong to the live settings window; regular reading dialogs retain their own root.</summary>
private SettingsWindow ConfigurationPage => _settingsWindow ?? throw new InvalidOperationException("Open Settings before editing configuration.");
/// <summary>Short confirmation dialogs remain owned by the current Settings root.</summary>
private XamlRoot ConfigurationRoot => _settingsWindow?.DialogRoot ?? Content.XamlRoot;
/// <summary>Native file pickers use the settings window's identity instead of a background document window.</summary>
private Microsoft.UI.WindowId ConfigurationWindowId => _settingsWindow?.AppWindow.Id ?? AppWindow.Id;
/// <summary>Reuses a modeless settings window instead of hiding/reopening a modal tabbed shell for each action.</summary>
private void Settings_Click(object sender, RoutedEventArgs e)
{
if(_closed)
{
return;
}
if(_settingsWindow is null)
{
_settingsWindow = new SettingsWindow(_settings, SaveReadingAppearanceAsync, SaveStartupSettings, RunSettingsActionAsync);
WinUIShell.ApplyTheme((FrameworkElement)_settingsWindow.Content, _settings.ThemeKind);
_settingsWindow.Closed += (_, _) => _settingsWindow = null;
}
_settingsWindow.Activate();
}
/// <summary>Checked persistence precedes sibling updates; unrelated window and theme settings merge from the latest store.</summary>
private async Task<bool> SaveReadingAppearanceAsync(Core.Themes.ReadingAppearance appearance)
{
if(_closed)
{
return false;
}
Core.AppSettings before = Core.SettingsChanges.Copy(_settings);
Core.AppSettings draft = Core.SettingsChanges.Copy(before);
draft.ContentWidthEnabled = appearance.LimitWidth;
draft.ContentWidthPx = appearance.WidthPixels;
draft.GlobalCss = appearance.GlobalCss;
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
if(!AppIdentity.SettingsStore.Save(merged))
{
return false;
}
await ((App)Application.Current).ApplyReadingAppearanceAsync(merged.ReadingAppearance);
return true;
}
/// <summary>Only startup fields change in the latest store; checked persistence precedes reader and Settings broadcasts.</summary>
private bool SaveStartupSettings(bool showSplash, int milliseconds, bool checkUpdates, bool showOutline, bool showSource)
{
if(_closed)
{
return false;
}
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
settings.ShowSplashScreen = showSplash;
settings.SplashMilliseconds = milliseconds;
settings.CheckForUpdatesOnStartup = checkUpdates;
settings.ShowOutlineByDefault = showOutline;
settings.ShowSourceByDefault = showSource;
settings.Sanitize();
if(!AppIdentity.SettingsStore.Save(settings))
{
return false;
}
((App)Application.Current).ApplyStartupSettings(settings);
return true;
}
/// <summary>Shared saved startup options become the baseline so later reader saves cannot restore older choices.</summary>
internal void ApplyStartupSettings(Core.AppSettings settings)
{
if(_closed)
{
return;
}
_settings.ShowSplashScreen = _lastSavedSettings.ShowSplashScreen = settings.ShowSplashScreen;
_settings.SplashMilliseconds = _lastSavedSettings.SplashMilliseconds = settings.SplashMilliseconds;
_settings.CheckForUpdatesOnStartup = _lastSavedSettings.CheckForUpdatesOnStartup = settings.CheckForUpdatesOnStartup;
_settings.ShowOutlineByDefault = _lastSavedSettings.ShowOutlineByDefault = settings.ShowOutlineByDefault;
_settings.ShowSourceByDefault = _lastSavedSettings.ShowSourceByDefault = settings.ShowSourceByDefault;
_settingsWindow?.UpdateStartup(settings);
}
/// <summary>Only fixed native Settings controls launch configuration; document content cannot request these operations.</summary>
private Task RunSettingsActionAsync(string action) => action switch
{
"EditDocumentThemeMenu" => EditDocumentThemeAsync(),
"ManageDocumentThemesMenu" => ManageDocumentThemesAsync(),
"ImportDocumentThemesMenu" => ImportThemesAsync(),
"ExportDocumentThemesMenu" => ExportThemesAsync(),
"RestoreDocumentThemesMenu" => RestoreDocumentThemesAsync(),
"ConfigureEditorMenuItem" => ConfigureEditorAsync(),
_ => Task.CompletedTask,
};
}
@@ -54,10 +54,10 @@ public sealed partial class MainWindow
return;
}
double position = scroller.VerticalOffset;
if(SourceText.SelectionLength == 0 && SourceText.Text.Length > 0)
if(SourceText.SelectionLength == 0 && SourceText.TextLength > 0)
{
int first = 0;
int last = SourceText.Text.Length - 1;
int last = SourceText.TextLength - 1;
while(first < last)
{
int middle = first + (last - first) / 2;
@@ -218,7 +218,7 @@ public sealed partial class MainWindow
var anchors = new List<(double Source, double Reader)>();
foreach(Core.Documents.DocumentSourceHeading heading in source.Headings)
{
if(heading.Start < SourceText.Text.Length && frame.Headings.TryGetValue(heading.Index, out double readerTop))
if(heading.Start < SourceText.TextLength && frame.Headings.TryGetValue(heading.Index, out double readerTop))
{
global::Windows.Foundation.Rect rectangle = SourceText.GetRectFromCharacterIndex(heading.Start, trailingEdge: false);
double sourceTop = rectangle.Y;
+58 -11
View File
@@ -5,6 +5,7 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
namespace MarkdownViewr.Windows;
@@ -13,12 +14,21 @@ namespace MarkdownViewr.Windows;
public sealed partial class MainWindow
{
private string _sourceMarkdown = "";
private double _sourceFraction = 0.5;
/// <summary>Restores the independent source preference after XAML initialization.</summary>
private void InitializeSource()
{
SourceText.SetTheme(ActiveDocumentTheme());
SourceText.CopyFailed += (_, _) =>
{
if(!_closed)
{
StatusText.Text = "The source could not be copied. Please try again.";
}
};
SourceToggle.IsChecked = _settings.ShowSource;
RefreshSource();
InitializeSourceSync();
@@ -30,27 +40,32 @@ public sealed partial class MainWindow
private void RefreshSource()
{
ApplyReadingWidths();
RefreshToolbarReadingState();
SourceText.Visibility = _settings.ShowSource ? Visibility.Visible : Visibility.Collapsed;
string text = _settings.ShowSource ? _sourceDocument?.Text ?? _sourceMarkdown : "";
if(!string.Equals(SourceText.Text, text, StringComparison.Ordinal))
bool changed = !string.Equals(SourceText.Text, text, StringComparison.Ordinal);
SourceText.SetSource(text, _sourceDocument);
if(changed)
{
SourceText.Text = text;
HoldSourceSync(preserveSelection: false);
}
}
/// <summary>Constrains displayed pane widths while retaining the user's preferred widths for larger windows.</summary>
/// <summary>Splits the document/source space proportionally, excluding the outline and its divider.</summary>
private void ApplyReadingWidths()
{
double available = Math.Max(0, RootGrid.ActualWidth - 200);
double outline = _settings.ShowOutline ? Math.Min(_settings.OutlineWidth, Math.Max(0, available - 6 - (_settings.ShowSource ? 166 : 0))) : 0;
double source = _settings.ShowSource ? Math.Min(_settings.SourceWidth, Math.Max(0, available - outline - (_settings.ShowOutline ? 6 : 0) - 6)) : 0;
double shared = Math.Max(0, RootGrid.ActualWidth - outline - (_settings.ShowOutline ? 6 : 0) - 6);
double fraction = Core.Layout.SourcePaneLayout.Fraction(shared, _sourceFraction);
OutlineColumn.Width = new GridLength(outline);
OutlineDividerColumn.Width = new GridLength(_settings.ShowOutline ? 6 : 0);
SourceColumn.Width = new GridLength(source);
DocumentColumn.Width = new GridLength(_settings.ShowSource ? 1 - fraction : 1, GridUnitType.Star);
SourceColumn.Width = _settings.ShowSource ? new GridLength(fraction, GridUnitType.Star) : new GridLength(0);
SourceDividerColumn.Width = new GridLength(_settings.ShowSource ? 6 : 0);
SourceDivider.IsTabStop = _settings.ShowSource;
}
@@ -63,6 +78,10 @@ public sealed partial class MainWindow
private void Source_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSource = SourceToggle.IsChecked;
if(_settings.ShowSource)
{
_sourceFraction = 0.5;
}
Save();
RefreshSource();
QueueFind();
@@ -70,15 +89,43 @@ public sealed partial class MainWindow
/// <summary>The source pane grows toward the left and stays within its validated width range.</summary>
private void SourceDivider_DragDelta(object sender, DragDeltaEventArgs e)
/// <summary>The source pane grows toward the left within the available shared reading area.</summary>
private void SourceDivider_DragDelta(object sender, DragDeltaEventArgs e) => ResizeSource(e.HorizontalChange);
/// <summary>Keyboard users resize with Left/Right or restore equal panes with Home.</summary>
private void SourceDivider_KeyDown(object sender, KeyRoutedEventArgs e)
{
_settings.SourceWidth = Math.Clamp(SourceColumn.ActualWidth - e.HorizontalChange, 160, 600);
ApplyReadingWidths();
if(!_settings.ShowSource)
{
return;
}
switch(e.Key)
{
case global::Windows.System.VirtualKey.Left:
ResizeSource(-24);
break;
case global::Windows.System.VirtualKey.Right:
ResizeSource(24);
break;
case global::Windows.System.VirtualKey.Home:
_sourceFraction = 0.5;
ApplyReadingWidths();
break;
default:
return;
}
e.Handled = true;
}
/// <summary>Writes the completed resize through AppKit's atomic settings store.</summary>
private void SourceDivider_DragCompleted(object sender, DragCompletedEventArgs e) => Save();
/// <summary>The ratio belongs to this window; reopening source intentionally starts again at50/50.</summary>
private void ResizeSource(double horizontalChange)
{
double shared = DocumentColumn.ActualWidth + SourceColumn.ActualWidth;
_sourceFraction = Core.Layout.SourcePaneLayout.Resize(shared, SourceColumn.ActualWidth, horizontalChange);
ApplyReadingWidths();
}
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
namespace MarkdownViewr.Windows;
/// <summary>Commits copied theme changes to the latest store and refreshes owned readers.</summary>
public sealed partial class MainWindow
{
private ulong _themeSettingsRevision;
/// <summary>The native manager edits a snapshot; cancellation cannot affect another reader or persisted state.</summary>
private async Task ManageDocumentThemesAsync()
{
Core.AppSettings before = Core.SettingsChanges.Copy(_settings);
Core.AppSettings? edited = await new ThemeManagementDialog(before, ConfigurationWindowId).ShowAsync(ConfigurationPage, () => !_closed);
if(edited is not null && !_closed)
{
await CommitThemeDraftAsync(before, edited);
}
}
/// <summary>Native menu cycling follows enabled saved order.</summary>
private async void NextDocumentTheme_Click(object sender, RoutedEventArgs e) => await CycleDocumentThemeAsync(forward: true);
/// <summary>Previous cycles backward with wraparound.</summary>
private async void PreviousDocumentTheme_Click(object sender, RoutedEventArgs e) => await CycleDocumentThemeAsync(forward: false);
/// <summary>An intentionally empty enabled list has no cycle action and retains the safe current surface.</summary>
private async Task CycleDocumentThemeAsync(bool forward)
{
Core.AppSettings before = Core.SettingsChanges.Copy(_settings);
Core.Themes.ReaderTheme? next = Core.Themes.ThemeManagement.Cycle(before, forward);
if(next is not null)
{
Core.AppSettings draft = Core.SettingsChanges.Copy(before);
draft.DocumentTheme = next.Name;
await CommitThemeDraftAsync(before, draft);
}
}
/// <summary>Failed persistence does not apply an unsaved theme draft; sibling fields untouched by this edit retain their latest values.</summary>
private async Task<bool> CommitThemeDraftAsync(Core.AppSettings before, Core.AppSettings edited)
{
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, edited);
if(!AppIdentity.SettingsStore.Save(merged))
{
StatusText.Text = "Could not save document themes.";
return false;
}
await ((App)Application.Current).ApplyThemeSettingsAsync(merged);
return true;
}
/// <summary>Shared palettes and their menu state update each window's save baseline before asynchronous rendering.</summary>
internal async Task ApplyThemeSettingsAsync(Core.AppSettings settings)
{
if(_closed)
{
return;
}
ulong revision = ++_themeSettingsRevision;
_settings.DocumentTheme = _lastSavedSettings.DocumentTheme = settings.DocumentTheme;
_settings.UserThemes = settings.UserThemes.ToArray();
_lastSavedSettings.UserThemes = settings.UserThemes.ToArray();
_settings.ThemeOrder = settings.ThemeOrder.ToArray();
_lastSavedSettings.ThemeOrder = settings.ThemeOrder.ToArray();
_settings.DisabledThemes = settings.DisabledThemes.ToArray();
_lastSavedSettings.DisabledThemes = settings.DisabledThemes.ToArray();
_settings.DeletedBuiltInThemes = settings.DeletedBuiltInThemes.ToArray();
_lastSavedSettings.DeletedBuiltInThemes = settings.DeletedBuiltInThemes.ToArray();
RefreshDocumentThemes();
try
{
_browserInitialization ??= _browser.InitializeAsync();
await _browserInitialization;
if(!_closed && revision == _themeSettingsRevision)
{
await ApplyDocumentThemeAsync(ActiveDocumentTheme());
}
}
catch(System.Runtime.InteropServices.COMException exception)
{
if(!_closed)
{
StatusText.Text = "The document themes could not be displayed.";
App.Log.Error("The document themes could not be displayed.", exception);
}
}
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows;
/// <summary>Native theme transfer commits only explicitly selected, validated imports or user-chosen export destinations.</summary>
public sealed partial class MainWindow
{
private bool _transferringThemes;
/// <summary>Picker cancellation leaves settings intact; rejected files do not prevent other selected themes from importing.</summary>
private async Task ImportThemesAsync()
{
if(_closed || _transferringThemes)
{
return;
}
_transferringThemes = true;
try
{
Core.AppSettings before = Core.SettingsChanges.Copy(_settings);
ThemeTransfer.ImportOutcome? result = await ThemeTransfer.ImportAsync(ConfigurationWindowId, ConfigurationRoot, before);
if(result is not null && !_closed)
{
if(result.Imported > 0 && !await CommitThemeDraftAsync(before, result.Draft))
{
return;
}
if(!_closed)
{
StatusText.Text = FormattableString.Invariant($"Themes imported: {result.Imported}. Skipped: {result.Skipped}. Failed: {result.Failed}.");
}
}
}
catch(Exception exception) when(exception is System.Runtime.InteropServices.COMException or ObjectDisposedException or InvalidOperationException or ArgumentException)
{
ReportThemeTransferError(exception);
}
finally
{
_transferringThemes = false;
}
}
/// <summary>Export offers all visible themes and writes the exact validated JSON after native destination confirmation.</summary>
private async Task ExportThemesAsync()
{
if(_closed || _transferringThemes)
{
return;
}
_transferringThemes = true;
try
{
Core.Themes.ReaderTheme? theme = await ThemeTransfer.SelectExportAsync(ConfigurationRoot, _settings);
if(theme is not null && !_closed && await ThemeTransfer.ExportAsync(ConfigurationWindowId, theme) && !_closed)
{
StatusText.Text = "Theme exported.";
}
}
catch(Exception exception) when(exception is System.Runtime.InteropServices.COMException or ObjectDisposedException or InvalidOperationException or IOException or UnauthorizedAccessException or ArgumentException)
{
ReportThemeTransferError(exception);
}
finally
{
_transferringThemes = false;
}
}
/// <summary>Errors disclose only exception kind/HRESULT; paths and theme content stay private.</summary>
private void ReportThemeTransferError(Exception exception)
{
if(!_closed)
{
StatusText.Text = "Theme transfer could not be completed. Check the selected file or destination.";
App.Log.Warning(FormattableString.Invariant($"Theme transfer could not be completed ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
}
}
@@ -0,0 +1,167 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
namespace MarkdownViewr.Windows;
/// <summary>Matches the reference document toolbar while sharing native menu action paths and responsive overflow.</summary>
public sealed partial class MainWindow
{
private readonly ToolTip _editorUnavailableTip = new();
private readonly DispatcherTimer _editorHoverDelay = new() { Interval = TimeSpan.FromMilliseconds(500) };
/// <summary>Fixed primary commands and reading actions share the same native menu state.</summary>
private void InitializeToolbar()
{
RefreshToolbarReadingState();
ReaderToolbar.Opening += (_, _) => RefreshToolbarEditors();
_editorUnavailableTip.PlacementTarget = ToolbarEditorButton;
AutomationProperties.SetAutomationId(_editorUnavailableTip, "EditorUnavailableTip");
_editorHoverDelay.Tick += (_, _) =>
{
_editorHoverDelay.Stop();
if(!_closed && !ToolbarEditorButton.IsEnabled)
{
_editorUnavailableTip.XamlRoot = ReaderToolbar.XamlRoot;
_editorUnavailableTip.IsOpen = true;
}
};
ReaderToolbar.PointerMoved += EditorUnavailablePointerMoved;
ReaderToolbar.PointerExited += (_, _) => CloseEditorExplanation();
}
/// <summary>Menu changes and toolbar clicks reflect the same visibility/depth preferences and accessible toggle state.</summary>
private void RefreshToolbarReadingState()
{
ToolbarOutlineButton.IsChecked = _settings.ShowOutline;
ToolbarSourceButton.IsChecked = _settings.ShowSource;
ToolTipService.SetToolTip(ToolbarOutlineButton, _settings.ShowOutline ? "Hide table of contents" : "Show table of contents");
ToolTipService.SetToolTip(ToolbarSourceButton, _settings.ShowSource ? "Hide Markdown source" : "Show Markdown source");
}
/// <summary>The toolbar routes TOC visibility through the same saved menu operation.</summary>
private void ToolbarOutline_Click(object sender, RoutedEventArgs e)
{
OutlineToggle.IsChecked = ToolbarOutlineButton.IsChecked == true;
Outline_Click(sender, e);
}
/// <summary>Source toggling uses the shared snapshot,50/50 opening and find refresh path.</summary>
private void ToolbarSource_Click(object sender, RoutedEventArgs e)
{
SourceToggle.IsChecked = ToolbarSourceButton.IsChecked == true;
Source_Click(sender, e);
}
/// <summary>Only enabled choices appear, following the same saved order and current selection as View.</summary>
private void RefreshToolbarThemes()
{
ToolbarThemeFlyout.Items.Clear();
string selected = ActiveDocumentTheme().Name;
ToolbarThemeButton.Label = selected;
ToolbarThemeButton.IsEnabled = _themes.Count > 0;
AutomationProperties.SetName(ToolbarThemeButton, "Document theme: " + selected);
foreach(Core.Themes.ReaderTheme theme in _themes)
{
var item = new RadioMenuFlyoutItem { Text = theme.Name, Tag = theme, GroupName = "ToolbarTheme", IsChecked = string.Equals(selected, theme.Name, StringComparison.Ordinal) };
AutomationProperties.SetAutomationId(item, "ToolbarTheme:" + theme.Name);
item.Click += DocumentTheme_Click;
ToolbarThemeFlyout.Items.Add(item);
}
}
/// <summary>The reference launches one existing editor directly; multiple existing editors expose named file/folder choices.</summary>
private void RefreshToolbarEditors()
{
Core.Editors.EditorConfiguration[] editors = _settings.ConfiguredEditors.Where(editor => File.Exists(editor.Executable)).ToArray();
string? unavailable = _documentPath is null ? "Open a document before opening it in an editor."
: !File.Exists(_documentPath) ? "The document is missing. Restore it before opening an editor."
: _settings.ConfiguredEditors.Length == 0 ? "No editor is configured. Choose one in File → Settings → Editors."
: editors.Length == 0 ? "The configured editor is unavailable. Choose an installed editor in File → Settings → Editors."
: null;
ToolbarEditorButton.IsEnabled = unavailable is null;
ToolbarEditorButton.Label = editors.Length == 1 ? "Open in " + editors[0].Name : "Open in editor";
ToolbarEditorButton.Tag = editors.Length == 1 ? editors[0].Id : null;
AutomationProperties.SetName(ToolbarEditorButton, ToolbarEditorButton.Label);
string explanation = unavailable ?? "Open the document in a configured editor.";
ToolTipService.SetToolTip(ToolbarEditorButton, explanation);
AutomationProperties.SetHelpText(ToolbarEditorButton, explanation);
AutomationProperties.SetHelpText(OpenEditorMenuItem, explanation);
ToolTipService.SetToolTip(OpenEditorMenuItem, explanation);
_editorUnavailableTip.Content = explanation;
CloseEditorExplanation();
ToolbarEditorButton.Flyout = null;
if(editors.Length > 1)
{
var choices = new MenuFlyout();
foreach(Core.Editors.EditorConfiguration editor in editors)
{
var item = new MenuFlyoutItem { Text = editor.Name + (editor.OpensFolder ? " (folder)" : ""), Tag = editor.Id };
AutomationProperties.SetAutomationId(item, "ToolbarEditor:" + editor.Id);
item.Click += OpenEditor_Click;
choices.Items.Add(item);
}
ToolbarEditorButton.Flyout = choices;
}
}
/// <summary>The enabled toolbar receives hover even over a disabled child; native ToolTip explains its actual unavailable state.</summary>
private void EditorUnavailablePointerMoved(object sender, PointerRoutedEventArgs args)
{
global::Windows.Foundation.Point point = args.GetCurrentPoint(ToolbarEditorButton).Position;
if(!ToolbarEditorButton.IsEnabled && point.X >= 0 && point.Y >= 0 && point.X < ToolbarEditorButton.ActualWidth && point.Y < ToolbarEditorButton.ActualHeight)
{
if(!_editorHoverDelay.IsEnabled && !_editorUnavailableTip.IsOpen)
{
_editorHoverDelay.Start();
}
}
else
{
CloseEditorExplanation();
}
}
/// <summary>Leaving the disabled command or changing its state cancels pending hover and closes the owned tooltip.</summary>
private void CloseEditorExplanation()
{
_editorHoverDelay.Stop();
_editorUnavailableTip.IsOpen = false;
}
/// <summary>A direct editor action uses its stable identity; multiple choices are presented by the native flyout.</summary>
private void ToolbarEditor_Click(object sender, RoutedEventArgs e)
{
if(ToolbarEditorButton.Tag is Guid)
{
OpenEditor_Click(sender, e);
}
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ public sealed partial class MainWindow
control.IsEnabled = enabled;
}
string percent = Math.Round(_readerZoom.Scale * 100).ToString(System.Globalization.CultureInfo.InvariantCulture) + "%";
ZoomResetButton.Content = percent;
ZoomResetButton.Label = percent;
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(ZoomResetButton, "Actual size (" + percent + ")");
}
}
+81 -20
View File
@@ -3,6 +3,7 @@
x:Class="MarkdownViewr.Windows.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MarkdownViewr.Windows"
xmlns:primitives="using:Microsoft.UI.Xaml.Controls.Primitives">
<Window.SystemBackdrop>
@@ -15,6 +16,7 @@
<KeyboardAccelerator Key="P" Modifiers="Control" Invoked="Print_Invoked" />
</Grid.KeyboardAccelerators>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -27,7 +29,7 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<MenuBar>
<MenuBarItem Title="File">
<MenuBarItem Title="File" AccessKey="F">
<MenuFlyoutItem x:Name="NewWindowMenuItem" Text="New window" Click="NewWindow_Click">
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator Key="N" Modifiers="Control" />
@@ -44,7 +46,7 @@
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="OpenEditorMenuItem" Text="Open in editor" Click="OpenEditor_Click" IsEnabled="False" />
<MenuFlyoutSubItem x:Name="OpenEditorChoices" Text="Open in editor" Visibility="Collapsed" />
<MenuFlyoutItem x:Name="ConfigureEditorMenuItem" Text="Configure editor..." Click="ConfigureEditor_Click" />
<MenuFlyoutItem x:Name="SettingsMenuItem" Text="Settings…" AccessKey="S" Click="Settings_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="CloseWindowMenuItem" Text="Close window" Click="CloseWindow_Click">
<MenuFlyoutItem.KeyboardAccelerators>
@@ -65,9 +67,15 @@
<ToggleMenuFlyoutItem x:Name="OutlineToggle" Text="Table of contents" Click="Outline_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutSubItem x:Name="DocumentThemesMenu" Text="Document theme" />
<MenuFlyoutItem x:Name="EditDocumentThemeMenu" Text="Edit document theme..." Click="EditDocumentTheme_Click" />
<MenuFlyoutItem x:Name="RestoreDocumentThemesMenu" Text="Restore built-in themes" Click="RestoreDocumentThemes_Click" />
<MenuFlyoutItem x:Name="NextDocumentThemeMenu" Text="Next document theme" Click="NextDocumentTheme_Click" KeyboardAcceleratorTextOverride="Ctrl+Shift+Down" />
<MenuFlyoutItem x:Name="PreviousDocumentThemeMenu" Text="Previous document theme" Click="PreviousDocumentTheme_Click" KeyboardAcceleratorTextOverride="Ctrl+Shift+Up" />
<MenuFlyoutSeparator />
<MenuFlyoutSubItem x:Name="MarkdownExtensionsMenu" Text="Markdown extensions">
<ToggleMenuFlyoutItem x:Name="HighlightExtension" Text="Highlight (==text==)" Tag="Highlight" Click="MarkdownExtension_Click" />
<ToggleMenuFlyoutItem x:Name="SuperscriptExtension" Text="Superscript (^text^)" Tag="Superscript" Click="MarkdownExtension_Click" />
<ToggleMenuFlyoutItem x:Name="SubscriptExtension" Text="Subscript (~text~)" Tag="Subscript" Click="MarkdownExtension_Click" />
<ToggleMenuFlyoutItem x:Name="UnderlineExtension" Text="Underline (++text++)" Tag="Underline" Click="MarkdownExtension_Click" />
</MenuFlyoutSubItem>
<MenuFlyoutSubItem x:Name="FrontmatterMenu" Text="Frontmatter">
<RadioMenuFlyoutItem x:Name="FrontmatterHide" Text="Hide" GroupName="Frontmatter" Tag="Hide" Click="Frontmatter_Click" />
<RadioMenuFlyoutItem x:Name="FrontmatterMetadata" Text="Show as metadata" GroupName="Frontmatter" Tag="Metadata" Click="Frontmatter_Click" />
@@ -77,25 +85,55 @@
<RadioMenuFlyoutItem x:Name="ThemeSystem" Text="System theme" GroupName="Theme" Tag="System" Click="Theme_Click" />
<RadioMenuFlyoutItem x:Name="ThemeLight" Text="Light theme" GroupName="Theme" Tag="Light" Click="Theme_Click" />
<RadioMenuFlyoutItem x:Name="ThemeDark" Text="Dark theme" GroupName="Theme" Tag="Dark" Click="Theme_Click" />
<MenuFlyoutSeparator />
<ToggleMenuFlyoutItem x:Name="SplashToggle" Text="Show splash at startup" Click="Splash_Click" />
<ToggleMenuFlyoutItem x:Name="UpdateCheckToggle" Text="Check for updates at startup" Click="UpdateCheck_Click" />
</MenuBarItem>
<MenuBarItem Title="Help">
<MenuBarItem Title="Help" AccessKey="H">
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" AutomationProperties.AutomationId="MenuGitHub" Click="GitHub_Click" />
<MenuFlyoutItem Text="Submit an issue…" AccessKey="I" AutomationProperties.AutomationId="MenuSubmitIssue" ToolTipService.ToolTip="Report a bug or request a feature on GitHub" Click="SubmitIssue_Click" />
<MenuFlyoutItem Text="Community support…" AccessKey="S" AutomationProperties.AutomationId="MenuCommunitySupport" ToolTipService.ToolTip="Ask a question or share tips in GitHub Discussions" Click="CommunitySupport_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="CheckForUpdatesMenuItem" Text="Check for updates" Click="CheckForUpdates_Click" />
<MenuFlyoutItem x:Name="ActivityLogMenuItem" Text="Activity log" Click="ActivityLog_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="AboutMenuItem" Text="About MarkdownViewr" Click="About_Click" />
</MenuBarItem>
</MenuBar>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0" VerticalAlignment="Center">
<Button x:Name="ZoomOutButton" Content="−" Tag="ZoomOut" Click="Zoom_Click" IsEnabled="False" AutomationProperties.Name="Zoom out" ToolTipService.ToolTip="Zoom out (Ctrl+-)" />
<Button x:Name="ZoomResetButton" Content="100%" Tag="ZoomReset" Click="Zoom_Click" IsEnabled="False" AutomationProperties.Name="Actual size" ToolTipService.ToolTip="Actual size (Ctrl+0)" />
<Button x:Name="ZoomInButton" Content="+" Tag="ZoomIn" Click="Zoom_Click" IsEnabled="False" AutomationProperties.Name="Zoom in" ToolTipService.ToolTip="Zoom in (Ctrl++)" />
</StackPanel>
</Grid>
<Grid x:Name="FindBar" Grid.Row="1" Visibility="Collapsed" Margin="16,8" ColumnSpacing="8">
<CommandBar x:Name="ReaderToolbar" Grid.Row="1" Height="48" Margin="12,4,12,4" HorizontalAlignment="Left" DefaultLabelPosition="Right" IsDynamicOverflowEnabled="True" AutomationProperties.Name="Document toolbar">
<CommandBar.Content>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Spacing="4" Margin="0,0,8,0">
<ToggleButton x:Name="ToolbarOutlineButton" Height="40" Padding="12,0" VerticalAlignment="Center" Click="ToolbarOutline_Click" AutomationProperties.Name="Table of contents" ToolTipService.ToolTip="Show table of contents">
<StackPanel Orientation="Horizontal" Spacing="8">
<SymbolIcon Symbol="Bullets" />
<TextBlock Text="Contents" VerticalAlignment="Center" />
</StackPanel>
</ToggleButton>
<Button x:Name="ToolbarPrintButton" Height="40" Padding="12,0" VerticalAlignment="Center" Click="Print_Click" IsEnabled="False" AutomationProperties.Name="Print" ToolTipService.ToolTip="Print document">
<StackPanel Orientation="Horizontal" Spacing="8">
<SymbolIcon Symbol="Print" />
<TextBlock Text="Print" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</CommandBar.Content>
<AppBarToggleButton x:Name="ToolbarSourceButton" Icon="Document" Label="Source" Click="ToolbarSource_Click" AutomationProperties.Name="Markdown source" ToolTipService.ToolTip="Show Markdown source" />
<AppBarSeparator />
<AppBarButton x:Name="ZoomOutButton" Icon="ZoomOut" Label="Zoom out" Tag="ZoomOut" Click="Zoom_Click" IsEnabled="False" AutomationProperties.Name="Zoom out" ToolTipService.ToolTip="Zoom out (Ctrl+-)" />
<AppBarButton x:Name="ZoomResetButton" Icon="Refresh" Label="100%" Tag="ZoomReset" Click="Zoom_Click" IsEnabled="False" AutomationProperties.Name="Actual size" ToolTipService.ToolTip="Actual size (Ctrl+0)" />
<AppBarButton x:Name="ZoomInButton" Icon="ZoomIn" Label="Zoom in" Tag="ZoomIn" Click="Zoom_Click" IsEnabled="False" AutomationProperties.Name="Zoom in" ToolTipService.ToolTip="Zoom in (Ctrl++)" />
<AppBarSeparator />
<AppBarButton x:Name="ToolbarThemeButton" Label="Theme" MaxWidth="220" ToolTipService.ToolTip="Document theme">
<AppBarButton.Icon>
<FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xE790;" />
</AppBarButton.Icon>
<AppBarButton.Flyout>
<MenuFlyout x:Name="ToolbarThemeFlyout" />
</AppBarButton.Flyout>
</AppBarButton>
<AppBarButton x:Name="ToolbarEditorButton" Icon="Edit" Label="Open in editor" Click="ToolbarEditor_Click" IsEnabled="False" AutomationProperties.Name="Open in editor" ToolTipService.ToolTip="Configure an editor in File → Settings → Editors" />
</CommandBar>
<Grid x:Name="FindBar" Grid.Row="2" Visibility="Collapsed" Margin="16,8" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -109,12 +147,23 @@
<Button x:Name="FindNextButton" Grid.Column="3" Content="Next" Click="FindNext_Click" IsEnabled="False" />
<Button x:Name="FindCloseButton" Grid.Column="4" Content="Close find" Click="FindClose_Click" />
</Grid>
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="16,8" Text="Starting the document surface..." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" AutomationProperties.AutomationId="StatusText" AutomationProperties.Name="{Binding Text, ElementName=StatusText}" AutomationProperties.AccessibilityView="Content" AutomationProperties.LiveSetting="Polite" />
<Grid Grid.Row="2">
<Grid x:Name="DocumentFooter" Grid.Row="4" Margin="16,4" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock x:Name="StatusText" Margin="0,4" VerticalAlignment="Center" Text="Starting the document surface..." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" AutomationProperties.AutomationId="StatusText" AutomationProperties.Name="{Binding Text, ElementName=StatusText}" AutomationProperties.AccessibilityView="Content" AutomationProperties.LiveSetting="Polite" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock x:Name="FileModifiedText" FontSize="12" VerticalAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Visibility="Collapsed" AutomationProperties.AutomationId="FileModifiedText" AutomationProperties.Name="{Binding Text, ElementName=FileModifiedText}" />
<HyperlinkButton x:Name="FileStaleButton" Padding="0,4" Visibility="Collapsed" Click="Reload_Click" AutomationProperties.AutomationId="FileStaleButton" AutomationProperties.LiveSetting="Polite" />
<HyperlinkButton x:Name="OpenDocumentFolderButton" Content="Open folder" Padding="0,4" IsEnabled="False" Click="OpenDocumentFolder_Click" AutomationProperties.AutomationId="OpenDocumentFolderButton" ToolTipService.ToolTip="Open this file's containing folder in Explorer" />
</StackPanel>
</Grid>
<Grid Grid.Row="3">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="OutlineColumn" Width="0" />
<ColumnDefinition x:Name="OutlineDividerColumn" Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition x:Name="DocumentColumn" Width="*" />
<ColumnDefinition x:Name="SourceDividerColumn" Width="0" />
<ColumnDefinition x:Name="SourceColumn" Width="0" />
</Grid.ColumnDefinitions>
@@ -149,14 +198,26 @@
</ControlTemplate>
</primitives:Thumb.Template>
</primitives:Thumb>
<primitives:Thumb x:Name="SourceDivider" Grid.Column="3" Background="{ThemeResource DividerStrokeColorDefaultBrush}" DragDelta="SourceDivider_DragDelta" DragCompleted="SourceDivider_DragCompleted" AutomationProperties.Name="Resize Markdown source">
<primitives:Thumb x:Name="SourceDivider" Grid.Column="3" Background="{ThemeResource DividerStrokeColorDefaultBrush}" DragDelta="SourceDivider_DragDelta" KeyDown="SourceDivider_KeyDown" AutomationProperties.Name="Resize Markdown source" AutomationProperties.HelpText="Use Left and Right arrow keys to resize; Home restores equal document and source widths.">
<primitives:Thumb.Template>
<ControlTemplate TargetType="primitives:Thumb">
<Border Background="{TemplateBinding Background}" />
<Border x:Name="SourceGrip" Background="{TemplateBinding Background}" BorderBrush="{ThemeResource ControlStrongStrokeColorDefaultBrush}">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="FocusStates">
<VisualState x:Name="Unfocused" />
<VisualState x:Name="PointerFocused" />
<VisualState x:Name="Focused">
<VisualState.Setters>
<Setter Target="SourceGrip.BorderThickness" Value="2" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Border>
</ControlTemplate>
</primitives:Thumb.Template>
</primitives:Thumb>
<TextBox x:Name="SourceText" Grid.Column="4" Visibility="Collapsed" IsReadOnly="True" AcceptsReturn="True" TextWrapping="NoWrap" FontFamily="Consolas" Padding="12" ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" AutomationProperties.Name="Markdown source" />
<local:SourceTextView x:Name="SourceText" Grid.Column="4" Visibility="Collapsed" IsReadOnly="True" TextWrapping="NoWrap" FontFamily="Consolas" Padding="12" ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" AutomationProperties.Name="Markdown source" />
<WebView2 x:Name="DocumentView" Grid.Column="2" Loaded="DocumentView_Loaded" AutomationProperties.Name="Markdown document" />
</Grid>
</Grid>
+54 -51
View File
@@ -36,11 +36,13 @@ public sealed partial class MainWindow : Window, IDisposable
public MainWindow(string? documentPath = null, bool initialWindow = true)
{
_initialDocumentPath = documentPath;
_settings.ShowOutline = _settings.ShowOutlineByDefault;
_settings.ShowSource = _settings.ShowSourceByDefault;
_lastSavedSettings = Core.SettingsChanges.Copy(_settings);
InitializeComponent();
RefreshRecentDocuments();
_themes = Core.Themes.ThemeCatalog.Combine(_settings.UserThemes.Select(Core.Themes.ReaderTheme.Parse));
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message, ActiveDocumentTheme());
_themes = Core.Themes.ThemeManagement.Enabled(_settings);
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message, ActiveDocumentTheme(), _settings.ReadingAppearance);
CreateDocumentThemeMenu();
InitializeOutline();
InitializeSource();
@@ -48,8 +50,11 @@ public sealed partial class MainWindow : Window, IDisposable
InitializeKeyboard();
InitializeZoom();
InitializePrinting();
InitializeMarkdownExtensions();
RefreshEditorCommands();
InitializeToolbar();
InitializeInputDiagnostics();
InitializeDocumentFooter();
(_settings.FrontmatterKind switch { Core.Documents.FrontmatterMode.Metadata => FrontmatterMetadata, Core.Documents.FrontmatterMode.Markdown => FrontmatterMarkdown, _ => FrontmatterHide }).IsChecked = true;
Title = AppIdentity.Info.DisplayName;
FitInitialWindow();
@@ -61,8 +66,6 @@ public sealed partial class MainWindow : Window, IDisposable
AppIdentity.Info.DisplayName,
App.Log);
SplashToggle.IsChecked = _settings.ShowSplashScreen;
UpdateCheckToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
(_settings.ThemeKind switch { AppTheme.Light => ThemeLight, AppTheme.Dark => ThemeDark, _ => ThemeSystem }).IsChecked = true;
if(initialWindow)
@@ -130,7 +133,7 @@ public sealed partial class MainWindow : Window, IDisposable
{
return;
}
StatusText.Text = "Choose File → Open to read a Markdown document.";
StatusText.Text = _activationError ? "One or more startup file paths could not be opened." : "Choose File → Open to read a Markdown document.";
if(_initialDocumentPath is { } path)
{
await OpenDocumentAsync(path);
@@ -208,7 +211,7 @@ public sealed partial class MainWindow : Window, IDisposable
_browserInitialization ??= _browser.InitializeAsync();
await _browserInitialization;
Core.Documents.DocumentViewport viewport = reload ? await _browser.CaptureViewportAsync() : default;
(DocumentFiles files, Core.Documents.DocumentOutline outline) = await ReadDocumentAsync(path, _settings.FrontmatterKind);
(DocumentFiles files, Core.Documents.DocumentOutline outline) = await ReadDocumentAsync(path, _settings.FrontmatterKind, _settings.MarkdownExtensions);
if(_closed)
{
return;
@@ -220,9 +223,15 @@ public sealed partial class MainWindow : Window, IDisposable
App.Log.Warning(FormattableString.Invariant($"Could not open a Markdown document. Reload={reload}; exception={exception.GetType().Name}; HRESULT=0x{exception.HResult:X8}; UIThread={DispatcherQueue.HasThreadAccess}."));
if(!_closed)
{
if(reload)
{
MarkDocumentStale();
}
StatusText.Text = reload && exception is FileNotFoundException
? "The document is missing. Showing the last version; it will reload when restored."
: "Could not open this document: " + exception.Message;
: reload
? "The document could not be reloaded. Showing the previous version; use File → Reload."
: "Could not open this document: " + exception.Message;
}
}
@@ -261,6 +270,7 @@ public sealed partial class MainWindow : Window, IDisposable
{
if(string.Equals(_documentPath, watchedPath, StringComparison.Ordinal))
{
MarkDocumentStale();
_ = OpenDocumentAsync(watchedPath, reload: true);
}
}),
@@ -269,6 +279,7 @@ public sealed partial class MainWindow : Window, IDisposable
if(!_closed)
{
StatusText.Text = "Live reload encountered a filesystem error. Use File → Reload if changes stop appearing.";
MarkDocumentStale();
App.Log.Warning("The document watcher reported a filesystem error.");
}
}));
@@ -276,6 +287,7 @@ public sealed partial class MainWindow : Window, IDisposable
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or ArgumentException)
{
StatusText.Text = "Live reload is unavailable for this folder. Use File → Reload.";
MarkDocumentStale();
App.Log.Warning("Could not start the document watcher.");
}
}
@@ -283,7 +295,7 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Retries only transient missing/sharing violations from an editor's replace/save operation.</summary>
private static async Task<(DocumentFiles Files, Core.Documents.DocumentOutline Outline)> ReadDocumentAsync(string path, Core.Documents.FrontmatterMode frontmatter)
private static async Task<(DocumentFiles Files, Core.Documents.DocumentOutline Outline)> ReadDocumentAsync(string path, Core.Documents.FrontmatterMode frontmatter, Core.Documents.MarkdownExtensions extensions)
{
for(int attempt = 0; ; attempt++)
{
@@ -292,7 +304,7 @@ public sealed partial class MainWindow : Window, IDisposable
return await Task.Run(() =>
{
var document = DocumentFiles.Open(path);
return (document, new Core.Documents.DocumentOutline(document.Markdown, frontmatter));
return (document, new Core.Documents.DocumentOutline(document.Markdown, frontmatter, extensions));
});
}
catch(IOException exception) when(attempt < 3 && (exception.HResult & 0xFFFF) is 2 or 3 or 32 or 33)
@@ -317,6 +329,7 @@ public sealed partial class MainWindow : Window, IDisposable
RefreshSource();
RefreshOutline();
_documentPath = Path.GetFullPath(path);
CommitDocumentFooter(files);
RefreshEditorCommands();
Title = Path.GetFileName(path) + " — " + AppIdentity.Info.DisplayName;
StatusText.Text = (reload ? "Reloaded " : "Opened ") + Path.GetFileName(path);
@@ -343,6 +356,10 @@ public sealed partial class MainWindow : Window, IDisposable
_findDelay?.Stop();
_watcher?.Dispose();
_watcher = null;
_footerPoll.Stop();
CloseEditorExplanation();
_settingsWindow?.Close();
SourceText.Dispose();
_browser.Close();
}
GC.SuppressFinalize(this);
@@ -360,6 +377,7 @@ public sealed partial class MainWindow : Window, IDisposable
{
DocumentThemesMenu.Items.Clear();
string selected = ActiveDocumentTheme().Name;
NextDocumentThemeMenu.IsEnabled = PreviousDocumentThemeMenu.IsEnabled = _themes.Count > 0;
foreach(Core.Themes.ReaderTheme theme in _themes)
{
var item = new RadioMenuFlyoutItem { Text = theme.Name, Tag = theme, GroupName = "DocumentTheme", IsChecked = string.Equals(selected, theme.Name, StringComparison.Ordinal) };
@@ -367,6 +385,7 @@ public sealed partial class MainWindow : Window, IDisposable
item.Click += DocumentTheme_Click;
DocumentThemesMenu.Items.Add(item);
}
RefreshToolbarThemes();
}
@@ -375,9 +394,10 @@ public sealed partial class MainWindow : Window, IDisposable
private async void DocumentTheme_Click(object sender, RoutedEventArgs e)
{
var theme = (Core.Themes.ReaderTheme)((FrameworkElement)sender).Tag;
_settings.DocumentTheme = theme.Name;
Save();
await ApplyDocumentThemeAsync(theme);
Core.AppSettings before = Core.SettingsChanges.Copy(_settings);
Core.AppSettings draft = Core.SettingsChanges.Copy(before);
draft.DocumentTheme = theme.Name;
await CommitThemeDraftAsync(before, draft);
}
@@ -396,44 +416,36 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Resolves user overrides and safely falls back after a saved custom theme is removed.</summary>
private Core.Themes.ReaderTheme ActiveDocumentTheme()
=> _themes.FirstOrDefault(theme => string.Equals(theme.Name, _settings.DocumentTheme, StringComparison.Ordinal))
?? Core.Themes.ThemeCatalog.Find(null);
private Core.Themes.ReaderTheme ActiveDocumentTheme() => Core.Themes.ThemeManagement.Active(_settings);
/// <summary>A same-name edit overrides the original; a new name creates a custom theme.</summary>
private async void EditDocumentTheme_Click(object sender, RoutedEventArgs e)
private async Task EditDocumentThemeAsync()
{
Core.Themes.ReaderTheme? edited = await ThemeEditor.ShowAsync(Content.XamlRoot, ActiveDocumentTheme());
Core.AppSettings before = Core.SettingsChanges.Copy(_settings);
Core.Themes.ReaderTheme original = ActiveDocumentTheme();
Core.Themes.ReaderTheme? edited = await ThemeEditor.ShowAsync(ConfigurationPage, original, Core.Themes.ThemeManagement.All(before).Select(theme => theme.Name));
if(edited is null || _closed)
{
return;
}
_settings.UserThemes = _settings.UserThemes
.Where(json => !string.Equals(Core.Themes.ReaderTheme.Parse(json).Name, edited.Name, StringComparison.Ordinal))
.Append(edited.Json).ToArray();
_settings.DocumentTheme = edited.Name;
Save();
RefreshDocumentThemes();
await ApplyDocumentThemeAsync(edited);
Core.AppSettings draft = Core.Themes.ThemeManagement.SaveEdited(before, original, edited);
await CommitThemeDraftAsync(before, draft);
}
/// <summary>Restores original palettes and removes their overrides, retaining separately named custom themes.</summary>
private async void RestoreDocumentThemes_Click(object sender, RoutedEventArgs e)
private async Task RestoreDocumentThemesAsync()
{
var dialog = new ContentDialog { XamlRoot = Content.XamlRoot, Title = "Restore built-in themes?", Content = "Edits to the 20 built-in themes will be removed. Your separately named custom themes will be kept.", PrimaryButtonText = "Restore themes", CloseButtonText = "Cancel" };
var dialog = new ContentDialog { XamlRoot = ConfigurationRoot, Title = "Restore built-in themes?", Content = "Original built-ins will be restored and enabled. Edited built-ins will be kept as uniquely named custom themes.", PrimaryButtonText = "Restore themes", CloseButtonText = "Cancel" };
if(await dialog.ShowAsync() != ContentDialogResult.Primary || _closed)
{
return;
}
var builtIn = Core.Themes.ThemeCatalog.BuiltIn.Select(theme => theme.Name).ToHashSet(StringComparer.Ordinal);
_settings.UserThemes = _settings.UserThemes.Where(json => !builtIn.Contains(Core.Themes.ReaderTheme.Parse(json).Name)).ToArray();
Save();
RefreshDocumentThemes();
await ApplyDocumentThemeAsync(ActiveDocumentTheme());
Core.AppSettings before = Core.SettingsChanges.Copy(_settings);
await CommitThemeDraftAsync(before, Core.Themes.ThemeManagement.RestoreBuiltIns(before));
}
@@ -441,7 +453,7 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Rebuilds the visible catalog after a successful preference edit.</summary>
private void RefreshDocumentThemes()
{
_themes = Core.Themes.ThemeCatalog.Combine(_settings.UserThemes.Select(Core.Themes.ReaderTheme.Parse));
_themes = Core.Themes.ThemeManagement.Enabled(_settings);
CreateDocumentThemeMenu();
}
@@ -450,6 +462,7 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Applies a theme without allowing browser errors to escape a native event handler.</summary>
private async Task ApplyDocumentThemeAsync(Core.Themes.ReaderTheme theme)
{
SourceText.SetTheme(theme);
try
{
_browserInitialization ??= _browser.InitializeAsync();
@@ -472,24 +485,10 @@ public sealed partial class MainWindow : Window, IDisposable
{
_settings.Theme = (string)((FrameworkElement)sender).Tag;
WinUIShell.ApplyTheme(RootGrid, _settings.ThemeKind);
Save();
}
/// <summary>View, Show splash at startup: saves the preference.</summary>
private void Splash_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSplashScreen = SplashToggle.IsChecked;
Save();
}
/// <summary>View, Check for updates at startup: saves the preference.</summary>
private void UpdateCheck_Click(object sender, RoutedEventArgs e)
{
_settings.CheckForUpdatesOnStartup = UpdateCheckToggle.IsChecked;
if(_settingsWindow is not null)
{
WinUIShell.ApplyTheme((FrameworkElement)_settingsWindow.Content, _settings.ThemeKind);
}
Save();
}
@@ -506,7 +505,11 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Help, About: shows the About dialog.</summary>
private async void About_Click(object sender, RoutedEventArgs e) => await AboutDialog.ShowAsync(AppIdentity.Info, Content.XamlRoot);
private async void About_Click(object sender, RoutedEventArgs e)
{
AboutDialog dialog = AppAbout.Create(Content.XamlRoot);
await DialogGuard.ShowAsync(dialog);
}
@@ -26,6 +26,7 @@
<Content Include="Assets\Brand\app-icon.svg" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\THIRD-PARTY-NOTICES.md" Link="THIRD-PARTY-NOTICES.md" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\THIRD-PARTY-LICENSES\**\*.txt" Link="ThirdPartyLicenses\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
@@ -23,6 +23,8 @@ internal sealed class PrintOptionsDialog
private readonly ComboBox _width = new() { Name = "PrintWidth", Header = "Content width", HorizontalAlignment = HorizontalAlignment.Stretch };
private readonly CheckBox _borders = new() { Name = "PrintBorders", Content = "Add borders to code and content blocks" };
private readonly NumberBox _padding = new() { Name = "PrintPadding", Header = "Page padding (px)", Minimum = 0, Maximum = 96, SmallChange = 8, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
private readonly CheckBox _openPdf = new() { Name = "PrintOpenPdf", Content = "Open PDF after saving" };
private readonly CheckBox _openFolder = new() { Name = "PrintOpenPdfFolder", Content = "Open containing folder after saving" };
private readonly TextBlock _error = new() { Name = "PrintError", TextWrapping = TextWrapping.Wrap };
private readonly ContentDialog _dialog;
@@ -48,7 +50,7 @@ internal sealed class PrintOptionsDialog
_borders.IsChecked = options.Borders;
_padding.Value = options.PaddingPixels;
var panel = new StackPanel { Spacing = 8, MinWidth = 300 };
foreach(FrameworkElement control in new FrameworkElement[] { _theme, _backgrounds, _images, _zoom, _width, _borders, _padding, _error })
foreach(FrameworkElement control in new FrameworkElement[] { _theme, _backgrounds, _images, _zoom, _width, _borders, _padding, _openPdf, _openFolder, _error })
{
AutomationProperties.SetAutomationId(control, control.Name);
panel.Children.Add(control);
@@ -62,6 +64,8 @@ internal sealed class PrintOptionsDialog
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Primary,
};
AutomationProperties.SetAutomationId(_dialog, "PrintOptionsDialog");
_dialog.Resources["ContentDialogMaxWidth"] = 640d;
_dialog.PrimaryButtonClick += (_, args) => args.Cancel = !ValidatePadding();
_dialog.SecondaryButtonClick += (_, args) => args.Cancel = !ValidatePadding();
}
@@ -71,6 +75,9 @@ internal sealed class PrintOptionsDialog
/// <summary>Only committed print/PDF actions return preferences; closing the draft leaves saved choices untouched.</summary>
public async Task<PrintOptions?> ShowAsync(XamlRoot root)
{
double width = Math.Max(0, Math.Min(520, root.Size.Width - 96));
((FrameworkElement)((ScrollViewer)_dialog.Content).Content).MinWidth = width;
_dialog.Resources["ContentDialogMinWidth"] = width + 48;
_dialog.XamlRoot = root;
ContentDialogResult result = await _dialog.ShowAsync();
SaveAsPdf = result == ContentDialogResult.Secondary;
@@ -86,6 +93,16 @@ internal sealed class PrintOptionsDialog
/// <summary>A deliberate post-export action, never run after cancellation or failed generation.</summary>
public bool OpenPdfAfterSave => _openPdf.IsChecked == true;
/// <summary>The folder may be opened independently or together with the generated PDF.</summary>
public bool OpenFolderAfterSave => _openFolder.IsChecked == true;
/// <summary>A blank or invalid number stays in the draft instead of silently changing the user's page layout.</summary>
private bool ValidatePadding()
{
+64 -11
View File
@@ -4,6 +4,7 @@
*/
using Microsoft.UI.Dispatching;
using Microsoft.Windows.AppLifecycle;
using Velopack;
@@ -15,19 +16,71 @@ namespace MarkdownViewr.Windows;
/// </summary>
public static class Program
{
/// <summary>Runs Velopack's hooks first (they may exit the process), then starts the WinUI application.</summary>
[STAThread]
private static void Main()
{
// Keep this direct call: vpk verifies the entry assembly before creating installers.
VelopackApp.Build().Run();
/// <summary>The primary's bounded current-user inbox is attached by App only after the native reader initializes.</summary>
internal static ActivationInbox? Inbox { get; private set; }
// The remainder mirrors the XAML-generated Main.
/// <summary>The initial canonical request preserves sender-relative paths and SDK rich file activation.</summary>
internal static ActivationRequest InitialRequest { get; private set; } = new([]);
/// <summary>Runs install hooks before XAML, chooses a profile/install-specific primary, then starts or forwards the native document request.</summary>
[STAThread]
private static int Main()
{
// Keep Build/Run in this entry point: vpk verifies it before creating installers.
VelopackApp.Build()
.OnAfterInstallFastCallback(_ => InstalledFileTypes.Register())
.OnAfterUpdateFastCallback(_ => InstalledFileTypes.Register())
.OnBeforeUninstallFastCallback(_ => InstalledFileTypes.Unregister())
.Run();
WinRT.ComWrappersSupport.InitializeComWrappers();
Microsoft.UI.Xaml.Application.Start(callbackParams =>
InitialRequest = DesktopActivation.ReadInitialRequest();
string key = ActivationRequest.InstanceKey(AppContext.BaseDirectory, Path.GetDirectoryName(AppIdentity.Paths.SettingsFile)!);
var primary = AppInstance.FindOrRegisterForKey(key);
if(!primary.IsCurrent)
{
SynchronizationContext.SetSynchronizationContext(new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()));
_ = new App();
});
return ForwardToPrimary(key, primary.ProcessId);
}
using var inbox = new ActivationInbox(key, DesktopActivation.ReportFailure);
Inbox = inbox;
try
{
Microsoft.UI.Xaml.Application.Start(callbackParams =>
{
SynchronizationContext.SetSynchronizationContext(new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()));
_ = new App();
});
}
finally
{
Inbox = null;
primary.UnregisterKey();
}
return 0;
}
/// <summary>Current-user .NET pipe delivery avoids the SDK STA redirection deadlock and resolves relative paths before crossing processes.</summary>
private static int ForwardToPrimary(string key, uint processId)
{
try
{
DesktopActivation.AllowPrimaryForeground(processId);
bool delivered = ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult();
if(!delivered)
{
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
}
return delivered ? 0 : 1;
}
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidDataException or OperationCanceledException or UnauthorizedAccessException)
{
DesktopActivation.ReportFailure(exception);
return 1;
}
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using CoreAppearance = MarkdownViewr.Windows.Core.Themes.ReadingAppearance;
namespace MarkdownViewr.Windows;
/// <summary>Native routine layout fields and an advanced CSS draft validate before checked persistence.</summary>
internal sealed class ReadingAppearanceEditor
{
private readonly ToggleSwitch _limit;
private readonly NumberBox _width;
private readonly TextBox _css;
private readonly TextBlock _error = new() { TextWrapping = TextWrapping.Wrap };
/// <summary>Native fields keep routine layout changes separate from unsubmitted CSS and print data.</summary>
public ReadingAppearanceEditor(CoreAppearance current)
{
_limit = new ToggleSwitch { Header = "Limit reading width", IsOn = current.LimitWidth };
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(_limit, "ReadingAppearanceLimit");
_width = new NumberBox { Header = "Maximum content width (pixels at 100%)", Minimum = 200, Maximum = 4000, SmallChange = 50, LargeChange = 200, Value = current.WidthPixels };
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(_width, "ReadingAppearanceWidth");
_css = new TextBox
{
Header = "Global custom CSS",
Text = current.GlobalCss,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
MaxLength = CoreAppearance.MaximumCssCharacters,
Height = 160,
};
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(_css, "ReadingAppearanceCss");
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(_error, "ReadingAppearanceError");
Fields.Children.Add(_limit);
Fields.Children.Add(_width);
Fields.Children.Add(new TextBlock { Text = "Global CSS applies across document themes. Per-theme CSS takes precedence. Print layout has its own settings.", TextWrapping = TextWrapping.Wrap });
Fields.Children.Add(_css);
Fields.Children.Add(_error);
_limit.Toggled += (_, _) => LayoutChanged?.Invoke(this, EventArgs.Empty);
_width.ValueChanged += (_, _) => LayoutChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>The native form remains alive while navigating categories or editing a document theme.</summary>
public StackPanel Fields { get; } = new() { Spacing = 12 };
/// <summary>The unsubmitted CSS draft stays local even when other readers save new shared preferences.</summary>
public string CssDraft => _css.Text;
/// <summary>Shared saved layout updates native fields without replacing a deliberately edited CSS draft.</summary>
public void Update(CoreAppearance appearance, bool preserveCss)
{
_limit.IsOn = appearance.LimitWidth;
_width.Value = appearance.WidthPixels;
if(!preserveCss)
{
_css.Text = appearance.GlobalCss;
}
}
/// <summary>Prevents concurrent edits during a checked save while retaining ordinary native disabled states.</summary>
public void SetEnabled(bool enabled)
{
_limit.IsEnabled = enabled;
_width.IsEnabled = enabled;
_css.IsEnabled = enabled;
}
/// <summary>Routine layout edits can be saved independently from the unsubmitted advanced CSS draft.</summary>
public event EventHandler? LayoutChanged;
/// <summary>Routine width changes preserve the last saved CSS until the user explicitly applies a new draft.</summary>
public CoreAppearance? ReadLayout(string savedCss) => Validate(new CoreAppearance(_limit.IsOn, _width.Value, savedCss));
/// <summary>Invalid input keeps Settings open and directs focus to a readable error.</summary>
public CoreAppearance? Read() => Validate(new CoreAppearance(_limit.IsOn, _width.Value, _css.Text));
/// <summary>Invalid native fields produce a visible, focusable validation message without saving a partial draft.</summary>
private CoreAppearance? Validate(CoreAppearance draft)
{
try
{
draft.Validate();
_error.Text = "";
return draft;
}
catch(InvalidOperationException exception)
{
_error.Text = exception.Message;
_error.StartBringIntoView();
return null;
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>Long configuration forms use the normal Settings window, with native Back and explicit draft actions.</summary>
internal sealed partial class SettingsWindow
{
private readonly StackPanel _editActions = new() { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(24, 12, 24, 12), Visibility = Visibility.Collapsed };
private TaskCompletionSource<ContentDialogResult>? _editCompletion;
/// <summary>Window closure cancels the draft; configuration callers cannot continue editing a closed owner.</summary>
internal bool IsOpen => !_closed;
/// <summary>Shows one scrollable form within Settings and retains invalid drafts until validation succeeds or the user cancels.</summary>
internal Task<ContentDialogResult> ShowEditorAsync(string title, UIElement fields, string saveLabel, Func<bool>? validate = null)
{
if(_closed)
{
return Task.FromResult(ContentDialogResult.None);
}
if(_editCompletion is not null)
{
throw new InvalidOperationException("A Settings edit is already open.");
}
var completion = new TaskCompletionSource<ContentDialogResult>(TaskCreationOptions.RunContinuationsAsynchronously);
_editCompletion = completion;
_navigation.Header = title;
_navigation.IsBackButtonVisible = NavigationViewBackButtonVisible.Visible;
foreach(NavigationViewItem item in _navigation.MenuItems.Cast<NavigationViewItem>())
{
item.IsEnabled = false;
}
_pageScroll.Content = null;
_pageScroll.Content = fields;
_pageScroll.ChangeView(null, 0, null, disableAnimation: true);
var save = new Button { Content = saveLabel, MinWidth = 120 };
var cancel = new Button { Content = "Cancel", MinWidth = 120 };
AutomationProperties.SetAutomationId(save, "PrimaryButton");
AutomationProperties.SetAutomationId(cancel, "CloseButton");
save.Click += (_, _) =>
{
if(validate?.Invoke() != false)
{
EndEdit(ContentDialogResult.Primary);
}
};
cancel.Click += (_, _) => EndEdit(ContentDialogResult.None);
_editActions.Children.Clear();
_editActions.Children.Add(save);
_editActions.Children.Add(cancel);
_editActions.Visibility = Visibility.Visible;
return completion.Task;
}
/// <summary>Detaches the edit before completing its task, allowing a manager to open another page while preserving its copied draft.</summary>
internal void EndEdit(ContentDialogResult result)
{
TaskCompletionSource<ContentDialogResult>? completion = _editCompletion;
if(completion is null)
{
return;
}
_editCompletion = null;
_pageScroll.Content = null;
_editActions.Children.Clear();
_editActions.Visibility = Visibility.Collapsed;
if(!_closed)
{
_navigation.IsBackButtonVisible = NavigationViewBackButtonVisible.Collapsed;
foreach(NavigationViewItem item in _navigation.MenuItems.Cast<NavigationViewItem>())
{
item.IsEnabled = true;
}
SelectPage((string)((NavigationViewItem)_navigation.SelectedItem).Tag);
}
completion.TrySetResult(result);
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>Default-file configuration is an explicit native Settings action owned by Windows, never by document content.</summary>
internal sealed partial class SettingsWindow
{
/// <summary>Installed builds offer the OS chooser; unregistered previews explain why they cannot become a registered default here.</summary>
private void AddDefaultFileTypes(StackPanel page)
{
page.Children.Add(new TextBlock { Text = "Default file types", FontSize = 20, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, Margin = new Thickness(0, 16, 0, 0) });
var guidance = new TextBlock
{
Text = "Choose MarkdownViewr for .md, .markdown, .mdown or .mkd in Windows Default Apps. Plain text (.txt) is optional. Windows keeps your choices until you change them.",
TextWrapping = TextWrapping.Wrap,
};
AutomationProperties.SetAutomationId(guidance, "SettingsDefaultFilesGuidance");
page.Children.Add(guidance);
var choose = new Button { Content = "Choose default file types", HorizontalAlignment = HorizontalAlignment.Left, IsEnabled = false };
AutomationProperties.SetAutomationId(choose, "SettingsDefaultFilesButton");
try
{
choose.IsEnabled = InstalledFileTypes.GetDefaultAppsUri() is not null;
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or System.Security.SecurityException or ArgumentException)
{
ReportError(exception);
}
choose.Click += async (_, _) => await OpenDefaultFileTypesAsync();
page.Children.Add(choose);
if(!choose.IsEnabled)
{
page.Children.Add(new TextBlock { Text = "Install or repair MarkdownViewr to make it available in Open with and Default Apps. This preview does not change Windows file associations.", TextWrapping = TextWrapping.Wrap });
}
}
/// <summary>Revalidates the installed registration at click time and opens only Microsoft's fixed settings scheme.</summary>
private async Task OpenDefaultFileTypesAsync()
{
if(_closed || _busy)
{
return;
}
_busy = true;
try
{
Uri? target = InstalledFileTypes.GetDefaultAppsUri();
if(target is null)
{
_status.Text = "Install or repair MarkdownViewr, then try again.";
}
else if(await global::Windows.System.Launcher.LaunchUriAsync(target))
{
if(!_closed)
{
_status.Text = "Choose MarkdownViewr for your Markdown extensions in Windows Default Apps. You can leave .txt unchanged.";
}
}
else if(!_closed)
{
_status.Text = "Open Windows Settings → Apps → Default apps, search for .md, then choose MarkdownViewr.";
}
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or System.Security.SecurityException or System.Runtime.InteropServices.COMException or InvalidOperationException or ArgumentException)
{
if(!_closed)
{
_status.Text = "Open Windows Settings → Apps → Default apps, search for .md, then choose MarkdownViewr.";
}
App.Log.Warning(FormattableString.Invariant($"Default Apps could not be opened ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
finally
{
_busy = false;
}
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>Configuration pages use normal Windows controls; each explicit theme/editor edit retains its own Save and Cancel.</summary>
internal sealed partial class SettingsWindow
{
/// <summary>Theme editing, transfer and management are grouped together instead of crowded into the reader's View menu.</summary>
private StackPanel ThemesPage()
{
var page = new StackPanel { Spacing = 16, MaxWidth = 720 };
page.Children.Add(new TextBlock { Text = "Customize document fonts, colors and spacing, or manage your saved themes.", TextWrapping = TextWrapping.Wrap });
page.Children.Add(Action("EditDocumentThemeMenu", "Edit current theme", "Fonts, colors, sizes and custom CSS"));
page.Children.Add(Action("ManageDocumentThemesMenu", "Manage themes", "Create, copy, reorder, enable or delete themes"));
var transfer = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 };
transfer.Children.Add(Action("ImportDocumentThemesMenu", "Import", "Read themes from JSON files"));
transfer.Children.Add(Action("ExportDocumentThemesMenu", "Export", "Save a theme as JSON"));
page.Children.Add(transfer);
page.Children.Add(Action("RestoreDocumentThemesMenu", "Restore built-in themes", "Keep edited themes as custom copies"));
return page;
}
/// <summary>External-editor configuration is discoverable as a normal page with one deliberate edit action.</summary>
private StackPanel EditorsPage()
{
var page = new StackPanel { Spacing = 16, MaxWidth = 720 };
page.Children.Add(new TextBlock { Text = "Choose the editors used by Open in editor. Editors can open the document or its containing folder.", TextWrapping = TextWrapping.Wrap });
page.Children.Add(Action("ConfigureEditorMenuItem", "Configure editors", "Add, remove and reorder editors"));
return page;
}
/// <summary>Native labeled buttons show ordinary explanatory text rather than custom settings widgets.</summary>
private Button Action(string identity, string label, string description)
{
var text = new StackPanel { Spacing = 4 };
text.Children.Add(new TextBlock { Text = label, TextWrapping = TextWrapping.Wrap });
text.Children.Add(new TextBlock { Text = description, TextWrapping = TextWrapping.Wrap, Opacity = 0.7 });
var button = new Button { Content = text, HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalContentAlignment = HorizontalAlignment.Left, Padding = new Thickness(16, 12, 16, 12) };
AutomationProperties.SetAutomationId(button, identity);
AutomationProperties.SetName(button, label);
button.Click += async (_, _) =>
{
if(_closed || _busy)
{
return;
}
_busy = true;
try
{
await _runAction(identity);
}
catch(Exception exception)
{
ReportError(exception);
}
finally
{
_busy = false;
}
};
return button;
}
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Settings;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>Native Advanced controls expose the same splash toggle, bounds and duration steps as gclo.</summary>
internal sealed partial class SettingsWindow
{
private readonly ToggleSwitch _showSplash = new() { Header = "Show splash screen at startup" };
private readonly NumberBox _splashDuration = new() { Header = "Splash duration (milliseconds)", Minimum = ShellSettings.MinSplashMilliseconds, Maximum = ShellSettings.MaxSplashMilliseconds, SmallChange = 250, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline, HorizontalAlignment = HorizontalAlignment.Left, Width = 280 };
private readonly ToggleSwitch _checkStartupUpdates = new() { Header = "Check for updates at startup" };
private readonly ToggleSwitch _showOutlineByDefault = new() { Header = "Show Contents in new windows" };
private readonly ToggleSwitch _showSourceByDefault = new() { Header = "Show Source in new windows" };
private readonly Func<bool, int, bool, bool, bool, bool> _saveStartup;
/// <summary>Initial launch owns the shared branded AppKit overlay; later document windows open directly into their reader.</summary>
private StackPanel AdvancedPage(Core.AppSettings current)
{
var page = new StackPanel { Spacing = 16, MaxWidth = 720 };
page.Children.Add(new TextBlock { Text = "Startup", FontSize = 20, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
page.Children.Add(new TextBlock { Text = "Changes apply the next time you launch MarkdownViewr. The splash fades away automatically while your document loads.", TextWrapping = TextWrapping.Wrap });
AutomationProperties.SetAutomationId(_showSplash, "SettingsSplashToggle");
AutomationProperties.SetAutomationId(_splashDuration, "SettingsSplashDurationBox");
AutomationProperties.SetAutomationId(_checkStartupUpdates, "SettingsStartupUpdatesToggle");
ToolTipService.SetToolTip(_splashDuration, "Time before the splash fades: 500–30000 milliseconds, in 250 millisecond steps.");
UpdateStartup(current);
_showSplash.Toggled += (_, _) => SaveStartup();
_splashDuration.ValueChanged += (_, _) => SaveStartup();
_checkStartupUpdates.Toggled += (_, _) => SaveStartup();
page.Children.Add(_showSplash);
page.Children.Add(_splashDuration);
page.Children.Add(_checkStartupUpdates);
page.Children.Add(new TextBlock { Text = "New document windows", FontSize = 20, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
page.Children.Add(new TextBlock { Text = "Contents and Source start hidden by default. Toolbar changes affect only the current window.", TextWrapping = TextWrapping.Wrap });
AutomationProperties.SetAutomationId(_showOutlineByDefault, "SettingsDefaultContentsToggle");
AutomationProperties.SetAutomationId(_showSourceByDefault, "SettingsDefaultSourceToggle");
_showOutlineByDefault.Toggled += (_, _) => SaveStartup();
_showSourceByDefault.Toggled += (_, _) => SaveStartup();
page.Children.Add(_showOutlineByDefault);
page.Children.Add(_showSourceByDefault);
AddDefaultFileTypes(page);
return page;
}
/// <summary>Saved sibling preferences refresh without generating a new save or altering an unsaved CSS draft.</summary>
internal void UpdateStartup(Core.AppSettings settings)
{
if(_closed)
{
return;
}
bool previous = _updating;
_updating = true;
try
{
_showSplash.IsOn = settings.ShowSplashScreen;
_splashDuration.Value = settings.SplashMilliseconds;
_splashDuration.IsEnabled = settings.ShowSplashScreen;
_checkStartupUpdates.IsOn = settings.CheckForUpdatesOnStartup;
_showOutlineByDefault.IsOn = settings.ShowOutlineByDefault;
_showSourceByDefault.IsOn = settings.ShowSourceByDefault;
}
finally
{
_updating = previous;
}
}
/// <summary>Routine startup choices save immediately; invalid numeric input never reaches persisted preferences.</summary>
private void SaveStartup()
{
if(_closed || _updating)
{
return;
}
double duration = _splashDuration.Value;
if(!double.IsFinite(duration))
{
_status.Text = "Enter a splash duration between 500 and 30000 milliseconds.";
return;
}
_splashDuration.IsEnabled = _showSplash.IsOn;
int milliseconds = (int)Math.Round(Math.Clamp(duration, ShellSettings.MinSplashMilliseconds, ShellSettings.MaxSplashMilliseconds));
try
{
_status.Text = _saveStartup(_showSplash.IsOn, milliseconds, _checkStartupUpdates.IsOn, _showOutlineByDefault.IsOn, _showSourceByDefault.IsOn) ? "Saved. Applies to new windows and the next launch." : "Could not save the setting. Please try again.";
}
catch(Exception exception)
{
ReportError(exception);
}
}
}
+210
View File
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.WinUI;
using MarkdownViewr.Windows.Core.Themes;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
namespace MarkdownViewr.Windows;
/// <summary>A normal resizable WinUI settings window with adaptive navigation and independently scrolling native pages.</summary>
internal sealed partial class SettingsWindow : Window
{
private readonly Grid _root = new();
private readonly NavigationView _navigation = new() { PaneDisplayMode = NavigationViewPaneDisplayMode.Auto, IsSettingsVisible = false, IsBackButtonVisible = NavigationViewBackButtonVisible.Collapsed, OpenPaneLength = 220, AlwaysShowHeader = true };
private readonly Dictionary<string, (string Title, UIElement Page)> _pages = new(StringComparer.Ordinal);
private readonly ScrollViewer _pageScroll = new() { Padding = new Thickness(24, 12, 24, 24), HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
private readonly TextBlock _status = new() { TextWrapping = TextWrapping.Wrap, Margin = new Thickness(24, 8, 24, 12) };
private readonly ReadingAppearanceEditor _appearance;
private readonly Func<ReadingAppearance, Task<bool>> _saveAppearance;
private readonly Func<string, Task> _runAction;
private string _savedCss;
private bool _busy;
private bool _closed;
private bool _updating;
/// <summary>Settings is a separate window; routine layout saves immediately and advanced CSS has an explicit Apply action.</summary>
public SettingsWindow(Core.AppSettings current, Func<ReadingAppearance, Task<bool>> saveAppearance, Func<bool, int, bool, bool, bool, bool> saveStartup, Func<string, Task> runAction)
{
Title = AppIdentity.Info.DisplayName + " — Settings";
SystemBackdrop = new MicaBackdrop();
_appearance = new ReadingAppearanceEditor(current.ReadingAppearance);
_savedCss = current.GlobalCss;
_saveStartup = saveStartup;
_saveAppearance = saveAppearance;
_runAction = runAction;
_root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
_root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
_root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
Grid.SetRow(_editActions, 1);
_root.Children.Add(_editActions);
_root.Children.Add(_navigation);
Grid.SetRow(_status, 2);
AutomationProperties.SetAutomationId(_root, "SettingsWindowRoot");
AutomationProperties.SetName(_root, "Settings");
AutomationProperties.SetAutomationId(_status, "SettingsStatus");
AutomationProperties.SetLiveSetting(_status, AutomationLiveSetting.Polite);
_root.Children.Add(_status);
Content = _root;
AddPage("SettingsAppearanceTab", "Appearance", Symbol.Font, AppearancePage());
AddPage("SettingsThemesTab", "Document themes", Symbol.Library, ThemesPage());
AddPage("SettingsEditorsTab", "Editors", Symbol.Edit, EditorsPage());
AddPage("SettingsAdvancedTab", "Advanced", Symbol.Setting, AdvancedPage(current));
_navigation.Content = _pageScroll;
_navigation.SelectionChanged += (_, args) => SelectPage((string)((NavigationViewItem)args.SelectedItem).Tag);
_navigation.SelectedItem = _navigation.MenuItems[0];
_appearance.LayoutChanged += async (_, _) => await SaveAppearanceAsync(includeCss: false);
_navigation.BackRequested += (_, _) => EndEdit(ContentDialogResult.None);
Closed += (_, _) =>
{
_closed = true;
EndEdit(ContentDialogResult.None);
};
this.SetAppIcon(AppIdentity.Info);
FitWindow();
}
/// <summary>Uses normal Windows resizing while keeping navigation and settings controls within the monitor work area.</summary>
private void FitWindow()
{
double scale = this.DpiScale();
global::Windows.Graphics.RectInt32 area = Microsoft.UI.Windowing.DisplayArea.GetFromWindowId(AppWindow.Id, Microsoft.UI.Windowing.DisplayAreaFallback.Primary).WorkArea;
AppWindow.Resize(new global::Windows.Graphics.SizeInt32(Math.Min((int)(960 * scale), area.Width), Math.Min((int)(720 * scale), area.Height)));
if(AppWindow.Presenter is Microsoft.UI.Windowing.OverlappedPresenter presenter)
{
presenter.PreferredMinimumWidth = Math.Min((int)(640 * scale), area.Width);
presenter.PreferredMinimumHeight = Math.Min((int)(420 * scale), area.Height);
}
}
/// <summary>The current settings XamlRoot owns theme/editor dialogs, rather than the reading window beneath it.</summary>
public XamlRoot DialogRoot => _root.XamlRoot;
/// <summary>Reader broadcasts refresh saved fields and CSS baselines while retaining unsubmitted CSS drafts.</summary>
public void UpdateAppearance(ReadingAppearance appearance)
{
if(_closed)
{
return;
}
_updating = true;
try
{
bool preserveCss = !string.Equals(_appearance.CssDraft, _savedCss, StringComparison.Ordinal);
_appearance.Update(appearance, preserveCss);
_savedCss = appearance.GlobalCss;
}
finally
{
_updating = false;
}
}
/// <summary>Pages use native NavigationView items and ordinary scrolling, with no nested tab or custom modal-shell behavior.</summary>
private void AddPage(string identity, string title, Symbol icon, UIElement content)
{
var item = new NavigationViewItem { Content = title, Icon = new SymbolIcon(icon), Tag = identity };
AutomationProperties.SetAutomationId(item, identity);
if(string.Equals(identity, "SettingsThemesTab", StringComparison.Ordinal))
{
item.Icon = new FontIcon { FontFamily = new FontFamily("Segoe Fluent Icons"), Glyph = "\uE790" };
}
_navigation.MenuItems.Add(item);
_pages.Add(identity, (title, content));
}
/// <summary>Category switches retain deliberate CSS drafts and keyboard-accessible fields within the same live window.</summary>
private void SelectPage(string identity)
{
(string title, UIElement page) = _pages[identity];
_navigation.Header = title;
_pageScroll.Content = null;
_pageScroll.Content = page;
}
/// <summary>Native layout controls save their validated changes; unsubmitted CSS stays a separate draft.</summary>
private StackPanel AppearancePage()
{
var page = new StackPanel { Spacing = 16, MaxWidth = 720, HorizontalAlignment = HorizontalAlignment.Stretch };
page.Children.Add(_appearance.Fields);
var apply = new Button { Content = "Apply custom CSS", HorizontalAlignment = HorizontalAlignment.Left };
AutomationProperties.SetAutomationId(apply, "SettingsApplyCss");
apply.Click += async (_, _) => await SaveAppearanceAsync(includeCss: true);
page.Children.Add(apply);
return page;
}
/// <summary>A validated save reports persistence failure and never applies an unsaved document appearance.</summary>
private async Task SaveAppearanceAsync(bool includeCss)
{
if(_closed || _busy || _updating)
{
return;
}
ReadingAppearance? draft = includeCss ? _appearance.Read() : _appearance.ReadLayout(_savedCss);
if(draft is null)
{
return;
}
_busy = true;
_appearance.SetEnabled(false);
try
{
if(await _saveAppearance(draft) && !_closed)
{
_status.Text = "Saved.";
}
else if(!_closed)
{
_status.Text = "Could not save the setting. Please try again.";
}
}
catch(Exception exception)
{
ReportError(exception);
}
finally
{
_busy = false;
if(!_closed)
{
_appearance.SetEnabled(true);
}
}
}
/// <summary>Errors disclose no document paths or authored CSS, and stay visible in the owned settings window.</summary>
private void ReportError(Exception exception)
{
if(!_closed)
{
_status.Text = "The setting could not be changed. Please try again.";
App.Log.Warning(FormattableString.Invariant($"Settings could not be completed ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Automation.Provider;
namespace MarkdownViewr.Windows;
/// <summary>Adds read-only native plain-value accessibility while preserving RichEdit's own actual text-range provider.</summary>
internal sealed class SourceTextAutomationPeer(SourceTextView owner) : RichEditBoxAutomationPeer(owner), IValueProvider
{
/// <summary>Source cannot be edited by accessibility, just as it cannot be edited by keyboard or paste.</summary>
public bool IsReadOnly => true;
/// <summary>Returns the real native plain text, never a cached renderer diagnostic or synthetic test value.</summary>
public string Value => owner.Text;
/// <summary>Read-only source rejects accessibility editing rather than silently modifying the committed document.</summary>
public void SetValue(string value) => throw new InvalidOperationException("Markdown source is read-only.");
/// <summary>Only Value is extended; native RichEdit supplies Text, selection and scrolling patterns.</summary>
protected override object GetPatternCore(PatternInterface patternInterface) => patternInterface == PatternInterface.Value ? this : base.GetPatternCore(patternInterface);
}
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
using MarkdownViewr.Windows.Core.Themes;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using NativeText = Microsoft.UI.Text;
namespace MarkdownViewr.Windows;
/// <summary>Parser classification runs off-thread; bounded native paint batches discard stale snapshots and palettes.</summary>
public sealed partial class SourceTextView
{
/// <summary>Only the most recent literal snapshot can install tokens after background parsing completes.</summary>
private async Task HighlightAsync(string text, int generation, CancellationToken cancellationToken)
{
try
{
IReadOnlyList<SourceToken> tokens = await Task.Run(() => MarkdownSourceHighlighting.Parse(text, cancellationToken), cancellationToken);
if(_disposed || generation != _generation || cancellationToken.IsCancellationRequested)
{
return;
}
_tokens = tokens;
await PaintAsync();
}
catch(OperationCanceledException)
{
// A newer snapshot or closing window owns the source now.
}
catch(Exception exception)
{
App.Log.Warning(FormattableString.Invariant($"Source highlighting could not complete ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
}
/// <summary>Windows high contrast overrides semantic colors; ordinary light/dark modes use readable document accents.</summary>
private async Task PaintAsync()
{
try
{
await PaintPaletteAsync();
}
catch(Exception exception)
{
App.Log.Warning(FormattableString.Invariant($"Source colors could not be applied ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
}
/// <summary>Applies the current native palette and yields between batches, leaving ordinary source readable on failure.</summary>
private async Task PaintPaletteAsync()
{
if(_disposed)
{
return;
}
int generation = ++_paintGeneration;
int snapshot = _generation;
var palette = new SourcePalette(_theme, ActualTheme == ElementTheme.Dark);
bool highContrast = _accessibility.HighContrast;
global::Windows.UI.Color foreground = highContrast ? _systemColors.UIElementColor(global::Windows.UI.ViewManagement.UIElementType.WindowText) : Color(palette.Foreground);
global::Windows.UI.Color background = highContrast ? _systemColors.UIElementColor(global::Windows.UI.ViewManagement.UIElementType.Window) : Color(palette.Background);
Background = new SolidColorBrush(background);
Foreground = new SolidColorBrush(foreground);
NativeText.ITextCharacterFormat defaults = Document.GetDefaultCharacterFormat();
defaults.ForegroundColor = foreground;
defaults.Bold = NativeText.FormatEffect.Off;
defaults.Italic = NativeText.FormatEffect.Off;
defaults.Strikethrough = NativeText.FormatEffect.Off;
WriteNative(() =>
{
Document.SetDefaultCharacterFormat(defaults);
Document.GetRange(0, TextLength).CharacterFormat = defaults;
});
IReadOnlyList<SourceToken> tokens = _tokens;
for(int first = 0; first < tokens.Count; first += 256)
{
if(_disposed || generation != _paintGeneration || snapshot != _generation)
{
return;
}
PaintBatch(tokens, first, palette, foreground, highContrast);
await Task.Yield();
}
}
/// <summary>Independent ranges change character appearance only, retaining source selection, spelling and scroll positions.</summary>
private void PaintBatch(IReadOnlyList<SourceToken> tokens, int first, SourcePalette palette, global::Windows.UI.Color foreground, bool highContrast)
{
WriteNative(() =>
{
Document.BatchDisplayUpdates();
try
{
for(int index = first; index < Math.Min(first + 256, tokens.Count); index++)
{
SourceToken token = tokens[index];
NativeText.ITextRange range = Document.GetRange(token.Start, token.Start + token.Length);
NativeText.ITextCharacterFormat format = range.CharacterFormat;
format.ForegroundColor = highContrast ? foreground : Color(palette.Color(token.Kind));
if(token.Kind is SourceTokenKind.Heading or SourceTokenKind.Strong)
{
format.Bold = NativeText.FormatEffect.On;
}
if(token.Kind is SourceTokenKind.Emphasis or SourceTokenKind.Quote)
{
format.Italic = NativeText.FormatEffect.On;
}
if(token.Kind == SourceTokenKind.Strikethrough)
{
format.Strikethrough = NativeText.FormatEffect.On;
}
range.CharacterFormat = format;
}
}
finally
{
Document.ApplyDisplayUpdates();
}
});
}
}
+228
View File
@@ -0,0 +1,228 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using MarkdownViewr.Windows.Core.Documents;
using MarkdownViewr.Windows.Core.Themes;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using NativeText = Microsoft.UI.Text;
namespace MarkdownViewr.Windows;
/// <summary>Read-only native source with passive character formatting, literal copying and real text accessibility.</summary>
public sealed partial class SourceTextView : RichEditBox, IDisposable
{
private readonly global::Windows.UI.ViewManagement.AccessibilitySettings _accessibility = new();
private readonly global::Windows.UI.ViewManagement.UISettings _systemColors = new();
private CancellationTokenSource? _classification;
private IReadOnlyList<SourceToken> _tokens = [];
private ReaderTheme _theme = ThemeCatalog.Find(null);
private DocumentSourceText? _snapshot;
private string _input = "";
private int _generation;
private int _paintGeneration;
private bool _disposed;
/// <summary>Source never loads rich text or enables URL input; copy emits only deliberate plain text.</summary>
public SourceTextView()
{
IsReadOnly = true;
IsSpellCheckEnabled = false;
IsTextPredictionEnabled = false;
ClipboardCopyFormat = RichEditClipboardFormat.PlainText;
InputScope = new InputScope();
InputScope.Names.Add(new InputScopeName { NameValue = InputScopeNameValue.Default });
ActualThemeChanged += ThemeChanged;
_systemColors.ColorValuesChanged += SystemColorsChanged;
CopyingToClipboard += CopySource;
}
/// <summary>Reads actual native plain text, excluding RichEdit's final paragraph sentinel.</summary>
public string Text
{
get
{
Document.GetRange(0, TextLength).GetText(NativeText.TextGetOptions.None, out string text);
return text;
}
}
/// <summary>Literal UTF16 character count bounds native selection and geometry without repeatedly allocating the document.</summary>
public int TextLength => _input.Length;
/// <summary>Reports the actual native selection, keeping paired find and read-only source focus intact.</summary>
public int SelectionLength => Document.Selection.EndPosition - Document.Selection.StartPosition;
/// <summary>Commits a new shared snapshot only as literal native text; parser work can never replace or mutate it.</summary>
public void SetSource(string text, DocumentSourceText? snapshot)
{
ArgumentNullException.ThrowIfNull(text);
_snapshot = snapshot;
if(string.Equals(_input, text, StringComparison.Ordinal))
{
return;
}
_input = text;
WriteNative(() => Document.SetText(NativeText.TextSetOptions.None, text));
_tokens = [];
_classification?.Cancel();
_classification?.Dispose();
_classification = new CancellationTokenSource();
_ = HighlightAsync(text, ++_generation, _classification.Token);
}
/// <summary>Palette changes recolor the current tokens without reparsing or altering the selected text.</summary>
public void SetTheme(ReaderTheme theme)
{
_theme = theme;
_ = PaintAsync();
}
/// <summary>Find selections remain native UTF16 ranges, never HTML or synthetic accessibility values.</summary>
public void Select(int start, int length)
{
int first = Math.Clamp(start, 0, TextLength);
Document.Selection.SetRange(first, Math.Clamp(start + length, first, TextLength));
}
/// <summary>Returns native paragraph geometry in unscrolled control coordinates, preserving the source scroll contract.</summary>
public global::Windows.Foundation.Rect GetRectFromCharacterIndex(int index, bool trailingEdge)
{
int position = Math.Clamp(index + (trailingEdge ? 1 : 0), 0, TextLength);
Document.GetRange(position, Math.Min(position + 1, TextLength)).GetRect(NativeText.PointOptions.ClientCoordinates | NativeText.PointOptions.AllowOffClient, out global::Windows.Foundation.Rect rectangle, out _);
ScrollViewer? scroller = FindScroller(this);
rectangle.Y += scroller?.VerticalOffset ?? 0;
return rectangle;
}
/// <summary>Preserves native Text/Scroll patterns while exposing the actual read-only plain value for accessibility.</summary>
protected override AutomationPeer OnCreateAutomationPeer() => new SourceTextAutomationPeer(this);
/// <summary>Mode changes use native Windows colors; document CSS cannot style or execute within this control.</summary>
private void ThemeChanged(FrameworkElement sender, object args) => _ = PaintAsync();
/// <summary>System high contrast takes precedence over document accents and marshals back to the owned UI queue.</summary>
private void SystemColorsChanged(global::Windows.UI.ViewManagement.UISettings sender, object args) => DispatcherQueue.TryEnqueue(() =>
{
if(!_disposed)
{
_ = PaintAsync();
}
});
/// <summary>A native clipboard failure can be reported by the owning reader without exposing selected document content.</summary>
public event EventHandler? CopyFailed;
/// <summary>Only an explicit native Copy action receives exact original visible source, with no rich-text clipboard formats.</summary>
private void CopySource(RichEditBox sender, TextControlCopyingToClipboardEventArgs args)
{
int start = Math.Clamp(Document.Selection.StartPosition, 0, TextLength);
int end = Math.Clamp(Document.Selection.EndPosition, start, TextLength);
string text = _snapshot?.GetCopyText(start, end - start) ?? Text.Substring(start, end - start);
args.Handled = true;
try
{
var package = new global::Windows.ApplicationModel.DataTransfer.DataPackage();
package.SetText(text);
global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package);
}
catch(System.Runtime.InteropServices.COMException exception)
{
App.Log.Warning(FormattableString.Invariant($"The source could not be copied ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
CopyFailed?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>Closing cancels stale classification and painting and releases the system accessibility subscription.</summary>
public void Dispose()
{
if(!_disposed)
{
_disposed = true;
_generation++;
_paintGeneration++;
_classification?.Cancel();
_classification?.Dispose();
_systemColors.ColorValuesChanged -= SystemColorsChanged;
ActualThemeChanged -= ThemeChanged;
CopyingToClipboard -= CopySource;
}
GC.SuppressFinalize(this);
}
/// <summary>Read-only RichEdit denies host range formatting; native writes run synchronously with read-only restored before yielding.</summary>
private void WriteNative(Action write)
{
IsReadOnly = false;
try
{
write();
Document.ClearUndoRedoHistory();
}
finally
{
IsReadOnly = true;
}
}
/// <summary>Recurses only through this source control's native template; no desktop or unrelated application is queried.</summary>
private static ScrollViewer? FindScroller(DependencyObject element)
{
if(element is ScrollViewer scroller)
{
return scroller;
}
for(int index = 0; index < VisualTreeHelper.GetChildrenCount(element); index++)
{
if(FindScroller(VisualTreeHelper.GetChild(element, index)) is { } found)
{
return found;
}
}
return null;
}
/// <summary>Converts only validated six-digit RGB values to native colors, never authored CSS.</summary>
private static global::Windows.UI.Color Color(string value) => global::Windows.UI.Color.FromArgb(255, byte.Parse(value.AsSpan(1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), byte.Parse(value.AsSpan(3, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), byte.Parse(value.AsSpan(5, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture));
}
+64 -35
View File
@@ -15,13 +15,10 @@ namespace MarkdownViewr.Windows;
internal static class ThemeEditor
{
/// <summary>Returns a validated edited theme, or null after cancellation.</summary>
public static async Task<Core.Themes.ReaderTheme?> ShowAsync(XamlRoot xamlRoot, Core.Themes.ReaderTheme theme)
public static async Task<Core.Themes.ReaderTheme?> ShowAsync(SettingsWindow host, Core.Themes.ReaderTheme theme, IEnumerable<string>? existingNames = null)
{
JsonNode source = JsonNode.Parse(theme.Json)!;
TextBox name = Field("Name (a new name creates a custom theme)", theme.Name, "ThemeEditorName", 80);
TextBox font = Field("Code font", source["fonts"]!["code"]!.GetValue<string>(), "ThemeEditorCodeFont", 80);
var size = new NumberBox { Header = "Code font size", Minimum = 6, Maximum = 96, Value = source["sizes"]!["codeFontSize"]!.GetValue<double>() };
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(size, "ThemeEditorCodeSize");
TextBox css = Field("Custom CSS", source["customCSS"]?.GetValue<string>() ?? "", "ThemeEditorCss", 128 * 1024);
css.AcceptsReturn = true;
css.Height = 160;
@@ -31,56 +28,88 @@ internal static class ThemeEditor
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(error, "ThemeEditorError");
var fields = new StackPanel { Spacing = 12 };
fields.Children.Add(name);
fields.Children.Add(font);
fields.Children.Add(size);
Dictionary<string, TextBox> fonts = AddFonts(fields, source["fonts"]!.AsObject());
Dictionary<string, NumberBox> sizes = AddSizes(fields, source["sizes"]!.AsObject());
Dictionary<string, TextBox> colors = AddColors(fields, source["colors"]!.AsObject());
fields.Children.Add(css);
fields.Children.Add(error);
var dialog = new ContentDialog
{
XamlRoot = xamlRoot,
Title = "Edit document theme",
PrimaryButtonText = "Save theme",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Primary,
Content = new ScrollViewer { Content = fields, Height = Math.Clamp(xamlRoot.Size.Height - 220, 120, 440), Width = 480, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled },
};
Core.Themes.ReaderTheme? edited = null;
dialog.PrimaryButtonClick += (_, args) =>
bool Validate()
{
try
{
edited = ReadEdited(source, name.Text, font.Text, size.Value, css.Text, colors);
Core.Themes.ReaderTheme candidate = theme.Edit(name.Text, fonts.ToDictionary(pair => pair.Key, pair => pair.Value.Text, StringComparer.Ordinal),
sizes.ToDictionary(pair => pair.Key, pair => pair.Value.Value, StringComparer.Ordinal),
colors.ToDictionary(pair => pair.Key, pair => pair.Value.Text, StringComparer.Ordinal), css.Text);
Core.Themes.ThemeManagement.ValidateEditName(theme.Name, candidate.Name, existingNames ?? []);
edited = candidate;
return true;
}
catch(Exception exception) when(exception is JsonException or ArgumentException)
{
error.Text = exception.Message;
args.Cancel = true;
error.StartBringIntoView();
return false;
}
};
_ = await dialog.ShowAsync();
return edited;
}
ContentDialogResult result = await host.ShowEditorAsync("Edit document theme", fields, "Save theme", Validate);
return result == ContentDialogResult.Primary ? edited : null;
}
/// <summary>Updates only the edited fields and runs the same schema/CSS validation as imported themes.</summary>
private static Core.Themes.ReaderTheme ReadEdited(JsonNode source, string name, string font, double size, string css, Dictionary<string, TextBox> colors)
/// <summary>All three reference font families remain editable through stable native controls.</summary>
private static Dictionary<string, TextBox> AddFonts(StackPanel fields, JsonObject source)
{
if(!double.IsFinite(size))
var fonts = new Dictionary<string, TextBox>(StringComparer.Ordinal);
foreach((string key, string label, string id) in new[]
{
throw new JsonException("Enter a code font size between 6 and 96.");
}
source["name"] = name.Trim();
source["fonts"]!["code"] = font.Trim();
source["sizes"]!["codeFontSize"] = size;
source["customCSS"] = css;
foreach((string key, TextBox value) in colors)
("body", "Body font", "ThemeEditorBodyFont"),
("heading", "Heading font", "ThemeEditorHeadingFont"),
("code", "Code font", "ThemeEditorCodeFont"),
})
{
source["colors"]![key] = value.Text.Trim();
TextBox input = Field(label, source[key]!.GetValue<string>(), id, 80);
fonts.Add(key, input);
fields.Children.Add(input);
}
return Core.Themes.ReaderTheme.Parse(source.ToJsonString());
return fonts;
}
/// <summary>Font-size and line-height controls use the same finite bounds as the saved schema.</summary>
private static Dictionary<string, NumberBox> AddSizes(StackPanel fields, JsonObject source)
{
var sizes = new Dictionary<string, NumberBox>(StringComparer.Ordinal);
foreach((string key, string label, string id) in new[]
{
("baseFontSize", "Body font size", "ThemeEditorBodySize"),
("h1Size", "Heading 1 size", "ThemeEditorHeading1Size"),
("h2Size", "Heading 2 size", "ThemeEditorHeading2Size"),
("h3Size", "Heading 3 size", "ThemeEditorHeading3Size"),
("h4Size", "Heading 4 size", "ThemeEditorHeading4Size"),
("h5Size", "Heading 5 size", "ThemeEditorHeading5Size"),
("h6Size", "Heading 6 size", "ThemeEditorHeading6Size"),
("codeFontSize", "Code font size", "ThemeEditorCodeSize"),
("lineHeight", "Line height", "ThemeEditorLineHeight"),
})
{
bool lineHeight = string.Equals(key, "lineHeight", StringComparison.Ordinal);
var input = new NumberBox
{
Header = label,
Minimum = lineHeight ? 0.5 : 6,
Maximum = lineHeight ? 4 : 96,
SmallChange = lineHeight ? 0.1 : 1,
LargeChange = lineHeight ? 0.5 : 5,
Value = source[key]!.GetValue<double>(),
};
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(input, id);
sizes.Add(key, input);
fields.Children.Add(input);
}
return sizes;
}
@@ -106,8 +135,8 @@ internal static class ThemeEditor
"codeText" => "Code text",
"blockquoteBorder" => "Quote border",
"blockquoteBackground" => "Quote background",
"highlightBackground" => "Selection background",
"highlightText" => "Selection text",
"highlightBackground" => "Highlight/selection background",
"highlightText" => "Highlight/selection text",
_ => null,
};
if(label is null)
@@ -0,0 +1,299 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Themes;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>A copied native theme-list draft cannot alter saved choices until the owner presses Save.</summary>
internal sealed class ThemeManagementDialog
{
private AppSettings _draft;
private readonly ListBox _list = new() { Name = "ThemeList", Height = 360 };
private readonly CheckBox _enabled = new() { Name = "ThemeListEnabled", Content = "Enabled in theme menu and cycling" };
private readonly TextBlock _status = new() { TextWrapping = TextWrapping.Wrap };
private readonly Grid _content;
private SettingsWindow? _host;
private string? _selected;
private bool _loading;
private readonly Microsoft.UI.WindowId _owner;
private ManagerAction _action;
private ReaderTheme? _requestedTheme;
/// <summary>The manager includes disabled choices and keeps deletion/restoration inside the cancel-safe draft.</summary>
public ThemeManagementDialog(AppSettings settings, Microsoft.UI.WindowId owner)
{
_draft = SettingsChanges.Copy(settings);
_owner = owner;
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
actions.Children.Add(ActionButton("ThemeListUp", "Move up", () => ChangeSelected(up: true)));
actions.Children.Add(ActionButton("ThemeListDown", "Move down", () => ChangeSelected(up: false)));
actions.Children.Add(ActionButton("ThemeListDelete", "Delete", DeleteSelected));
var panel = new StackPanel { Spacing = 8 };
panel.Children.Add(new TextBlock { Text = "Disabled themes stay editable. Deleted built-ins can be restored. Save commits these changes; Cancel discards them.", TextWrapping = TextWrapping.Wrap });
panel.Children.Add(_enabled);
var editActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
editActions.Children.Add(ActionButton("ThemeListNew", "New", () => RequestEdit(ThemeManagement.NewTheme(_draft))));
editActions.Children.Add(ActionButton("ThemeListCopy", "Copy", () => EditSelected(copy: true)));
editActions.Children.Add(ActionButton("ThemeListEdit", "Edit", () => EditSelected(copy: false)));
editActions.Children.Add(ActionButton("ThemeListDefault", "Use selected", UseSelected));
panel.Children.Add(editActions);
var transfers = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
transfers.Children.Add(ActionButton("ThemeListImport", "Import...", () => RequestTransfer(ManagerAction.Import)));
transfers.Children.Add(ActionButton("ThemeListExport", "Export...", () => RequestTransfer(ManagerAction.Export)));
panel.Children.Add(transfers);
panel.Children.Add(actions);
panel.Children.Add(ActionButton("ThemeListRestore", "Restore built-ins", Restore));
panel.Children.Add(_status);
_content = new Grid { ColumnSpacing = 16, RowSpacing = 16 };
_content.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(220) });
_content.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
_content.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
_content.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
_content.Children.Add(_list);
Grid.SetColumn(panel, 1);
_content.Children.Add(panel);
_content.SizeChanged += (_, _) =>
{
bool narrow = _content.ActualWidth < 600;
_content.ColumnDefinitions[0].Width = narrow ? new GridLength(1, GridUnitType.Star) : new GridLength(220);
_content.ColumnDefinitions[1].Width = narrow ? new GridLength(0) : new GridLength(1, GridUnitType.Star);
Grid.SetColumn(panel, narrow ? 0 : 1);
Grid.SetRow(panel, narrow ? 1 : 0);
};
_list.SelectionChanged += (_, _) => Select();
_enabled.Checked += (_, _) => SetEnabled(true);
_enabled.Unchecked += (_, _) => SetEnabled(false);
Refresh(ThemeManagement.Active(_draft).Name);
}
/// <summary>Only explicit Save returns the copied theme state to the owning window.</summary>
public async Task<AppSettings?> ShowAsync(SettingsWindow host, Func<bool> isOpen)
{
_host = host;
while(isOpen() && host.IsOpen)
{
_action = ManagerAction.None;
if(await host.ShowEditorAsync("Manage document themes", _content, "Save themes") == ContentDialogResult.Primary)
{
return _draft;
}
if(!isOpen() || _action == ManagerAction.None)
{
return null;
}
try
{
await RunActionAsync(host.DialogRoot);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.COMException or ObjectDisposedException or InvalidOperationException or ArgumentException)
{
_status.Text = "The selected theme action could not be completed. Your draft is retained.";
App.Log.Warning(FormattableString.Invariant($"A native theme action could not be completed ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
if(isOpen())
{
Refresh(_selected);
}
}
return null;
}
/// <summary>Native action identities are available to accessibility and real acceptance tests.</summary>
private static Button ActionButton(string name, string text, Action action)
{
var button = new Button { Name = name, Content = text };
button.Click += (_, _) => action();
return button;
}
/// <summary>Shows saved order and enabled state while retaining selection through moves.</summary>
private void Refresh(string? selected)
{
_loading = true;
_list.Items.Clear();
foreach(ReaderTheme theme in ThemeManagement.All(_draft))
{
var item = new ListBoxItem { Content = theme.Name, Tag = theme.Name };
AutomationProperties.SetAutomationId(item, "ThemeChoice:" + theme.Name);
_list.Items.Add(item);
if(string.Equals(selected, theme.Name, StringComparison.Ordinal))
{
_list.SelectedItem = item;
}
}
if(_list.SelectedItem is null && _list.Items.Count > 0)
{
_list.SelectedIndex = 0;
}
_loading = false;
Select();
}
/// <summary>Selection changes update only native draft controls.</summary>
private void Select()
{
if(_loading)
{
return;
}
_loading = true;
_selected = (_list.SelectedItem as ListBoxItem)?.Tag as string;
_enabled.IsEnabled = _selected is not null;
_enabled.IsChecked = _selected is not null && !_draft.DisabledThemes.Contains(_selected, StringComparer.Ordinal);
_loading = false;
}
/// <summary>Enable/disable changes preserve JSON and all other draft fields.</summary>
private void SetEnabled(bool enabled)
{
if(!_loading && _selected is not null)
{
_draft = ThemeManagement.SetEnabled(_draft, _selected, enabled);
}
}
/// <summary>Reorders a selected visible entry, including a disabled theme.</summary>
private void ChangeSelected(bool up)
{
if(_selected is not null)
{
_draft = ThemeManagement.Move(_draft, _selected, up);
Refresh(_selected);
}
}
/// <summary>Deletion remains reversible with Cancel; saved built-in deletion is restorable.</summary>
private void DeleteSelected()
{
if(_selected is not null)
{
_draft = ThemeManagement.Delete(_draft, _selected);
_status.Text = "Removed from this draft. Save commits the deletion; Cancel discards it.";
Refresh(null);
}
}
/// <summary>Restoration retains edited palettes as renamed custom themes and re-enables all choices.</summary>
private void Restore()
{
_draft = ThemeManagement.RestoreBuiltIns(_draft);
_status.Text = "Original built-ins restored in this draft. Edited built-ins are kept as renamed custom themes.";
Refresh(_draft.DocumentTheme);
}
/// <summary>Each secondary dialog finishes before the manager reopens; cancellation keeps the copied draft intact.</summary>
private async Task RunActionAsync(XamlRoot root)
{
if(_action == ManagerAction.Edit && _requestedTheme is { } original)
{
ReaderTheme? edited = await ThemeEditor.ShowAsync(_host!, original, ThemeManagement.All(_draft).Select(theme => theme.Name));
if(edited is not null)
{
_draft = ThemeManagement.SaveEdited(_draft, original, edited);
_selected = edited.Name;
}
}
else if(_action == ManagerAction.Import)
{
ThemeTransfer.ImportOutcome? result = await ThemeTransfer.ImportAsync(_owner, root, _draft);
if(result is not null)
{
_draft = result.Draft;
_status.Text = FormattableString.Invariant($"Themes imported into this draft: {result.Imported}. Skipped: {result.Skipped}. Failed: {result.Failed}.");
}
}
else if(_action == ManagerAction.Export && _requestedTheme is { } theme && await ThemeTransfer.ExportAsync(_owner, theme))
{
_status.Text = "The selected draft theme was exported. Save themes commits these settings; Cancel discards them.";
}
}
/// <summary>New/copy/edit actions reuse the full native editor without nesting incompatible content dialogs.</summary>
private void RequestEdit(ReaderTheme theme)
{
_requestedTheme = theme;
_action = ManagerAction.Edit;
_host!.EndEdit(ContentDialogResult.None);
}
/// <summary>Disabled choices remain editable and copyable without enabling them prematurely.</summary>
private void EditSelected(bool copy)
{
ReaderTheme? theme = ThemeManagement.All(_draft).FirstOrDefault(theme => string.Equals(theme.Name, _selected, StringComparison.Ordinal));
if(theme is not null)
{
RequestEdit(copy ? ThemeManagement.CopyTheme(_draft, theme) : theme);
}
}
/// <summary>Choosing a default explicitly enables it; selection alone never changes the reading palette.</summary>
private void UseSelected()
{
if(_selected is not null)
{
_draft = ThemeManagement.SetEnabled(_draft, _selected, enabled: true);
_draft.DocumentTheme = _selected;
_status.Text = "Selected as the default in this draft. Save themes applies it.";
Refresh(_selected);
}
}
/// <summary>Export can select a disabled theme, while import has its own owned multiple-file picker.</summary>
private void RequestTransfer(ManagerAction action)
{
_requestedTheme = ThemeManagement.All(_draft).FirstOrDefault(theme => string.Equals(theme.Name, _selected, StringComparison.Ordinal));
if(action == ManagerAction.Import || _requestedTheme is not null)
{
_action = action;
_host!.EndEdit(ContentDialogResult.None);
}
}
/// <summary>Only native action buttons can request the next manager operation.</summary>
private enum ManagerAction
{
None,
Edit,
Import,
Export,
}
}
+145
View File
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text;
using System.Text.Json;
using MarkdownViewr.Windows.Core.Themes;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Windows.Storage.Pickers;
namespace MarkdownViewr.Windows;
/// <summary>Deliberate native pickers transfer validated theme JSON without exposing document-created commands.</summary>
internal static class ThemeTransfer
{
/// <summary>Counts are user-visible results; file paths/JSON are never written to the activity log.</summary>
public sealed record ImportOutcome(AppSettings Draft, int Imported, int Skipped, int Failed);
/// <summary>Multiple selected JSON files are read serially within the schema bound and each conflict has its own explicit choice.</summary>
public static async Task<ImportOutcome?> ImportAsync(Microsoft.UI.WindowId owner, XamlRoot root, AppSettings settings)
{
var picker = new FileOpenPicker(owner);
picker.FileTypeFilter.Add(".json");
IReadOnlyList<PickFileResult> files = await picker.PickMultipleFilesAsync();
if(files.Count == 0)
{
return null;
}
AppSettings draft = Core.SettingsChanges.Copy(settings);
int imported = 0;
int skipped = 0;
int failed = 0;
foreach(PickFileResult file in files)
{
try
{
await using var stream = new FileStream(file.Path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
ReaderTheme theme = await ThemeJson.ReadAsync(stream);
if(theme.SchemaVersion > 1 && !await ConfirmNewerAsync(root, theme))
{
skipped++;
continue;
}
ThemeImportResolution resolution = ThemeManagement.All(draft).Any(item => string.Equals(item.Name, theme.Name, StringComparison.Ordinal))
? await ResolveConflictAsync(root, theme) : ThemeImportResolution.KeepBoth;
if(resolution == ThemeImportResolution.Skip)
{
skipped++;
continue;
}
draft = ThemeManagement.Import(draft, theme, resolution, approveNewerSchema: theme.SchemaVersion > 1);
imported++;
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or JsonException or DecoderFallbackException)
{
failed++;
App.Log.Warning(FormattableString.Invariant($"A selected theme could not be imported ({exception.GetType().Name}, 0x{exception.HResult:X8})."));
}
}
return new ImportOutcome(draft, imported, skipped, failed);
}
/// <summary>The reference newer-schema warning requires confirmation even when the known fields validate.</summary>
private static async Task<bool> ConfirmNewerAsync(XamlRoot root, ReaderTheme theme)
{
var dialog = new ContentDialog
{
XamlRoot = root,
Title = "Theme uses a newer schema",
Content = $"{theme.Name} uses theme schema {theme.SchemaVersion}. It may not display correctly. Import anyway?",
PrimaryButtonText = "Import anyway",
CloseButtonText = "Skip",
};
return await dialog.ShowAsync() == ContentDialogResult.Primary;
}
/// <summary>Closing a conflict skips it; Keep both retains both schemas and metadata under a deterministic unique name.</summary>
private static async Task<ThemeImportResolution> ResolveConflictAsync(XamlRoot root, ReaderTheme theme)
{
var dialog = new ContentDialog
{
XamlRoot = root,
Title = "Theme already exists",
Content = $"A theme named {theme.Name} already exists. Choose how to handle the imported copy.",
PrimaryButtonText = "Overwrite",
SecondaryButtonText = "Keep both",
CloseButtonText = "Skip",
DefaultButton = ContentDialogButton.Secondary,
};
return await dialog.ShowAsync() switch
{
ContentDialogResult.Primary => ThemeImportResolution.Overwrite,
ContentDialogResult.Secondary => ThemeImportResolution.KeepBoth,
_ => ThemeImportResolution.Skip,
};
}
/// <summary>Exporting a selected theme, including a disabled choice or draft, never changes saved settings.</summary>
public static async Task<bool> ExportAsync(Microsoft.UI.WindowId owner, ReaderTheme theme)
{
var picker = new FileSavePicker(owner) { SuggestedFileName = ThemeJson.SuggestedFileName(theme), DefaultFileExtension = ".json" };
picker.FileTypeChoices.Add("Theme JSON", [".json"]);
PickFileResult? file = await picker.PickSaveFileAsync();
if(file is null)
{
return false;
}
await File.WriteAllTextAsync(file.Path, theme.Json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
return true;
}
/// <summary>A separate native selector exposes every visible theme for export, including disabled choices.</summary>
public static async Task<ReaderTheme?> SelectExportAsync(XamlRoot root, AppSettings settings)
{
var choices = new ComboBox { Name = "ThemeExportChoice", Header = "Theme", MinWidth = 280 };
foreach(ReaderTheme theme in ThemeManagement.All(settings))
{
var item = new ComboBoxItem { Content = theme.Name, Tag = theme };
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(item, "ThemeExport:" + theme.Name);
choices.Items.Add(item);
}
choices.SelectedItem = choices.Items.OfType<ComboBoxItem>().FirstOrDefault(item => string.Equals(((ReaderTheme)item.Tag).Name, settings.DocumentTheme, StringComparison.Ordinal));
if(choices.SelectedItem is null && choices.Items.Count > 0)
{
choices.SelectedIndex = 0;
}
var dialog = new ContentDialog { XamlRoot = root, Title = "Export document theme", Content = choices, PrimaryButtonText = "Export theme", CloseButtonText = "Cancel", IsPrimaryButtonEnabled = choices.Items.Count > 0 };
ContentDialogResult result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary && choices.SelectedItem is ComboBoxItem { Tag: ReaderTheme selected } ? selected : null;
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>The actual restored package inventory and credit destinations must stay in sync when dependencies change.</summary>
public sealed class AboutComponentsTests
{
/// <summary>Every application/build/test package restored for this repository is acknowledged without omitted transitive libraries.</summary>
[Fact]
public void Catalog_RestoredAssets_CoversEveryPackage()
{
string root = FindRepository();
string[] paths = ["src/MarkdownViewr.Windows/obj/project.assets.json", "tests/MarkdownViewr.Windows.Core.Tests/obj/project.assets.json", "tests/MarkdownViewr.Windows.UiTests/obj/project.assets.json"];
var restored = new HashSet<string>(StringComparer.Ordinal);
foreach(string path in paths)
{
using var assets = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, path)));
foreach(JsonProperty library in assets.RootElement.GetProperty("libraries").EnumerateObject())
{
if(string.Equals(library.Value.GetProperty("type").GetString(), "package", StringComparison.Ordinal))
{
restored.Add(library.Name);
}
}
}
AboutComponent[] credits = AboutComponents.Runtime.Concat(AboutComponents.Development).ToArray();
var credited = credits.Where(credit => !string.Equals(credit.Name, ".NET Runtime", StringComparison.Ordinal)).Select(credit => credit.Name + "/" + credit.Version).ToHashSet(StringComparer.Ordinal);
Assert.True(restored.SetEquals(credited), "About credits must match actual restored direct/transitive package versions. Missing: " + string.Join(", ", restored.Except(credited, StringComparer.Ordinal)) + "; stale: " + string.Join(", ", credited.Except(restored, StringComparer.Ordinal)));
Assert.Equal(credits.Length, credits.Select(credit => credit.Name).Distinct(StringComparer.Ordinal).Count());
foreach(AboutComponent credit in credits)
{
Assert.NotEmpty(credit.License);
Assert.Equal("https", new Uri(credit.ProjectUrl).Scheme);
Assert.Equal("https", new Uri(credit.LicenseUrl).Scheme);
Assert.DoesNotContain("deprecateLicenseUrl", credit.LicenseUrl, StringComparison.Ordinal);
}
Assert.Contains(AboutComponents.Runtime, credit => string.Equals(credit.Name, "Microsoft.Web.WebView2", StringComparison.Ordinal));
Assert.Contains(AboutComponents.Runtime, credit => string.Equals(credit.Name, "AngleSharp.Css", StringComparison.Ordinal));
Assert.Contains(AboutComponents.Development, credit => string.Equals(credit.Name, "FlaUI.Core", StringComparison.Ordinal));
}
/// <summary>Build-produced assets are independent evidence of what the repository currently uses, not a duplicated fixture list.</summary>
private static string FindRepository()
{
for(DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
{
if(File.Exists(Path.Combine(directory.FullName, "MarkdownViewr.Windows.slnx")))
{
return directory.FullName;
}
}
throw new DirectoryNotFoundException("Restore/build this repository before checking its actual dependency credits.");
}
}
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more