Skip to content

Commit 7306974

Browse files
mohnjilesclaude
andcommitted
Docs viewer: content zoom, scrollbar inset, fix code-span link rendering
- Browser-style zoom (50-200%, persisted in settings): the document wrapper is re-parented into a LayoutTransformControl inside the internal scroll viewer, so content scales but the scrollbar doesn't; compact zoom overlay in the bottom-right corner - 18px right inset between content and the overlay scrollbar - Links whose text is inline code (the docs breadcrumbs, e.g. [`Home`](../README.md)) rendered as literal text — Markdown.Avalonia can't parse code spans inside link text, so strip the backticks in a fence-aware preprocessing pass before rendering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2367c1d commit 7306974

6 files changed

Lines changed: 251 additions & 1 deletion

File tree

StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
34
using System.Windows.Input;
45
using Avalonia;
56
using Avalonia.Controls;
7+
using Avalonia.Media;
68
using Avalonia.Threading;
79
using Avalonia.VisualTree;
810
using ColorTextBlock.Avalonia;
@@ -42,16 +44,61 @@ public ICommand? LinkCommand
4244
set => SetValue(LinkCommandProperty, value);
4345
}
4446

47+
/// <summary>
48+
/// Zoom factor applied to the rendered document content (1.0 = 100%).
49+
/// Scales the content inside the internal scroll viewer, so the scrollbar is unaffected.
50+
/// </summary>
51+
public static readonly StyledProperty<double> ContentZoomProperty = AvaloniaProperty.Register<
52+
DocumentationMarkdownViewer,
53+
double
54+
>(nameof(ContentZoom), 1.0);
55+
4556
public string? ImageBaseUrl
4657
{
4758
get => GetValue(ImageBaseUrlProperty);
4859
set => SetValue(ImageBaseUrlProperty, value);
4960
}
5061

62+
public double ContentZoom
63+
{
64+
get => GetValue(ContentZoomProperty);
65+
set => SetValue(ContentZoomProperty, value);
66+
}
67+
68+
/// <summary>
69+
/// Hosts the document content inside the internal scroll viewer so zoom can scale the
70+
/// content without scaling the scrollbar. Null if the base control's composition changes.
71+
/// </summary>
72+
private readonly LayoutTransformControl? zoomHost;
73+
5174
public DocumentationMarkdownViewer()
5275
{
5376
ApplyLinkCommand();
5477
ApplyImageBaseUrl();
78+
79+
// The base ctor composes a non-templated inner ScrollViewer (a direct visual child)
80+
// whose Content is the document wrapper, and never reassigns Content afterwards
81+
// (page changes only swap the wrapper's Document). Re-parent the wrapper into a
82+
// LayoutTransformControl so zoom scales the document but not the scrollbar, and add
83+
// right margin so the overlay scrollbar doesn't cover the rightmost text.
84+
if (this.GetVisualChildren().OfType<ScrollViewer>().FirstOrDefault() is { } innerViewer)
85+
{
86+
if (innerViewer.Content is Control content)
87+
{
88+
innerViewer.Content = null;
89+
zoomHost = new LayoutTransformControl
90+
{
91+
Child = content,
92+
Margin = new Thickness(0, 0, 18, 0),
93+
};
94+
innerViewer.Content = zoomHost;
95+
}
96+
else
97+
{
98+
// Fallback: at least keep the scrollbar off the content.
99+
innerViewer.Padding = new Thickness(0, 0, 18, 0);
100+
}
101+
}
55102
}
56103

57104
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
@@ -66,6 +113,20 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
66113
{
67114
ApplyImageBaseUrl();
68115
}
116+
else if (change.Property == ContentZoomProperty)
117+
{
118+
ApplyContentZoom();
119+
}
120+
}
121+
122+
private void ApplyContentZoom()
123+
{
124+
if (zoomHost is null)
125+
return;
126+
127+
// Guard against zero/negative values from bad bindings.
128+
var zoom = Math.Clamp(ContentZoom, 0.25, 4.0);
129+
zoomHost.LayoutTransform = new ScaleTransform(zoom, zoom);
69130
}
70131

