Skip to content

Commit 2367c1d

Browse files
mohnjilesclaude
andcommitted
Docs viewer UX: tree nav, site ordering, footer placement, anchor scrolling
From GUI smoke-test feedback: - Sidebar is now a TreeView (CheckpointsPage pattern): sections as expandable parent nodes (expanded by default), pages as leaves, root README hoisted to a top-level leaf; selection stays synced with link-driven navigation - Sections and pages ordered to match the docs site nav (preferred section order, overview-first within sections) instead of alphabetical - Documentation footer entry moved above Support Us so the pre-existing footer cluster keeps its muscle-memory positions; navigates by type since it left FooterPages - In-page ToC anchor links now scroll to their heading: GitHub-style slugs matched against rendered Heading1-6 blocks, measurement deferred to a layout pass; cross-page page.md#anchor links navigate then scroll - New-style partial observable property on the shared nav-node base Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1f54148 commit 2367c1d

15 files changed

Lines changed: 529 additions & 84 deletions

StabilityMatrix.Avalonia/App.axaml.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -464,11 +464,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services)
464464
provider.GetRequiredService<OutputsPageViewModel>(),
465465
provider.GetRequiredService<WorkflowsPageViewModel>(),
466466
},
467-
FooterPages =
468-
{
469-
provider.GetRequiredService<DocumentationViewModel>(),
470-
provider.GetRequiredService<SettingsViewModel>(),
471-
},
467+
FooterPages = { provider.GetRequiredService<SettingsViewModel>() },
472468
});
473469
}
474470

StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Windows.Input;
34
using Avalonia;
5+
using Avalonia.Controls;
6+
using Avalonia.Threading;
7+
using Avalonia.VisualTree;
8+
using ColorTextBlock.Avalonia;
49
using Markdown.Avalonia;
10+
using StabilityMatrix.Core.Models.Documentation;
511

612
namespace StabilityMatrix.Avalonia.Controls;
713

