Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions StabilityMatrix.Avalonia/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services)
provider.GetRequiredService<ISecretsManager>(),
provider.GetRequiredService<INavigationService<MainWindowViewModel>>(),
provider.GetRequiredService<INavigationService<SettingsViewModel>>(),
provider.GetRequiredService<IDocumentationNavigationService>(),
provider.GetRequiredService<IDistributedSubscriber<string, Uri>>()
)
{
Expand Down
67 changes: 67 additions & 0 deletions StabilityMatrix.Avalonia/Controls/DocsHelpButton.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Microsoft.Extensions.DependencyInjection;
using NLog;
using StabilityMatrix.Avalonia.Services;

namespace StabilityMatrix.Avalonia.Controls;

/// <summary>
/// Contextual help button that opens the in-app documentation viewer at <see cref="DocsPath"/>.
/// Resolves navigation itself so that adding help to a surface needs no view model plumbing.
/// </summary>
/// <remarks>
/// Prefer a <see cref="Core.Models.Documentation.DocumentationPages"/> constant over a literal
/// path so a moved page is fixed in one place.
/// </remarks>
public class DocsHelpButton : Button
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();

public static readonly StyledProperty<string?> DocsPathProperty = AvaloniaProperty.Register<
DocsHelpButton,
string?
>(nameof(DocsPath));

/// <summary>
/// Page path relative to the docs root, e.g. <c>advanced/environment-variables.md</c>.
/// </summary>
public string? DocsPath
{
get => GetValue(DocsPathProperty);
set => SetValue(DocsPathProperty, value);
}

public static readonly StyledProperty<string?> AnchorProperty = AvaloniaProperty.Register<
DocsHelpButton,
string?
>(nameof(Anchor));

/// <summary>
/// Optional heading slug within the page to scroll to, e.g. <c>setting-a-variable</c>.
/// </summary>
public string? Anchor
{
get => GetValue(AnchorProperty);
set => SetValue(AnchorProperty, value);
}

protected override Type StyleKeyOverride => typeof(DocsHelpButton);

protected override void OnClick()
{
base.OnClick();

if (Design.IsDesignMode)
return;

if (string.IsNullOrWhiteSpace(DocsPath))
{
Logger.Warn("{Control} clicked with no {Property} set", nameof(DocsHelpButton), nameof(DocsPath));
return;
}

App.Services?.GetService<IDocumentationNavigationService>()?.OpenDocumentation(DocsPath, Anchor);
}
}
259 changes: 259 additions & 0 deletions StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ColorTextBlock.Avalonia;
using Markdown.Avalonia;
using StabilityMatrix.Core.Models.Documentation;

namespace StabilityMatrix.Avalonia.Controls;

/// <summary>
/// A <see cref="BetterMarkdownScrollViewer"/> that routes hyperlink clicks through a
/// bindable <see cref="LinkCommand"/> (so relative <c>.md</c> links can navigate in-app
/// and external links can open in the browser) and resolves relative image paths against
/// <see cref="ImageBaseUrl"/> via the engine's asset path root.
/// </summary>
public class DocumentationMarkdownViewer : BetterMarkdownScrollViewer
{
/// <summary>
/// Command invoked when a hyperlink is clicked. The command parameter is the raw href string.
/// </summary>
public static readonly StyledProperty<ICommand?> LinkCommandProperty = AvaloniaProperty.Register<
DocumentationMarkdownViewer,
ICommand?
>(nameof(LinkCommand));

/// <summary>
/// Base URL used to resolve relative image paths in the rendered markdown
/// (e.g. the raw URL of the current page's folder).
/// </summary>
public static readonly StyledProperty<string?> ImageBaseUrlProperty = AvaloniaProperty.Register<
DocumentationMarkdownViewer,
string?
>(nameof(ImageBaseUrl));

public ICommand? LinkCommand
{
get => GetValue(LinkCommandProperty);
set => SetValue(LinkCommandProperty, value);
}

/// <summary>
/// Zoom factor applied to the rendered document content (1.0 = 100%).
/// Scales the content inside the internal scroll viewer, so the scrollbar is unaffected.
/// </summary>
public static readonly StyledProperty<double> ContentZoomProperty = AvaloniaProperty.Register<
DocumentationMarkdownViewer,
double
>(nameof(ContentZoom), 1.0);

public string? ImageBaseUrl
{
get => GetValue(ImageBaseUrlProperty);
set => SetValue(ImageBaseUrlProperty, value);
}

public double ContentZoom
{
get => GetValue(ContentZoomProperty);
set => SetValue(ContentZoomProperty, value);
}

/// <summary>
/// Hosts the document content inside the internal scroll viewer so zoom can scale the
/// content without scaling the scrollbar. Null if the base control's composition changes.
/// </summary>
private readonly LayoutTransformControl? zoomHost;

public DocumentationMarkdownViewer()
{
ApplyLinkCommand();
ApplyImageBaseUrl();

// The base ctor composes a non-templated inner ScrollViewer (a direct visual child)
// whose Content is the document wrapper, and never reassigns Content afterwards
// (page changes only swap the wrapper's Document). Re-parent the wrapper into a
// LayoutTransformControl so zoom scales the document but not the scrollbar, and add
// right margin so the overlay scrollbar doesn't cover the rightmost text.
if (this.GetVisualChildren().OfType<ScrollViewer>().FirstOrDefault() is { } innerViewer)
{
if (innerViewer.Content is Control content)
{
innerViewer.Content = null;
zoomHost = new LayoutTransformControl
{
Child = content,
Margin = new Thickness(0, 0, 18, 0),
};
innerViewer.Content = zoomHost;
}
else
{
// Fallback: at least keep the scrollbar off the content.
innerViewer.Padding = new Thickness(0, 0, 18, 0);
}
}
}

protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);