71132
private void ApplyLinkCommand()

StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@ namespace StabilityMatrix.Avalonia.ViewModels;
2727
[RegisterSingleton<DocumentationViewModel>]
2828
public partial class DocumentationViewModel : PageViewModelBase
2929
{
30+
private const double MinZoom = 0.5;
31+
private const double MaxZoom = 2.0;
32+
private const double ZoomStep = 0.1;
33+
3034
private readonly ILogger<DocumentationViewModel> logger;
3135
private readonly IDocumentationService documentationService;
36+
private readonly ISettingsManager settingsManager;
3237

3338
public override string Title => "Documentation";
3439

@@ -72,6 +77,14 @@ public partial class DocumentationViewModel : PageViewModelBase
7277
[NotifyPropertyChangedFor(nameof(IsContentVisible))]
7378
private bool isDocsUnavailable;
7479

80+
/// <summary>Content zoom factor for the markdown viewer (1.0 = 100%). Persisted in settings.</summary>
81+
[ObservableProperty]
82+
[NotifyPropertyChangedFor(nameof(ZoomPercentText))]
83+
public partial double ZoomFactor { get; set; }
84+
85+
/// <summary>Current zoom level formatted for display, e.g. "100%".</summary>
86+
public string ZoomPercentText => $"{Math.Round(ZoomFactor * 100)}%";
87+
7588
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
7689

7790
public bool IsContentVisible => !IsDocsUnavailable;
@@ -87,24 +100,41 @@ public partial class DocumentationViewModel : PageViewModelBase
87100

88101
private CancellationTokenSource? pageCts;
89102
private bool hasLoadedTree;
103+
private bool hasRegisteredSettingsRelays;
90104

91105
/// <summary>Anchor slug to scroll to after the next page load completes (cross-page anchor links).</summary>
92106
private string? pendingAnchor;
93107

94108
public DocumentationViewModel(
95109
ILogger<DocumentationViewModel> logger,
96-
IDocumentationService documentationService
110+
IDocumentationService documentationService,
111+
ISettingsManager settingsManager
97112
)
98113
{
99114
this.logger = logger;
100115
this.documentationService = documentationService;
116+
this.settingsManager = settingsManager;
101117
LinkClickedCommand = new RelayCommand<string>(OnLinkClicked);
118+
ZoomFactor = 1.0;
102119
}
103120

104121
public override async Task OnLoadedAsync()
105122
{
106123
await base.OnLoadedAsync();
107124

125+
if (!hasRegisteredSettingsRelays)
126+
{
127+
hasRegisteredSettingsRelays = true;
128+
129+
// Load the persisted zoom level and keep it saved on change.
130+
settingsManager.RelayPropertyFor(
131+
this,
132+
vm => vm.ZoomFactor,
133+
settings => settings.DocumentationZoomFactor,
134+
true
135+
);
136+
}
137+
108138
if (!hasLoadedTree)
109139
{
110140
await LoadTreeAsync();
@@ -117,6 +147,24 @@ private async Task RetryAsync()
117147
await LoadTreeAsync(forceRefresh: true);
118148
}
119149

150+
[RelayCommand]
151+
private void ZoomIn()
152+
{
153+
ZoomFactor = Math.Min(MaxZoom, Math.Round(ZoomFactor + ZoomStep, 2));
154+
}
155+
156+
[RelayCommand]
157+
private void ZoomOut()
158+
{
159+
ZoomFactor = Math.Max(MinZoom, Math.Round(ZoomFactor - ZoomStep, 2));
160+
}
161+
162+
[RelayCommand]
163+
private void ResetZoom()
164+
{
165+
ZoomFactor = 1.0;
166+
}
167+
120168
private async Task LoadTreeAsync(bool forceRefresh = false)
121169
{
122170
IsTreeLoading = true;
@@ -229,6 +277,9 @@ private async Task LoadPageAsync(string docsRelativePath, string? anchor = null)
229277
);
230278
ct.ThrowIfCancellationRequested();
231279

280+
// Fold code-span link text ([`Home`](url) -> [Home](url)) that the markdown
281+
// renderer can't parse, then absolutize relative image URLs.
282+
markdown = DocumentationPathResolver.RewriteCodeSpanLinks(markdown);
232283
CurrentMarkdown = DocumentationPathResolver.RewriteImageUrls(docsRelativePath, markdown);
233284
CurrentImageBaseUrl = GetPageFolderRawUrl(docsRelativePath);
234285

StabilityMatrix.Avalonia/Views/DocumentationPage.axaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,51 @@
138138
<controls:DocumentationMarkdownViewer
139139
x:Name="MarkdownViewer"
140140
Margin="24,16"
141+
ContentZoom="{Binding ZoomFactor}"
141142
ImageBaseUrl="{Binding CurrentImageBaseUrl}"
142143
IsVisible="{Binding !HasError}"
143144
LinkCommand="{Binding LinkClickedCommand}"
144145
Markdown="{Binding CurrentMarkdown, Mode=OneWay}" />
146+
147+
<!-- Zoom overlay (bottom-right corner, clear of the scrollbar) -->
148+
<Border
149+
Margin="0,0,32,20"
150+
Padding="2"
151+
HorizontalAlignment="Right"
152+
VerticalAlignment="Bottom"
153+
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
154+
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
155+
BorderThickness="1"
156+
CornerRadius="8"
157+
IsVisible="{Binding !HasError}">
158+
<StackPanel Orientation="Horizontal" Spacing="2">
159+
<Button
160+
Padding="6"
161+
Classes="transparent"
162+
Command="{Binding ZoomOutCommand}"
163+
ToolTip.Tip="Zoom out">
164+
<fluentAvalonia:SymbolIcon FontSize="16" Symbol="ZoomOut" />
165+
</Button>
166+
<Button
167+
MinWidth="48"
168+
Padding="6"
169+
Classes="transparent"
170+
Command="{Binding ResetZoomCommand}"
171+
ToolTip.Tip="Reset zoom">
172+
<TextBlock
173+
HorizontalAlignment="Center"
174+
FontSize="12"
175+
Text="{Binding ZoomPercentText}" />
176+
</Button>
177+
<Button
178+
Padding="6"
179+
Classes="transparent"
180+
Command="{Binding ZoomInCommand}"
181+
ToolTip.Tip="Zoom in">
182+
<fluentAvalonia:SymbolIcon FontSize="16" Symbol="ZoomIn" />
183+
</Button>
184+
</StackPanel>
185+
</Border>
145186
</Grid>
146187
</Grid>
147188
</Grid>

StabilityMatrix.Core/Models/Documentation/DocumentationPathResolver.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,58 @@ public static string RewriteImageUrls(string currentPagePath, string markdown)
207207
);
208208
}
209209

