diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 7f7be2d5f..79a07ee91 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -451,6 +451,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services) provider.GetRequiredService(), provider.GetRequiredService>(), provider.GetRequiredService>(), + provider.GetRequiredService(), provider.GetRequiredService>() ) { diff --git a/StabilityMatrix.Avalonia/Controls/DocsHelpButton.cs b/StabilityMatrix.Avalonia/Controls/DocsHelpButton.cs new file mode 100644 index 000000000..b26f1bd2b --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/DocsHelpButton.cs @@ -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; + +/// +/// Contextual help button that opens the in-app documentation viewer at . +/// Resolves navigation itself so that adding help to a surface needs no view model plumbing. +/// +/// +/// Prefer a constant over a literal +/// path so a moved page is fixed in one place. +/// +public class DocsHelpButton : Button +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public static readonly StyledProperty DocsPathProperty = AvaloniaProperty.Register< + DocsHelpButton, + string? + >(nameof(DocsPath)); + + /// + /// Page path relative to the docs root, e.g. advanced/environment-variables.md. + /// + public string? DocsPath + { + get => GetValue(DocsPathProperty); + set => SetValue(DocsPathProperty, value); + } + + public static readonly StyledProperty AnchorProperty = AvaloniaProperty.Register< + DocsHelpButton, + string? + >(nameof(Anchor)); + + /// + /// Optional heading slug within the page to scroll to, e.g. setting-a-variable. + /// + 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()?.OpenDocumentation(DocsPath, Anchor); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs b/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs new file mode 100644 index 000000000..a1263747d --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs @@ -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; + +/// +/// A that routes hyperlink clicks through a +/// bindable (so relative .md links can navigate in-app +/// and external links can open in the browser) and resolves relative image paths against +/// via the engine's asset path root. +/// +public class DocumentationMarkdownViewer : BetterMarkdownScrollViewer +{ + /// + /// Command invoked when a hyperlink is clicked. The command parameter is the raw href string. + /// + public static readonly StyledProperty LinkCommandProperty = AvaloniaProperty.Register< + DocumentationMarkdownViewer, + ICommand? + >(nameof(LinkCommand)); + + /// + /// Base URL used to resolve relative image paths in the rendered markdown + /// (e.g. the raw URL of the current page's folder). + /// + public static readonly StyledProperty ImageBaseUrlProperty = AvaloniaProperty.Register< + DocumentationMarkdownViewer, + string? + >(nameof(ImageBaseUrl)); + + public ICommand? LinkCommand + { + get => GetValue(LinkCommandProperty); + set => SetValue(LinkCommandProperty, value); + } + + /// + /// Zoom factor applied to the rendered document content (1.0 = 100%). + /// Scales the content inside the internal scroll viewer, so the scrollbar is unaffected. + /// + public static readonly StyledProperty 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); + } + + /// + /// 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. + /// + 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().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(); + } + 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", + ]; + + /// + /// Scrolls the rendered content so the heading matching the given GitHub-style anchor slug + /// is brought to the top of the viewport. + /// + /// The bare heading slug (no leading #). + /// true if a matching heading was found at call time; otherwise false. + 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; + } + + /// + /// Locates the heading control whose slug matches, applying GitHub-style duplicate suffixes + /// (-1, -2, ...) in document order. + /// + private CTextBlock? FindHeadingBySlug(string slug) + { + var seen = new Dictionary(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); + } +} diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index fe9ce0b73..867b9d276 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -5041,5 +5041,59 @@ public static string Error_GeminiAccessForbidden { return ResourceManager.GetString("Error_GeminiAccessForbidden", resourceCulture); } } + + public static string Label_Documentation { + get { + return ResourceManager.GetString("Label_Documentation", resourceCulture); + } + } + + public static string Label_ViewDocumentation { + get { + return ResourceManager.GetString("Label_ViewDocumentation", resourceCulture); + } + } + + public static string Label_DocumentationNotAvailable { + get { + return ResourceManager.GetString("Label_DocumentationNotAvailable", resourceCulture); + } + } + + public static string Label_ZoomIn { + get { + return ResourceManager.GetString("Label_ZoomIn", resourceCulture); + } + } + + public static string Label_ZoomOut { + get { + return ResourceManager.GetString("Label_ZoomOut", resourceCulture); + } + } + + public static string Label_ResetZoom { + get { + return ResourceManager.GetString("Label_ResetZoom", resourceCulture); + } + } + + public static string Text_DocumentationNotAvailable { + get { + return ResourceManager.GetString("Text_DocumentationNotAvailable", resourceCulture); + } + } + + public static string Error_DocumentationListingLoadFailed { + get { + return ResourceManager.GetString("Error_DocumentationListingLoadFailed", resourceCulture); + } + } + + public static string Error_DocumentationPageLoadFailed { + get { + return ResourceManager.GetString("Error_DocumentationPageLoadFailed", resourceCulture); + } + } } } diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index d172e3bcb..a9257a69a 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -1832,4 +1832,31 @@ Account tokens will not be viewable after saving, please make a note of them if Custom... + + Documentation + + + View documentation + + + Documentation is not available yet + + + Zoom in + + + Zoom out + + + Reset zoom + + + The documentation is coming soon. Please check back in a future release. + + + Could not load the documentation listing. Check your connection and try again. + + + Could not load this page. Check your connection and try again. + diff --git a/StabilityMatrix.Avalonia/Services/DocumentationNavigationService.cs b/StabilityMatrix.Avalonia/Services/DocumentationNavigationService.cs new file mode 100644 index 000000000..c207a8291 --- /dev/null +++ b/StabilityMatrix.Avalonia/Services/DocumentationNavigationService.cs @@ -0,0 +1,30 @@ +using System; +using Injectio.Attributes; +using StabilityMatrix.Avalonia.Animations; +using StabilityMatrix.Avalonia.ViewModels; + +namespace StabilityMatrix.Avalonia.Services; + +/// +/// Single entry point for every contextual help affordance in the app, so that call sites +/// only name the docs page they want and never touch the navigation frame or the viewer's state. +/// +[RegisterSingleton] +public class DocumentationNavigationService( + INavigationService navigationService, + Lazy documentationViewModel +) : IDocumentationNavigationService +{ + /// + public void OpenDocumentation(string? docsRelativePath = null, string? anchor = null) + { + // Queue the target before navigating: on the viewer's first load the nav tree would + // otherwise select its landing page after we navigate, discarding the request. + if (!string.IsNullOrWhiteSpace(docsRelativePath)) + { + documentationViewModel.Value.RequestPage(docsRelativePath, anchor); + } + + navigationService.NavigateTo(new BetterEntranceNavigationTransition()); + } +} diff --git a/StabilityMatrix.Avalonia/Services/IDocumentationNavigationService.cs b/StabilityMatrix.Avalonia/Services/IDocumentationNavigationService.cs new file mode 100644 index 000000000..b482a1223 --- /dev/null +++ b/StabilityMatrix.Avalonia/Services/IDocumentationNavigationService.cs @@ -0,0 +1,17 @@ +namespace StabilityMatrix.Avalonia.Services; + +/// +/// Opens the in-app documentation viewer, optionally at a specific page. +/// +public interface IDocumentationNavigationService +{ + /// + /// Navigates to the documentation viewer. + /// + /// + /// Page path relative to the docs root, e.g. advanced/environment-variables.md. + /// When null, the viewer opens on its landing page. + /// + /// Optional heading slug to scroll to within the page. + void OpenDocumentation(string? docsRelativePath = null, string? anchor = null); +} diff --git a/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml b/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml new file mode 100644 index 000000000..5d5aeaee6 --- /dev/null +++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Styles/ControlThemes/_index.axaml b/StabilityMatrix.Avalonia/Styles/ControlThemes/_index.axaml index 723dceaf9..170cad0f6 100644 --- a/StabilityMatrix.Avalonia/Styles/ControlThemes/_index.axaml +++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/_index.axaml @@ -4,6 +4,7 @@ x:CompileBindings="True"> + diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/PageViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/PageViewModelBase.cs index 3d1da920b..60b9b66c3 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/PageViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/PageViewModelBase.cs @@ -19,4 +19,13 @@ public abstract class PageViewModelBase : DisposableViewModelBase public abstract string Title { get; } public abstract IconSource IconSource { get; } + + /// + /// Documentation page this surface links to, as a + /// constant. A shell that hosts + /// pages renders this as a single help button in its header, so pages declare their own + /// help target instead of the shell guessing — and no surface ends up with two. + /// Null means this page has no contextual documentation. + /// + public virtual string? DocsPath => null; } diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs new file mode 100644 index 000000000..2cd6c6269 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs @@ -0,0 +1,14 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace StabilityMatrix.Avalonia.ViewModels.Documentation; + +/// +/// Base for nodes shown in the documentation navigation tree (sections and pages). +/// Exposes the expansion state consumed by the TreeView's TreeViewItem style binding. +/// +public abstract partial class DocumentationNavNode : ObservableObject +{ + /// Whether the corresponding TreeViewItem is expanded. + [ObservableProperty] + public partial bool IsExpanded { get; set; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs new file mode 100644 index 000000000..490f7e22c --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs @@ -0,0 +1,13 @@ +namespace StabilityMatrix.Avalonia.ViewModels.Documentation; + +/// +/// A single navigable documentation page entry (leaf) in the sidebar tree. +/// +public partial class DocumentationPageNavItem : DocumentationNavNode +{ + /// Display title, e.g. "Overview". + public required string Title { get; init; } + + /// Path relative to the docs root, e.g. getting-started/overview.md. + public required string Path { get; init; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs new file mode 100644 index 000000000..498445671 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace StabilityMatrix.Avalonia.ViewModels.Documentation; + +/// +/// A section grouping in the documentation sidebar (e.g. "Getting Started"). +/// +public partial class DocumentationSectionNavItem : DocumentationNavNode +{ + public DocumentationSectionNavItem() + { + // Sections are expanded by default. + IsExpanded = true; + } + + /// Section title. Empty for the root section (renders without a header). + public required string Title { get; init; } + + /// Whether this section has a visible header (i.e. is not the root section). + public bool HasHeader => !string.IsNullOrEmpty(Title); + + /// Pages within this section. + public required IReadOnlyList Pages { get; init; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs new file mode 100644 index 000000000..4e83fc9e7 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using AsyncAwaitBestPractices; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FluentAvalonia.UI.Controls; +using FluentIcons.Common; +using Injectio.Attributes; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Documentation; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models.Documentation; +using StabilityMatrix.Core.Processes; +using StabilityMatrix.Core.Services; +using Symbol = FluentIcons.Common.Symbol; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; + +namespace StabilityMatrix.Avalonia.ViewModels; + +[View(typeof(Views.DocumentationPage))] +[RegisterSingleton] +public partial class DocumentationViewModel : PageViewModelBase +{ + private const double MinZoom = 0.5; + private const double MaxZoom = 2.0; + private const double ZoomStep = 0.1; + + private readonly ILogger logger; + private readonly IDocumentationService documentationService; + private readonly ISettingsManager settingsManager; + + public override string Title => Resources.Label_Documentation; + + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.BookOpen, IconVariant = IconVariant.Filled }; + + public ObservableCollection Sections { get; } = []; + + /// + /// Flattened source for the navigation TreeView: root-level pages are hoisted to + /// top-level leaves and each non-root section is a parent node. Items are either + /// or . + /// + public ObservableCollection TreeItems { get; } = []; + + [ObservableProperty] + private DocumentationPageNavItem? selectedPage; + + /// Two-way bound to the nav TreeView's SelectedItem; routes page leaves to . + [ObservableProperty] + private object? selectedTreeItem; + + [ObservableProperty] + private string? currentMarkdown; + + /// Raw base URL of the currently displayed page's folder (for relative image resolution). + [ObservableProperty] + private string? currentImageBaseUrl; + + [ObservableProperty] + private bool isTreeLoading; + + [ObservableProperty] + private bool isPageLoading; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? errorMessage; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsContentVisible))] + private bool isDocsUnavailable; + + /// Content zoom factor for the markdown viewer (1.0 = 100%). Persisted in settings. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ZoomPercentText))] + public partial double ZoomFactor { get; set; } + + /// Current zoom level formatted for display, e.g. "100%". + public string ZoomPercentText => $"{Math.Round(ZoomFactor * 100)}%"; + + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); + + public bool IsContentVisible => !IsDocsUnavailable; + + /// Command bound to the markdown viewer to intercept hyperlink clicks. + public ICommand LinkClickedCommand { get; } + + /// + /// Raised when an in-page anchor should be scrolled to. The argument is the bare heading slug. + /// The view subscribes and forwards to the markdown viewer (keeps the VM free of control refs). + /// + public event EventHandler? AnchorRequested; + + private CancellationTokenSource? pageCts; + private bool hasLoadedTree; + private bool hasRegisteredSettingsRelays; + + /// Anchor slug to scroll to after the next page load completes (cross-page anchor links). + private string? pendingAnchor; + + /// + /// Page requested via before the nav tree was available. + /// applies it in place of the default landing page. + /// + private (string Path, string? Anchor)? pendingDeepLink; + + public DocumentationViewModel( + ILogger logger, + IDocumentationService documentationService, + ISettingsManager settingsManager + ) + { + this.logger = logger; + this.documentationService = documentationService; + this.settingsManager = settingsManager; + LinkClickedCommand = new RelayCommand(OnLinkClicked); + ZoomFactor = 1.0; + } + + public override async Task OnLoadedAsync() + { + await base.OnLoadedAsync(); + + if (!hasRegisteredSettingsRelays) + { + hasRegisteredSettingsRelays = true; + + // Load the persisted zoom level and keep it saved on change. + settingsManager.RelayPropertyFor( + this, + vm => vm.ZoomFactor, + settings => settings.DocumentationZoomFactor, + true + ); + } + + if (!hasLoadedTree) + { + await LoadTreeAsync(); + } + } + + /// + /// Opens a specific documentation page, for contextual help entry points such as a + /// ? button. Safe to call before this page has ever been shown: the request is + /// queued and applied once the nav tree loads, so it cannot be overwritten by the + /// default landing page. + /// + /// + /// Path relative to the docs root, e.g. advanced/environment-variables.md. + /// + /// Optional heading slug to scroll to. + public void RequestPage(string docsRelativePath, string? anchor = null) + { + if (string.IsNullOrWhiteSpace(docsRelativePath)) + return; + + if (hasLoadedTree) + { + NavigateToPath(docsRelativePath, anchor); + } + else + { + pendingDeepLink = (docsRelativePath, anchor); + } + } + + [RelayCommand] + private async Task RetryAsync() + { + await LoadTreeAsync(forceRefresh: true); + } + + [RelayCommand] + private void ZoomIn() + { + ZoomFactor = Math.Min(MaxZoom, Math.Round(ZoomFactor + ZoomStep, 2)); + } + + [RelayCommand] + private void ZoomOut() + { + ZoomFactor = Math.Max(MinZoom, Math.Round(ZoomFactor - ZoomStep, 2)); + } + + [RelayCommand] + private void ResetZoom() + { + ZoomFactor = 1.0; + } + + private async Task LoadTreeAsync(bool forceRefresh = false) + { + IsTreeLoading = true; + ErrorMessage = null; + IsDocsUnavailable = false; + + try + { + var sections = await documentationService.GetSectionsAsync(forceRefresh); + + Sections.Clear(); + TreeItems.Clear(); + foreach (var section in sections) + { + var navSection = new DocumentationSectionNavItem + { + Title = section.Title, + Pages = section + .Pages.Select(p => new DocumentationPageNavItem { Title = p.Title, Path = p.Path }) + .ToList(), + }; + Sections.Add(navSection); + + // Hoist the root (empty-title) section's pages to top-level tree leaves; + // real sections become parent nodes. + if (navSection.HasHeader) + { + TreeItems.Add(navSection); + } + else + { + foreach (var page in navSection.Pages) + TreeItems.Add(page); + } + } + + hasLoadedTree = true; + + // A queued deep link (contextual help button) wins over the default landing page. + if (pendingDeepLink is { } deepLink) + { + pendingDeepLink = null; + NavigateToPath(deepLink.Path, deepLink.Anchor); + } + else + { + // Select the first available page (docs README landing page comes first). + var firstPage = Sections.SelectMany(s => s.Pages).FirstOrDefault(); + if (firstPage is not null) + { + SelectedPage = firstPage; + } + } + } + catch (DocumentationNotAvailableException e) + { + logger.LogInformation(e, "Documentation is not available yet"); + IsDocsUnavailable = true; + Sections.Clear(); + TreeItems.Clear(); + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to load documentation tree"); + ErrorMessage = Resources.Error_DocumentationListingLoadFailed; + Sections.Clear(); + TreeItems.Clear(); + } + finally + { + IsTreeLoading = false; + } + } + + partial void OnSelectedPageChanged(DocumentationPageNavItem? oldValue, DocumentationPageNavItem? newValue) + { + // Keep the tree's visual selection in sync (e.g. when navigation comes from a link click). + SelectedTreeItem = newValue; + + if (newValue is null) + return; + + // Capture any anchor queued by a cross-page link so it can't race a later navigation. + var anchor = pendingAnchor; + pendingAnchor = null; + LoadPageAsync(newValue.Path, anchor).SafeFireAndForget(); + } + + partial void OnSelectedTreeItemChanged(object? value) + { + // Only page leaves load content; selecting a section node is a no-op. + if (value is DocumentationPageNavItem page) + SelectedPage = page; + } + + private async Task LoadPageAsync(string docsRelativePath, string? anchor = null) + { + // Replace the CTS before any await so rapid selections can't race on the old one, + // then cancel the superseded load. + var oldCts = pageCts; + var newCts = new CancellationTokenSource(); + pageCts = newCts; + var ct = newCts.Token; + + if (oldCts is not null) + { + await oldCts.CancelAsync(); + oldCts.Dispose(); + } + + IsPageLoading = true; + ErrorMessage = null; + + try + { + var markdown = await documentationService.GetPageMarkdownAsync( + docsRelativePath, + cancellationToken: ct + ); + ct.ThrowIfCancellationRequested(); + + // Fold code-span link text ([`Home`](url) -> [Home](url)) that the markdown + // renderer can't parse, then absolutize relative image URLs. + markdown = DocumentationPathResolver.RewriteCodeSpanLinks(markdown); + CurrentMarkdown = DocumentationPathResolver.RewriteImageUrls(docsRelativePath, markdown); + CurrentImageBaseUrl = GetPageFolderRawUrl(docsRelativePath); + + // The page content is now set; ask the view to scroll to the requested anchor. + // The viewer defers the actual measurement to a layout pass, so this can't race + // the (already-completed) page load. + if (!string.IsNullOrEmpty(anchor)) + AnchorRequested?.Invoke(this, anchor); + } + catch (OperationCanceledException) + { + // Superseded by a newer selection; ignore. + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to load documentation page {Path}", docsRelativePath); + ErrorMessage = Resources.Error_DocumentationPageLoadFailed; + CurrentMarkdown = null; + } + finally + { + IsPageLoading = false; + } + } + + private void OnLinkClicked(string? href) + { + if (string.IsNullOrWhiteSpace(href)) + return; + + var currentPath = SelectedPage?.Path ?? "README.md"; + var resolved = DocumentationPathResolver.ResolveLink(currentPath, href); + + switch (resolved.Kind) + { + case DocumentationPathResolver.LinkKind.External: + ProcessRunner.OpenUrl(resolved.Target); + break; + + case DocumentationPathResolver.LinkKind.InternalPage: + NavigateToPath(resolved.Target, resolved.Fragment); + break; + + case DocumentationPathResolver.LinkKind.Anchor: + // Same-page anchor: ask the view to scroll to the heading. + if (!string.IsNullOrEmpty(resolved.Target)) + AnchorRequested?.Invoke(this, resolved.Target); + break; + } + } + + private void NavigateToPath(string docsRelativePath, string? fragment = null) + { + var match = Sections + .SelectMany(s => s.Pages) + .FirstOrDefault(p => string.Equals(p.Path, docsRelativePath, StringComparison.OrdinalIgnoreCase)); + + // Already on the target page: SelectedPage won't re-fire, so handle the anchor directly. + if (match is not null && ReferenceEquals(match, SelectedPage)) + { + if (!string.IsNullOrEmpty(fragment)) + AnchorRequested?.Invoke(this, fragment); + return; + } + + pendingAnchor = fragment; + + if (match is not null) + { + SelectedPage = match; + } + else + { + // Not part of the discovered tree (e.g. a page not yet listed) — load it directly. + var anchor = pendingAnchor; + pendingAnchor = null; + LoadPageAsync(docsRelativePath, anchor).SafeFireAndForget(); + } + } + + private static string GetPageFolderRawUrl(string docsRelativePath) + { + var separatorIndex = docsRelativePath.LastIndexOf('/'); + var folder = separatorIndex < 0 ? string.Empty : docsRelativePath[..(separatorIndex + 1)]; + return DocumentationConstants.GetRawUrl($"{DocumentationConstants.DocsRoot}/{folder}"); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs index e317d78fb..3809cf340 100644 --- a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs @@ -8,6 +8,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Media.Animation; using MessagePipe; @@ -55,6 +56,7 @@ public partial class MainWindowViewModel : ViewModelBase private readonly ISecretsManager secretsManager; private readonly INavigationService navigationService; private readonly INavigationService settingsNavService; + private readonly IDocumentationNavigationService documentationNavigationService; private readonly IDistributedSubscriber showWindowSubscriber; public string Greeting => "Welcome to Avalonia!"; @@ -108,6 +110,7 @@ public MainWindowViewModel( ISecretsManager secretsManager, INavigationService navigationService, INavigationService settingsNavService, + IDocumentationNavigationService documentationNavigationService, IDistributedSubscriber showWindowSubscriber ) { @@ -124,6 +127,7 @@ IDistributedSubscriber showWindowSubscriber this.secretsManager = secretsManager; this.navigationService = navigationService; this.settingsNavService = settingsNavService; + this.documentationNavigationService = documentationNavigationService; this.showWindowSubscriber = showWindowSubscriber; ProgressManagerViewModel = dialogFactory.Get(); UpdateViewModel = dialogFactory.Get(); @@ -139,6 +143,16 @@ public override void OnLoaded() SelectedCategory ??= Pages.FirstOrDefault(); } + /// + /// Opens the documentation viewer. Bound to F1 as the app-wide help gesture, which is the + /// global entry point now that documentation is not a sidebar item. + /// + [RelayCommand] + private void OpenDocumentation() + { + documentationNavigationService.OpenDocumentation(); + } + protected override async Task OnInitialLoadedAsync() { await base.OnLoadedAsync(); diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/MainPackageManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/MainPackageManagerViewModel.cs index 8a0e1b034..49f28a0c5 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/MainPackageManagerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/MainPackageManagerViewModel.cs @@ -18,6 +18,7 @@ using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Documentation; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Services; @@ -46,6 +47,9 @@ public partial class MainPackageManagerViewModel : PageViewModelBase public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Box, IconVariant = IconVariant.Filled }; + /// + public override string? DocsPath => DocumentationPages.SupportedPackages; + /// /// List of installed packages /// diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs index e69614be0..890651013 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs @@ -25,6 +25,7 @@ using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Database; +using StabilityMatrix.Core.Models.Documentation; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Packages; @@ -52,6 +53,9 @@ IPyInstallationManager pyInstallationManager public override string Title { get; } = package.DisplayName; public override IconSource IconSource => new SymbolIconSource(); + /// + public override string? DocsPath => DocumentationPages.InstallingPackages; + public string FullInstallPath => Path.Combine(settingsManager.LibraryDir, "Packages", InstallName); public bool ShowReleaseMode => SelectedPackage.ShouldIgnoreReleases == false; public bool ShowBranchMode => SelectedPackage.ShouldIgnoreBranches == false; diff --git a/StabilityMatrix.Avalonia/ViewModels/RunningPackageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/RunningPackageViewModel.cs index a79c7e5cd..f336b6779 100644 --- a/StabilityMatrix.Avalonia/ViewModels/RunningPackageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/RunningPackageViewModel.cs @@ -10,6 +10,7 @@ using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Documentation; using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Services; @@ -33,6 +34,9 @@ public partial class RunningPackageViewModel : PageViewModelBase, IDisposable, I public override string Title => RunningPackage.InstalledPackage.DisplayName ?? "Running Package"; public override IconSource IconSource => new SymbolIconSource(); + /// + public override string? DocsPath => DocumentationPages.CommonIssues; + [ObservableProperty] private bool autoScrollToEnd; diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index 40d3aa65f..ef8d91fbb 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -87,6 +87,7 @@ public partial class MainSettingsViewModel : PageViewModelBase private readonly ITrackedDownloadService trackedDownloadService; private readonly IModelIndexService modelIndexService; private readonly INavigationService settingsNavigationService; + private readonly IDocumentationNavigationService documentationNavigationService; private readonly IAccountsService accountsService; private readonly ICivitBaseModelTypeService baseModelTypeService; @@ -234,6 +235,7 @@ public MainSettingsViewModel( ICompletionProvider completionProvider, IModelIndexService modelIndexService, INavigationService settingsNavigationService, + IDocumentationNavigationService documentationNavigationService, IAccountsService accountsService, ICivitBaseModelTypeService baseModelTypeService ) @@ -247,6 +249,7 @@ ICivitBaseModelTypeService baseModelTypeService this.completionProvider = completionProvider; this.modelIndexService = modelIndexService; this.settingsNavigationService = settingsNavigationService; + this.documentationNavigationService = documentationNavigationService; this.accountsService = accountsService; this.baseModelTypeService = baseModelTypeService; @@ -1725,6 +1728,12 @@ public void OnVersionClick() } } + [RelayCommand] + private void OpenDocumentation() + { + documentationNavigationService.OpenDocumentation(); + } + [RelayCommand] private async Task ShowLicensesDialog() { diff --git a/StabilityMatrix.Avalonia/Views/ConsoleOutputPage.axaml b/StabilityMatrix.Avalonia/Views/ConsoleOutputPage.axaml index a834de737..c2bc14a6a 100644 --- a/StabilityMatrix.Avalonia/Views/ConsoleOutputPage.axaml +++ b/StabilityMatrix.Avalonia/Views/ConsoleOutputPage.axaml @@ -6,6 +6,7 @@ xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit" xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:docs="clr-namespace:StabilityMatrix.Core.Models.Documentation;assembly=StabilityMatrix.Core" xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" diff --git a/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml new file mode 100644 index 000000000..4f2d50086 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml.cs b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml.cs new file mode 100644 index 000000000..a36541760 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml.cs @@ -0,0 +1,39 @@ +using System; +using Injectio.Attributes; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.ViewModels; + +namespace StabilityMatrix.Avalonia.Views; + +[RegisterSingleton] +public partial class DocumentationPage : UserControlBase +{ + private DocumentationViewModel? subscribedViewModel; + + public DocumentationPage() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (subscribedViewModel is not null) + { + subscribedViewModel.AnchorRequested -= OnAnchorRequested; + } + + subscribedViewModel = DataContext as DocumentationViewModel; + + if (subscribedViewModel is not null) + { + subscribedViewModel.AnchorRequested += OnAnchorRequested; + } + } + + private void OnAnchorRequested(object? sender, string anchor) + { + // Forward the anchor request to the markdown viewer (keeps the VM free of control refs). + MarkdownViewer?.ScrollToAnchor(anchor); + } +} diff --git a/StabilityMatrix.Avalonia/Views/InferencePage.axaml b/StabilityMatrix.Avalonia/Views/InferencePage.axaml index fbb20a6ae..8c43b66b5 100644 --- a/StabilityMatrix.Avalonia/Views/InferencePage.axaml +++ b/StabilityMatrix.Avalonia/Views/InferencePage.axaml @@ -6,6 +6,7 @@ xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:docs="clr-namespace:StabilityMatrix.Core.Models.Documentation;assembly=StabilityMatrix.Core" xmlns:fluentIcons="using:FluentIcons.Avalonia.Fluent" xmlns:icons="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia" xmlns:inference="clr-namespace:StabilityMatrix.Core.Models.Inference;assembly=StabilityMatrix.Core" @@ -185,7 +186,7 @@ + Value="fa-solid fa-rocket" /> @@ -224,6 +225,9 @@ + + +