Skip to content

Commit 7e76681

Browse files
authored
Merge pull request #1677 from LykosAI/feat/in-app-docs-viewer
feat: in-app documentation viewer
2 parents c255230 + 02407cd commit 7e76681

46 files changed

Lines changed: 2857 additions & 63 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

StabilityMatrix.Avalonia/App.axaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services)
451451
provider.GetRequiredService<ISecretsManager>(),
452452
provider.GetRequiredService<INavigationService<MainWindowViewModel>>(),
453453
provider.GetRequiredService<INavigationService<SettingsViewModel>>(),
454+
provider.GetRequiredService<IDocumentationNavigationService>(),
454455
provider.GetRequiredService<IDistributedSubscriber<string, Uri>>()
455456
)
456457
{
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System;
2+
using Avalonia;
3+
using Avalonia.Controls;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using NLog;
6+
using StabilityMatrix.Avalonia.Services;
7+
8+
namespace StabilityMatrix.Avalonia.Controls;
9+
10+
/// <summary>
11+
/// Contextual help button that opens the in-app documentation viewer at <see cref="DocsPath"/>.
12+
/// Resolves navigation itself so that adding help to a surface needs no view model plumbing.
13+
/// </summary>
14+
/// <remarks>
15+
/// Prefer a <see cref="Core.Models.Documentation.DocumentationPages"/> constant over a literal
16+
/// path so a moved page is fixed in one place.
17+
/// </remarks>
18+
public class DocsHelpButton : Button
19+
{
20+
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
21+
22+
public static readonly StyledProperty<string?> DocsPathProperty = AvaloniaProperty.Register<
23+
DocsHelpButton,
24+
string?
25+
>(nameof(DocsPath));
26+
27+
/// <summary>
28+
/// Page path relative to the docs root, e.g. <c>advanced/environment-variables.md</c>.
29+
/// </summary>
30+
public string? DocsPath
31+
{
32+
get => GetValue(DocsPathProperty);
33+
set => SetValue(DocsPathProperty, value);
34+
}
35+
36+
public static readonly StyledProperty<string?> AnchorProperty = AvaloniaProperty.Register<
37+
DocsHelpButton,
38+
string?
39+
>(nameof(Anchor));
40+
41+
/// <summary>
42+
/// Optional heading slug within the page to scroll to, e.g. <c>setting-a-variable</c>.
43+
/// </summary>
44+
public string? Anchor
45+
{
46+
get => GetValue(AnchorProperty);
47+
set => SetValue(AnchorProperty, value);
48+
}
49+
50+
protected override Type StyleKeyOverride => typeof(DocsHelpButton);
51+
52+
protected override void OnClick()
53+
{
54+
base.OnClick();
55+
56+
if (Design.IsDesignMode)
57+
return;
58+
59+
if (string.IsNullOrWhiteSpace(DocsPath))
60+
{
61+
Logger.Warn("{Control} clicked with no {Property} set", nameof(DocsHelpButton), nameof(DocsPath));
62+
return;
63+
}
64+
65+
App.Services?.GetService<IDocumentationNavigationService>()?.OpenDocumentation(DocsPath, Anchor);
66+
}
67+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Windows.Input;
5+
using Avalonia;
6+
using Avalonia.Controls;
7+
using Avalonia.Media;
8+
using Avalonia.Threading;
9+
using Avalonia.VisualTree;
10+
using ColorTextBlock.Avalonia;
11+
using Markdown.Avalonia;
12+
using StabilityMatrix.Core.Models.Documentation;
13+
14+
namespace StabilityMatrix.Avalonia.Controls;
15+
16+
/// <summary>
17+
/// A <see cref="BetterMarkdownScrollViewer"/> that routes hyperlink clicks through a
18+
/// bindable <see cref="LinkCommand"/> (so relative <c>.md</c> links can navigate in-app
19+
/// and external links can open in the browser) and resolves relative image paths against
20+
/// <see cref="ImageBaseUrl"/> via the engine's asset path root.
21+
/// </summary>
22+
public class DocumentationMarkdownViewer : BetterMarkdownScrollViewer
23+
{
24+
/// <summary>
25+
/// Command invoked when a hyperlink is clicked. The command parameter is the raw href string.
26+
/// </summary>
27+
public static readonly StyledProperty<ICommand?> LinkCommandProperty = AvaloniaProperty.Register<
28+
DocumentationMarkdownViewer,
29+
ICommand?
30+
>(nameof(LinkCommand));
31+
32+
/// <summary>
33+
/// Base URL used to resolve relative image paths in the rendered markdown
34+
/// (e.g. the raw URL of the current page's folder).
35+
/// </summary>
36+
public static readonly StyledProperty<string?> ImageBaseUrlProperty = AvaloniaProperty.Register<
37+
DocumentationMarkdownViewer,
38+
string?
39+
>(nameof(ImageBaseUrl));
40+
41+
public ICommand? LinkCommand
42+
{
43+
get => GetValue(LinkCommandProperty);
44+
set => SetValue(LinkCommandProperty, value);
45+
}
46+
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+
56+
public string? ImageBaseUrl
57+
{
58+
get => GetValue(ImageBaseUrlProperty);
59+
set => SetValue(ImageBaseUrlProperty, value);
60+
}
61+
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+
74+
public DocumentationMarkdownViewer()
75+
{
76+
ApplyLinkCommand();
77+
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+
}
102+
}
103+
104+
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
105+
{
106+
base.OnPropertyChanged(change);
107+
108+
if (change.Property == LinkCommandProperty)
109+
{
110+
ApplyLinkCommand();
111+
}
112+
else if (change.Property == ImageBaseUrlProperty)
113+
{
114+
ApplyImageBaseUrl();
115+
}
116+
else if (change.Property == ContentZoomProperty)
117+
{
118+
ApplyContentZoom();
119+
}
120+
else if (change.Property == MarkdownProperty)
121+
{
122+
// Page changes swap the document inside the same scroll viewer, which otherwise
123+
// keeps the previous page's offset and drops the reader mid-way down a page they
124+
// have never seen. Anchor navigation re-scrolls on a later layout pass, so a
125+
// deep link to a heading still wins over this.
126+
ScrollValue = new Vector(ScrollValue.X, 0);
127+
}
128+
}
129+
130+
private void ApplyContentZoom()
131+
{
132+
if (zoomHost is null)
133+
return;
134+
135+
// Guard against zero/negative values from bad bindings.
136+
var zoom = Math.Clamp(ContentZoom, 0.25, 4.0);
137+
zoomHost.LayoutTransform = new ScaleTransform(zoom, zoom);
138+
}
139+
140+
private void ApplyLinkCommand()
141+
{
142+
// The engine owns the HyperlinkCommand used for all rendered links. The Engine getter
143+
// always returns an IMarkdownEngine2 (custom IMarkdownEngine values are upgraded to a
144+
// wrapper that only implements IMarkdownEngine2), so match on that interface.
145+
if (Engine is IMarkdownEngine2 engine)
146+
{
147+
engine.HyperlinkCommand = LinkCommand;
148+
}
149+
}
150+
151+
private void ApplyImageBaseUrl()
152+
{
153+
// AssetPathRoot flows through to the engine's bitmap loader so relative image
154+
// paths resolve against the raw docs URL.
155+
AssetPathRoot = ImageBaseUrl ?? string.Empty;
156+
}
157+
158+
private static readonly string[] HeadingClasses =
159+
[
160+
"Heading1",
161+
"Heading2",
162+
"Heading3",
163+
"Heading4",
164+
"Heading5",
165+
"Heading6",
166+
];
167+
168+
/// <summary>
169+
/// Scrolls the rendered content so the heading matching the given GitHub-style anchor slug
170+
/// is brought to the top of the viewport.
171+
/// </summary>
172+
/// <param name="anchor">The bare heading slug (no leading <c>#</c>).</param>
173+
/// <returns><c>true</c> if a matching heading was found at call time; otherwise <c>false</c>.</returns>
174+
public bool ScrollToAnchor(string anchor)
175+
{
176+
if (string.IsNullOrWhiteSpace(anchor))
177+
return false;
178+
179+
var slug = DocumentationPathResolver.Slugify(anchor);
180+
if (slug.Length == 0)
181+
return false;
182+
183+
// Content is built synchronously when Markdown changes, but layout/measure (needed for
184+
// TranslatePoint) only runs on the next layout pass — defer the actual scroll.
185+
var found = FindHeadingBySlug(slug) is not null;
186+
187+
Dispatcher.UIThread.Post(
188+
() =>
189+
{
190+
var target = FindHeadingBySlug(slug);
191+
if (target is not null)
192+
ScrollHeadingIntoView(target);
193+
},
194+
DispatcherPriority.Background
195+
);
196+
197+
return found;
198+
}
199+
200+
/// <summary>
201+
/// Locates the heading control whose slug matches, applying GitHub-style duplicate suffixes
202+
/// (<c>-1</c>, <c>-2</c>, ...) in document order.
203+
/// </summary>
204+
private CTextBlock? FindHeadingBySlug(string slug)
205+
{
206+
var seen = new Dictionary<string, int>(StringComparer.Ordinal);
207+
208+
foreach (var descendant in this.GetVisualDescendants())
209+
{
210+
if (descendant is not CTextBlock textBlock || !IsHeading(textBlock))
211+
continue;
212+
213+
var baseSlug = DocumentationPathResolver.Slugify(textBlock.Text ?? string.Empty);
214+
if (baseSlug.Length == 0)
215+
continue;
216+
217+
string effectiveSlug;
218+
if (seen.TryGetValue(baseSlug, out var count))
219+
{
220+
effectiveSlug = $"{baseSlug}-{count}";
221+
seen[baseSlug] = count + 1;
222+
}
223+
else
224+
{
225+
effectiveSlug = baseSlug;
226+
seen[baseSlug] = 1;
227+
}
228+
229+
if (string.Equals(effectiveSlug, slug, StringComparison.Ordinal))
230+
return textBlock;
231+
}
232+
233+
return null;
234+
}
235+
236+
private static bool IsHeading(StyledElement control)
237+
{
238+
foreach (var cls in HeadingClasses)
239+
{
240+
if (control.Classes.Contains(cls))
241+
return true;
242+
}
243+
244+
return false;
245+
}
246+
247+
private void ScrollHeadingIntoView(Visual heading)
248+
{
249+
// Position of the heading relative to this control's viewport, plus the current scroll
250+
// offset, gives the heading's Y within the scrollable content.
251+
var current = ScrollValue;
252+
var point = heading.TranslatePoint(new Point(0, 0), this);
253+
if (point is null)
254+
return;
255+
256+
var targetY = Math.Max(0, point.Value.Y + current.Y);
257+
ScrollValue = new Vector(current.X, targetY);
258+
}
259+
}

StabilityMatrix.Avalonia/Languages/Resources.Designer.cs

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)