210+
// Matches a (non-image) markdown link whose text contains inline code, e.g. [`Home`](../README.md)
211+
private static readonly Regex CodeSpanLinkRegex = new(
212+
@"(?<!!)\[(?<text>[^\]]*`[^\]]*)\]\((?<url>[^)]+)\)",
213+
RegexOptions.Compiled
214+
);
215+
216+
/// <summary>
217+
/// Rewrites markdown links whose link text contains inline code spans
218+
/// (e.g. <c>[`Home`](../README.md)</c>) into plain-text links (<c>[Home](../README.md)</c>),
219+
/// because Markdown.Avalonia's inline parser cannot parse code spans inside link text and
220+
/// renders the whole construct literally. Content inside fenced code blocks
221+
/// (<c>```</c>/<c>~~~</c>) is left untouched.
222+
/// </summary>
223+
/// <param name="markdown">The raw markdown content.</param>
224+
public static string RewriteCodeSpanLinks(string markdown)
225+
{
226+
if (string.IsNullOrEmpty(markdown) || !markdown.Contains('`'))
227+
return markdown;
228+
229+
// Split into lines so fenced code blocks can be skipped; '\n' split keeps any '\r'
230+
// at line ends, and rejoining with '\n' restores the original line endings.
231+
var lines = markdown.Split('\n');
232+
var inFence = false;
233+
234+
for (var i = 0; i < lines.Length; i++)
235+
{
236+
var trimmed = lines[i].TrimStart();
237+
if (
238+
trimmed.StartsWith("```", StringComparison.Ordinal)
239+
|| trimmed.StartsWith("~~~", StringComparison.Ordinal)
240+
)
241+
{
242+
inFence = !inFence;
243+
continue;
244+
}
245+
246+
if (inFence)
247+
continue;
248+
249+
lines[i] = CodeSpanLinkRegex.Replace(
250+
lines[i],
251+
match =>
252+
{
253+
var text = match.Groups["text"].Value.Replace("`", string.Empty);
254+
return $"[{text}]({match.Groups["url"].Value})";
255+
}
256+
);
257+
}
258+
259+
return string.Join('\n', lines);
260+
}
261+
210262
/// <summary>
211263
/// Resolves a relative image path in the current page into an absolute raw URL so it renders.
212264
/// </summary>

