feat(source): show literal snapshots beside the reader

Persist source visibility and width, refresh the committed snapshot on reload, and keep the reader usable with both panes open. Add hosted literal/read-only/reload acceptance and opt-in native event traces for the two outstanding pointer failures.

Refs: #8, #9, #10

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-10 21:50:34 -05:00
1 parent d59dba2c96
commit 9ff820f477
13 files changed
+243 -8

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
- **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.
- **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.
- **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. Heading/scroll synchronization and paired find remain in development.
- **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.
+21
View File
@@ -99,3 +99,24 @@ before production publication; active Azure profiles do not prove successful sig
Pinned reference: 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021 under
AppKit/artifacts/markdownviewr-reference. Preserve original MIT attribution on adaptations.
## Literal source and precise native event follow-up
Latest prior head d59dba2c96d126730f491720fd548c43c30dd97b: CI 38105483808
terminal FAILURE (31/33 loose native/UI); CodeQL 38105483850 and scripts
38105483489 SUCCESS. Mouse down/up settling did not solve the two input cases.
Retained log: artifacts/pointer-states-ui-38105483808.log. No local GUI input.
Issue #10 records the full source/sync/find/keys acceptance. The next implementation
adds a native read-only literal source pane (including hidden frontmatter), persisted
visibility/preferred width, both-side width bounds and the same committed reload snapshot.
This does not complete synchronized headings/scroll/collapse, paired find or vim keys.
The browser's scripts/messages/host objects remain disabled. New actual UI test checks
literal hostile source, read-only state, saved settings, reload and hiding.
166 core tests/100% line coverage, zero-warning build and formatting pass locally.
Hosted acceptance and physical source resize remain required.
Fixture-only MARKDOWNVIEWR_INPUT_TRACE=1 records native pressed/released/capture-lost,
outline drag events and frontmatter clicks without changing routing or logging content.
This tests whether WinUI receives the exact physical events; no third timing workaround
or weakened outcome assertion. The next exact hosted handles must be observed.
+7 -3
View File
@@ -19,11 +19,15 @@
- [x] Implement persistent resizable TOC/depth, safe heading/local-fragment jumps and independent nested section collapse (issue #9).
- [x] Verify hosted heading jumps, independent nested collapse and theme/reload state on 5c3988e (31/33 UI pass).
- [ ] Verify physical sidebar resize and frontmatter input follow-up; keep exact assertions.
- [ ] Add source pane, synchronized navigation, find and vim keys.
- [x] Implement literal read-only source pane, saved visibility/width and shared committed reload snapshots (issue #10).
- [ ] Verify hosted source visibility/read-only/literal/reload acceptance and physical source resize.
- [ ] Add synchronized headings/scroll/collapse, paired find and vim keys (issue #10).
- [x] Implement and core-test hidden/metadata/Markdown frontmatter modes, with persisted native controls and attribution.
- [ ] Verify actual hosted frontmatter mode switching and code-font size editing.
- [x] Verify actual code-font size 17 editing in hosted UI runs.
- [ ] Verify actual hosted frontmatter mode switching.
- [ ] Add safe configured editor launch and printing options.
- [ ] Add multiple document windows, recent files and Windows activation/file associations.
- [ ] Verify hosted checks on each actual implementation head; PSScriptAnalyzer engine crash remains tracked in #5.
- [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.
+2
View File
@@ -25,6 +25,8 @@ physical directory. It does not authorize native commands or remote resource acc
| 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 |
| Browser viewport | Fixed origin-checked native query; validate two finite fractions; invariant numeric restore only; authored scripts/messages stay disabled |
| Source pane | Same bounded committed snapshot in a native read-only TextBox; literal HTML/CSS/script text never enters a renderer or command interpreter; hidden pane removed from accessibility surface |
| Fixture input diagnostics | Explicit opt-in environment switch; observes handled native pointer/drag events without changing routing; logs owned control names, coordinates and button state only, never document text or keystrokes |
| Browser surface | Scripts, messages, host objects, permissions, downloads, frames and popups disabled; restrictive CSP |
| Local images | Per-document URI prefix; one decode; filename/extension policy; final open-handle containment; 16 MiB limit |
| Remote resources | Denied by CSP and resource interception |
@@ -46,10 +46,21 @@ public sealed class AppSettings : ShellSettings
public double OutlineWidth { get; set; } = 220;
/// <summary>Shows the literal, read-only Markdown snapshot beside the rendered document.</summary>
public bool ShowSource { get; set; }
/// <summary>Last source pane width in device-independent pixels.</summary>
public double SourceWidth { get; set; } = 320;
/// <inheritdoc/>
public override void Sanitize()
{
base.Sanitize();
SourceWidth = double.IsFinite(SourceWidth) ? Math.Clamp(SourceWidth, 160, 600) : 320;
OutlineDepth = Math.Clamp(OutlineDepth, 1, 6);
OutlineWidth = double.IsFinite(OutlineWidth) ? Math.Clamp(OutlineWidth, 140, 500) : 220;
Frontmatter = FrontmatterKind.ToString();
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
namespace MarkdownViewr.Windows;
/// <summary>Opt-in native event evidence for isolated fixtures; never records document text or keystrokes.</summary>
public sealed partial class MainWindow
{
/// <summary>Observes handled events without changing routing, focus, capture or control behavior.</summary>
private void InitializeInputDiagnostics()
{
if(!string.Equals(Environment.GetEnvironmentVariable("MARKDOWNVIEWR_INPUT_TRACE"), "1", StringComparison.Ordinal))
{
return;
}
foreach(UIElement element in new UIElement[] { RootGrid, OutlineDivider, SourceDivider, FrontmatterHide, FrontmatterMetadata, FrontmatterMarkdown })
{
element.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler((sender, args) => TracePointer("pressed", sender, args)), handledEventsToo: true);
element.AddHandler(UIElement.PointerReleasedEvent, new PointerEventHandler((sender, args) => TracePointer("released", sender, args)), handledEventsToo: true);
element.AddHandler(UIElement.PointerCaptureLostEvent, new PointerEventHandler((sender, args) => TracePointer("capture-lost", sender, args)), handledEventsToo: true);
}
OutlineDivider.DragStarted += (_, args) => App.Log.Info(FormattableString.Invariant($"Input trace: outline drag started; x={args.HorizontalOffset}; y={args.VerticalOffset}."));
OutlineDivider.DragDelta += (_, args) => App.Log.Info(FormattableString.Invariant($"Input trace: outline drag delta; x={args.HorizontalChange}; y={args.VerticalChange}."));
OutlineDivider.DragCompleted += (_, args) => App.Log.Info(FormattableString.Invariant($"Input trace: outline drag completed; canceled={args.Canceled}."));
foreach(Microsoft.UI.Xaml.Controls.RadioMenuFlyoutItem item in new[] { FrontmatterHide, FrontmatterMetadata, FrontmatterMarkdown })
{
item.Click += (_, _) => App.Log.Info($"Input trace: {item.Name} click; checked={item.IsChecked}.");
}
}
/// <summary>Records only owned native control names, button state and local pointer coordinates.</summary>
private void TracePointer(string operation, object sender, PointerRoutedEventArgs args)
{
Microsoft.UI.Input.PointerPoint point = args.GetCurrentPoint((UIElement)sender);
App.Log.Info(FormattableString.Invariant($"Input trace: {operation}; target={((FrameworkElement)sender).Name}; source={(args.OriginalSource as FrameworkElement)?.Name}; left={point.Properties.IsLeftButtonPressed}; handled={args.Handled}; x={point.Position.X}; y={point.Position.Y}."));
}
}
@@ -34,8 +34,7 @@ public sealed partial class MainWindow
private void ApplyOutlineVisibility()
{
OutlinePanel.Visibility = _settings.ShowOutline ? Visibility.Visible : Visibility.Collapsed;
OutlineColumn.Width = new GridLength(_settings.ShowOutline ? _settings.OutlineWidth : 0);
OutlineDividerColumn.Width = new GridLength(_settings.ShowOutline ? 6 : 0);
ApplyReadingWidths();
}
@@ -129,7 +128,7 @@ public sealed partial class MainWindow
private void OutlineDivider_DragDelta(object sender, DragDeltaEventArgs e)
{
_settings.OutlineWidth = Math.Clamp(OutlineColumn.ActualWidth + e.HorizontalChange, 140, 500);
OutlineColumn.Width = new GridLength(_settings.OutlineWidth);
ApplyReadingWidths();
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls.Primitives;
namespace MarkdownViewr.Windows;
/// <summary>Displays the same committed snapshot as literal text, without passing source through HTML.</summary>
public sealed partial class MainWindow
{
private string _sourceMarkdown = "";
/// <summary>Restores the independent source preference after XAML initialization.</summary>
private void InitializeSource()
{
SourceToggle.IsChecked = _settings.ShowSource;
RefreshSource();
}
/// <summary>A hidden source pane relinquishes its columns and accessibility surface.</summary>
private void RefreshSource()
{
ApplyReadingWidths();
SourceText.Visibility = _settings.ShowSource ? Visibility.Visible : Visibility.Collapsed;
string text = _settings.ShowSource ? _sourceMarkdown : "";
if(!string.Equals(SourceText.Text, text, StringComparison.Ordinal))
{
SourceText.Text = text;
}
}
/// <summary>Constrains displayed pane widths while retaining the user's preferred widths for larger windows.</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;
OutlineColumn.Width = new GridLength(outline);
OutlineDividerColumn.Width = new GridLength(_settings.ShowOutline ? 6 : 0);
SourceColumn.Width = new GridLength(source);
SourceDividerColumn.Width = new GridLength(_settings.ShowSource ? 6 : 0);
}
/// <summary>Keeps at least 200 reading pixels when the window shrinks or both panes are open.</summary>
private void ReadingArea_SizeChanged(object sender, SizeChangedEventArgs e) => ApplyReadingWidths();
/// <summary>Persists the view choice while preserving the actual Markdown, including hidden frontmatter.</summary>
private void Source_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSource = SourceToggle.IsChecked;
Save();
RefreshSource();
}
/// <summary>The source pane grows toward the left and stays within its validated width range.</summary>
private void SourceDivider_DragDelta(object sender, DragDeltaEventArgs e)
{
_settings.SourceWidth = Math.Clamp(SourceColumn.ActualWidth - e.HorizontalChange, 160, 600);
ApplyReadingWidths();
}
/// <summary>Writes the completed resize through AppKit's atomic settings store.</summary>
private void SourceDivider_DragCompleted(object sender, DragCompletedEventArgs e) => Save();
}
+12 -1
View File
@@ -9,7 +9,7 @@
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid x:Name="RootGrid">
<Grid x:Name="RootGrid" SizeChanged="ReadingArea_SizeChanged">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -28,6 +28,7 @@
<MenuFlyoutItem x:Name="ExitMenuItem" Text="Exit" Click="Exit_Click" />
</MenuBarItem>
<MenuBarItem Title="View">
<ToggleMenuFlyoutItem x:Name="SourceToggle" Text="Markdown source" Click="Source_Click" />
<ToggleMenuFlyoutItem x:Name="OutlineToggle" Text="Table of contents" Click="Outline_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutSubItem x:Name="DocumentThemesMenu" Text="Document theme" />
@@ -61,6 +62,8 @@
<ColumnDefinition x:Name="OutlineColumn" Width="0" />
<ColumnDefinition x:Name="OutlineDividerColumn" Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition x:Name="SourceDividerColumn" Width="0" />
<ColumnDefinition x:Name="SourceColumn" Width="0" />
</Grid.ColumnDefinitions>
<Grid x:Name="OutlinePanel" Visibility="Collapsed" Padding="8">
<Grid.RowDefinitions>
@@ -94,6 +97,14 @@
</primitives:Thumb.Template>
</primitives:Thumb>
<WebView2 x:Name="DocumentView" Grid.Column="2" Loaded="DocumentView_Loaded" AutomationProperties.Name="Markdown document" />
<primitives:Thumb x:Name="SourceDivider" Grid.Column="3" Background="{ThemeResource DividerStrokeColorDefaultBrush}" DragDelta="SourceDivider_DragDelta" DragCompleted="SourceDivider_DragCompleted" AutomationProperties.Name="Resize Markdown source">
<primitives:Thumb.Template>
<ControlTemplate TargetType="primitives:Thumb">
<Border Background="{TemplateBinding Background}" />
</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" />
</Grid>
</Grid>
</Window>
@@ -40,6 +40,8 @@ public sealed partial class MainWindow : Window, IDisposable
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message, ActiveDocumentTheme());
CreateDocumentThemeMenu();
InitializeOutline();
InitializeSource();
InitializeInputDiagnostics();
(_settings.FrontmatterKind switch { Core.Documents.FrontmatterMode.Metadata => FrontmatterMetadata, Core.Documents.FrontmatterMode.Markdown => FrontmatterMarkdown, _ => FrontmatterHide }).IsChecked = true;
Title = AppIdentity.Info.DisplayName;
FitInitialWindow();
@@ -280,6 +282,8 @@ public sealed partial class MainWindow : Window, IDisposable
private void ShowDocument(DocumentFiles files, Core.Documents.DocumentOutline outline, string path, Core.Documents.DocumentViewport viewport, bool reload)
{
_browser.Show(files, outline.Html, Path.GetFileName(path), viewport, outline.Headings);
_sourceMarkdown = files.Markdown;
RefreshSource();
_outline = outline;
RefreshOutline();
_documentPath = Path.GetFullPath(path);
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Invalid saved widths cannot hide the reading surface or prevent startup.</summary>
public sealed class SourcePreferencesTests
{
/// <summary>Both visibility choices retain bounded widths; nonfinite values restore the default.</summary>
[Theory]
[InlineData(true, double.NaN, 320)]
[InlineData(false, double.PositiveInfinity, 320)]
[InlineData(true, -100, 160)]
[InlineData(false, 900, 600)]
[InlineData(true, 280, 280)]
public void Sanitize_SourcePreferences_RetainsVisibilityAndClampsWidth(bool visible, double width, double expected)
{
var settings = new AppSettings { ShowSource = visible, SourceWidth = width };
settings.Sanitize();
Assert.Equal(visible, settings.ShowSource);
Assert.Equal(expected, settings.SourceWidth);
}
}
@@ -43,6 +43,7 @@ public sealed class AppSession : IDisposable
var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" };
startInfo.Environment[AppIdentity.Info.DataDirectoryVariable] = DataDirectory;
startInfo.Environment["MARKDOWNVIEWR_INPUT_TRACE"] = "1";
if(document is not null)
{
string path = Path.Combine(DataDirectory, "document.md");
@@ -206,6 +206,35 @@ public class AppTests
}
/// <summary>Source remains literal and read-only, follows committed reloads and persists its visibility.</summary>
[Fact]
public void Source_ShowReloadAndHide_PreservesLiteralSnapshot()
{
const string Markdown = "---\nsecret: literal metadata\n---\n# Source acceptance\n\n<script>document.body.textContent='Executed source';</script>\n\n```html\n<button onclick='evil()'>Literal code</button>\n```";
using var session = new AppSession(document: Markdown);
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Source acceptance")), "the document heading", TimeSpan.FromSeconds(30));
Assert.Null(session.FindInApp(cf => cf.ByAutomationId("SourceText")));
session.InvokeMenuItem("View", "SourceToggle");
AutomationElement source = session.WaitForElement("SourceText");
Assert.True(source.Patterns.Value.Pattern.IsReadOnly.Value);
string value = source.Patterns.Value.Pattern.Value.Value.Replace("\r\n", "\n", StringComparison.Ordinal);
Assert.Equal(Markdown, value);
using(var settings = JsonDocument.Parse(session.ReadSettings()))
{
Assert.True(settings.RootElement.GetProperty("ShowSource").GetBoolean());
Assert.Equal(320, settings.RootElement.GetProperty("SourceWidth").GetDouble());
}
File.WriteAllText(Path.Combine(session.DataDirectory, "document.md"), "# Source replacement\n\n**New literal text**");
session.WaitFor(() => source.Patterns.Value.Pattern.Value.Value.Contains("**New literal text**", StringComparison.Ordinal) ? source : null, "the source snapshot after reload");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Source replacement")), "the rendered replacement heading");
session.InvokeMenuItem("View", "SourceToggle");
session.WaitForElementGone("SourceText");
using var hidden = JsonDocument.Parse(session.ReadSettings());
Assert.False(hidden.RootElement.GetProperty("ShowSource").GetBoolean());
_output.WriteLine(session.DescribeDocumentStatus());
}
/// <summary>The native depth choice filters exact headings and a real divider drag saves its new width.</summary>
[Fact]
public void Outline_DepthAndResize_PersistsNativeChoices()
@@ -245,7 +274,7 @@ public class AppTests
}
catch(TimeoutException exception)
{
throw new TimeoutException($"{exception.Message} Divider: {divider.BoundingRectangle}; pointer: {FlaUI.Core.Input.Mouse.Position}; settings: {session.ReadSettings()}; tree: {session.DescribeDocumentStatus()}", exception);
throw new TimeoutException($"{exception.Message} Divider: {divider.BoundingRectangle}; pointer: {FlaUI.Core.Input.Mouse.Position}; logs: {session.ReadLogs()}; settings: {session.ReadSettings()}; tree: {session.DescribeDocumentStatus()}", exception);
}
using var saved = JsonDocument.Parse(session.ReadSettings());
Assert.True(saved.RootElement.GetProperty("ShowOutline").GetBoolean());