@@ -79,4 +85,106 @@ private void ApplyImageBaseUrl()
7985
// paths resolve against the raw docs URL.
8086
AssetPathRoot = ImageBaseUrl ?? string.Empty;
8187
}
88+
89+
private static readonly string[] HeadingClasses =
90+
[
91+
"Heading1",
92+
"Heading2",
93+
"Heading3",
94+
"Heading4",
95+
"Heading5",
96+
"Heading6",
97+
];
98+
99+
/// <summary>
100+
/// Scrolls the rendered content so the heading matching the given GitHub-style anchor slug
101+
/// is brought to the top of the viewport.
102+
/// </summary>
103+
/// <param name="anchor">The bare heading slug (no leading <c>#</c>).</param>
104+
/// <returns><c>true</c> if a matching heading was found at call time; otherwise <c>false</c>.</returns>
105+
public bool ScrollToAnchor(string anchor)
106+
{
107+
if (string.IsNullOrWhiteSpace(anchor))
108+
return false;
109+
110+
var slug = DocumentationPathResolver.Slugify(anchor);
111+
if (slug.Length == 0)
112+
return false;
113+
114+
// Content is built synchronously when Markdown changes, but layout/measure (needed for
115+
// TranslatePoint) only runs on the next layout pass — defer the actual scroll.
116+
var found = FindHeadingBySlug(slug) is not null;
117+
118+
Dispatcher.UIThread.Post(
119+
() =>
120+
{
121+
var target = FindHeadingBySlug(slug);
122+
if (target is not null)
123+
ScrollHeadingIntoView(target);
124+
},
125+
DispatcherPriority.Background
126+
);
127+
128+
return found;
129+
}
130+
131+
/// <summary>
132+
/// Locates the heading control whose slug matches, applying GitHub-style duplicate suffixes
133+
/// (<c>-1</c>, <c>-2</c>, ...) in document order.
134+
/// </summary>
135+
private CTextBlock? FindHeadingBySlug(string slug)
136+
{
137+
var seen = new Dictionary<string, int>(StringComparer.Ordinal);
138+
139+
foreach (var descendant in this.GetVisualDescendants())
140+
{
141+
if (descendant is not CTextBlock textBlock || !IsHeading(textBlock))
142+
continue;
143+
144+
var baseSlug = DocumentationPathResolver.Slugify(textBlock.Text ?? string.Empty);
145+
if (baseSlug.Length == 0)
146+
continue;
147+
148+
string effectiveSlug;
149+
if (seen.TryGetValue(baseSlug, out var count))
150+
{
151+
effectiveSlug = $"{baseSlug}-{count}";
152+
seen[baseSlug] = count + 1;
153+
}
154+
else
155+
{
156+
effectiveSlug = baseSlug;
157+
seen[baseSlug] = 1;
158+
}
159+
160+
if (string.Equals(effectiveSlug, slug, StringComparison.Ordinal))
161+
return textBlock;
162+
}
163+
164+
return null;
165+
}
166+
167+
private static bool IsHeading(StyledElement control)
168+
{
169+
foreach (var cls in HeadingClasses)
170+
{
171+
if (control.Classes.Contains(cls))
172+
return true;
173+
}
174+
175+
return false;
176+
}
177+
178+
private void ScrollHeadingIntoView(Visual heading)
179+
{
180+
// Position of the heading relative to this control's viewport, plus the current scroll
181+
// offset, gives the heading's Y within the scrollable content.
182+
var current = ScrollValue;
183+
var point = heading.TranslatePoint(new Point(0, 0), this);
184+
if (point is null)
185+
return;
186+
187+
var targetY = Math.Max(0, point.Value.Y + current.Y);
188+
ScrollValue = new Vector(current.X, targetY);
189+
}
82190
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using CommunityToolkit.Mvvm.ComponentModel;
2+
3+
namespace StabilityMatrix.Avalonia.ViewModels.Documentation;
4+
5+
/// <summary>
6+
/// Base for nodes shown in the documentation navigation tree (sections and pages).
7+
/// Exposes the expansion state consumed by the TreeView's <c>TreeViewItem</c> style binding.
8+
/// </summary>
9+
public abstract partial class DocumentationNavNode : ObservableObject
10+
{
11+
/// <summary>Whether the corresponding <c>TreeViewItem</c> is expanded.</summary>
12+
[ObservableProperty]
13+
public partial bool IsExpanded { get; set; }
14+
}
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
1-
using CommunityToolkit.Mvvm.ComponentModel;
2-
31
namespace StabilityMatrix.Avalonia.ViewModels.Documentation;
42

53
/// <summary>
6-
/// A single navigable documentation page entry in the sidebar.
4+
/// A single navigable documentation page entry (leaf) in the sidebar tree.
75
/// </summary>
8-
public partial class DocumentationPageNavItem : ObservableObject
6+
public partial class DocumentationPageNavItem : DocumentationNavNode
97
{
108
/// <summary>Display title, e.g. "Overview".</summary>
119
public required string Title { get; init; }
1210

1311
/// <summary>Path relative to the docs root, e.g. <c>getting-started/overview.md</c>.</summary>
1412
public required string Path { get; init; }
15-
16-
[ObservableProperty]
17-
private bool isSelected;
1813
}

StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@ namespace StabilityMatrix.Avalonia.ViewModels.Documentation;
55
/// <summary>
66
/// A section grouping in the documentation sidebar (e.g. "Getting Started").
77
/// </summary>
8-
public class DocumentationSectionNavItem
8+
public partial class DocumentationSectionNavItem : DocumentationNavNode
99
{
10+
public DocumentationSectionNavItem()
11+
{
12+
// Sections are expanded by default.
13+
IsExpanded = true;
14+
}
15+
1016
/// <summary>Section title. Empty for the root section (renders without a header).</summary>
1117
public required string Title { get; init; }
1218

StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,20 @@ public partial class DocumentationViewModel : PageViewModelBase
3737

3838
public ObservableCollection<DocumentationSectionNavItem> Sections { get; } = [];
3939

40+
/// <summary>
41+
/// Flattened source for the navigation <c>TreeView</c>: root-level pages are hoisted to
42+
/// top-level leaves and each non-root section is a parent node. Items are either
43+
/// <see cref="DocumentationSectionNavItem"/> or <see cref="DocumentationPageNavItem"/>.
44+
/// </summary>
45+
public ObservableCollection<object> TreeItems { get; } = [];
46+
4047
[ObservableProperty]
4148
private DocumentationPageNavItem? selectedPage;
4249

50+
/// <summary>Two-way bound to the nav TreeView's SelectedItem; routes page leaves to <see cref="SelectedPage"/>.</summary>
51+
[ObservableProperty]
52+
private object? selectedTreeItem;
53+
4354
[ObservableProperty]
4455
private string? currentMarkdown;
4556

@@ -68,9 +79,18 @@ public partial class DocumentationViewModel : PageViewModelBase
6879
/// <summary>Command bound to the markdown viewer to intercept hyperlink clicks.</summary>
6980
public ICommand LinkClickedCommand { get; }
7081

82+
/// <summary>
83+
/// Raised when an in-page anchor should be scrolled to. The argument is the bare heading slug.
84+
/// The view subscribes and forwards to the markdown viewer (keeps the VM free of control refs).
85+
/// </summary>
86+
public event EventHandler<string>? AnchorRequested;
87+
7188
private CancellationTokenSource? pageCts;
7289
private bool hasLoadedTree;
7390

91+
/// <summary>Anchor slug to scroll to after the next page load completes (cross-page anchor links).</summary>
92+
private string? pendingAnchor;
93+
7494
public DocumentationViewModel(
7595
ILogger<DocumentationViewModel> logger,
7696
IDocumentationService documentationService
@@ -97,13 +117,6 @@ private async Task RetryAsync()
97117
await LoadTreeAsync(forceRefresh: true);
98118
}
99119

100-
[RelayCommand]
101-
private void SelectPage(DocumentationPageNavItem? page)
102-
{
103-
if (page is not null)
104-
SelectedPage = page;
105-
}
106-
107120
private async Task LoadTreeAsync(bool forceRefresh = false)
108121
{
109122
IsTreeLoading = true;
@@ -115,21 +128,29 @@ private async Task LoadTreeAsync(bool forceRefresh = false)
115128
var sections = await documentationService.GetSectionsAsync(forceRefresh);
116129

117130
Sections.Clear();
131+
TreeItems.Clear();
118132
foreach (var section in sections)
119133
{
120-
Sections.Add(
121-
new DocumentationSectionNavItem
122-
{
123-
Title = section.Title,
124-
Pages = section
125-
.Pages.Select(p => new DocumentationPageNavItem
126-
{
127-
Title = p.Title,
128-
Path = p.Path,
129-
})
130-
.ToList(),
131-
}
132-
);
134+
var navSection = new DocumentationSectionNavItem
135+
{
136+
Title = section.Title,
137+
Pages = section
138+
.Pages.Select(p => new DocumentationPageNavItem { Title = p.Title, Path = p.Path })
139+
.ToList(),
140+
};
141+
Sections.Add(navSection);
142+
143+
// Hoist the root (empty-title) section's pages to top-level tree leaves;
144+
// real sections become parent nodes.
145+
if (navSection.HasHeader)
146+
{
147+
TreeItems.Add(navSection);
148+
}
149+
else
150+
{
151+
foreach (var page in navSection.Pages)
152+
TreeItems.Add(page);
153+
}
133154
}
134155

135156
hasLoadedTree = true;
@@ -146,12 +167,14 @@ private async Task LoadTreeAsync(bool forceRefresh = false)
146167
logger.LogInformation(e, "Documentation is not available yet");
147168
IsDocsUnavailable = true;
148169
Sections.Clear();
170+
TreeItems.Clear();
149171
}
150172
catch (Exception e)
151173
{
152174
logger.LogWarning(e, "Failed to load documentation tree");
153175
ErrorMessage = "Could not load the documentation listing. Check your connection and try again.";
154176
Sections.Clear();
177+
TreeItems.Clear();
155178
}
156179
finally
157180
{
@@ -161,17 +184,26 @@ private async Task LoadTreeAsync(bool forceRefresh = false)
161184

162185
partial void OnSelectedPageChanged(DocumentationPageNavItem? oldValue, DocumentationPageNavItem? newValue)
163186
{
164-
if (oldValue is not null)
165-
oldValue.IsSelected = false;
187+
// Keep the tree's visual selection in sync (e.g. when navigation comes from a link click).
188+
SelectedTreeItem = newValue;
166189

167190
if (newValue is null)
168191
return;
169192

170-
newValue.IsSelected = true;
171-
LoadPageAsync(newValue.Path).SafeFireAndForget();
193+
// Capture any anchor queued by a cross-page link so it can't race a later navigation.
194+
var anchor = pendingAnchor;
195+
pendingAnchor = null;
196+
LoadPageAsync(newValue.Path, anchor).SafeFireAndForget();
172197
}
173198

174-
private async Task LoadPageAsync(string docsRelativePath)
199+
partial void OnSelectedTreeItemChanged(object? value)
200+
{
201+
// Only page leaves load content; selecting a section node is a no-op.
202+
if (value is DocumentationPageNavItem page)
203+
SelectedPage = page;
204+
}
205+
206+
private async Task LoadPageAsync(string docsRelativePath, string? anchor = null)
175207
{
176208
// Replace the CTS before any await so rapid selections can't race on the old one,
177209
// then cancel the superseded load.
@@ -199,6 +231,12 @@ private async Task LoadPageAsync(string docsRelativePath)
199231

200232
CurrentMarkdown = DocumentationPathResolver.RewriteImageUrls(docsRelativePath, markdown);
201233
CurrentImageBaseUrl = GetPageFolderRawUrl(docsRelativePath);
234+
235+
// The page content is now set; ask the view to scroll to the requested anchor.
236+
// The viewer defers the actual measurement to a layout pass, so this can't race
237+
// the (already-completed) page load.
238+
if (!string.IsNullOrEmpty(anchor))
239+
AnchorRequested?.Invoke(this, anchor);
202240
}
203241
catch (OperationCanceledException)
204242
{
@@ -231,29 +269,43 @@ private void OnLinkClicked(string? href)
231269
break;
232270

233271
case DocumentationPathResolver.LinkKind.InternalPage:
234-
NavigateToPath(resolved.Target);
272+
NavigateToPath(resolved.Target, resolved.Fragment);
235273
break;
236274

237275
case DocumentationPathResolver.LinkKind.Anchor:
238-
// In-page anchors are not currently supported; no-op.
276+
// Same-page anchor: ask the view to scroll to the heading.
277+
if (!string.IsNullOrEmpty(resolved.Target))
278+
AnchorRequested?.Invoke(this, resolved.Target);
239279
break;
240280
}
241281
}
242282

243-
private void NavigateToPath(string docsRelativePath)
283+
private void NavigateToPath(string docsRelativePath, string? fragment = null)
244284
{
245285
var match = Sections
246286
.SelectMany(s => s.Pages)
247287
.FirstOrDefault(p => string.Equals(p.Path, docsRelativePath, StringComparison.OrdinalIgnoreCase));
248288

289+
// Already on the target page: SelectedPage won't re-fire, so handle the anchor directly.
290+
if (match is not null && ReferenceEquals(match, SelectedPage))
291+
{
292+
if (!string.IsNullOrEmpty(fragment))
293+
AnchorRequested?.Invoke(this, fragment);
294+
return;
295+
}
296+
297+
pendingAnchor = fragment;
298+
249299
if (match is not null)
250300
{
251301
SelectedPage = match;
252302
}
253303
else
254304
{
255305
// Not part of the discovered tree (e.g. a page not yet listed) — load it directly.
256-
LoadPageAsync(docsRelativePath).SafeFireAndForget();
306+
var anchor = pendingAnchor;
307+
pendingAnchor = null;
308+
LoadPageAsync(docsRelativePath, anchor).SafeFireAndForget();
257309
}
258310
}
259311

0 commit comments

Comments
 (0)