StabilityMatrix.Core/Models/Settings/Settings.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ public IReadOnlyDictionary<string, string> EnvironmentVariables
260260

261261
public double CivArchiveBrowserResizeFactor { get; set; } = 1.0d;
262262

263+
/// <summary>Content zoom factor for the in-app documentation viewer (1.0 = 100%).</summary>
264+
public double DocumentationZoomFactor { get; set; } = 1.0d;
265+
263266
public bool CivArchiveBrowserFitCardImages { get; set; } = true;
264267

265268
public bool HideEarlyAccessModels { get; set; }

StabilityMatrix.Tests/Core/DocumentationPathResolverTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,48 @@ public void Slugify_ProducesGitHubStyleSlug(string input, string expected)
141141
Assert.AreEqual(expected, DocumentationPathResolver.Slugify(input));
142142
}
143143

144+
[TestMethod]
145+
public void RewriteCodeSpanLinks_FoldsCodeSpanLinkText()
146+
{
147+
const string markdown = "[`Home`](../README.md) > [`Section Overview`](overview.md)";
148+
149+
var result = DocumentationPathResolver.RewriteCodeSpanLinks(markdown);
150+
151+
Assert.AreEqual("[Home](../README.md) > [Section Overview](overview.md)", result);
152+
}
153+
154+
[TestMethod]
155+
public void RewriteCodeSpanLinks_MultipleCodeSpansInOneLink_StripsAllBackticks()
156+
{
157+
const string markdown = "[`a` and `b`](target.md)";
158+
159+
var result = DocumentationPathResolver.RewriteCodeSpanLinks(markdown);
160+
161+
Assert.AreEqual("[a and b](target.md)", result);
162+
}
163+
164+
[TestMethod]
165+
public void RewriteCodeSpanLinks_InsideFencedCodeBlock_IsUntouched()
166+
{
167+
const string markdown = "before\n\n```md\n[`Home`](../README.md)\n```\n\n[`Home`](../README.md)\n";
168+
169+
var result = DocumentationPathResolver.RewriteCodeSpanLinks(markdown);
170+
171+
// The occurrence inside the fence is untouched; the one outside is rewritten.
172+
StringAssert.Contains(result, "```md\n[`Home`](../README.md)\n```");
173+
StringAssert.Contains(result, "\n\n[Home](../README.md)\n");
174+
}
175+
176+
[TestMethod]
177+
public void RewriteCodeSpanLinks_PlainLinksAndText_AreUntouched()
178+
{
179+
const string markdown = "[Home](../README.md) with `inline code` and ![`alt`](img.png) image\n";
180+
181+
var result = DocumentationPathResolver.RewriteCodeSpanLinks(markdown);
182+
183+
Assert.AreEqual(markdown, result);
184+
}
185+
144186
[TestMethod]
145187
public void ResolveImageUrl_RelativeParentPath_ResolvesToRawUrl()
146188
{

0 commit comments

Comments
 (0)