if (change.Property == LinkCommandProperty)
{
ApplyLinkCommand();
}
else if (change.Property == ImageBaseUrlProperty)
{
ApplyImageBaseUrl();
}
Comment thread
mohnjiles marked this conversation as resolved.
else if (change.Property == ContentZoomProperty)
{
ApplyContentZoom();
}
else if (change.Property == MarkdownProperty)
{
// Page changes swap the document inside the same scroll viewer, which otherwise
// keeps the previous page's offset and drops the reader mid-way down a page they
// have never seen. Anchor navigation re-scrolls on a later layout pass, so a
// deep link to a heading still wins over this.
ScrollValue = new Vector(ScrollValue.X, 0);
}
}

private void ApplyContentZoom()
{
if (zoomHost is null)
return;

// Guard against zero/negative values from bad bindings.
var zoom = Math.Clamp(ContentZoom, 0.25, 4.0);
zoomHost.LayoutTransform = new ScaleTransform(zoom, zoom);
}

private void ApplyLinkCommand()
{
// The engine owns the HyperlinkCommand used for all rendered links. The Engine getter
// always returns an IMarkdownEngine2 (custom IMarkdownEngine values are upgraded to a
// wrapper that only implements IMarkdownEngine2), so match on that interface.
if (Engine is IMarkdownEngine2 engine)
{
engine.HyperlinkCommand = LinkCommand;
}
}

private void ApplyImageBaseUrl()
{
// AssetPathRoot flows through to the engine's bitmap loader so relative image
// paths resolve against the raw docs URL.
AssetPathRoot = ImageBaseUrl ?? string.Empty;
}

private static readonly string[] HeadingClasses =
[
"Heading1",
"Heading2",
"Heading3",
"Heading4",
"Heading5",
"Heading6",
];

/// <summary>
/// Scrolls the rendered content so the heading matching the given GitHub-style anchor slug
/// is brought to the top of the viewport.
/// </summary>
/// <param name="anchor">The bare heading slug (no leading <c>#</c>).</param>
/// <returns><c>true</c> if a matching heading was found at call time; otherwise <c>false</c>.</returns>
public bool ScrollToAnchor(string anchor)
{
if (string.IsNullOrWhiteSpace(anchor))
return false;

var slug = DocumentationPathResolver.Slugify(anchor);
if (slug.Length == 0)
return false;

// Content is built synchronously when Markdown changes, but layout/measure (needed for
// TranslatePoint) only runs on the next layout pass — defer the actual scroll.
var found = FindHeadingBySlug(slug) is not null;

Dispatcher.UIThread.Post(
() =>
{
var target = FindHeadingBySlug(slug);
if (target is not null)
ScrollHeadingIntoView(target);
},
DispatcherPriority.Background
);

return found;
}

/// <summary>
/// Locates the heading control whose slug matches, applying GitHub-style duplicate suffixes
/// (<c>-1</c>, <c>-2</c>, ...) in document order.
/// </summary>
private CTextBlock? FindHeadingBySlug(string slug)
{
var seen = new Dictionary<string, int>(StringComparer.Ordinal);

foreach (var descendant in this.GetVisualDescendants())
{
if (descendant is not CTextBlock textBlock || !IsHeading(textBlock))
continue;

var baseSlug = DocumentationPathResolver.Slugify(textBlock.Text ?? string.Empty);
if (baseSlug.Length == 0)
continue;

string effectiveSlug;
if (seen.TryGetValue(baseSlug, out var count))
{
effectiveSlug = $"{baseSlug}-{count}";
seen[baseSlug] = count + 1;
}
else
{
effectiveSlug = baseSlug;
seen[baseSlug] = 1;
}

if (string.Equals(effectiveSlug, slug, StringComparison.Ordinal))
return textBlock;
}

return null;
}

private static bool IsHeading(StyledElement control)
{
foreach (var cls in HeadingClasses)
{
if (control.Classes.Contains(cls))
return true;
}

return false;
}

private void ScrollHeadingIntoView(Visual heading)
{
// Position of the heading relative to this control's viewport, plus the current scroll
// offset, gives the heading's Y within the scrollable content.
var current = ScrollValue;
var point = heading.TranslatePoint(new Point(0, 0), this);
if (point is null)
return;

var targetY = Math.Max(0, point.Value.Y + current.Y);
ScrollValue = new Vector(current.X, targetY);
}
}
54 changes: 54 additions & 0 deletions StabilityMatrix.Avalonia/Languages/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading