From 5c997a67bbf1e2a0bd0f8d4efcadd66f941f7c15 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:42:12 +0100 Subject: [PATCH 01/17] WIP - pager --- .editorconfig | 1 + Module/TextMate.format.ps1xml | 19 ++ TextMate.slnx | 2 +- src/Cmdlets/TextMateCmdletBase.cs | 17 +- src/Core/HighlightedText.cs | 183 ++++++++++++- src/Utilities/Pager.cs | 439 ++++++++++++++++++++++++++++++ src/Utilities/VTHelpers.cs | 20 ++ 7 files changed, 664 insertions(+), 17 deletions(-) create mode 100644 src/Utilities/Pager.cs create mode 100644 src/Utilities/VTHelpers.cs diff --git a/.editorconfig b/.editorconfig index 048fa3f..b68e138 100644 --- a/.editorconfig +++ b/.editorconfig @@ -121,6 +121,7 @@ dotnet_diagnostic.IDE0029.severity = warning dotnet_diagnostic.IDE0030.severity = warning dotnet_diagnostic.IDE0270.severity = warning dotnet_diagnostic.IDE0019.severity = warning +dotnet_diagnostic.IDE0010.severity = none # Prefer var when the type is apparent (modern and concise) # how does this work with IDE0007? diff --git a/Module/TextMate.format.ps1xml b/Module/TextMate.format.ps1xml index bc839ef..a497d95 100644 --- a/Module/TextMate.format.ps1xml +++ b/Module/TextMate.format.ps1xml @@ -41,5 +41,24 @@ + diff --git a/TextMate.slnx b/TextMate.slnx index ef6d148..a88a2bd 100644 --- a/TextMate.slnx +++ b/TextMate.slnx @@ -1,3 +1,3 @@ - + diff --git a/src/Cmdlets/TextMateCmdletBase.cs b/src/Cmdlets/TextMateCmdletBase.cs index e051457..3fe770b 100644 --- a/src/Cmdlets/TextMateCmdletBase.cs +++ b/src/Cmdlets/TextMateCmdletBase.cs @@ -39,6 +39,15 @@ public abstract class TextMateCmdletBase : PSCmdlet { [Parameter] public SwitchParameter LineNumbers { get; set; } + /// + /// When present, wrap output in a Spectre Panel. + /// + [Parameter] + public SwitchParameter Panel { get; set; } + + // [Parameter] + // public SwitchParameter Page { get; set; } + /// /// Fixed language or extension token used for rendering. /// @@ -156,7 +165,9 @@ protected override void EndProcessing() { ? null : new HighlightedText { Renderables = renderables, - ShowLineNumbers = LineNumbers.IsPresent + ShowLineNumbers = LineNumbers.IsPresent, + Language = token, + WrapInPanel = Panel.IsPresent }; } @@ -180,7 +191,9 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { if (renderables is not null) { yield return new HighlightedText { Renderables = renderables, - ShowLineNumbers = LineNumbers.IsPresent + ShowLineNumbers = LineNumbers.IsPresent, + Language = token, + WrapInPanel = Panel.IsPresent }; } } diff --git a/src/Core/HighlightedText.cs b/src/Core/HighlightedText.cs index 0dc5c28..f05b454 100644 --- a/src/Core/HighlightedText.cs +++ b/src/Core/HighlightedText.cs @@ -2,6 +2,7 @@ using System.Linq; using Spectre.Console; using Spectre.Console.Rendering; +using PSTextMate.Utilities; namespace PSTextMate.Core; @@ -14,40 +15,92 @@ public sealed class HighlightedText : Renderable { /// /// The highlighted renderables ready for display. /// - public required IRenderable[] Renderables { get; init; } + public IRenderable[] Renderables { get; set; } = []; + + // Optional view into an external renderable sequence to avoid allocating + // new arrays when rendering paged slices. When _viewSource is non-null, + // rendering methods use the view (Skip/Take) rather than the `Renderables` array. + private IEnumerable? _viewSource; + private int _viewStart; + private int _viewCount; /// /// When true, prepend line numbers with a gutter separator. /// - public bool ShowLineNumbers { get; init; } + public bool ShowLineNumbers { get; set; } /// /// Starting line number for the gutter. /// - public int LineNumberStart { get; init; } = 1; + public int LineNumberStart { get; set; } = 1; /// /// Optional fixed width for the line number column. /// - public int? LineNumberWidth { get; init; } + public int? LineNumberWidth { get; set; } /// /// Separator inserted between the line number and content. /// - public string GutterSeparator { get; init; } = " │ "; + public string GutterSeparator { get; set; } = " │ "; /// /// Number of lines contained in this highlighted text. /// - public int LineCount => Renderables.Length; + public int LineCount => _viewSource is null ? Renderables.Length : _viewCount; + + /// + /// Configure this instance to render a view (slice) of an external renderable + /// sequence without allocating a new array. Call to + /// return to rendering the local array. + /// + public void SetView(IEnumerable source, int start, int count) { + _viewSource = source ?? throw new ArgumentNullException(nameof(source)); + _viewStart = Math.Max(0, start); + _viewCount = Math.Max(0, count); + } + + /// + /// Clears any active view so the instance renders its own array. + /// + public void ClearView() { + _viewSource = null; + _viewStart = 0; + _viewCount = 0; + } + + private IEnumerable GetRenderablesEnumerable() => + _viewSource is null ? Renderables : _viewSource.Skip(_viewStart).Take(_viewCount); + public string Language { get; set; } = string.Empty; + + /// + /// When true, consumers should render this highlighted text inside a Panel. + /// This is preserved across slices and allows the pager to respect panel state. + /// + public bool WrapInPanel { get; set; } /// /// Renders the highlighted text by combining all renderables into a single output. /// protected override IEnumerable Render(RenderOptions options, int maxWidth) { - // Delegate to Rows which efficiently renders all renderables - var rows = new Rows(Renderables); + // If a panel wrapper is requested, render the inner content via a dedicated IRenderable + // and let Spectre.Console's Panel handle borders/padding. + if (WrapInPanel) { + // Fast path: if we don't need line numbers, wrap the raw rows directly + // to avoid creating the InnerContentRenderable wrapper. + if (!ShowLineNumbers) { + var rowsInner = new Rows(GetRenderablesEnumerable()); + var panelInner = new Panel(rowsInner) { Padding = new Padding(0, 0) }; + return ((IRenderable)panelInner).Render(options, maxWidth); + } + var inner = new InnerContentRenderable(this); + var panel = new Panel(inner) { Padding = new Padding(0, 0) }; + return ((IRenderable)panel).Render(options, maxWidth); + } + + // Delegate to Rows which efficiently renders all renderables + var rows = new Rows(GetRenderablesEnumerable()); return !ShowLineNumbers ? ((IRenderable)rows).Render(options, maxWidth) : RenderWithLineNumbers(rows, options, maxWidth); } @@ -55,12 +108,46 @@ protected override IEnumerable Render(RenderOptions options, int maxWid /// Measures the dimensions of the highlighted text. /// protected override Measurement Measure(RenderOptions options, int maxWidth) { - // Delegate to Rows for measurement - var rows = new Rows(Renderables); + if (WrapInPanel) { + if (!ShowLineNumbers) { + var rowsInner = new Rows(GetRenderablesEnumerable()); + var panelInner = new Panel(rowsInner) { Padding = new Padding(0, 0) }; + return ((IRenderable)panelInner).Measure(options, maxWidth); + } + + var inner = new InnerContentRenderable(this); + var panel = new Panel(inner) { Padding = new Padding(0, 0) }; + return ((IRenderable)panel).Measure(options, maxWidth); + } + // Delegate to Rows for measurement + var rows = new Rows(GetRenderablesEnumerable()); return !ShowLineNumbers ? ((IRenderable)rows).Measure(options, maxWidth) : MeasureWithLineNumbers(rows, options, maxWidth); } + // Inner wrapper that presents the HighlightedText's content (with or without line numbers) + // as an IRenderable so it can be embedded in containers like Panel without recursion. + private sealed class InnerContentRenderable : IRenderable { + private readonly HighlightedText _parent; + public InnerContentRenderable(HighlightedText parent) { + _parent = parent; + } + + public IEnumerable Render(RenderOptions options, int maxWidth) { + var rows = new Rows(_parent.GetRenderablesEnumerable()); + return !_parent.ShowLineNumbers + ? ((IRenderable)rows).Render(options, maxWidth) + : _parent.RenderWithLineNumbers(rows, options, maxWidth); + } + + public Measurement Measure(RenderOptions options, int maxWidth) { + var rows = new Rows(_parent.GetRenderablesEnumerable()); + return !_parent.ShowLineNumbers + ? ((IRenderable)rows).Measure(options, maxWidth) + : _parent.MeasureWithLineNumbers(rows, options, maxWidth); + } + } + private IEnumerable RenderWithLineNumbers(Rows rows, RenderOptions options, int maxWidth) { (List segments, int width, int contentWidth) = RenderInnerSegments(rows, options, maxWidth); return PrefixLineNumbers(segments, options, width, contentWidth); @@ -94,7 +181,8 @@ private IEnumerable PrefixLineNumbers(List segments, RenderOpt foreach (List line in SplitLines(segments)) { string label = lineNumber.ToString(CultureInfo.InvariantCulture).PadLeft(width) + GutterSeparator; - foreach (Segment segment in ((IRenderable)new Text(label)).Render(options, contentWidth)) { + int gutterWidth = width + GutterSeparator.Length; + foreach (Segment segment in ((IRenderable)new Text(label)).Render(options, gutterWidth)) { yield return segment; } @@ -138,6 +226,38 @@ private static int CountLines(List segments) { return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; } + // Helper used by external callers to measure this instance's renderables by + // height (in rows) for a given width. Returns an array of heights aligned + // with the current `Renderables` array (or the underlying source when a + // view is active). + public int[] MeasureRenderables(int width) { + Capabilities caps = AnsiConsole.Console.Profile.Capabilities; + var size = new Size(width, Math.Max(1, Console.WindowHeight)); + var options = new RenderOptions(caps, size); + + IEnumerable source = _viewSource is null ? Renderables : _viewSource; + var list = new List(source.Count()); + + foreach (IRenderable? r in source) { + if (r is null) { + list.Add(0); + continue; + } + + try { + var segments = r.Render(options, width).ToList(); + int lines = CountLines(segments); + if (lines <= 0) lines = 1; + list.Add(lines); + } + catch { + list.Add(1); + } + } + + return [.. list]; + } + private int ResolveLineNumberWidth(int lineCount) { if (LineNumberWidth.HasValue && LineNumberWidth.Value > 0) { return LineNumberWidth.Value; @@ -154,7 +274,12 @@ private int ResolveLineNumberWidth(int lineCount) { /// Border style to use (default: Rounded) /// Panel containing the highlighted text public Panel ToPanel(string? title = null, BoxBorder? border = null) { - Panel panel = new(this); + // Build the panel around the actual inner content instead of `this` to avoid + // creating nested panels when consumers already wrap the object. + IRenderable content = !ShowLineNumbers ? new Rows(Renderables) : new InnerContentRenderable(this); + + var panel = new Panel(content); + panel.Padding(0, 0); if (!string.IsNullOrEmpty(title)) { panel.Header(title); @@ -170,8 +295,6 @@ public Panel ToPanel(string? title = null, BoxBorder? border = null) { return panel; } - // public override string ToString() => ToPanel(); - /// /// Wraps the highlighted text with padding. /// @@ -185,4 +308,36 @@ public Panel ToPanel(string? title = null, BoxBorder? border = null) { /// Padding size for all sides /// Padder containing the highlighted text public Padder WithPadding(int size) => new(this, new Padding(size)); + /// + /// Create a page-scoped HighlightedText that reuses this instance's settings + /// but contains only a slice of the underlying renderables. + /// + /// Zero-based start index into . + /// Number of renderables to include. + /// Optional stable gutter width to apply to the slice. + /// A new representing the requested slice. + public HighlightedText Slice(int start, int count, int? overrideLineNumberWidth = null) { + return new HighlightedText { + Renderables = [.. Renderables.Skip(start).Take(count)], + ShowLineNumbers = ShowLineNumbers, + LineNumberStart = LineNumberStart + start, + LineNumberWidth = overrideLineNumberWidth ?? LineNumberWidth, + GutterSeparator = GutterSeparator, + Language = Language, + WrapInPanel = WrapInPanel + }; + } + public void ShowPager() { + if (LineCount <= 0) return; + + using var pager = new Pager(this); + pager.Show(); + } + public IRenderable? AutoPage() { + if (LineCount > Console.WindowHeight - 2) { + ShowPager(); + return null; + } + return this; + } } diff --git a/src/Utilities/Pager.cs b/src/Utilities/Pager.cs new file mode 100644 index 0000000..840d4b7 --- /dev/null +++ b/src/Utilities/Pager.cs @@ -0,0 +1,439 @@ +using System.Globalization; +using Spectre.Console; +using Spectre.Console.Rendering; +using PSTextMate.Core; + +namespace PSTextMate.Utilities; + +/// +/// Simple pager implemented with Spectre.Console Live display. +/// Interaction keys: +/// - Up/Down: move one line +/// - PageUp/PageDown: move by page +/// - Home/End: go to start/end +/// - q or Escape: quit +/// +public sealed class Pager : IDisposable { + private readonly IReadOnlyList _renderables; + private readonly HighlightedText? _sourceHighlightedText; + private readonly int? _originalLineNumberStart; + private readonly int? _originalLineNumberWidth; + private readonly int? _stableLineNumberWidth; + private int _top; + private int WindowHeight; + private int WindowWidth; + private readonly object _lock = new(); + private int _lastRenderedRows; + // Cached measured heights (in rows) for each renderable at the current width + private List _renderableHeights = []; + private bool IsMarkdownSource() + => _sourceHighlightedText is not null + && _sourceHighlightedText.Language.Contains("markdown", StringComparison.OrdinalIgnoreCase); + + private bool PageContainsImages(int clampedTop, int pageHeight) { + if (_sourceHighlightedText is not null && !IsMarkdownSource()) { + return false; + } + + foreach (IRenderable? r in _renderables.Skip(clampedTop).Take(pageHeight)) { + if (r is null) continue; + string name = r.GetType().Name; + if (name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + // Compute the maximum valid _top index such that starting at that index there + // are at least `contentRows` rows available to render (based on + // `_renderableHeights`). Falls back to a simple count-based heuristic when + // heights are not known. + private int MaxTopForContentRows(int contentRows) { + if (contentRows <= 0) return 0; + if (_renderableHeights == null || _renderableHeights.Count == 0) { + return Math.Max(0, _renderables.Count - contentRows); + } + + int n = _renderableHeights.Count; + int[] suffix = new int[n]; + suffix[n - 1] = _renderableHeights[n - 1]; + for (int i = n - 2; i >= 0; i--) suffix[i] = suffix[i + 1] + _renderableHeights[i]; + + for (int i = n - 1; i >= 0; i--) { + if (suffix[i] >= contentRows) return i; + } + + return 0; + } + + public Pager(HighlightedText highlightedText) { + _sourceHighlightedText = highlightedText; + + int totalLines = highlightedText.LineCount; + int lastLineNumber = highlightedText.LineNumberStart + Math.Max(0, totalLines - 1); + _stableLineNumberWidth = highlightedText.LineNumberWidth ?? lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; + _originalLineNumberStart = highlightedText.LineNumberStart; + _originalLineNumberWidth = highlightedText.LineNumberWidth; + + // Reference the underlying renderable array directly to avoid copying. + _renderables = highlightedText.Renderables; + _top = 0; + } + + public Pager(IEnumerable renderables) { + var list = renderables?.ToList(); + _renderables = list is null ? [] : (IReadOnlyList)list; + _top = 0; + } + private void Navigate(LiveDisplayContext ctx) { + bool running = true; + (WindowWidth, WindowHeight) = GetPagerSize(); + bool forceRedraw = true; + + while (running) { + (int width, int pageHeight) = GetPagerSize(); + // Reserve last row for footer + int contentRows = Math.Max(1, pageHeight - 1); + + bool resized = width != WindowWidth || pageHeight != WindowHeight; + if (resized) { + AnsiConsole.Console.Profile.Width = width; + + // Detect shrink (large -> small). If terminal shrank, do a full clear+redraw + bool shrank = pageHeight < WindowHeight; + + WindowWidth = width; + WindowHeight = pageHeight; + + // Clamp current top to the new page size so content doesn't jump + int maxTopAfterResize = MaxTopForContentRows(contentRows); + _top = Math.Clamp(_top, 0, maxTopAfterResize); + + if (shrank) { + // Full clear then update reserved scroll region to match new height. + // ClearScreenAlt resets terminal state, so set the scroll region after clearing. + VTHelpers.ClearScreenAlt(); + VTHelpers.ResetScrollRegion(); + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + + // Immediate redraw via Spectre Live (already safe after ClearScreenAlt) + ctx.UpdateTarget(BuildRenderable(width, contentRows)); + ctx.Refresh(); + DrawFooter(width, contentRows); + _lastRenderedRows = contentRows; + forceRedraw = false; + // skip the later redraw block + continue; + } + // On grow or same-size width change, update reserved row and mark for redraw normally + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + forceRedraw = true; + } + + // Redraw if needed (initial, resize, or after navigation) + if (resized || forceRedraw) { + // Avoid a full clear here to reduce flicker; update in-place instead + VTHelpers.SetCursorPosition(1, 1); + + // Determine if this page contains image renderables that may emit raw sequences. + int maxTop = MaxTopForContentRows(contentRows); + int clampedTop = Math.Clamp(_top, 0, maxTop); + bool pageHasImages = PageContainsImages(clampedTop, contentRows); + + if (pageHasImages) { + // Full clear + reserve ensures the terminal is in a known state before image output + VTHelpers.ClearScreenAlt(); + VTHelpers.ResetScrollRegion(); + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + } + + // Recalculate per-renderable heights for current width so we can + // page by renderable boundaries (important for multi-row images). + if (_sourceHighlightedText is not null) { + _renderableHeights = [.. _sourceHighlightedText.MeasureRenderables(width)]; + } + else { + RecalculateRenderableHeights(width); + } + + // Update Spectre Live target (Spectre handles rendering and wrapping) + IRenderable target = BuildRenderable(width, contentRows); + ctx.UpdateTarget(target); + ctx.Refresh(); + + // Draw footer manually on reserved row + DrawFooter(width, contentRows); + + // Clear any previously-rendered lines that are now beyond contentRows. + if (_lastRenderedRows > contentRows) { + for (int r = contentRows + 1; r <= _lastRenderedRows; r++) { + VTHelpers.ClearRow(r); + } + } + + _lastRenderedRows = contentRows; + forceRedraw = false; + } + + // Wait for input, checking for resize while idle + if (!Console.KeyAvailable) { + Thread.Sleep(50); + continue; + } + + ConsoleKeyInfo key = Console.ReadKey(true); + lock (_lock) { + switch (key.Key) { + case ConsoleKey.DownArrow: + ScrollRenderable(1, contentRows); + forceRedraw = true; + break; + case ConsoleKey.UpArrow: + ScrollRenderable(-1, contentRows); + forceRedraw = true; + break; + case ConsoleKey.Spacebar: + case ConsoleKey.PageDown: + PageDown(contentRows); + forceRedraw = true; + break; + case ConsoleKey.PageUp: + PageUp(contentRows); + forceRedraw = true; + break; + case ConsoleKey.Home: + GoToTop(); + forceRedraw = true; + break; + case ConsoleKey.End: + GoToEnd(contentRows); + forceRedraw = true; + break; + case ConsoleKey.Q: + case ConsoleKey.Escape: + running = false; + break; + } + } + } + } + + private static (int width, int height) GetPagerSize() { + int width = Console.WindowWidth > 0 ? Console.WindowWidth : 80; + int height = Console.WindowHeight > 0 ? Console.WindowHeight : 40; + return (width, height); + } + private void Scroll(int delta, int pageHeight) { + int maxTop = Math.Max(0, _renderables.Count - pageHeight); + _top = Math.Clamp(_top + delta, 0, maxTop); + } + + // Scroll by renderable units. Uses _renderableHeights to advance by entries + // rather than single rows so multi-row images are not split. + private void ScrollRenderable(int delta, int contentRows) { + if (_renderables.Count == 0) return; + + int direction = Math.Sign(delta); + if (direction == 0) return; + + int candidate = _top + direction; + candidate = Math.Clamp(candidate, 0, _renderables.Count - 1); + + // Clamp candidate to the maximum valid top based on contentRows so we don't + // end up with a starting index that cannot produce a full page when using + // renderable heights. + int maxTop = MaxTopForContentRows(contentRows); + candidate = Math.Clamp(candidate, 0, Math.Max(0, maxTop)); + + _top = candidate; + } + + private void PageDown(int contentRows) { + if (_renderables.Count == 0) return; + + // Advance _top forward until we've skipped at least contentRows rows + int rowsSkipped = 0; + int idx = _top; + while (idx < _renderables.Count && rowsSkipped < contentRows) { + rowsSkipped += GetRenderableHeight(idx); + idx++; + } + _top = Math.Clamp(idx, 0, Math.Max(0, _renderables.Count - 1)); + } + + private void PageUp(int contentRows) { + if (_renderables.Count == 0) return; + + int rowsSkipped = 0; + int idx = _top - 1; + while (idx >= 0 && rowsSkipped < contentRows) { + rowsSkipped += GetRenderableHeight(idx); + idx--; + } + _top = Math.Clamp(idx + 1, 0, Math.Max(0, _renderables.Count - 1)); + } + + private int GetRenderableHeight(int index) + => index < 0 || index >= _renderableHeights.Count ? 1 : _renderableHeights[index]; + + private void RecalculateRenderableHeights(int width) { + _renderableHeights = new List(_renderables.Count); + Capabilities capabilities = AnsiConsole.Console.Profile.Capabilities; + var size = new Size(width, Math.Max(1, Console.WindowHeight)); + var options = new RenderOptions(capabilities, size); + + // To avoid side-effects (e.g. sixel/pixel images) during off-screen + // measurement, only render a limited window around the current view + // and skip obvious image-like renderables. Everything else is given a + // conservative default of 1 row when not measured. + int count = _renderables.Count; + int window = Math.Max(1, WindowHeight > 0 ? WindowHeight : Console.WindowHeight); + int measureStart = Math.Max(0, _top - window); + int measureEnd = Math.Min(count, _top + window * 2); // lookahead a couple pages + + for (int i = 0; i < count; i++) { + IRenderable? r = _renderables[i]; + if (r is null) { + _renderableHeights.Add(0); + continue; + } + + // Only attempt to fully measure renderables in the nearby window. + if (i < measureStart || i >= measureEnd) { + _renderableHeights.Add(1); + continue; + } + + string name = r.GetType().Name; + if (name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Image", StringComparison.OrdinalIgnoreCase)) { + // Skip measuring image-like renderables to avoid side-effects. + _renderableHeights.Add(1); + continue; + } + + try { + var segments = r.Render(options, width).ToList(); + int lines = CountLinesSegments(segments); + if (lines <= 0) lines = 1; + _renderableHeights.Add(lines); + } + catch { + // Fallback: assume single-line if rendering for measurement fails + _renderableHeights.Add(1); + } + } + } + + private static int CountLinesSegments(List segments) { + if (segments.Count == 0) { + return 0; + } + + int lineBreaks = segments.Count(segment => segment.IsLineBreak); + return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; + } + private void GoToTop() => _top = 0; + + private void GoToEnd(int pageHeight) + => _top = Math.Max(0, _renderables.Count - pageHeight); + + // Accepts dynamic width and pageHeight; footer is drawn outside Live target + private IRenderable BuildRenderable(int width, int pageHeight) { + int maxTop = Math.Max(0, _renderables.Count - pageHeight); + int clampedTop = Math.Clamp(_top, 0, maxTop); + int end = Math.Min(clampedTop + pageHeight, _renderables.Count); + var pageRenderables = _renderables.Skip(clampedTop).Take(end - clampedTop).ToList(); + + if (_sourceHighlightedText is not null) { + // Configure the provided HighlightedText instance to view the current + // page of underlying renderables. Do not allocate a new HighlightedText + // — mutate the view on the source instance and let its Render/Measure + // handle panel wrapping and line-numbering. + _sourceHighlightedText.SetView(_renderables, clampedTop, end - clampedTop); + _sourceHighlightedText.LineNumberStart = (_originalLineNumberStart ?? 1) + clampedTop; + _sourceHighlightedText.LineNumberWidth = _stableLineNumberWidth; + + return _sourceHighlightedText; + } + + // Avoid allocating a new list/array for the page; use a deferred enumerable. + return new Rows(_renderables.Skip(clampedTop).Take(end - clampedTop)); + } + + private void DrawFooter(int width, int contentRows) { + int maxTop = Math.Max(0, _renderables.Count - contentRows); + int clampedTop = Math.Clamp(_top, 0, maxTop); + int end = Math.Min(clampedTop + contentRows, _renderables.Count); + int total = _renderables.Count; + int pos = Math.Min(clampedTop + 1, Math.Max(0, total)); + + string keys = "Up/Down: ↑↓ PgUp/PgDn: PgUp/PgDn/Spacebar Home/End: Home/End q/Esc: Quit"; + string status = $" {pos}-{end}/{total} "; + int remaining = Math.Max(0, width - keys.Length - status.Length - 2); + string spacer = new(' ', remaining); + string line = keys + spacer + status; + if (line.Length > width) line = line[..width]; + + // Write footer directly to reserved row (contentRows + 1) + int footerRow = contentRows + 1; + VTHelpers.SetCursorPosition(footerRow, 1); + Console.Write(line.PadRight(width)); + } + + public void Show() { + // Switch to alternate screen buffer + VTHelpers.EnterAlternateBuffer(); + VTHelpers.HideCursor(); + // Console.CursorVisible = false; + try { + (int width, int pageHeight) = GetPagerSize(); + int contentRows = Math.Max(1, pageHeight - 1); + + // Start with a clean screen then reserve the last row as a non-scrolling footer region + // VTHelpers.ClearScreen(); + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + + // Initial target for Spectre Live (footer is drawn manually) + AnsiConsole.Console.Profile.Width = width; + IRenderable initial = BuildRenderable(width, contentRows); + _lastRenderedRows = contentRows; + + // If the initial page contains images, clear+reserve to ensure safe image rendering + int initialMaxTop = Math.Max(0, _renderables.Count - contentRows); + int initialClamped = Math.Clamp(_top, 0, initialMaxTop); + if (PageContainsImages(initialClamped, contentRows)) { + VTHelpers.ClearScreenAlt(); + VTHelpers.ResetScrollRegion(); + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + } + + AnsiConsole.Live(initial).Start(ctx => { + // Draw footer once before entering the interactive loop + DrawFooter(width, contentRows); + // Enter interactive loop using the live display context + Navigate(ctx); + }); + } + finally { + // Clear any active view on the source highlighted text to avoid + // leaving its state mutated after the pager exits, and restore + // original line-number settings. + if (_sourceHighlightedText is not null) { + _sourceHighlightedText.ClearView(); + _sourceHighlightedText.LineNumberStart = _originalLineNumberStart ?? 1; + _sourceHighlightedText.LineNumberWidth = _originalLineNumberWidth; + } + // Reset scroll region and restore normal screen buffer + VTHelpers.ResetScrollRegion(); + VTHelpers.ShowCursor(); + VTHelpers.ExitAlternateBuffer(); + // Console.CursorVisible = true; + } + } + + public void Dispose() { + // No resources to dispose, but required for IDisposable + } +} diff --git a/src/Utilities/VTHelpers.cs b/src/Utilities/VTHelpers.cs new file mode 100644 index 0000000..2b375a9 --- /dev/null +++ b/src/Utilities/VTHelpers.cs @@ -0,0 +1,20 @@ +using PSTextMate.Core; + +namespace PSTextMate.Utilities; + +public static class VTHelpers { + public static void EnterAlternateBuffer() => Console.Write("\x1b[?1049h"); + public static void ExitAlternateBuffer() => Console.Write("\x1b[?1049l"); + public static void HideCursor() => Console.Write("\x1b[?25l"); + public static void ShowCursor() => Console.Write("\x1b[?25h"); + public static void ClearScreen() => Console.Write("\x1b[2J\x1b[H"); + public static void ClearScreenAlt() => Console.Write("\x1bc"); + public static void ClearRow(int row) => Console.Write($"\x1b[{row};1H\x1b[2K"); + public static void SetCursorPosition(int row, int column) => Console.Write($"\x1b[{row};{column}H"); + public static void CursorHome() => Console.Write("\x1b[H"); + // Set the vertical scroll region from line 1 to `height` (DECSTBM) + public static void ReserveRow(int height) => Console.Write($"\x1b[1;{height}r"); + // Reset scroll region to full height (CSI r) + public static void ResetScrollRegion() => Console.Write("\x1b[r"); + +} From c12d7d2d9199574902bae313224d74edefae21fe Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:22:05 +0100 Subject: [PATCH 02/17] ALC --- .editorconfig | 1 + .gitignore | 1 + Module/TextMate.format.ps1xml | 6 +- Module/TextMate.psd1 | 15 +- Module/TextMate.psm1 | 44 ++ TextMate.build.ps1 | 21 +- TextMate.slnx | 3 +- build.ps1 | 2 + src/PSTextMate.ALC/LoadContext.cs | 146 +++++ src/PSTextMate.ALC/PSTextMate.ALC.csproj | 15 + src/{ => PSTextMate}/Cmdlets/FormatCSharp.cs | 0 .../Cmdlets/FormatMarkdown.cs | 0 .../Cmdlets/FormatPowershell.cs | 0 .../Cmdlets/FormatTextMateCmdlet.cs | 0 .../Cmdlets/GetTextMateGrammar.cs | 0 src/{ => PSTextMate}/Cmdlets/TestTextMate.cs | 0 .../Cmdlets/TextMateCmdletBase.cs | 0 src/{ => PSTextMate}/Core/CacheManager.cs | 0 src/{ => PSTextMate}/Core/HighlightedText.cs | 85 ++- .../Core/MarkdigTextMateScopeMapper.cs | 0 src/{ => PSTextMate}/Core/MarkdownRenderer.cs | 0 src/{ => PSTextMate}/Core/MarkdownToken.cs | 0 src/{ => PSTextMate}/Core/StandardRenderer.cs | 0 src/{ => PSTextMate}/Core/StyleHelper.cs | 0 .../Core/TextMateProcessor.cs | 0 src/{ => PSTextMate}/Core/TokenProcessor.cs | 8 +- .../PSTextMate.csproj} | 4 +- .../Rendering/BlockRenderer.cs | 0 .../Rendering/CodeBlockRenderer.cs | 0 .../Rendering/HeadingRenderer.cs | 0 .../Rendering/HorizontalRuleRenderer.cs | 0 .../Rendering/HtmlBlockRenderer.cs | 0 .../Rendering/ImageBlockRenderer.cs | 0 .../Rendering/ImageRenderer.cs | 6 +- .../Rendering/ListRenderer.cs | 4 +- .../Rendering/MarkdownRenderer.cs | 0 .../Rendering/ParagraphRenderer.cs | 21 +- .../Rendering/QuoteRenderer.cs | 0 .../Rendering/TableRenderer.cs | 9 +- .../Utilities/AssemblyInfo.cs | 0 src/{ => PSTextMate}/Utilities/Completers.cs | 0 src/{ => PSTextMate}/Utilities/Helpers.cs | 0 .../Utilities/ITextMateStyler.cs | 0 src/{ => PSTextMate}/Utilities/ImageFile.cs | 0 .../Utilities/InlineTextExtractor.cs | 0 .../Utilities/MarkdownPatterns.cs | 0 src/PSTextMate/Utilities/Pager.cs | 563 ++++++++++++++++++ src/PSTextMate/Utilities/PagerNormal.cs | 239 ++++++++ .../Utilities/SpectreRenderBridge.cs | 40 ++ .../Utilities/SpectreStyleCompat.cs | 61 ++ .../Utilities/SpectreTextMateStyler.cs | 0 .../Utilities/StringBuilderExtensions.cs | 6 +- .../Utilities/StringBuilderPool.cs | 0 .../Utilities/StringExtensions.cs | 0 src/PSTextMate/Utilities/TMConsole.cs | 96 +++ .../Utilities/TextMateResolver.cs | 0 .../Utilities/ThemeExtensions.cs | 0 .../Utilities/TokenStyleProcessor.cs | 0 .../Utilities/VTConversion.cs | 5 +- src/{ => PSTextMate}/Utilities/VTHelpers.cs | 2 +- src/PSTextMate/Utilities/Writer.cs | 99 +++ src/Utilities/Pager.cs | 439 -------------- tests/testhelper.psm1 | 23 +- 63 files changed, 1441 insertions(+), 523 deletions(-) create mode 100644 Module/TextMate.psm1 create mode 100644 src/PSTextMate.ALC/LoadContext.cs create mode 100644 src/PSTextMate.ALC/PSTextMate.ALC.csproj rename src/{ => PSTextMate}/Cmdlets/FormatCSharp.cs (100%) rename src/{ => PSTextMate}/Cmdlets/FormatMarkdown.cs (100%) rename src/{ => PSTextMate}/Cmdlets/FormatPowershell.cs (100%) rename src/{ => PSTextMate}/Cmdlets/FormatTextMateCmdlet.cs (100%) rename src/{ => PSTextMate}/Cmdlets/GetTextMateGrammar.cs (100%) rename src/{ => PSTextMate}/Cmdlets/TestTextMate.cs (100%) rename src/{ => PSTextMate}/Cmdlets/TextMateCmdletBase.cs (100%) rename src/{ => PSTextMate}/Core/CacheManager.cs (100%) rename src/{ => PSTextMate}/Core/HighlightedText.cs (77%) rename src/{ => PSTextMate}/Core/MarkdigTextMateScopeMapper.cs (100%) rename src/{ => PSTextMate}/Core/MarkdownRenderer.cs (100%) rename src/{ => PSTextMate}/Core/MarkdownToken.cs (100%) rename src/{ => PSTextMate}/Core/StandardRenderer.cs (100%) rename src/{ => PSTextMate}/Core/StyleHelper.cs (100%) rename src/{ => PSTextMate}/Core/TextMateProcessor.cs (100%) rename src/{ => PSTextMate}/Core/TokenProcessor.cs (94%) rename src/{TextMate.csproj => PSTextMate/PSTextMate.csproj} (90%) rename src/{ => PSTextMate}/Rendering/BlockRenderer.cs (100%) rename src/{ => PSTextMate}/Rendering/CodeBlockRenderer.cs (100%) rename src/{ => PSTextMate}/Rendering/HeadingRenderer.cs (100%) rename src/{ => PSTextMate}/Rendering/HorizontalRuleRenderer.cs (100%) rename src/{ => PSTextMate}/Rendering/HtmlBlockRenderer.cs (100%) rename src/{ => PSTextMate}/Rendering/ImageBlockRenderer.cs (100%) rename src/{ => PSTextMate}/Rendering/ImageRenderer.cs (96%) rename src/{ => PSTextMate}/Rendering/ListRenderer.cs (95%) rename src/{ => PSTextMate}/Rendering/MarkdownRenderer.cs (100%) rename src/{ => PSTextMate}/Rendering/ParagraphRenderer.cs (88%) rename src/{ => PSTextMate}/Rendering/QuoteRenderer.cs (100%) rename src/{ => PSTextMate}/Rendering/TableRenderer.cs (94%) rename src/{ => PSTextMate}/Utilities/AssemblyInfo.cs (100%) rename src/{ => PSTextMate}/Utilities/Completers.cs (100%) rename src/{ => PSTextMate}/Utilities/Helpers.cs (100%) rename src/{ => PSTextMate}/Utilities/ITextMateStyler.cs (100%) rename src/{ => PSTextMate}/Utilities/ImageFile.cs (100%) rename src/{ => PSTextMate}/Utilities/InlineTextExtractor.cs (100%) rename src/{ => PSTextMate}/Utilities/MarkdownPatterns.cs (100%) create mode 100644 src/PSTextMate/Utilities/Pager.cs create mode 100644 src/PSTextMate/Utilities/PagerNormal.cs create mode 100644 src/PSTextMate/Utilities/SpectreRenderBridge.cs create mode 100644 src/PSTextMate/Utilities/SpectreStyleCompat.cs rename src/{ => PSTextMate}/Utilities/SpectreTextMateStyler.cs (100%) rename src/{ => PSTextMate}/Utilities/StringBuilderExtensions.cs (93%) rename src/{ => PSTextMate}/Utilities/StringBuilderPool.cs (100%) rename src/{ => PSTextMate}/Utilities/StringExtensions.cs (100%) create mode 100644 src/PSTextMate/Utilities/TMConsole.cs rename src/{ => PSTextMate}/Utilities/TextMateResolver.cs (100%) rename src/{ => PSTextMate}/Utilities/ThemeExtensions.cs (100%) rename src/{ => PSTextMate}/Utilities/TokenStyleProcessor.cs (100%) rename src/{ => PSTextMate}/Utilities/VTConversion.cs (96%) rename src/{ => PSTextMate}/Utilities/VTHelpers.cs (91%) create mode 100644 src/PSTextMate/Utilities/Writer.cs delete mode 100644 src/Utilities/Pager.cs diff --git a/.editorconfig b/.editorconfig index b68e138..e009c0a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -157,6 +157,7 @@ csharp_style_unused_value_expression_statement_preference = unused_local_variabl # Nullable reference types - enabled as suggestions; project opt-in controls runtime enforcement nullable = enable csharp_style_prefer_primary_constructors = false +dotnet_diagnostic.IDE0072.severity = none # Formatting / newline preferences # prefer Stroustrup diff --git a/.gitignore b/.gitignore index ec061ea..76b6eba 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ debug.md ref/** Copilot-Processing.md tools/** +src/Utilities/TMConsole.cs diff --git a/Module/TextMate.format.ps1xml b/Module/TextMate.format.ps1xml index a497d95..3892dab 100644 --- a/Module/TextMate.format.ps1xml +++ b/Module/TextMate.format.ps1xml @@ -41,7 +41,7 @@ - + diff --git a/Module/TextMate.psd1 b/Module/TextMate.psd1 index 7dd35b3..890aa52 100644 --- a/Module/TextMate.psd1 +++ b/Module/TextMate.psd1 @@ -1,5 +1,5 @@ @{ - RootModule = 'lib/PSTextMate.dll' + RootModule = 'TextMate.psm1' ModuleVersion = '0.1.0' GUID = 'fe78d2cb-2418-4308-9309-a0850e504cd6' Author = 'trackd' @@ -24,13 +24,12 @@ 'Show-TextMate' ) FormatsToProcess = 'TextMate.format.ps1xml' - RequiredModules = @( - @{ - ModuleName = 'PwshSpectreConsole' - ModuleVersion = '2.3.0' - MaximumVersion = '2.99.99' - } - ) + RequiredModules = @() + # @{ + # ModuleName = 'PwshSpectreConsole' + # ModuleVersion = '2.5.0' + # MaximumVersion = '2.99.99' + # } PrivateData = @{ PSData = @{ Tags = 'Windows', 'Linux', 'OSX', 'TextMate', 'Markdown', 'SyntaxHighlighting' diff --git a/Module/TextMate.psm1 b/Module/TextMate.psm1 new file mode 100644 index 0000000..0e2c93e --- /dev/null +++ b/Module/TextMate.psm1 @@ -0,0 +1,44 @@ +using namespace System.IO +using namespace System.Management.Automation +using namespace System.Reflection + +$importModule = Get-Command -Name Import-Module -Module Microsoft.PowerShell.Core +$isReload = $true +$alcAssemblyPath = [Path]::Combine($PSScriptRoot, 'lib', 'PSTextMate.ALC.dll') + +if (-not (Test-Path -Path $alcAssemblyPath -PathType Leaf)) { + throw "Could not find required ALC assembly at '$alcAssemblyPath'." +} + +if (-not ('PSTextMate.ALC.LoadContext' -as [type])) { + $isReload = $false + Add-Type -Path $alcAssemblyPath +} +else { + $loadedAlcAssemblyPath = [PSTextMate.ALC.LoadContext].Assembly.Location + if ([Path]::GetFullPath($loadedAlcAssemblyPath) -ne [Path]::GetFullPath($alcAssemblyPath)) { + throw "PSTextMate.ALC.LoadContext is already loaded from '$loadedAlcAssemblyPath'. Restart PowerShell to load this module from '$alcAssemblyPath'." + } +} + +$mainModule = [PSTextMate.ALC.LoadContext]::Initialize() +$innerMod = &$importModule -Assembly $mainModule -PassThru + + +if ($isReload) { + # https://github.com/PowerShell/PowerShell/issues/20710 + $addExportedCmdlet = [PSModuleInfo].GetMethod( + 'AddExportedCmdlet', + [BindingFlags]'Instance, NonPublic' + ) + $addExportedAlias = [PSModuleInfo].GetMethod( + 'AddExportedAlias', + [BindingFlags]'Instance, NonPublic' + ) + foreach ($cmd in $innerMod.ExportedCmdlets.Values) { + $addExportedCmdlet.Invoke($ExecutionContext.SessionState.Module, @(, $cmd)) + } + foreach ($alias in $innerMod.ExportedAliases.Values) { + $addExportedAlias.Invoke($ExecutionContext.SessionState.Module, @(, $alias)) + } +} diff --git a/TextMate.build.ps1 b/TextMate.build.ps1 index 401fb60..552b720 100644 --- a/TextMate.build.ps1 +++ b/TextMate.build.ps1 @@ -5,21 +5,22 @@ param( [switch]$SkipHelp, [switch]$SkipTests ) + Write-Host "$($PSBoundParameters.GetEnumerator())" -ForegroundColor Cyan $modulename = [System.IO.Path]::GetFileName($PSCommandPath) -replace '\.build\.ps1$' +# $modulename = 'PSTextMate' $script:folders = @{ ModuleName = $modulename ProjectRoot = $PSScriptRoot TempLib = Join-Path $PSScriptRoot 'templib' - SourcePath = Join-Path $PSScriptRoot 'src' OutputPath = Join-Path $PSScriptRoot 'output' DestinationPath = Join-Path $PSScriptRoot 'output' 'lib' ModuleSourcePath = Join-Path $PSScriptRoot 'module' DocsPath = Join-Path $PSScriptRoot 'docs' 'en-US' TestPath = Join-Path $PSScriptRoot 'tests' - CsprojPath = Join-Path $PSScriptRoot 'src' "$modulename.csproj" + CsprojPath = Join-Path $PSScriptRoot 'src' 'PSTextMate' 'PSTextMate.csproj' } task Clean { @@ -35,7 +36,9 @@ task Build { Write-Warning 'C# project not found, skipping Build' return } - exec { dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.TempLib } + exec { + dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.TempLib + } $null = New-Item -Path $folders.outputPath -ItemType Directory -Force $rids = @('win-x64', 'osx-arm64', 'linux-x64','linux-arm64','win-arm64') foreach ($rid in $rids) { @@ -77,12 +80,6 @@ task GenerateHelp -if (-not $SkipHelp) { return } - if (-Not (Get-Module PwshSpectreConsole -ListAvailable)) { - # just temporarily while im refactoring the PwshSpectreConsole module. - $ParentPath = Split-Path $folders.ProjectRoot -Parent - Import-Module (Join-Path $ParentPath 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') - } - Import-Module $modulePath -Force $helpOutputPath = Join-Path $folders.OutputPath 'en-US' @@ -110,12 +107,6 @@ task Test -if (-not $SkipTests) { return } - if (-not (Get-Module PwshSpectreConsole -ListAvailable)) { - # just temporarily while im refactoring the PwshSpectreConsole module. - $ParentPath = Split-Path $folders.ProjectRoot -Parent - Import-Module (Join-Path $ParentPath 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') - } - Import-Module (Join-Path $folders.OutputPath ($folders.ModuleName + '.psd1')) -ErrorAction Stop Import-Module (Join-Path $folders.TestPath 'testhelper.psm1') -ErrorAction Stop diff --git a/TextMate.slnx b/TextMate.slnx index a88a2bd..934a824 100644 --- a/TextMate.slnx +++ b/TextMate.slnx @@ -1,3 +1,4 @@ - + + diff --git a/build.ps1 b/build.ps1 index 0572d59..871d70b 100644 --- a/build.ps1 +++ b/build.ps1 @@ -9,6 +9,8 @@ param( ) $ErrorActionPreference = 'Stop' + + # Helper function to get paths $buildparams = @{ Configuration = $Configuration diff --git a/src/PSTextMate.ALC/LoadContext.cs b/src/PSTextMate.ALC/LoadContext.cs new file mode 100644 index 0000000..5da4c9b --- /dev/null +++ b/src/PSTextMate.ALC/LoadContext.cs @@ -0,0 +1,146 @@ +#if NET5_0_OR_GREATER +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Loader; + +namespace PSTextMate.ALC; + +/// +/// Custom AssemblyLoadContext for isolating and resolving assemblies in .NET 5.0 or greater environments. +/// +public class LoadContext : AssemblyLoadContext { + private static LoadContext? _instance; + private static readonly object _sync = new(); + + private readonly Assembly _thisAssembly; + private readonly AssemblyName _thisAssemblyName; + private readonly Assembly _moduleAssembly; + private readonly string _assemblyDir; + private readonly string[] _nativeProbeDirs; + + private LoadContext(string mainModulePathAssemblyPath) + : base(name: "PSTextMate", isCollectible: false) { + _assemblyDir = Path.GetDirectoryName(mainModulePathAssemblyPath) ?? ""; + _thisAssembly = typeof(LoadContext).Assembly; + _thisAssemblyName = _thisAssembly.GetName(); + _moduleAssembly = LoadFromAssemblyPath(mainModulePathAssemblyPath); + _nativeProbeDirs = BuildNativeProbeDirs(_assemblyDir); + } + + protected override Assembly? Load(AssemblyName assemblyName) { + if (AssemblyName.ReferenceMatchesDefinition(_thisAssemblyName, assemblyName)) { + return _thisAssembly; + } + + foreach (Assembly loadedAssembly in AppDomain.CurrentDomain.GetAssemblies()) { + if (!AssemblyName.ReferenceMatchesDefinition(loadedAssembly.GetName(), assemblyName)) { + continue; + } + + AssemblyLoadContext? loadContext = GetLoadContext(loadedAssembly); + if (ReferenceEquals(loadContext, Default)) { + return loadedAssembly; + } + } + + string asmPath = Path.Join(_assemblyDir, $"{assemblyName.Name}.dll"); + return File.Exists(asmPath) ? LoadFromAssemblyPath(asmPath) : null; + } + + protected override nint LoadUnmanagedDll(string unmanagedDllName) { + foreach (string candidateName in GetNativeLibraryFileNames(unmanagedDllName)) { + foreach (string probeDir in _nativeProbeDirs) { + string candidatePath = Path.Combine(probeDir, candidateName); + if (!File.Exists(candidatePath)) { + continue; + } + + return LoadUnmanagedDllFromPath(candidatePath); + } + } + + return IntPtr.Zero; + } + + private static string[] GetNativeLibraryFileNames(string unmanagedDllName) { + return Path.HasExtension(unmanagedDllName) + ? [unmanagedDllName] + : RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? [$"{unmanagedDllName}.dll"] + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? [$"lib{unmanagedDllName}.dylib", $"{unmanagedDllName}.dylib"] + : [$"lib{unmanagedDllName}.so", $"{unmanagedDllName}.so"]; + } + + private static string[] BuildNativeProbeDirs(string assemblyDir) { + List dirs = [assemblyDir]; + + foreach (string ridDir in GetPreferredRidDirectories()) { + string candidate = Path.Combine(assemblyDir, ridDir); + if (Directory.Exists(candidate)) { + dirs.Add(candidate); + } + } + + return [.. dirs]; + } + + private static string[] GetPreferredRidDirectories() { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? RuntimeInformation.ProcessArchitecture switch { + Architecture.X64 => ["win-x64"], + Architecture.Arm64 => ["win-arm64", "win-x64"], + Architecture.X86 => ["win-x86", "win-x64"], + Architecture.Arm => ["win-arm"], + _ => ["win-x64"] + } + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? RuntimeInformation.ProcessArchitecture switch { + Architecture.Arm64 => ["osx-arm64"], + Architecture.X64 => ["osx-x64", "osx-arm64"], + _ => ["osx-arm64"] + } + : RuntimeInformation.ProcessArchitecture switch { + Architecture.Arm64 => ["linux-arm64", "linux-x64"], + Architecture.X64 => ["linux-x64", "linux-arm64"], + _ => ["linux-x64"] + }; + } + + public static Assembly Initialize() { + LoadContext? instance = _instance; + if (instance is not null) { + return instance._moduleAssembly; + } + + lock (_sync) { + if (_instance is not null) { + return _instance._moduleAssembly; + } + + string assemblyPath = typeof(LoadContext).Assembly.Location; + string assemblyDir = Path.GetDirectoryName(assemblyPath) + ?? throw new InvalidOperationException("Unable to determine PSTextMate.ALC assembly directory."); + string assemblyName = Path.GetFileNameWithoutExtension(assemblyPath); + + const string AlcSuffix = ".ALC"; + if (!assemblyName.EndsWith(AlcSuffix, StringComparison.Ordinal)) { + throw new InvalidOperationException($"Unexpected ALC assembly name '{assemblyName}'."); + } + + string moduleName = assemblyName[..^AlcSuffix.Length]; + string modulePath = Path.Combine(assemblyDir, $"{moduleName}.dll"); + if (!File.Exists(modulePath)) { + throw new FileNotFoundException($"Could not load file or assembly '{modulePath}'. The system cannot find the file specified.", modulePath); + } + + _instance = new LoadContext(modulePath); + return _instance._moduleAssembly; + } + } +} +#endif diff --git a/src/PSTextMate.ALC/PSTextMate.ALC.csproj b/src/PSTextMate.ALC/PSTextMate.ALC.csproj new file mode 100644 index 0000000..268f73b --- /dev/null +++ b/src/PSTextMate.ALC/PSTextMate.ALC.csproj @@ -0,0 +1,15 @@ + + + PSTextMate.ALC + net8.0 + true + latest + enable + + + + + + + + diff --git a/src/Cmdlets/FormatCSharp.cs b/src/PSTextMate/Cmdlets/FormatCSharp.cs similarity index 100% rename from src/Cmdlets/FormatCSharp.cs rename to src/PSTextMate/Cmdlets/FormatCSharp.cs diff --git a/src/Cmdlets/FormatMarkdown.cs b/src/PSTextMate/Cmdlets/FormatMarkdown.cs similarity index 100% rename from src/Cmdlets/FormatMarkdown.cs rename to src/PSTextMate/Cmdlets/FormatMarkdown.cs diff --git a/src/Cmdlets/FormatPowershell.cs b/src/PSTextMate/Cmdlets/FormatPowershell.cs similarity index 100% rename from src/Cmdlets/FormatPowershell.cs rename to src/PSTextMate/Cmdlets/FormatPowershell.cs diff --git a/src/Cmdlets/FormatTextMateCmdlet.cs b/src/PSTextMate/Cmdlets/FormatTextMateCmdlet.cs similarity index 100% rename from src/Cmdlets/FormatTextMateCmdlet.cs rename to src/PSTextMate/Cmdlets/FormatTextMateCmdlet.cs diff --git a/src/Cmdlets/GetTextMateGrammar.cs b/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs similarity index 100% rename from src/Cmdlets/GetTextMateGrammar.cs rename to src/PSTextMate/Cmdlets/GetTextMateGrammar.cs diff --git a/src/Cmdlets/TestTextMate.cs b/src/PSTextMate/Cmdlets/TestTextMate.cs similarity index 100% rename from src/Cmdlets/TestTextMate.cs rename to src/PSTextMate/Cmdlets/TestTextMate.cs diff --git a/src/Cmdlets/TextMateCmdletBase.cs b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs similarity index 100% rename from src/Cmdlets/TextMateCmdletBase.cs rename to src/PSTextMate/Cmdlets/TextMateCmdletBase.cs diff --git a/src/Core/CacheManager.cs b/src/PSTextMate/Core/CacheManager.cs similarity index 100% rename from src/Core/CacheManager.cs rename to src/PSTextMate/Core/CacheManager.cs diff --git a/src/Core/HighlightedText.cs b/src/PSTextMate/Core/HighlightedText.cs similarity index 77% rename from src/Core/HighlightedText.cs rename to src/PSTextMate/Core/HighlightedText.cs index f05b454..95f8369 100644 --- a/src/Core/HighlightedText.cs +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -23,6 +23,10 @@ public sealed class HighlightedText : Renderable { private IEnumerable? _viewSource; private int _viewStart; private int _viewCount; + // When a view is active, keep the total document line count when available + // so line-number gutter width can be computed against the full document + // (prevents gutter from changing across pages). + private int _documentLineCount = -1; /// /// When true, prepend line numbers with a gutter separator. @@ -58,6 +62,12 @@ public void SetView(IEnumerable source, int start, int count) { _viewSource = source ?? throw new ArgumentNullException(nameof(source)); _viewStart = Math.Max(0, start); _viewCount = Math.Max(0, count); + // Try to capture the full source count when possible (ICollection/IReadOnlyCollection/IList) + _documentLineCount = source is ICollection coll + ? coll.Count + : source is IReadOnlyCollection rocoll + ? rocoll.Count + : source is System.Collections.ICollection nonGeneric ? nonGeneric.Count : -1; } /// @@ -67,6 +77,7 @@ public void ClearView() { _viewSource = null; _viewStart = 0; _viewCount = 0; + _documentLineCount = -1; } private IEnumerable GetRenderablesEnumerable() => @@ -167,7 +178,10 @@ private Measurement MeasureWithLineNumbers(Rows rows, RenderOptions options, int int actualLineCount = CountLines(segments); int actualWidth = ResolveLineNumberWidth(actualLineCount); - if (actualWidth != width) { + // If we have a document-wide line count available or an explicit + // LineNumberWidth, prefer that value and avoid reflowing based on + // measured content, which would make the gutter change size. + if (!LineNumberWidth.HasValue && _documentLineCount <= 0 && actualWidth != width) { width = actualWidth; contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); segments = [.. ((IRenderable)rows).Render(options, contentWidth)]; @@ -245,10 +259,11 @@ public int[] MeasureRenderables(int width) { } try { - var segments = r.Render(options, width).ToList(); - int lines = CountLines(segments); - if (lines <= 0) lines = 1; - list.Add(lines); + // Prefer Measure() to avoid rendering side-effects (images, sixels). + Measurement m = r.Measure(options, width); + int maxWidth = Math.Max(1, m.Max); + int estimatedLines = maxWidth <= width ? 1 : (int)Math.Ceiling((double)maxWidth / width); + list.Add(Math.Max(1, estimatedLines)); } catch { list.Add(1); @@ -258,12 +273,47 @@ public int[] MeasureRenderables(int width) { return [.. list]; } + /// + /// Measure each renderable and return the full Measurement for each item. + /// This is similar to but preserves the + /// Measurement (min/max) which callers can use to compute both width and + /// estimated height. + /// + public Measurement[] MeasureRenderablesFull(int width) { + Capabilities caps = AnsiConsole.Console.Profile.Capabilities; + var size = new Size(width, Math.Max(1, Console.WindowHeight)); + var options = new RenderOptions(caps, size); + + IEnumerable source = _viewSource is null ? Renderables : _viewSource; + var list = new List(source.Count()); + + foreach (IRenderable? r in source) { + if (r is null) { + list.Add(new Measurement(1, 1)); + continue; + } + + try { + Measurement m = r.Measure(options, width); + list.Add(m); + } + catch { + list.Add(new Measurement(1, 1)); + } + } + + return [.. list]; + } + private int ResolveLineNumberWidth(int lineCount) { if (LineNumberWidth.HasValue && LineNumberWidth.Value > 0) { return LineNumberWidth.Value; } - int lastLineNumber = LineNumberStart + Math.Max(0, lineCount - 1); + // Prefer computing width based on the total document line count when + // available so the gutter remains stable across paged views. + int effectiveTotal = _documentLineCount > 0 ? _documentLineCount : lineCount; + int lastLineNumber = LineNumberStart + Math.Max(0, effectiveTotal - 1); return lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; } @@ -327,17 +377,34 @@ public HighlightedText Slice(int start, int count, int? overrideLineNumberWidth WrapInPanel = WrapInPanel }; } - public void ShowPager() { + + public void ShowAlternateBufferPager() { if (LineCount <= 0) return; using var pager = new Pager(this); pager.Show(); } - public IRenderable? AutoPage() { + public void ShowPager() { + if (LineCount <= 0) return; + + using var pager = new Pager(this); + pager.Show(useAlternateBuffer: true); + } + public IRenderable? AutoPage(bool alternate = true) { if (LineCount > Console.WindowHeight - 2) { - ShowPager(); + if (alternate) ShowAlternateBufferPager(); + else ShowPager(); return null; } return this; } + + /// + /// Renders this highlighted text to a string. + /// + public string Write(bool autoPage = true, bool alternatePager = true) + => Writer.Write(this, autoPage, alternatePager); + + public override string ToString() + => Writer.WriteToString(this, customItemFormatter: true); } diff --git a/src/Core/MarkdigTextMateScopeMapper.cs b/src/PSTextMate/Core/MarkdigTextMateScopeMapper.cs similarity index 100% rename from src/Core/MarkdigTextMateScopeMapper.cs rename to src/PSTextMate/Core/MarkdigTextMateScopeMapper.cs diff --git a/src/Core/MarkdownRenderer.cs b/src/PSTextMate/Core/MarkdownRenderer.cs similarity index 100% rename from src/Core/MarkdownRenderer.cs rename to src/PSTextMate/Core/MarkdownRenderer.cs diff --git a/src/Core/MarkdownToken.cs b/src/PSTextMate/Core/MarkdownToken.cs similarity index 100% rename from src/Core/MarkdownToken.cs rename to src/PSTextMate/Core/MarkdownToken.cs diff --git a/src/Core/StandardRenderer.cs b/src/PSTextMate/Core/StandardRenderer.cs similarity index 100% rename from src/Core/StandardRenderer.cs rename to src/PSTextMate/Core/StandardRenderer.cs diff --git a/src/Core/StyleHelper.cs b/src/PSTextMate/Core/StyleHelper.cs similarity index 100% rename from src/Core/StyleHelper.cs rename to src/PSTextMate/Core/StyleHelper.cs diff --git a/src/Core/TextMateProcessor.cs b/src/PSTextMate/Core/TextMateProcessor.cs similarity index 100% rename from src/Core/TextMateProcessor.cs rename to src/PSTextMate/Core/TextMateProcessor.cs diff --git a/src/Core/TokenProcessor.cs b/src/PSTextMate/Core/TokenProcessor.cs similarity index 94% rename from src/Core/TokenProcessor.cs rename to src/PSTextMate/Core/TokenProcessor.cs index 8427a00..3f2f4fb 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/PSTextMate/Core/TokenProcessor.cs @@ -66,7 +66,7 @@ public static (string processedText, Style? style) WriteTokenReturn( // If the style serializes to an empty markup string, treat it as no style // to avoid emitting empty [] tags which Spectre.Markup rejects. - string styleMarkup = styleHint.ToMarkup(); + string styleMarkup = SpectreStyleCompat.ToMarkup(styleHint); if (string.IsNullOrEmpty(styleMarkup)) { return (processedText, null); } @@ -88,7 +88,7 @@ public static void WriteToken( // Fast-path: if no escaping needed, append span directly with style-aware overload if (!escapeMarkup) { if (style is not null) { - string styleMarkup = style.ToMarkup(); + string styleMarkup = SpectreStyleCompat.ToMarkup(style); if (!string.IsNullOrEmpty(styleMarkup)) { builder.Append('[').Append(styleMarkup).Append(']').Append(text).Append("[/]").AppendLine(); } @@ -114,7 +114,7 @@ public static void WriteToken( if (!needsEscape) { // Safe fast-path: append span directly if (style is not null) { - string styleMarkup = style.ToMarkup(); + string styleMarkup = SpectreStyleCompat.ToMarkup(style); if (!string.IsNullOrEmpty(styleMarkup)) { builder.Append('[').Append(styleMarkup).Append(']').Append(text).Append("[/]").AppendLine(); } @@ -131,7 +131,7 @@ public static void WriteToken( // Slow path: fallback to the reliable Markup.Escape for correctness when special characters are present string escaped = Markup.Escape(text.ToString()); if (style is not null) { - string styleMarkup = style.ToMarkup(); + string styleMarkup = SpectreStyleCompat.ToMarkup(style); if (!string.IsNullOrEmpty(styleMarkup)) { builder.Append('[').Append(styleMarkup).Append(']').Append(escaped).Append("[/]").AppendLine(); } diff --git a/src/TextMate.csproj b/src/PSTextMate/PSTextMate.csproj similarity index 90% rename from src/TextMate.csproj rename to src/PSTextMate/PSTextMate.csproj index 72b0e53..af7068e 100644 --- a/src/TextMate.csproj +++ b/src/PSTextMate/PSTextMate.csproj @@ -14,11 +14,13 @@ - + + + diff --git a/src/Rendering/BlockRenderer.cs b/src/PSTextMate/Rendering/BlockRenderer.cs similarity index 100% rename from src/Rendering/BlockRenderer.cs rename to src/PSTextMate/Rendering/BlockRenderer.cs diff --git a/src/Rendering/CodeBlockRenderer.cs b/src/PSTextMate/Rendering/CodeBlockRenderer.cs similarity index 100% rename from src/Rendering/CodeBlockRenderer.cs rename to src/PSTextMate/Rendering/CodeBlockRenderer.cs diff --git a/src/Rendering/HeadingRenderer.cs b/src/PSTextMate/Rendering/HeadingRenderer.cs similarity index 100% rename from src/Rendering/HeadingRenderer.cs rename to src/PSTextMate/Rendering/HeadingRenderer.cs diff --git a/src/Rendering/HorizontalRuleRenderer.cs b/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs similarity index 100% rename from src/Rendering/HorizontalRuleRenderer.cs rename to src/PSTextMate/Rendering/HorizontalRuleRenderer.cs diff --git a/src/Rendering/HtmlBlockRenderer.cs b/src/PSTextMate/Rendering/HtmlBlockRenderer.cs similarity index 100% rename from src/Rendering/HtmlBlockRenderer.cs rename to src/PSTextMate/Rendering/HtmlBlockRenderer.cs diff --git a/src/Rendering/ImageBlockRenderer.cs b/src/PSTextMate/Rendering/ImageBlockRenderer.cs similarity index 100% rename from src/Rendering/ImageBlockRenderer.cs rename to src/PSTextMate/Rendering/ImageBlockRenderer.cs diff --git a/src/Rendering/ImageRenderer.cs b/src/PSTextMate/Rendering/ImageRenderer.cs similarity index 96% rename from src/Rendering/ImageRenderer.cs rename to src/PSTextMate/Rendering/ImageRenderer.cs index c73d65f..acf1e79 100644 --- a/src/Rendering/ImageRenderer.cs +++ b/src/PSTextMate/Rendering/ImageRenderer.cs @@ -325,7 +325,7 @@ private static bool TryCreateSpectreSixelImage(string imagePath, int? maxWidth, /// A markup string representing the image as a link private static Text CreateImageFallback(string altText, string imageUrl) { string linkText = $"🖼️ Image: {altText}"; - var style = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + Style style = SpectreStyleCompat.CreateWithLink(Color.Blue, null, Decoration.Underline, imageUrl); return new Text(linkText, style); } @@ -343,7 +343,7 @@ private static IRenderable CreateEnhancedImageFallback(string altText, string im // Build a text-based content with clickable link style string display = $"🖼️ {altText}{sizeText}"; - var linkStyle = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + Style linkStyle = SpectreStyleCompat.CreateWithLink(Color.Blue, null, Decoration.Underline, imageUrl); var text = new Text(display, linkStyle); return new Panel(text) .Header("Image (Sixel not available)") @@ -363,7 +363,7 @@ private static IRenderable CreateEnhancedImageFallback(string altText, string im /// A markup string representing the image as a link private static Text CreateImageFallbackInline(string altText, string imageUrl) { string display = $"🖼️ {altText}"; - var style = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + Style style = SpectreStyleCompat.CreateWithLink(Color.Blue, null, Decoration.Underline, imageUrl); return new Text(display, style); } diff --git a/src/Rendering/ListRenderer.cs b/src/PSTextMate/Rendering/ListRenderer.cs similarity index 95% rename from src/Rendering/ListRenderer.cs rename to src/PSTextMate/Rendering/ListRenderer.cs index 98a6d9c..0b70526 100644 --- a/src/Rendering/ListRenderer.cs +++ b/src/PSTextMate/Rendering/ListRenderer.cs @@ -128,8 +128,8 @@ private static void AppendInlineContent(Paragraph paragraph, ContainerInline? in if (string.IsNullOrEmpty(linkText)) { linkText = link.Url ?? ""; } - var linkStyle = new Style(Color.Blue, null, Decoration.Underline, link.Url); - paragraph.Append(linkText, linkStyle); + Style linkStyle = SpectreStyleCompat.Create(Color.Blue, null, Decoration.Underline); + SpectreStyleCompat.Append(paragraph, linkText, linkStyle, link.Url); break; case LineBreakInline: diff --git a/src/Rendering/MarkdownRenderer.cs b/src/PSTextMate/Rendering/MarkdownRenderer.cs similarity index 100% rename from src/Rendering/MarkdownRenderer.cs rename to src/PSTextMate/Rendering/MarkdownRenderer.cs diff --git a/src/Rendering/ParagraphRenderer.cs b/src/PSTextMate/Rendering/ParagraphRenderer.cs similarity index 88% rename from src/Rendering/ParagraphRenderer.cs rename to src/PSTextMate/Rendering/ParagraphRenderer.cs index 2892d70..a95667f 100644 --- a/src/Rendering/ParagraphRenderer.cs +++ b/src/PSTextMate/Rendering/ParagraphRenderer.cs @@ -72,8 +72,9 @@ private static void BuildTextSegments(List segments, ContainerInlin if (TryParseUsernameLinks(literalText, out TextSegment[]? usernameSegments)) { foreach (TextSegment segment in usernameSegments) { if (segment.IsUsername) { - var usernameStyle = new Style(Color.Blue, null, Decoration.Underline, $"https://github.com/{segment.Text.TrimStart('@')}"); - paragraph.Append(segment.Text, usernameStyle); + string usernameUrl = $"https://github.com/{segment.Text.TrimStart('@')}"; + Style usernameStyle = SpectreStyleCompat.Create(Color.Blue, null, Decoration.Underline); + SpectreStyleCompat.Append(paragraph, segment.Text, usernameStyle, usernameUrl); addedAny = true; } else { @@ -109,8 +110,8 @@ private static void BuildTextSegments(List segments, ContainerInlin string linkText = ExtractInlineText(linkInline); if (string.IsNullOrEmpty(linkText)) linkText = linkInline.Url ?? ""; Style baseLink = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); - var combined = new Style(baseLink.Foreground, baseLink.Background, baseLink.Decoration | decoration | Decoration.Underline, linkInline.Url); - paragraph.Append(linkText, combined); + Style combined = SpectreStyleCompat.Create(baseLink.Foreground, baseLink.Background, baseLink.Decoration | decoration | Decoration.Underline); + SpectreStyleCompat.Append(paragraph, linkText, combined, linkInline.Url); addedAny = true; break; default: @@ -181,8 +182,8 @@ private static void ProcessLinkAsText(Paragraph paragraph, LinkInline link, Them string altText = ExtractInlineText(link); if (string.IsNullOrEmpty(altText)) altText = "Image"; string imageLinkText = $"🖼️ {altText}"; - var style = new Style(Color.Blue, null, Decoration.Underline, link.Url); - paragraph.Append(imageLinkText, style); + Style style = SpectreStyleCompat.Create(Color.Blue, null, Decoration.Underline); + SpectreStyleCompat.Append(paragraph, imageLinkText, style, link.Url); return; } @@ -191,8 +192,8 @@ private static void ProcessLinkAsText(Paragraph paragraph, LinkInline link, Them Style linkStyle = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); // Create new style with link parameter - var styledLink = new Style(linkStyle.Foreground, linkStyle.Background, linkStyle.Decoration | Decoration.Underline, link.Url); - paragraph.Append(linkText, styledLink); + Style styledLink = SpectreStyleCompat.Create(linkStyle.Foreground, linkStyle.Background, linkStyle.Decoration | Decoration.Underline); + SpectreStyleCompat.Append(paragraph, linkText, styledLink, link.Url); } /// @@ -203,8 +204,8 @@ private static void ProcessAutoLinkAsText(Paragraph paragraph, AutolinkInline au if (string.IsNullOrEmpty(url)) return; Style linkStyle = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); - var styledLink = new Style(linkStyle.Foreground, linkStyle.Background, linkStyle.Decoration | Decoration.Underline, url); - paragraph.Append(url, styledLink); + Style styledLink = SpectreStyleCompat.Create(linkStyle.Foreground, linkStyle.Background, linkStyle.Decoration | Decoration.Underline); + SpectreStyleCompat.Append(paragraph, url, styledLink, url); } /// diff --git a/src/Rendering/QuoteRenderer.cs b/src/PSTextMate/Rendering/QuoteRenderer.cs similarity index 100% rename from src/Rendering/QuoteRenderer.cs rename to src/PSTextMate/Rendering/QuoteRenderer.cs diff --git a/src/Rendering/TableRenderer.cs b/src/PSTextMate/Rendering/TableRenderer.cs similarity index 94% rename from src/Rendering/TableRenderer.cs rename to src/PSTextMate/Rendering/TableRenderer.cs index 6f39bca..6feae9e 100644 --- a/src/Rendering/TableRenderer.cs +++ b/src/PSTextMate/Rendering/TableRenderer.cs @@ -8,6 +8,7 @@ using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; +using TCell = Markdig.Extensions.Tables.TableCell; namespace PSTextMate.Rendering; @@ -110,7 +111,7 @@ internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment var cells = new List(); for (int i = 0; i < row.Count; i++) { - if (row[i] is TableCell cell) { + if (row[i] is TCell cell) { string cellText = ExtractCellText(cell, theme); TableColumnAlign? alignment = i < table.ColumnDefinitions.Count ? table.ColumnDefinitions[i].Alignment : null; cells.Add(new TableCellContent(cellText, alignment)); @@ -126,7 +127,7 @@ internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment /// /// Extracts text from table cells using optimized inline processing. /// - private static string ExtractCellText(TableCell cell, Theme theme) { + private static string ExtractCellText(TCell cell, Theme theme) { StringBuilder textBuilder = StringBuilderPool.Rent(); foreach (Block block in cell) { @@ -185,7 +186,7 @@ private static void ExtractInlineText(ContainerInline inlines, StringBuilder bui private static Style GetTableBorderStyle(Theme theme) { string[] borderScopes = ["punctuation.definition.table"]; Style? style = TokenProcessor.GetStyleForScopes(borderScopes, theme); - return style is not null ? style : new Style(foreground: Color.Grey); + return style ?? new Style(foreground: Color.Grey); } /// @@ -196,7 +197,7 @@ private static Style GetHeaderStyle(Theme theme) { Style? baseStyle = TokenProcessor.GetStyleForScopes(headerScopes, theme); Color fgColor = baseStyle?.Foreground ?? Color.Yellow; Color? bgColor = baseStyle?.Background; - Decoration decoration = (baseStyle is not null ? baseStyle.Decoration : Decoration.None) | Decoration.Bold; + Decoration decoration = (baseStyle?.Decoration ?? Decoration.None) | Decoration.Bold; return new Style(fgColor, bgColor, decoration); } diff --git a/src/Utilities/AssemblyInfo.cs b/src/PSTextMate/Utilities/AssemblyInfo.cs similarity index 100% rename from src/Utilities/AssemblyInfo.cs rename to src/PSTextMate/Utilities/AssemblyInfo.cs diff --git a/src/Utilities/Completers.cs b/src/PSTextMate/Utilities/Completers.cs similarity index 100% rename from src/Utilities/Completers.cs rename to src/PSTextMate/Utilities/Completers.cs diff --git a/src/Utilities/Helpers.cs b/src/PSTextMate/Utilities/Helpers.cs similarity index 100% rename from src/Utilities/Helpers.cs rename to src/PSTextMate/Utilities/Helpers.cs diff --git a/src/Utilities/ITextMateStyler.cs b/src/PSTextMate/Utilities/ITextMateStyler.cs similarity index 100% rename from src/Utilities/ITextMateStyler.cs rename to src/PSTextMate/Utilities/ITextMateStyler.cs diff --git a/src/Utilities/ImageFile.cs b/src/PSTextMate/Utilities/ImageFile.cs similarity index 100% rename from src/Utilities/ImageFile.cs rename to src/PSTextMate/Utilities/ImageFile.cs diff --git a/src/Utilities/InlineTextExtractor.cs b/src/PSTextMate/Utilities/InlineTextExtractor.cs similarity index 100% rename from src/Utilities/InlineTextExtractor.cs rename to src/PSTextMate/Utilities/InlineTextExtractor.cs diff --git a/src/Utilities/MarkdownPatterns.cs b/src/PSTextMate/Utilities/MarkdownPatterns.cs similarity index 100% rename from src/Utilities/MarkdownPatterns.cs rename to src/PSTextMate/Utilities/MarkdownPatterns.cs diff --git a/src/PSTextMate/Utilities/Pager.cs b/src/PSTextMate/Utilities/Pager.cs new file mode 100644 index 0000000..b89c173 --- /dev/null +++ b/src/PSTextMate/Utilities/Pager.cs @@ -0,0 +1,563 @@ +using System.Globalization; +using Spectre.Console; +using Spectre.Console.Rendering; +using PSTextMate.Core; +using System.Reflection; + +namespace PSTextMate.Utilities; + +/// +/// Simple pager implemented with Spectre.Console Live display. +/// Interaction keys: +/// - Up/Down: move one renderable item +/// - PageUp/PageDown: move by one viewport of items +/// - Home/End: go to start/end +/// - q or Escape: quit +/// +public sealed class Pager : IDisposable { + private readonly IReadOnlyList _renderables; + private readonly HighlightedText? _sourceHighlightedText; + private readonly int? _originalLineNumberStart; + private readonly int? _originalLineNumberWidth; + private readonly int? _stableLineNumberWidth; + private int _top; + private int WindowHeight; + private int WindowWidth; + private readonly object _lock = new(); + private int _lastRenderedRows; + private List _renderableHeights = []; + private bool _lastPageHadImages; + + private readonly record struct ViewportWindow(int Top, int Count, int EndExclusive, bool HasImages); + + private bool ContainsImageRenderables() => _renderables.Any(IsImageRenderable); + + private static int? GetIntPropertyValue(object instance, string propertyName) { + PropertyInfo? property = instance.GetType().GetProperty(propertyName); + if (property is null || !property.CanRead) { + return null; + } + + object? value = property.GetValue(instance); + return value is int i ? i : null; + } + + private static double GetTerminalCellAspectRatio() { + try { + var compatibility = Type.GetType("PwshSpectreConsole.Terminal.Compatibility, PwshSpectreConsole"); + MethodInfo? getCellSize = compatibility?.GetMethod("GetCellSize", Type.EmptyTypes); + object? cellSize = getCellSize?.Invoke(null, null); + if (cellSize is null) { + return 0.5d; + } + + PropertyInfo? pixelWidthProperty = cellSize.GetType().GetProperty("PixelWidth"); + PropertyInfo? pixelHeightProperty = cellSize.GetType().GetProperty("PixelHeight"); + int pixelWidth = (int?)pixelWidthProperty?.GetValue(cellSize) ?? 0; + int pixelHeight = (int?)pixelHeightProperty?.GetValue(cellSize) ?? 0; + return pixelWidth <= 0 || pixelHeight <= 0 ? 0.5d : (double)pixelWidth / pixelHeight; + } + catch { + return 0.5d; + } + } + + private static int EstimateImageHeight(IRenderable renderable, int width, int contentRows, RenderOptions options) { + // If an explicit max height exists, it is the strongest signal. + int? explicitMaxHeight = GetIntPropertyValue(renderable, "MaxHeight"); + if (explicitMaxHeight.HasValue && explicitMaxHeight.Value > 0) { + return Math.Clamp(explicitMaxHeight.Value, 1, contentRows); + } + + int? imagePixelWidth = GetIntPropertyValue(renderable, "Width"); + int? imagePixelHeight = GetIntPropertyValue(renderable, "Height"); + + Measurement measure; + try { + measure = renderable.Measure(options, width); + } + catch { + return Math.Clamp(contentRows, 1, contentRows); + } + + int cellWidth = Math.Max(1, Math.Min(width, measure.Max)); + if (imagePixelWidth.HasValue && imagePixelWidth.Value > 0 && imagePixelHeight.HasValue && imagePixelHeight.Value > 0) { + double imageAspect = (double)imagePixelHeight.Value / imagePixelWidth.Value; + double cellAspectRatio = GetTerminalCellAspectRatio(); + int estimatedRows = (int)Math.Ceiling(imageAspect * cellWidth * cellAspectRatio); + return Math.Clamp(Math.Max(1, estimatedRows), 1, contentRows); + } + + // Last fallback: keep as atomic item, but estimate from measured width. + return Math.Clamp(Math.Max(1, (int)Math.Ceiling((double)measure.Max / width)), 1, contentRows); + } + + private bool IsMarkdownSource() + => _sourceHighlightedText is not null + && _sourceHighlightedText.Language.Contains("markdown", StringComparison.OrdinalIgnoreCase); + + private bool IsImageRenderable(IRenderable? renderable) { + if (renderable is null) { + return false; + } + + if (_sourceHighlightedText is not null && !IsMarkdownSource()) { + return false; + } + + string name = renderable.GetType().Name; + return name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Image", StringComparison.OrdinalIgnoreCase); + } + + private ViewportWindow BuildViewport(int proposedTop, int contentRows) { + if (_renderables.Count == 0) { + return new ViewportWindow(0, 0, 0, false); + } + + int clampedTop = Math.Clamp(proposedTop, 0, _renderables.Count - 1); + int rowsUsed = 0; + int count = 0; + bool hasImages = false; + + for (int i = clampedTop; i < _renderables.Count; i++) { + bool isImage = IsImageRenderable(_renderables[i]); + int height = Math.Clamp(GetRenderableHeight(i), 1, contentRows); + + if (count > 0 && rowsUsed + height > contentRows) { + break; + } + + rowsUsed += height; + count++; + hasImages |= isImage; + + if (rowsUsed >= contentRows) { + break; + } + } + + if (count == 0) { + count = 1; + hasImages = IsImageRenderable(_renderables[clampedTop]); + } + + return new ViewportWindow(clampedTop, count, clampedTop + count, hasImages); + } + + public Pager(HighlightedText highlightedText) { + _sourceHighlightedText = highlightedText; + + int totalLines = highlightedText.LineCount; + int lastLineNumber = highlightedText.LineNumberStart + Math.Max(0, totalLines - 1); + _stableLineNumberWidth = highlightedText.LineNumberWidth ?? lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; + _originalLineNumberStart = highlightedText.LineNumberStart; + _originalLineNumberWidth = highlightedText.LineNumberWidth; + + // Reference the underlying renderable array directly to avoid copying. + _renderables = highlightedText.Renderables; + _top = 0; + } + + public Pager(IEnumerable renderables) { + var list = renderables?.ToList(); + _renderables = list is null ? [] : (IReadOnlyList)list; + _top = 0; + } + private void Navigate(LiveDisplayContext ctx) { + bool running = true; + (WindowWidth, WindowHeight) = GetPagerSize(); + bool forceRedraw = true; + + while (running) { + (int width, int pageHeight) = GetPagerSize(); + // Reserve last row for footer + int contentRows = Math.Max(1, pageHeight - 1); + + bool resized = width != WindowWidth || pageHeight != WindowHeight; + if (resized) { + AnsiConsole.Console.Profile.Width = width; + + WindowWidth = width; + WindowHeight = pageHeight; + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + forceRedraw = true; + } + + // Redraw if needed (initial, resize, or after navigation) + if (resized || forceRedraw) { + RecalculateRenderableHeights(width, contentRows); + _top = Math.Clamp(_top, 0, GetMaxTop(contentRows)); + ViewportWindow viewport = BuildViewport(_top, contentRows); + _top = viewport.Top; + + bool fullClear = resized || viewport.HasImages || _lastPageHadImages; + if (fullClear) { + VTHelpers.ClearScreen(); + VTHelpers.ReserveRow(contentRows); + } + else { + VTHelpers.SetCursorPosition(1, 1); + } + + IRenderable target = BuildRenderable(viewport); + ctx.UpdateTarget(target); + ctx.Refresh(); + + DrawFooter(width, contentRows, viewport); + + // Clear any previously-rendered lines that are now beyond contentRows. + if (_lastRenderedRows > contentRows) { + for (int r = contentRows + 1; r <= _lastRenderedRows; r++) { + VTHelpers.ClearRow(r); + } + } + + _lastRenderedRows = contentRows; + _lastPageHadImages = viewport.HasImages; + forceRedraw = false; + } + + // Wait for input, checking for resize while idle + if (!Console.KeyAvailable) { + Thread.Sleep(50); + continue; + } + + ConsoleKeyInfo key = Console.ReadKey(true); + lock (_lock) { + switch (key.Key) { + case ConsoleKey.DownArrow: + ScrollRenderable(1); + forceRedraw = true; + break; + case ConsoleKey.UpArrow: + ScrollRenderable(-1); + forceRedraw = true; + break; + case ConsoleKey.Spacebar: + case ConsoleKey.PageDown: + PageDown(contentRows); + forceRedraw = true; + break; + case ConsoleKey.PageUp: + PageUp(contentRows); + forceRedraw = true; + break; + case ConsoleKey.Home: + GoToTop(); + forceRedraw = true; + break; + case ConsoleKey.End: + GoToEnd(contentRows); + forceRedraw = true; + break; + case ConsoleKey.Q: + case ConsoleKey.Escape: + running = false; + break; + } + } + } + } + + private static (int width, int height) GetPagerSize() { + int width = Console.WindowWidth > 0 ? Console.WindowWidth : 80; + int height = Console.WindowHeight > 0 ? Console.WindowHeight : 40; + return (width, height); + } + + private void ScrollRenderable(int delta) { + if (_renderables.Count == 0) return; + + int direction = Math.Sign(delta); + if (direction == 0) return; + + int maxTop = GetMaxTop(Math.Max(1, WindowHeight - 1)); + _top = Math.Clamp(_top + direction, 0, maxTop); + } + + private void PageDown(int contentRows) { + if (_renderables.Count == 0) return; + + ViewportWindow viewport = BuildViewport(_top, contentRows); + int maxTop = GetMaxTop(contentRows); + if (viewport.EndExclusive >= _renderables.Count) { + _top = maxTop; + return; + } + + _top = Math.Min(viewport.EndExclusive, maxTop); + } + + private void PageUp(int contentRows) { + if (_renderables.Count == 0) return; + + int rowsSkipped = 0; + int idx = _top - 1; + int nextTop = _top; + while (idx >= 0 && rowsSkipped < contentRows) { + rowsSkipped += Math.Clamp(GetRenderableHeight(idx), 1, contentRows); + nextTop = idx; + idx--; + } + + _top = Math.Clamp(nextTop, 0, _renderables.Count - 1); + } + + private int GetRenderableHeight(int index) + => index < 0 || index >= _renderableHeights.Count ? 1 : Math.Max(1, _renderableHeights[index]); + + private static int CountLinesSegments(List segments) { + if (segments.Count == 0) { + return 0; + } + + int lineBreaks = segments.Count(segment => segment.IsLineBreak); + return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; + } + + private void RecalculateRenderableHeights(int width, int contentRows) { + _renderableHeights = new List(_renderables.Count); + Capabilities capabilities = AnsiConsole.Console.Profile.Capabilities; + var size = new Size(width, Math.Max(1, Console.WindowHeight)); + var options = new RenderOptions(capabilities, size); + + for (int i = 0; i < _renderables.Count; i++) { + IRenderable? r = _renderables[i]; + if (r is null) { + _renderableHeights.Add(1); + continue; + } + + if (IsImageRenderable(r)) { + _renderableHeights.Add(EstimateImageHeight(r, width, contentRows, options)); + continue; + } + + try { + // For non-image renderables, render to segments to get accurate row count. + // This avoids overflow/cropping artifacts when wrapped text spans many rows. + var segments = r.Render(options, width).ToList(); + int lines = CountLinesSegments(segments); + _renderableHeights.Add(Math.Max(1, lines)); + } + catch { + // Fallback: assume single-line if measurement fails + _renderableHeights.Add(1); + } + } + } + + private void GoToTop() => _top = 0; + + private int GetMaxTop(int contentRows) { + if (_renderables.Count == 0) { + return 0; + } + + int top = _renderables.Count - 1; + int rows = Math.Clamp(GetRenderableHeight(top), 1, contentRows); + + while (top > 0) { + int previousHeight = Math.Clamp(GetRenderableHeight(top - 1), 1, contentRows); + if (rows + previousHeight > contentRows) { + break; + } + + rows += previousHeight; + top--; + } + + return top; + } + + private void GoToEnd(int contentRows) => _top = GetMaxTop(contentRows); + + private IRenderable BuildRenderable(ViewportWindow viewport) { + if (viewport.Count <= 0) { + return new Rows([]); + } + + if (_sourceHighlightedText is not null) { + _sourceHighlightedText.SetView(_renderables, viewport.Top, viewport.Count); + _sourceHighlightedText.LineNumberStart = (_originalLineNumberStart ?? 1) + viewport.Top; + _sourceHighlightedText.LineNumberWidth = _stableLineNumberWidth; + + return _sourceHighlightedText; + } + + return new Rows(_renderables.Skip(viewport.Top).Take(viewport.Count)); + } + + private void DrawFooter(int width, int contentRows, ViewportWindow viewport) { + int total = _renderables.Count; + int pos = total == 0 ? 0 : viewport.Top + 1; + int end = viewport.EndExclusive; + + string keys = "Up/Down: ↑↓ PgUp/PgDn: PgUp/PgDn/Spacebar Home/End: Home/End q/Esc: Quit"; + string status = $" {pos}-{end}/{total} "; + int remaining = Math.Max(0, width - keys.Length - status.Length - 2); + string spacer = new(' ', remaining); + string line = keys + spacer + status; + if (line.Length > width) line = line[..width]; + + // Write footer directly to reserved row (contentRows + 1) + int footerRow = contentRows + 1; + VTHelpers.SetCursorPosition(footerRow, 1); + Console.Write(line.PadRight(width)); + } + + private void NavigateDirect(bool useAlternateBuffer) { + bool running = true; + (WindowWidth, WindowHeight) = GetPagerSize(); + bool forceRedraw = true; + + while (running) { + (int width, int pageHeight) = GetPagerSize(); + int contentRows = Math.Max(1, pageHeight - 1); + + bool resized = width != WindowWidth || pageHeight != WindowHeight; + if (resized) { + AnsiConsole.Console.Profile.Width = width; + WindowWidth = width; + WindowHeight = pageHeight; + forceRedraw = true; + } + + if (resized || forceRedraw) { + RecalculateRenderableHeights(width, contentRows); + _top = Math.Clamp(_top, 0, GetMaxTop(contentRows)); + ViewportWindow viewport = BuildViewport(_top, contentRows); + _top = viewport.Top; + + VTHelpers.ClearScreen(); + if (useAlternateBuffer) { + VTHelpers.ReserveRow(contentRows); + } + + IRenderable target = BuildRenderable(viewport); + AnsiConsole.Write(target); + DrawFooter(width, contentRows, viewport); + forceRedraw = false; + } + + if (!Console.KeyAvailable) { + Thread.Sleep(50); + continue; + } + + ConsoleKeyInfo key = Console.ReadKey(true); + lock (_lock) { + switch (key.Key) { + case ConsoleKey.DownArrow: + ScrollRenderable(1); + forceRedraw = true; + break; + case ConsoleKey.UpArrow: + ScrollRenderable(-1); + forceRedraw = true; + break; + case ConsoleKey.Spacebar: + case ConsoleKey.PageDown: + PageDown(contentRows); + forceRedraw = true; + break; + case ConsoleKey.PageUp: + PageUp(contentRows); + forceRedraw = true; + break; + case ConsoleKey.Home: + GoToTop(); + forceRedraw = true; + break; + case ConsoleKey.End: + GoToEnd(contentRows); + forceRedraw = true; + break; + case ConsoleKey.Q: + case ConsoleKey.Escape: + running = false; + break; + } + } + } + } + + public void Show() => Show(useAlternateBuffer: true); + + public void Show(bool useAlternateBuffer) { + if (useAlternateBuffer) { + VTHelpers.EnterAlternateBuffer(); + } + VTHelpers.HideCursor(); + try { + // Sixel/pixel renderables are safest when written directly because + // Live's diff/crop pass can interfere with terminal image sequences. + if (ContainsImageRenderables()) { + NavigateDirect(useAlternateBuffer); + return; + } + + (int width, int pageHeight) = GetPagerSize(); + int contentRows = Math.Max(1, pageHeight - 1); + + // Start with a clean screen then reserve the last row as a non-scrolling footer region + if (useAlternateBuffer) { + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + } + + // Initial target for Spectre Live (footer is drawn manually) + AnsiConsole.Console.Profile.Width = width; + RecalculateRenderableHeights(width, contentRows); + ViewportWindow initialViewport = BuildViewport(_top, contentRows); + _top = initialViewport.Top; + IRenderable initial = BuildRenderable(initialViewport); + _lastRenderedRows = contentRows; + _lastPageHadImages = initialViewport.HasImages; + + // If the initial page contains images, clear appropriately to ensure safe image rendering + if (initialViewport.HasImages) { + if (useAlternateBuffer) { + VTHelpers.ClearScreen(); + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + } + else { + VTHelpers.ClearScreen(); + } + } + + AnsiConsole.Live(initial) + .AutoClear(true) + .Overflow(VerticalOverflow.Crop) + .Cropping(VerticalOverflowCropping.Bottom) + .Start(ctx => { + // Draw footer once before entering the interactive loop + DrawFooter(width, contentRows, initialViewport); + // Enter interactive loop using the live display context + Navigate(ctx); + }); + } + finally { + // Clear any active view on the source highlighted text to avoid + // leaving its state mutated after the pager exits, and restore + // original line-number settings. + if (_sourceHighlightedText is not null) { + _sourceHighlightedText.ClearView(); + _sourceHighlightedText.LineNumberStart = _originalLineNumberStart ?? 1; + _sourceHighlightedText.LineNumberWidth = _originalLineNumberWidth; + } + // Reset scroll region and restore normal screen buffer if used + if (useAlternateBuffer) { + VTHelpers.ResetScrollRegion(); + VTHelpers.ExitAlternateBuffer(); + } + VTHelpers.ShowCursor(); + } + } + + public void Dispose() { + // No resources to dispose, but required for IDisposable + } +} diff --git a/src/PSTextMate/Utilities/PagerNormal.cs b/src/PSTextMate/Utilities/PagerNormal.cs new file mode 100644 index 0000000..0510584 --- /dev/null +++ b/src/PSTextMate/Utilities/PagerNormal.cs @@ -0,0 +1,239 @@ +using System.Globalization; +using Spectre.Console; +using Spectre.Console.Rendering; +using System.Linq; +using PSTextMate.Core; + +namespace PSTextMate.Utilities; + +public class PagerNormal : IDisposable { + private readonly IReadOnlyList _renderables; + private readonly HighlightedText? _sourceHighlightedText; + private readonly Measurement[] _measurements; + // private readonly int? _originalLineNumberStart; + // private readonly int? _originalLineNumberWidth; + private readonly object _lock = new(); + + public PagerNormal(HighlightedText highlightedText) { + _sourceHighlightedText = highlightedText; + // _originalLineNumberStart = highlightedText.LineNumberStart; + // _originalLineNumberWidth = highlightedText.LineNumberWidth; + _renderables = highlightedText.Renderables; + _measurements = ComputeMeasurements(GetPagerSize().width); + } + + public PagerNormal(IEnumerable renderables) { + var list = renderables?.ToList(); + _renderables = list is null ? [] : (IReadOnlyList)list; + _measurements = ComputeMeasurements(GetPagerSize().width); + } + + private Measurement[] ComputeMeasurements(int width) { + if (_sourceHighlightedText is not null) { + return _sourceHighlightedText.MeasureRenderablesFull(width); + } + + Capabilities caps = AnsiConsole.Console.Profile.Capabilities; + var size = new Size(width, Math.Max(1, Console.WindowHeight)); + var options = new RenderOptions(caps, size); + + IReadOnlyList source = _renderables; + var list = new List(source.Count); + foreach (IRenderable? r in source) { + if (r is null) { + list.Add(new Measurement(1, 1)); + continue; + } + + try { + Measurement m = r.Measure(options, width); + list.Add(m); + } + catch { + list.Add(new Measurement(1, 1)); + } + } + + return [.. list]; + } + + private int GetRenderableRowCount(int index, int width) { + if (_measurements == null || index < 0 || index >= _measurements.Length) return 1; + Measurement m = _measurements[index]; + int maxWidth = Math.Max(1, m.Max); + return maxWidth <= width ? 1 : (int)Math.Ceiling((double)maxWidth / width); + } + + private int CountRenderablesForHeight(int startIndex, int availableRows, int width) { + int sum = 0; + int count = 0; + if (_measurements == null) return 1; + for (int i = startIndex; i < _measurements.Length; i++) { + int r = GetRenderableRowCount(i, width); + if (sum + r > availableRows) break; + sum += r; + count++; + } + + // Always show at least one renderable + return Math.Max(1, count); + } + + private static (int width, int height) GetPagerSize() { + int width = Console.WindowWidth > 0 ? Console.WindowWidth : 80; + int height = Console.WindowHeight > 0 ? Console.WindowHeight : 40; + return (width, height); + } + + // private static void ScrollUp(int lines) => Console.Write($"\x1b[{lines}S"); + // private static void ScrollDown(int lines) => Console.Write($"\x1b[{lines}T"); + private IRenderable BuildContent(int top, int contentRows) { + if (_sourceHighlightedText is not null) { + return _sourceHighlightedText.Slice(top, contentRows, _sourceHighlightedText.LineNumberWidth); + } + + var slice = _renderables.Skip(top).Take(contentRows).ToList(); + return new Rows(slice); + } + private static int ProcessKey(ConsoleKey key, int top, int availableRows, int totalCount, out bool running) { + running = true; + switch (key) { + case ConsoleKey.DownArrow: + if (top < Math.Max(0, totalCount - 1)) top++; + break; + case ConsoleKey.UpArrow: + if (top > 0) top--; + break; + case ConsoleKey.PageDown: + case ConsoleKey.Spacebar: + top = Math.Min(Math.Max(0, totalCount - availableRows), top + availableRows); + break; + case ConsoleKey.PageUp: + top = Math.Max(0, top - availableRows); + break; + case ConsoleKey.Home: + top = 0; + break; + case ConsoleKey.End: + top = Math.Max(0, totalCount - availableRows); + break; + case ConsoleKey.Q: + case ConsoleKey.Escape: + running = false; + break; + } + + return top; + } + private string BuildFooterLine(int top, int contentRows, int width) { + string keys = "Up/Down: ↑↓ PgUp/PgDn: PgUp/PgDn/Spacebar Home/End: Home/End q/Esc: Quit"; + string status = $" {Math.Min(top + 1, _renderables.Count)}-{Math.Min(top + contentRows, _renderables.Count)}/{_renderables.Count} "; + int remaining = Math.Max(0, width - keys.Length - status.Length - 2); + string spacer = new(' ', remaining); + string footerLine = keys + spacer + status; + if (footerLine.Length > width) footerLine = footerLine[..width]; + return footerLine; + } + public void Show() { + // VTHelpers.HideCursor(); + (int width, int height) = GetPagerSize(); + AnsiConsole.Console.Profile.Width = width; + int availableRows = Math.Max(1, Console.WindowHeight - 1); + int top = 0; + + int totalCount = _sourceHighlightedText is not null ? _sourceHighlightedText.Renderables.Length : _renderables.Count; + + // compute how many renderables fit in the availableRows starting at top + int initialCount = CountRenderablesForHeight(top, availableRows, width); + var composite = new CompositeRenderable(_sourceHighlightedText, _renderables, top, initialCount, width, BuildFooterLine); + + AnsiConsole.Live(composite) + .AutoClear(true) + .Overflow(VerticalOverflow.Ellipsis) + .Start(ctx => { + bool running = true; + while (running) { + if (!Console.KeyAvailable) { + Thread.Sleep(50); + continue; + } + + ConsoleKeyInfo key = Console.ReadKey(true); + int newTop = ProcessKey(key.Key, top, availableRows, totalCount, out running); + + lock (_lock) { + top = newTop; + int count = CountRenderablesForHeight(top, availableRows, width); + composite.Update(top, count, width); + if (!running) { + return; + } + ctx.Refresh(); + } + } + }); + + } + public void Dispose() => GC.SuppressFinalize(this); +} + + +// Composite renderable that presents content and a footer line as a single +// Spectre `IRenderable`. Navigation updates modify its internal state and +// calling `ctx.Refresh()` will cause Live to re-render using the current +// content/top values. +internal sealed class CompositeRenderable : IRenderable { + private readonly HighlightedText? _source; + private readonly IReadOnlyList? _list; + private readonly Func _footerBuilder; + private int _top; + private int _contentRows; + private int _width; + + public CompositeRenderable(HighlightedText? source, IReadOnlyList list, int top, int contentRows, int width, Func footerBuilder) { + _source = source; + _list = list; + _top = top; + _contentRows = contentRows; + _width = width; + _footerBuilder = footerBuilder; + } + + public void Update(int top, int contentRows, int width) { + _top = top; + _contentRows = contentRows; + _width = width; + } + + public IEnumerable Render(RenderOptions options, int maxWidth) { + IRenderable content; + if (_source is not null) { + content = _source.Slice(_top, _contentRows, _source.LineNumberWidth); + } + else { + IReadOnlyList sourceList = _list ?? []; + var slice = sourceList.Skip(_top).Take(_contentRows).ToList(); + content = new Rows(slice); + } + + var footer = new Panel(new Text(_footerBuilder(_top, _contentRows, _width))) { Padding = new Padding(0, 0) }; + var rows = new Rows(content, footer); + return ((IRenderable)rows).Render(options, maxWidth); + } + + public Measurement Measure(RenderOptions options, int maxWidth) { + IRenderable content; + if (_source is not null) { + content = _source.Slice(_top, _contentRows, _source.LineNumberWidth); + } + else { + IReadOnlyList sourceList = _list ?? []; + var slice = sourceList.Skip(_top).Take(_contentRows).ToList(); + content = new Rows(slice); + } + + var footer = new Panel(new Text(_footerBuilder(_top, _contentRows, _width))) { Padding = new Padding(0, 0) }; + var rows = new Rows(content, footer); + return ((IRenderable)rows).Measure(options, maxWidth); + } +} diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs new file mode 100644 index 0000000..a0a44e2 --- /dev/null +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Management.Automation.Host; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace PSTextMate.Utilities; + +/// +/// Provides an ALC-safe bridge for rendering Spectre objects to plain text. +/// +public static class SpectreRenderBridge { + /// + /// Renders a Spectre renderable object to a string. + /// + /// Object implementing . + /// When true, strips ANSI sequences from the output. + /// The rendered string output. + /// Thrown when is null. + /// Thrown when does not implement . + public static string RenderToString(object renderableObject, bool escapeAnsi = false) { + ArgumentNullException.ThrowIfNull(renderableObject); + + if (renderableObject is not IRenderable renderable) { + throw new ArgumentException( + $"Object of type '{renderableObject.GetType().FullName}' does not implement {nameof(IRenderable)}.", + nameof(renderableObject) + ); + } + + using StringWriter writer = new(); + var output = new AnsiConsoleOutput(writer); + var settings = new AnsiConsoleSettings { Out = output }; + IAnsiConsole console = AnsiConsole.Create(settings); + console.Write(renderable); + + string rendered = writer.ToString(); + return escapeAnsi ? PSHostUserInterface.GetOutputString(rendered, false) : rendered; + } +} diff --git a/src/PSTextMate/Utilities/SpectreStyleCompat.cs b/src/PSTextMate/Utilities/SpectreStyleCompat.cs new file mode 100644 index 0000000..58e9662 --- /dev/null +++ b/src/PSTextMate/Utilities/SpectreStyleCompat.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using Spectre.Console; + +namespace PSTextMate.Utilities; + +internal static class SpectreStyleCompat { + private static readonly ConstructorInfo? LinkStyleCtor = typeof(Style).GetConstructor([typeof(Color?), typeof(Color?), typeof(Decoration?), typeof(string)]); + private static readonly Type? LinkType = Type.GetType("Spectre.Console.Link, Spectre.Console.Ansi") + ?? Type.GetType("Spectre.Console.Link, Spectre.Console"); + private static readonly ConstructorInfo? LinkCtor = LinkType?.GetConstructor([typeof(string)]); + private static readonly MethodInfo? ParagraphAppendWithLink = FindParagraphAppendWithLink(); + + public static Style Create(Color? foreground = null, Color? background = null, Decoration? decoration = null) + => new(foreground, background, decoration); + + public static Style CreateWithLink(Color? foreground, Color? background, Decoration? decoration, string? link) { + return string.IsNullOrWhiteSpace(link) + ? new Style(foreground, background, decoration) + : LinkStyleCtor is not null + ? (Style)LinkStyleCtor.Invoke([foreground, background, decoration, link]) + : new Style(foreground, background, decoration); + } + + public static string ToMarkup(Style? style) { + if (style is null) { + return string.Empty; + } + + Style resolved = style ?? Style.Plain; + return resolved.ToMarkup(); + } + + public static Style Resolve(Style? style) => style ?? Style.Plain; + + public static void Append(Paragraph paragraph, string text, Style? style = null, string? link = null) { + ArgumentNullException.ThrowIfNull(paragraph); + + if (string.IsNullOrWhiteSpace(link)) { + paragraph.Append(text, style); + return; + } + + if (ParagraphAppendWithLink is not null && LinkCtor is not null) { + object? linkObject = LinkCtor.Invoke([link]); + ParagraphAppendWithLink.Invoke(paragraph, [text, style, linkObject]); + return; + } + + Style baseStyle = Resolve(style); + Style linked = CreateWithLink(baseStyle.Foreground, baseStyle.Background, baseStyle.Decoration, link); + paragraph.Append(text, linked); + } + + private static MethodInfo? FindParagraphAppendWithLink() + => typeof(Paragraph) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(method + => method.Name == nameof(Paragraph.Append) + && method.GetParameters() is { Length: 3 } parameters + && parameters[2].ParameterType.Name == "Link"); +} diff --git a/src/Utilities/SpectreTextMateStyler.cs b/src/PSTextMate/Utilities/SpectreTextMateStyler.cs similarity index 100% rename from src/Utilities/SpectreTextMateStyler.cs rename to src/PSTextMate/Utilities/SpectreTextMateStyler.cs diff --git a/src/Utilities/StringBuilderExtensions.cs b/src/PSTextMate/Utilities/StringBuilderExtensions.cs similarity index 93% rename from src/Utilities/StringBuilderExtensions.cs rename to src/PSTextMate/Utilities/StringBuilderExtensions.cs index 0027f27..75c08f9 100644 --- a/src/Utilities/StringBuilderExtensions.cs +++ b/src/PSTextMate/Utilities/StringBuilderExtensions.cs @@ -45,7 +45,7 @@ public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? s value ??= string.Empty; return style is not null ? builder.Append('[') - .Append(style.ToMarkup()) + .Append(SpectreStyleCompat.ToMarkup(style)) .Append(']') .Append(value.EscapeMarkup()) .Append("[/]") @@ -63,7 +63,7 @@ public static StringBuilder AppendWithStyleN(this StringBuilder builder, Style? value ??= string.Empty; return style is not null ? builder.Append('[') - .Append(style.ToMarkup()) + .Append(SpectreStyleCompat.ToMarkup(style)) .Append(']') .Append(value) .Append("[/] ") @@ -81,7 +81,7 @@ public static StringBuilder AppendWithStyleN(this StringBuilder builder, Style? public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, ReadOnlySpan value) { return style is not null ? builder.Append('[') - .Append(style.ToMarkup()) + .Append(SpectreStyleCompat.ToMarkup(style)) .Append(']') .Append(value) .Append("[/]") diff --git a/src/Utilities/StringBuilderPool.cs b/src/PSTextMate/Utilities/StringBuilderPool.cs similarity index 100% rename from src/Utilities/StringBuilderPool.cs rename to src/PSTextMate/Utilities/StringBuilderPool.cs diff --git a/src/Utilities/StringExtensions.cs b/src/PSTextMate/Utilities/StringExtensions.cs similarity index 100% rename from src/Utilities/StringExtensions.cs rename to src/PSTextMate/Utilities/StringExtensions.cs diff --git a/src/PSTextMate/Utilities/TMConsole.cs b/src/PSTextMate/Utilities/TMConsole.cs new file mode 100644 index 0000000..1887777 --- /dev/null +++ b/src/PSTextMate/Utilities/TMConsole.cs @@ -0,0 +1,96 @@ +#if netstandard2_0 +// just a reference implementation +using PSTextMate.Core; +using Spectre.Console; +using System.Text; +using Spectre.Console.Rendering; + +namespace PSTextMate.Utilities; + +public abstract class TMConsole : IAnsiConsole { + public Profile Profile { get; } + public IAnsiConsoleCursor Cursor { get; } + public IAnsiConsoleInput Input { get; } + public IExclusivityMode ExclusivityMode { get; } + public RenderPipeline Pipeline { get; } + + public int Width { get; set; } = 80; + public int Height { get; set; } = 25; + + public TMConsole() { + var writer = new NoopWriter(); + var output = new SimpleOutput(writer, () => Width, () => Height); + + Profile = new Profile(output, Encoding.Unicode) { + Capabilities = + { + ColorSystem = ColorSystem.TrueColor, + Unicode = true, + Ansi = false, + Links = false, + Legacy = false, + Interactive = false, + AlternateBuffer = false + }, + }; + + Cursor = new NoopConsoleCursor(); + Input = new NoopConsoleInput(); + ExclusivityMode = new ExclusivityMode(); + Pipeline = new RenderPipeline(); + } + + public abstract void Clear(bool home); + public abstract void Write(IRenderable renderable); +} + +public sealed class NoopWriter : TextWriter { + public override Encoding Encoding { get; } = Encoding.Unicode; +} + +public sealed class SimpleOutput : IAnsiConsoleOutput { + private readonly Func _width; + private readonly Func _height; + + public TextWriter Writer { get; } + public bool IsTerminal { get; } = true; + public int Width => _width(); + public int Height => _height(); + + public SimpleOutput(NoopWriter writer, Func width, Func height) { + _width = width ?? throw new ArgumentNullException(nameof(width)); + _height = height ?? throw new ArgumentNullException(nameof(height)); + + Writer = writer; + } + + public void SetEncoding(Encoding encoding) { + } +} + +internal sealed class NoopConsoleCursor : IAnsiConsoleCursor { + public void Show(bool show) { + } + + public void SetPosition(int column, int line) { + } + + public void Move(CursorDirection direction, int steps) { + } +} + +internal sealed class NoopConsoleInput : IAnsiConsoleInput { + public bool IsKeyAvailable() => false; + + public ConsoleKeyInfo? ReadKey(bool intercept) => null; + + public Task ReadKeyAsync(bool intercept, CancellationToken cancellationToken) + => Task.FromResult(null); +} + +internal sealed class ExclusivityMode : IExclusivityMode { + public T Run(Func func) => func(); + + public Task RunAsync(Func> func) => func(); +} +#endif diff --git a/src/Utilities/TextMateResolver.cs b/src/PSTextMate/Utilities/TextMateResolver.cs similarity index 100% rename from src/Utilities/TextMateResolver.cs rename to src/PSTextMate/Utilities/TextMateResolver.cs diff --git a/src/Utilities/ThemeExtensions.cs b/src/PSTextMate/Utilities/ThemeExtensions.cs similarity index 100% rename from src/Utilities/ThemeExtensions.cs rename to src/PSTextMate/Utilities/ThemeExtensions.cs diff --git a/src/Utilities/TokenStyleProcessor.cs b/src/PSTextMate/Utilities/TokenStyleProcessor.cs similarity index 100% rename from src/Utilities/TokenStyleProcessor.cs rename to src/PSTextMate/Utilities/TokenStyleProcessor.cs diff --git a/src/Utilities/VTConversion.cs b/src/PSTextMate/Utilities/VTConversion.cs similarity index 96% rename from src/Utilities/VTConversion.cs rename to src/PSTextMate/Utilities/VTConversion.cs index 587a4df..d6517e3 100644 --- a/src/Utilities/VTConversion.cs +++ b/src/PSTextMate/Utilities/VTConversion.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text; +using PSTextMate.Utilities; using Spectre.Console; namespace PSTextMate.Helpers; @@ -530,8 +531,8 @@ public void Reset() { Link = null; } - public readonly Style ToSpectreStyle() => - new(Foreground, Background, Decoration, Link); + public readonly Style ToSpectreStyle() + => SpectreStyleCompat.CreateWithLink(Foreground, Background, Decoration, Link); public readonly string ToMarkup() { // Use StringBuilder to avoid List allocation diff --git a/src/Utilities/VTHelpers.cs b/src/PSTextMate/Utilities/VTHelpers.cs similarity index 91% rename from src/Utilities/VTHelpers.cs rename to src/PSTextMate/Utilities/VTHelpers.cs index 2b375a9..21009ae 100644 --- a/src/Utilities/VTHelpers.cs +++ b/src/PSTextMate/Utilities/VTHelpers.cs @@ -8,7 +8,7 @@ public static class VTHelpers { public static void HideCursor() => Console.Write("\x1b[?25l"); public static void ShowCursor() => Console.Write("\x1b[?25h"); public static void ClearScreen() => Console.Write("\x1b[2J\x1b[H"); - public static void ClearScreenAlt() => Console.Write("\x1bc"); + public static void ClearScreenAlt() => ClearScreen(); public static void ClearRow(int row) => Console.Write($"\x1b[{row};1H\x1b[2K"); public static void SetCursorPosition(int row, int column) => Console.Write($"\x1b[{row};{column}H"); public static void CursorHome() => Console.Write("\x1b[H"); diff --git a/src/PSTextMate/Utilities/Writer.cs b/src/PSTextMate/Utilities/Writer.cs new file mode 100644 index 0000000..5d9846e --- /dev/null +++ b/src/PSTextMate/Utilities/Writer.cs @@ -0,0 +1,99 @@ +using System.IO; +using System.Runtime.CompilerServices; +using PSTextMate.Core; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace PSTextMate.Utilities; + +/// +/// High-throughput Spectre.Console string renderer facade. +/// Uses a cached in-memory Spectre console and returns rendered strings. +/// +public static class Writer { + private static readonly StringWriter StringConsoleWriter = new(); + private static readonly IAnsiConsole StringConsole = CreateStringConsole(StringConsoleWriter); + private static readonly object SyncRoot = new(); + + /// + /// Renders a single renderable to string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string Write(IRenderable renderable) { + ArgumentNullException.ThrowIfNull(renderable); + return WriteToString(renderable); + } + + /// + /// Renders highlighted text to string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string Write(HighlightedText highlightedText, bool autoPage = true, bool alternatePager = false) { + ArgumentNullException.ThrowIfNull(highlightedText); + + // Keep compatibility with prior callsites while remaining side-effect free. + _ = autoPage; + _ = alternatePager; + return WriteToString(highlightedText, customItemFormatter: true); + } + + /// + /// Renders a sequence of renderables as rows. + /// + public static string Write(IEnumerable renderables) { + ArgumentNullException.ThrowIfNull(renderables); + + return renderables is IRenderable[] array + ? array.Length == 0 ? string.Empty : array.Length == 1 ? WriteToString(array[0]) : WriteToString(new Rows(array)) + : renderables is IReadOnlyList list + ? list.Count == 0 ? string.Empty : list.Count == 1 ? WriteToString(list[0]) : WriteToString(new Rows(list)) + : WriteToString(new Rows(renderables)); + } + + /// + /// Renders a Spectre renderable to a reusable in-memory writer. + /// This mirrors the PwshSpectreConsole approach so the output can be streamed + /// as plain text, redirected, or post-processed by custom formatters. + /// + public static string WriteToString(IRenderable renderable, bool customItemFormatter = false, int? width = null) { + ArgumentNullException.ThrowIfNull(renderable); + + lock (SyncRoot) { + StringConsole.Profile.Width = ResolveWidth(customItemFormatter, width); + + StringConsole.Write(renderable); + string output = StringConsoleWriter.ToString().TrimEnd(); + StringConsoleWriter.GetStringBuilder().Clear(); + return output; + } + } + + /// + /// Compatibility wrapper for previous API shape. + /// No host-direct output is performed; this returns the rendered string only. + /// + public static string WriteToStringWithHostFallback(IRenderable renderable, bool customItemFormatter = false, int? width = null) + => WriteToString(renderable, customItemFormatter, width); + + private static IAnsiConsole CreateStringConsole(StringWriter writer) { + var settings = new AnsiConsoleSettings { + Out = new AnsiConsoleOutput(writer) + }; + + return AnsiConsole.Create(settings); + } + + private static int ResolveWidth(bool customItemFormatter, int? widthOverride) { + int width = widthOverride ?? GetConsoleWidth(); + return customItemFormatter && width > 1 ? width - 1 : Math.Max(1, width); + } + + private static int GetConsoleWidth() { + try { + return Console.WindowWidth > 0 ? Console.WindowWidth : 80; + } + catch { + return 80; + } + } +} diff --git a/src/Utilities/Pager.cs b/src/Utilities/Pager.cs deleted file mode 100644 index 840d4b7..0000000 --- a/src/Utilities/Pager.cs +++ /dev/null @@ -1,439 +0,0 @@ -using System.Globalization; -using Spectre.Console; -using Spectre.Console.Rendering; -using PSTextMate.Core; - -namespace PSTextMate.Utilities; - -/// -/// Simple pager implemented with Spectre.Console Live display. -/// Interaction keys: -/// - Up/Down: move one line -/// - PageUp/PageDown: move by page -/// - Home/End: go to start/end -/// - q or Escape: quit -/// -public sealed class Pager : IDisposable { - private readonly IReadOnlyList _renderables; - private readonly HighlightedText? _sourceHighlightedText; - private readonly int? _originalLineNumberStart; - private readonly int? _originalLineNumberWidth; - private readonly int? _stableLineNumberWidth; - private int _top; - private int WindowHeight; - private int WindowWidth; - private readonly object _lock = new(); - private int _lastRenderedRows; - // Cached measured heights (in rows) for each renderable at the current width - private List _renderableHeights = []; - private bool IsMarkdownSource() - => _sourceHighlightedText is not null - && _sourceHighlightedText.Language.Contains("markdown", StringComparison.OrdinalIgnoreCase); - - private bool PageContainsImages(int clampedTop, int pageHeight) { - if (_sourceHighlightedText is not null && !IsMarkdownSource()) { - return false; - } - - foreach (IRenderable? r in _renderables.Skip(clampedTop).Take(pageHeight)) { - if (r is null) continue; - string name = r.GetType().Name; - if (name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; - } - - // Compute the maximum valid _top index such that starting at that index there - // are at least `contentRows` rows available to render (based on - // `_renderableHeights`). Falls back to a simple count-based heuristic when - // heights are not known. - private int MaxTopForContentRows(int contentRows) { - if (contentRows <= 0) return 0; - if (_renderableHeights == null || _renderableHeights.Count == 0) { - return Math.Max(0, _renderables.Count - contentRows); - } - - int n = _renderableHeights.Count; - int[] suffix = new int[n]; - suffix[n - 1] = _renderableHeights[n - 1]; - for (int i = n - 2; i >= 0; i--) suffix[i] = suffix[i + 1] + _renderableHeights[i]; - - for (int i = n - 1; i >= 0; i--) { - if (suffix[i] >= contentRows) return i; - } - - return 0; - } - - public Pager(HighlightedText highlightedText) { - _sourceHighlightedText = highlightedText; - - int totalLines = highlightedText.LineCount; - int lastLineNumber = highlightedText.LineNumberStart + Math.Max(0, totalLines - 1); - _stableLineNumberWidth = highlightedText.LineNumberWidth ?? lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; - _originalLineNumberStart = highlightedText.LineNumberStart; - _originalLineNumberWidth = highlightedText.LineNumberWidth; - - // Reference the underlying renderable array directly to avoid copying. - _renderables = highlightedText.Renderables; - _top = 0; - } - - public Pager(IEnumerable renderables) { - var list = renderables?.ToList(); - _renderables = list is null ? [] : (IReadOnlyList)list; - _top = 0; - } - private void Navigate(LiveDisplayContext ctx) { - bool running = true; - (WindowWidth, WindowHeight) = GetPagerSize(); - bool forceRedraw = true; - - while (running) { - (int width, int pageHeight) = GetPagerSize(); - // Reserve last row for footer - int contentRows = Math.Max(1, pageHeight - 1); - - bool resized = width != WindowWidth || pageHeight != WindowHeight; - if (resized) { - AnsiConsole.Console.Profile.Width = width; - - // Detect shrink (large -> small). If terminal shrank, do a full clear+redraw - bool shrank = pageHeight < WindowHeight; - - WindowWidth = width; - WindowHeight = pageHeight; - - // Clamp current top to the new page size so content doesn't jump - int maxTopAfterResize = MaxTopForContentRows(contentRows); - _top = Math.Clamp(_top, 0, maxTopAfterResize); - - if (shrank) { - // Full clear then update reserved scroll region to match new height. - // ClearScreenAlt resets terminal state, so set the scroll region after clearing. - VTHelpers.ClearScreenAlt(); - VTHelpers.ResetScrollRegion(); - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); - - // Immediate redraw via Spectre Live (already safe after ClearScreenAlt) - ctx.UpdateTarget(BuildRenderable(width, contentRows)); - ctx.Refresh(); - DrawFooter(width, contentRows); - _lastRenderedRows = contentRows; - forceRedraw = false; - // skip the later redraw block - continue; - } - // On grow or same-size width change, update reserved row and mark for redraw normally - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); - forceRedraw = true; - } - - // Redraw if needed (initial, resize, or after navigation) - if (resized || forceRedraw) { - // Avoid a full clear here to reduce flicker; update in-place instead - VTHelpers.SetCursorPosition(1, 1); - - // Determine if this page contains image renderables that may emit raw sequences. - int maxTop = MaxTopForContentRows(contentRows); - int clampedTop = Math.Clamp(_top, 0, maxTop); - bool pageHasImages = PageContainsImages(clampedTop, contentRows); - - if (pageHasImages) { - // Full clear + reserve ensures the terminal is in a known state before image output - VTHelpers.ClearScreenAlt(); - VTHelpers.ResetScrollRegion(); - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); - } - - // Recalculate per-renderable heights for current width so we can - // page by renderable boundaries (important for multi-row images). - if (_sourceHighlightedText is not null) { - _renderableHeights = [.. _sourceHighlightedText.MeasureRenderables(width)]; - } - else { - RecalculateRenderableHeights(width); - } - - // Update Spectre Live target (Spectre handles rendering and wrapping) - IRenderable target = BuildRenderable(width, contentRows); - ctx.UpdateTarget(target); - ctx.Refresh(); - - // Draw footer manually on reserved row - DrawFooter(width, contentRows); - - // Clear any previously-rendered lines that are now beyond contentRows. - if (_lastRenderedRows > contentRows) { - for (int r = contentRows + 1; r <= _lastRenderedRows; r++) { - VTHelpers.ClearRow(r); - } - } - - _lastRenderedRows = contentRows; - forceRedraw = false; - } - - // Wait for input, checking for resize while idle - if (!Console.KeyAvailable) { - Thread.Sleep(50); - continue; - } - - ConsoleKeyInfo key = Console.ReadKey(true); - lock (_lock) { - switch (key.Key) { - case ConsoleKey.DownArrow: - ScrollRenderable(1, contentRows); - forceRedraw = true; - break; - case ConsoleKey.UpArrow: - ScrollRenderable(-1, contentRows); - forceRedraw = true; - break; - case ConsoleKey.Spacebar: - case ConsoleKey.PageDown: - PageDown(contentRows); - forceRedraw = true; - break; - case ConsoleKey.PageUp: - PageUp(contentRows); - forceRedraw = true; - break; - case ConsoleKey.Home: - GoToTop(); - forceRedraw = true; - break; - case ConsoleKey.End: - GoToEnd(contentRows); - forceRedraw = true; - break; - case ConsoleKey.Q: - case ConsoleKey.Escape: - running = false; - break; - } - } - } - } - - private static (int width, int height) GetPagerSize() { - int width = Console.WindowWidth > 0 ? Console.WindowWidth : 80; - int height = Console.WindowHeight > 0 ? Console.WindowHeight : 40; - return (width, height); - } - private void Scroll(int delta, int pageHeight) { - int maxTop = Math.Max(0, _renderables.Count - pageHeight); - _top = Math.Clamp(_top + delta, 0, maxTop); - } - - // Scroll by renderable units. Uses _renderableHeights to advance by entries - // rather than single rows so multi-row images are not split. - private void ScrollRenderable(int delta, int contentRows) { - if (_renderables.Count == 0) return; - - int direction = Math.Sign(delta); - if (direction == 0) return; - - int candidate = _top + direction; - candidate = Math.Clamp(candidate, 0, _renderables.Count - 1); - - // Clamp candidate to the maximum valid top based on contentRows so we don't - // end up with a starting index that cannot produce a full page when using - // renderable heights. - int maxTop = MaxTopForContentRows(contentRows); - candidate = Math.Clamp(candidate, 0, Math.Max(0, maxTop)); - - _top = candidate; - } - - private void PageDown(int contentRows) { - if (_renderables.Count == 0) return; - - // Advance _top forward until we've skipped at least contentRows rows - int rowsSkipped = 0; - int idx = _top; - while (idx < _renderables.Count && rowsSkipped < contentRows) { - rowsSkipped += GetRenderableHeight(idx); - idx++; - } - _top = Math.Clamp(idx, 0, Math.Max(0, _renderables.Count - 1)); - } - - private void PageUp(int contentRows) { - if (_renderables.Count == 0) return; - - int rowsSkipped = 0; - int idx = _top - 1; - while (idx >= 0 && rowsSkipped < contentRows) { - rowsSkipped += GetRenderableHeight(idx); - idx--; - } - _top = Math.Clamp(idx + 1, 0, Math.Max(0, _renderables.Count - 1)); - } - - private int GetRenderableHeight(int index) - => index < 0 || index >= _renderableHeights.Count ? 1 : _renderableHeights[index]; - - private void RecalculateRenderableHeights(int width) { - _renderableHeights = new List(_renderables.Count); - Capabilities capabilities = AnsiConsole.Console.Profile.Capabilities; - var size = new Size(width, Math.Max(1, Console.WindowHeight)); - var options = new RenderOptions(capabilities, size); - - // To avoid side-effects (e.g. sixel/pixel images) during off-screen - // measurement, only render a limited window around the current view - // and skip obvious image-like renderables. Everything else is given a - // conservative default of 1 row when not measured. - int count = _renderables.Count; - int window = Math.Max(1, WindowHeight > 0 ? WindowHeight : Console.WindowHeight); - int measureStart = Math.Max(0, _top - window); - int measureEnd = Math.Min(count, _top + window * 2); // lookahead a couple pages - - for (int i = 0; i < count; i++) { - IRenderable? r = _renderables[i]; - if (r is null) { - _renderableHeights.Add(0); - continue; - } - - // Only attempt to fully measure renderables in the nearby window. - if (i < measureStart || i >= measureEnd) { - _renderableHeights.Add(1); - continue; - } - - string name = r.GetType().Name; - if (name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) - || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase) - || name.Contains("Image", StringComparison.OrdinalIgnoreCase)) { - // Skip measuring image-like renderables to avoid side-effects. - _renderableHeights.Add(1); - continue; - } - - try { - var segments = r.Render(options, width).ToList(); - int lines = CountLinesSegments(segments); - if (lines <= 0) lines = 1; - _renderableHeights.Add(lines); - } - catch { - // Fallback: assume single-line if rendering for measurement fails - _renderableHeights.Add(1); - } - } - } - - private static int CountLinesSegments(List segments) { - if (segments.Count == 0) { - return 0; - } - - int lineBreaks = segments.Count(segment => segment.IsLineBreak); - return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; - } - private void GoToTop() => _top = 0; - - private void GoToEnd(int pageHeight) - => _top = Math.Max(0, _renderables.Count - pageHeight); - - // Accepts dynamic width and pageHeight; footer is drawn outside Live target - private IRenderable BuildRenderable(int width, int pageHeight) { - int maxTop = Math.Max(0, _renderables.Count - pageHeight); - int clampedTop = Math.Clamp(_top, 0, maxTop); - int end = Math.Min(clampedTop + pageHeight, _renderables.Count); - var pageRenderables = _renderables.Skip(clampedTop).Take(end - clampedTop).ToList(); - - if (_sourceHighlightedText is not null) { - // Configure the provided HighlightedText instance to view the current - // page of underlying renderables. Do not allocate a new HighlightedText - // — mutate the view on the source instance and let its Render/Measure - // handle panel wrapping and line-numbering. - _sourceHighlightedText.SetView(_renderables, clampedTop, end - clampedTop); - _sourceHighlightedText.LineNumberStart = (_originalLineNumberStart ?? 1) + clampedTop; - _sourceHighlightedText.LineNumberWidth = _stableLineNumberWidth; - - return _sourceHighlightedText; - } - - // Avoid allocating a new list/array for the page; use a deferred enumerable. - return new Rows(_renderables.Skip(clampedTop).Take(end - clampedTop)); - } - - private void DrawFooter(int width, int contentRows) { - int maxTop = Math.Max(0, _renderables.Count - contentRows); - int clampedTop = Math.Clamp(_top, 0, maxTop); - int end = Math.Min(clampedTop + contentRows, _renderables.Count); - int total = _renderables.Count; - int pos = Math.Min(clampedTop + 1, Math.Max(0, total)); - - string keys = "Up/Down: ↑↓ PgUp/PgDn: PgUp/PgDn/Spacebar Home/End: Home/End q/Esc: Quit"; - string status = $" {pos}-{end}/{total} "; - int remaining = Math.Max(0, width - keys.Length - status.Length - 2); - string spacer = new(' ', remaining); - string line = keys + spacer + status; - if (line.Length > width) line = line[..width]; - - // Write footer directly to reserved row (contentRows + 1) - int footerRow = contentRows + 1; - VTHelpers.SetCursorPosition(footerRow, 1); - Console.Write(line.PadRight(width)); - } - - public void Show() { - // Switch to alternate screen buffer - VTHelpers.EnterAlternateBuffer(); - VTHelpers.HideCursor(); - // Console.CursorVisible = false; - try { - (int width, int pageHeight) = GetPagerSize(); - int contentRows = Math.Max(1, pageHeight - 1); - - // Start with a clean screen then reserve the last row as a non-scrolling footer region - // VTHelpers.ClearScreen(); - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); - - // Initial target for Spectre Live (footer is drawn manually) - AnsiConsole.Console.Profile.Width = width; - IRenderable initial = BuildRenderable(width, contentRows); - _lastRenderedRows = contentRows; - - // If the initial page contains images, clear+reserve to ensure safe image rendering - int initialMaxTop = Math.Max(0, _renderables.Count - contentRows); - int initialClamped = Math.Clamp(_top, 0, initialMaxTop); - if (PageContainsImages(initialClamped, contentRows)) { - VTHelpers.ClearScreenAlt(); - VTHelpers.ResetScrollRegion(); - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); - } - - AnsiConsole.Live(initial).Start(ctx => { - // Draw footer once before entering the interactive loop - DrawFooter(width, contentRows); - // Enter interactive loop using the live display context - Navigate(ctx); - }); - } - finally { - // Clear any active view on the source highlighted text to avoid - // leaving its state mutated after the pager exits, and restore - // original line-number settings. - if (_sourceHighlightedText is not null) { - _sourceHighlightedText.ClearView(); - _sourceHighlightedText.LineNumberStart = _originalLineNumberStart ?? 1; - _sourceHighlightedText.LineNumberWidth = _originalLineNumberWidth; - } - // Reset scroll region and restore normal screen buffer - VTHelpers.ResetScrollRegion(); - VTHelpers.ShowCursor(); - VTHelpers.ExitAlternateBuffer(); - // Console.CursorVisible = true; - } - } - - public void Dispose() { - // No resources to dispose, but required for IDisposable - } -} diff --git a/tests/testhelper.psm1 b/tests/testhelper.psm1 index 8eed7d7..03162f6 100644 --- a/tests/testhelper.psm1 +++ b/tests/testhelper.psm1 @@ -1,28 +1,15 @@ using namespace System.Management.Automation; -using namespace Spectre.Console; -using namespace Spectre.Console.Rendering; function _GetSpectreRenderable { param( [Parameter(Mandatory)] - [Renderable] $RenderableObject, + [object] $RenderableObject, [switch] $EscapeAnsi ) - try { - $writer = [System.IO.StringWriter]::new() - $output = [AnsiConsoleOutput]::new($writer) - $settings = [AnsiConsoleSettings]::new() - $settings.Out = $output - $console = [AnsiConsole]::Create($settings) - $console.Write($RenderableObject) - if ($EscapeAnsi) { - return [Host.PSHostUserInterface]::GetOutputString($writer.ToString(),$false) - } - $writer.ToString() - } - finally { - ${writer}?.Dispose() - } + [PSTextMate.Utilities.SpectreRenderBridge]::RenderToString( + $RenderableObject, + $EscapeAnsi.IsPresent + ) } filter _EscapeAnsi { [Host.PSHostUserInterface]::GetOutputString($_, $false) From c99f5a4b2f9213e2f0d705a587d3ad38bbde01be Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:37:29 +0100 Subject: [PATCH 03/17] ALC + pager stuff + re-organize repo --- .gitignore | 4 +- src/PSTextMate/Cmdlets/FormatCSharp.cs | 2 - src/PSTextMate/Cmdlets/FormatMarkdown.cs | 2 - src/PSTextMate/Cmdlets/FormatPowershell.cs | 2 - .../Cmdlets/FormatTextMateCmdlet.cs | 5 - src/PSTextMate/Cmdlets/GetTextMateGrammar.cs | 2 - src/PSTextMate/Cmdlets/TestTextMate.cs | 3 - src/PSTextMate/Cmdlets/TextMateCmdletBase.cs | 6 - src/PSTextMate/Core/CacheManager.cs | 5 - src/PSTextMate/Core/HighlightedText.cs | 8 +- src/PSTextMate/Core/MarkdownRenderer.cs | 4 - src/PSTextMate/Core/MarkdownToken.cs | 2 - src/PSTextMate/Core/StandardRenderer.cs | 7 - src/PSTextMate/Core/StyleHelper.cs | 3 - src/PSTextMate/Core/TextMateProcessor.cs | 12 +- src/PSTextMate/Core/TokenProcessor.cs | 8 - src/PSTextMate/Rendering/BlockRenderer.cs | 11 +- src/PSTextMate/Rendering/CodeBlockRenderer.cs | 11 - src/PSTextMate/Rendering/HeadingRenderer.cs | 9 - .../Rendering/HorizontalRuleRenderer.cs | 3 - src/PSTextMate/Rendering/HtmlBlockRenderer.cs | 19 +- .../Rendering/ImageBlockRenderer.cs | 3 - src/PSTextMate/Rendering/ImageRenderer.cs | 179 +--------- src/PSTextMate/Rendering/ListRenderer.cs | 11 +- src/PSTextMate/Rendering/MarkdownRenderer.cs | 10 - src/PSTextMate/Rendering/ParagraphRenderer.cs | 13 - src/PSTextMate/Rendering/QuoteRenderer.cs | 5 - src/PSTextMate/Rendering/TableRenderer.cs | 12 +- src/PSTextMate/Sixel/CellSize.cs | 10 + src/PSTextMate/Sixel/Compatibility.cs | 232 +++++++++++++ src/PSTextMate/Sixel/ImageCanvas.cs | 197 +++++++++++ src/PSTextMate/Sixel/ImageSegment.cs | 29 ++ src/PSTextMate/Sixel/PixelImage.cs | 133 ++++++++ src/PSTextMate/Sixel/Sixel.cs | 40 +++ src/PSTextMate/Sixel/SixelRender.cs | 311 ++++++++++++++++++ src/PSTextMate/Utilities/AssemblyInfo.cs | 1 - src/PSTextMate/Utilities/Completers.cs | 8 - src/PSTextMate/Utilities/Helpers.cs | 8 +- src/PSTextMate/Utilities/ITextMateStyler.cs | 3 - src/PSTextMate/Utilities/ImageFile.cs | 7 - .../Utilities/InlineTextExtractor.cs | 3 - src/PSTextMate/Utilities/MarkdownPatterns.cs | 3 - src/PSTextMate/Utilities/Pager.cs | 6 - src/PSTextMate/Utilities/PagerNormal.cs | 6 - .../Utilities/SpectreRenderBridge.cs | 6 - .../Utilities/SpectreStyleCompat.cs | 3 - .../Utilities/SpectreTextMateStyler.cs | 5 - .../Utilities/StringBuilderExtensions.cs | 4 - src/PSTextMate/Utilities/StringBuilderPool.cs | 3 - src/PSTextMate/Utilities/StringExtensions.cs | 2 - src/PSTextMate/Utilities/TMConsole.cs | 96 ------ src/PSTextMate/Utilities/TextMateResolver.cs | 2 - src/PSTextMate/Utilities/ThemeExtensions.cs | 4 - .../Utilities/TokenStyleProcessor.cs | 6 - src/PSTextMate/Utilities/VTConversion.cs | 5 - src/PSTextMate/Utilities/VTHelpers.cs | 2 - src/PSTextMate/Utilities/Writer.cs | 23 +- src/PSTextMate/Utilities/using.cs | 43 +++ 58 files changed, 1041 insertions(+), 521 deletions(-) create mode 100644 src/PSTextMate/Sixel/CellSize.cs create mode 100644 src/PSTextMate/Sixel/Compatibility.cs create mode 100644 src/PSTextMate/Sixel/ImageCanvas.cs create mode 100644 src/PSTextMate/Sixel/ImageSegment.cs create mode 100644 src/PSTextMate/Sixel/PixelImage.cs create mode 100644 src/PSTextMate/Sixel/Sixel.cs create mode 100644 src/PSTextMate/Sixel/SixelRender.cs delete mode 100644 src/PSTextMate/Utilities/TMConsole.cs create mode 100644 src/PSTextMate/Utilities/using.cs diff --git a/.gitignore b/.gitignore index 76b6eba..53a4125 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,8 @@ src/.vs/* Module/lib debug.md [Oo]utput/ -*/obj/* -*/bin/* +**/obj/** +**/bin/** .github/chatmodes/* .github/instructions/* .github/prompts/* diff --git a/src/PSTextMate/Cmdlets/FormatCSharp.cs b/src/PSTextMate/Cmdlets/FormatCSharp.cs index a950cc7..86c090b 100644 --- a/src/PSTextMate/Cmdlets/FormatCSharp.cs +++ b/src/PSTextMate/Cmdlets/FormatCSharp.cs @@ -1,5 +1,3 @@ -using System.Management.Automation; -using PSTextMate.Core; namespace PSTextMate.Commands; diff --git a/src/PSTextMate/Cmdlets/FormatMarkdown.cs b/src/PSTextMate/Cmdlets/FormatMarkdown.cs index 38fa96e..a891819 100644 --- a/src/PSTextMate/Cmdlets/FormatMarkdown.cs +++ b/src/PSTextMate/Cmdlets/FormatMarkdown.cs @@ -1,5 +1,3 @@ -using System.Management.Automation; -using PSTextMate.Core; namespace PSTextMate.Commands; diff --git a/src/PSTextMate/Cmdlets/FormatPowershell.cs b/src/PSTextMate/Cmdlets/FormatPowershell.cs index bcaa7c1..21ebd3e 100644 --- a/src/PSTextMate/Cmdlets/FormatPowershell.cs +++ b/src/PSTextMate/Cmdlets/FormatPowershell.cs @@ -1,5 +1,3 @@ -using System.Management.Automation; -using PSTextMate.Core; namespace PSTextMate.Commands; diff --git a/src/PSTextMate/Cmdlets/FormatTextMateCmdlet.cs b/src/PSTextMate/Cmdlets/FormatTextMateCmdlet.cs index fdb5df4..5d72916 100644 --- a/src/PSTextMate/Cmdlets/FormatTextMateCmdlet.cs +++ b/src/PSTextMate/Cmdlets/FormatTextMateCmdlet.cs @@ -1,8 +1,3 @@ -using System.IO; -using System.Management.Automation; -using PSTextMate.Core; -using PSTextMate.Utilities; -using TextMateSharp.Grammars; namespace PSTextMate.Commands; diff --git a/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs b/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs index 0547b6a..1dd4a30 100644 --- a/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs +++ b/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs @@ -1,5 +1,3 @@ -using System.Management.Automation; -using TextMateSharp.Grammars; namespace PSTextMate.Commands; diff --git a/src/PSTextMate/Cmdlets/TestTextMate.cs b/src/PSTextMate/Cmdlets/TestTextMate.cs index f90f1b9..63ee757 100644 --- a/src/PSTextMate/Cmdlets/TestTextMate.cs +++ b/src/PSTextMate/Cmdlets/TestTextMate.cs @@ -1,6 +1,3 @@ -using System.IO; -using System.Management.Automation; -using TextMateSharp.Grammars; namespace PSTextMate.Commands; diff --git a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs index 3fe770b..7a959c9 100644 --- a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs +++ b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs @@ -1,9 +1,3 @@ -using System.Management.Automation; -using PSTextMate; -using PSTextMate.Core; -using PSTextMate.Utilities; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; namespace PSTextMate.Commands; diff --git a/src/PSTextMate/Core/CacheManager.cs b/src/PSTextMate/Core/CacheManager.cs index e5587af..44a8b66 100644 --- a/src/PSTextMate/Core/CacheManager.cs +++ b/src/PSTextMate/Core/CacheManager.cs @@ -1,8 +1,3 @@ -using System.Collections.Concurrent; -using TextMateSharp.Grammars; -using TextMateSharp.Registry; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Core/HighlightedText.cs b/src/PSTextMate/Core/HighlightedText.cs index 95f8369..dec871a 100644 --- a/src/PSTextMate/Core/HighlightedText.cs +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -1,9 +1,3 @@ -using System.Globalization; -using System.Linq; -using Spectre.Console; -using Spectre.Console.Rendering; -using PSTextMate.Utilities; - namespace PSTextMate.Core; /// @@ -67,7 +61,7 @@ public void SetView(IEnumerable source, int start, int count) { ? coll.Count : source is IReadOnlyCollection rocoll ? rocoll.Count - : source is System.Collections.ICollection nonGeneric ? nonGeneric.Count : -1; + : source is ICollection nonGeneric ? nonGeneric.Count : -1; } /// diff --git a/src/PSTextMate/Core/MarkdownRenderer.cs b/src/PSTextMate/Core/MarkdownRenderer.cs index f7f6e6f..b4d4b9d 100644 --- a/src/PSTextMate/Core/MarkdownRenderer.cs +++ b/src/PSTextMate/Core/MarkdownRenderer.cs @@ -1,7 +1,3 @@ -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Core/MarkdownToken.cs b/src/PSTextMate/Core/MarkdownToken.cs index d5a5eca..9009780 100644 --- a/src/PSTextMate/Core/MarkdownToken.cs +++ b/src/PSTextMate/Core/MarkdownToken.cs @@ -1,5 +1,3 @@ -using TextMateSharp.Grammars; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Core/StandardRenderer.cs b/src/PSTextMate/Core/StandardRenderer.cs index c24e5f4..3c43b3f 100644 --- a/src/PSTextMate/Core/StandardRenderer.cs +++ b/src/PSTextMate/Core/StandardRenderer.cs @@ -1,10 +1,3 @@ -using System.Text; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Core/StyleHelper.cs b/src/PSTextMate/Core/StyleHelper.cs index 2ff5602..a270c1d 100644 --- a/src/PSTextMate/Core/StyleHelper.cs +++ b/src/PSTextMate/Core/StyleHelper.cs @@ -1,6 +1,3 @@ -using Spectre.Console; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Core/TextMateProcessor.cs b/src/PSTextMate/Core/TextMateProcessor.cs index f5a8fd9..e448d6b 100644 --- a/src/PSTextMate/Core/TextMateProcessor.cs +++ b/src/PSTextMate/Core/TextMateProcessor.cs @@ -1,11 +1,3 @@ -using System.Text; -using PSTextMate.Core; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// @@ -32,7 +24,7 @@ public static class TextMateProcessor { } try { - (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); + (Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); // Resolve grammar using CacheManager which knows how to map language ids and extensions IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension) ?? throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); @@ -69,7 +61,7 @@ public static class TextMateProcessor { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); try { - (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); + (Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); if (grammar is null) { diff --git a/src/PSTextMate/Core/TokenProcessor.cs b/src/PSTextMate/Core/TokenProcessor.cs index 3f2f4fb..5281e2d 100644 --- a/src/PSTextMate/Core/TokenProcessor.cs +++ b/src/PSTextMate/Core/TokenProcessor.cs @@ -1,11 +1,3 @@ -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using System.Text; -using PSTextMate.Utilities; -using Spectre.Console; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Rendering/BlockRenderer.cs b/src/PSTextMate/Rendering/BlockRenderer.cs index f47090f..326558d 100644 --- a/src/PSTextMate/Rendering/BlockRenderer.cs +++ b/src/PSTextMate/Rendering/BlockRenderer.cs @@ -1,12 +1,3 @@ -using Markdig.Extensions.Tables; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Rendering; /// @@ -98,7 +89,7 @@ HtmlBlock html /// Extracts alt text from an image link inline. /// private static string ExtractImageAltText(LinkInline imageLink) { - var textBuilder = new System.Text.StringBuilder(); + var textBuilder = new StringBuilder(); foreach (Inline inline in imageLink) { if (inline is LiteralInline literal) { diff --git a/src/PSTextMate/Rendering/CodeBlockRenderer.cs b/src/PSTextMate/Rendering/CodeBlockRenderer.cs index 44a2df9..0f2b97b 100644 --- a/src/PSTextMate/Rendering/CodeBlockRenderer.cs +++ b/src/PSTextMate/Rendering/CodeBlockRenderer.cs @@ -1,14 +1,3 @@ -using System.Buffers; -using System.Text; -using Markdig.Helpers; -using Markdig.Syntax; -using PSTextMate.Core; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Rendering; /// diff --git a/src/PSTextMate/Rendering/HeadingRenderer.cs b/src/PSTextMate/Rendering/HeadingRenderer.cs index 56e2f69..72f00ea 100644 --- a/src/PSTextMate/Rendering/HeadingRenderer.cs +++ b/src/PSTextMate/Rendering/HeadingRenderer.cs @@ -1,12 +1,3 @@ -using System.Text; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using PSTextMate.Core; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Themes; - namespace PSTextMate.Rendering; /// diff --git a/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs b/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs index 253479e..d9933ef 100644 --- a/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs +++ b/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs @@ -1,6 +1,3 @@ -using Spectre.Console; -using Spectre.Console.Rendering; - namespace PSTextMate.Rendering; /// diff --git a/src/PSTextMate/Rendering/HtmlBlockRenderer.cs b/src/PSTextMate/Rendering/HtmlBlockRenderer.cs index b1104e4..77a7f80 100644 --- a/src/PSTextMate/Rendering/HtmlBlockRenderer.cs +++ b/src/PSTextMate/Rendering/HtmlBlockRenderer.cs @@ -1,12 +1,3 @@ -using Markdig.Syntax; -using PSTextMate.Core; -using Spectre.Console; -using Spectre.Console.Rendering; -using System.IO; -using System.Text.RegularExpressions; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Rendering; /// @@ -52,7 +43,7 @@ private static List ExtractHtmlLines(HtmlBlock htmlBlock) { var htmlLines = new List(); for (int i = 0; i < htmlBlock.Lines.Count; i++) { - Markdig.Helpers.StringLine line = htmlBlock.Lines.Lines[i]; + StringLine line = htmlBlock.Lines.Lines[i]; htmlLines.Add(line.Slice.ToString()); } @@ -106,11 +97,9 @@ private static bool TryExtractImageTag(List htmlLines, out HtmlImageTag? } Match digits = DimensionDigitsRegex().Match(value); - if (!digits.Success) { - return null; - } - - return int.TryParse(digits.Value, out int parsed) && parsed > 0 + return !digits.Success + ? null + : int.TryParse(digits.Value, out int parsed) && parsed > 0 ? parsed : null; } diff --git a/src/PSTextMate/Rendering/ImageBlockRenderer.cs b/src/PSTextMate/Rendering/ImageBlockRenderer.cs index e82b86e..698f0c0 100644 --- a/src/PSTextMate/Rendering/ImageBlockRenderer.cs +++ b/src/PSTextMate/Rendering/ImageBlockRenderer.cs @@ -1,6 +1,3 @@ -using Spectre.Console; -using Spectre.Console.Rendering; - namespace PSTextMate.Rendering; /// diff --git a/src/PSTextMate/Rendering/ImageRenderer.cs b/src/PSTextMate/Rendering/ImageRenderer.cs index acf1e79..7ac0c9f 100644 --- a/src/PSTextMate/Rendering/ImageRenderer.cs +++ b/src/PSTextMate/Rendering/ImageRenderer.cs @@ -1,10 +1,3 @@ -using System.Reflection; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; - -#pragma warning disable CS0103 // The name 'SixelImage' does not exist in the current context - namespace PSTextMate.Rendering; /// @@ -153,164 +146,42 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int /// Attempts to create a sixel renderable using the newest available implementation. /// private static bool TryCreateSixelRenderable(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) - => TryCreatePixelImage(imagePath, maxWidth, out result) || TryCreateSpectreSixelImage(imagePath, maxWidth, maxHeight, out result); + => TryCreatePixelImage(imagePath, maxWidth, maxHeight, out result); /// - /// Attempts to create a PixelImage from PwshSpectreConsole using reflection. + /// Attempts to create a local PixelImage backed by the new sixel implementation. /// - private static bool TryCreatePixelImage(string imagePath, int? maxWidth, out IRenderable? result) { + private static bool TryCreatePixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { result = null; try { - var pixelImageType = Type.GetType("PwshSpectreConsole.PixelImage, PwshSpectreConsole"); - if (pixelImageType is null) { + if (!Compatibility.TerminalSupportsSixel()) { + _lastSixelError = "Terminal does not report Sixel support."; return false; } - ConstructorInfo? constructor = pixelImageType.GetConstructor([typeof(string), typeof(bool)]); - if (constructor is null) { - _lastSixelError = "Constructor not found for PixelImage with (string, bool) parameters"; + if (!File.Exists(imagePath)) { + _lastSixelError = $"Image file not found: {imagePath}"; return false; } - object? pixelInstance; - try { - pixelInstance = constructor.Invoke([imagePath, false]); - } - catch (Exception ex) { - _lastSixelError = $"Failed to invoke PixelImage constructor: {ex.InnerException?.Message ?? ex.Message}"; - return false; - } - - if (pixelInstance is null) { - _lastSixelError = "PixelImage constructor returned null"; - return false; - } + var pixelImage = new PixelImage(imagePath, animationDisabled: false); if (maxWidth.HasValue) { - PropertyInfo? maxWidthProperty = pixelImageType.GetProperty("MaxWidth"); - if (maxWidthProperty?.CanWrite == true) { - maxWidthProperty.SetValue(pixelInstance, maxWidth.Value); - } - } - - if (pixelInstance is IRenderable renderable) { - result = renderable; - return true; - } - } - catch (Exception ex) { - _lastSixelError = ex.Message; - } - - return false; - } - - /// - /// Attempts to create a Spectre.Console SixelImage using reflection for backward compatibility. - /// - private static bool TryCreateSpectreSixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { - result = null; - - try { - // Try the direct approach - SixelImage is in Spectre.Console namespace - // but might be in different assemblies (Spectre.Console vs Spectre.Console.ImageSharp) - Type? sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") - ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); - - // If that fails, search through loaded assemblies - if (sixelImageType is null) { - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { - string? assemblyName = assembly.GetName().Name; - if (assemblyName?.Contains("Spectre.Console") == true) { - // SixelImage is in Spectre.Console namespace regardless of assembly - sixelImageType = assembly.GetType("Spectre.Console.SixelImage"); - if (sixelImageType is not null) { - break; - } - } - } - } - - if (sixelImageType is null) { - // Debug: Let's see what Spectre.Console types are available - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { - if (assembly.GetName().Name?.Contains("Spectre.Console") == true) { - string?[]? spectreTypes = [.. assembly.GetTypes() - .Where(t => t.Name.Contains("Sixel", StringComparison.OrdinalIgnoreCase)) - .Select(t => t.FullName) - .Where(name => name is not null)]; - - if (spectreTypes.Length > 0) { - // Found some Sixel-related types, try the first one - sixelImageType = assembly.GetType(spectreTypes[0]!); - break; - } - } - } - } - - if (sixelImageType is null) { - return false; - } - - // Create SixelImage instance - ConstructorInfo? constructor = sixelImageType.GetConstructor([typeof(string), typeof(bool)]); - if (constructor is null) { - _lastSixelError = "Constructor not found for SixelImage with (string, bool) parameters"; - return false; + pixelImage.MaxWidth = maxWidth.Value; } - object? sixelInstance; - try { - sixelInstance = constructor.Invoke([imagePath, false]); // false = animation disabled - } - catch (Exception ex) { - _lastSixelError = $"Failed to invoke SixelImage constructor: {ex.InnerException?.Message ?? ex.Message}"; - return false; - } - - if (sixelInstance is null) { - _lastSixelError = "SixelImage constructor returned null"; + // MaxHeight is handled internally by PixelImage through terminal-height based clipping + // when MaxWidth is not explicitly user-limited. + if (maxHeight.HasValue && maxHeight.Value <= 0) { + _lastSixelError = "MaxHeight must be greater than zero when specified."; return false; } - // Apply size constraints if available - if (maxWidth.HasValue) { - PropertyInfo? maxWidthProperty = sixelImageType.GetProperty("MaxWidth"); - if (maxWidthProperty?.CanWrite == true) { - maxWidthProperty.SetValue(sixelInstance, maxWidth.Value); - } - else { - // Try method-based approach as fallback - MethodInfo? maxWidthMethod = sixelImageType.GetMethod("MaxWidth"); - if (maxWidthMethod is not null) { - sixelInstance = maxWidthMethod.Invoke(sixelInstance, [maxWidth.Value]); - } - } - } - - if (maxHeight.HasValue) { - PropertyInfo? maxHeightProperty = sixelImageType.GetProperty("MaxHeight"); - if (maxHeightProperty?.CanWrite == true) { - maxHeightProperty.SetValue(sixelInstance, maxHeight.Value); - } - else { - // Try method-based approach as fallback - MethodInfo? maxHeightMethod = sixelImageType.GetMethod("MaxHeight"); - if (maxHeightMethod is not null) { - sixelInstance = maxHeightMethod.Invoke(sixelInstance, [maxHeight.Value]); - } - } - } - - if (sixelInstance is IRenderable renderable) { - result = renderable; - return true; - } + result = pixelImage; + return true; } catch (Exception ex) { - // Capture the error for debugging _lastSixelError = ex.Message; } @@ -385,25 +256,7 @@ private static Text CreateImageFallbackInline(string altText, string imageUrl) { /// True if SixelImage can be found public static bool IsSixelImageAvailable() { try { - - // Try direct approaches first - Type? sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") - ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); - - if (sixelImageType is not null) - return true; - - // Search through loaded assemblies - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { - string? assemblyName = assembly.GetName().Name; - if (assemblyName?.Contains("Spectre.Console") == true) { - sixelImageType = assembly.GetType("Spectre.Console.SixelImage"); - if (sixelImageType is not null) - return true; - } - } - - return false; + return Compatibility.TerminalSupportsSixel(); } catch { return false; diff --git a/src/PSTextMate/Rendering/ListRenderer.cs b/src/PSTextMate/Rendering/ListRenderer.cs index 0b70526..a8809a3 100644 --- a/src/PSTextMate/Rendering/ListRenderer.cs +++ b/src/PSTextMate/Rendering/ListRenderer.cs @@ -1,12 +1,3 @@ -using System.Text; -using Markdig.Extensions.TaskLists; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Themes; - namespace PSTextMate.Rendering; /// @@ -182,7 +173,7 @@ private static string RenderNestedListAsText(ListBlock list, Theme theme, int in builder.Append(isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji); } else if (list.IsOrdered) { - builder.Append(System.Globalization.CultureInfo.InvariantCulture, $"{number++}. "); + builder.Append(CultureInfo.InvariantCulture, $"{number++}. "); } else { builder.Append(UnorderedBullet); diff --git a/src/PSTextMate/Rendering/MarkdownRenderer.cs b/src/PSTextMate/Rendering/MarkdownRenderer.cs index 5279221..69e2ad6 100644 --- a/src/PSTextMate/Rendering/MarkdownRenderer.cs +++ b/src/PSTextMate/Rendering/MarkdownRenderer.cs @@ -1,13 +1,3 @@ -using Markdig; -using Markdig.Helpers; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Rendering; /// diff --git a/src/PSTextMate/Rendering/ParagraphRenderer.cs b/src/PSTextMate/Rendering/ParagraphRenderer.cs index a95667f..ba4a276 100644 --- a/src/PSTextMate/Rendering/ParagraphRenderer.cs +++ b/src/PSTextMate/Rendering/ParagraphRenderer.cs @@ -1,16 +1,3 @@ -using System.Text; -using System.Text.RegularExpressions; -using Markdig.Extensions; -using Markdig.Extensions.AutoLinks; -using Markdig.Extensions.TaskLists; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using PSTextMate.Core; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Themes; - namespace PSTextMate.Rendering; /// diff --git a/src/PSTextMate/Rendering/QuoteRenderer.cs b/src/PSTextMate/Rendering/QuoteRenderer.cs index 1ee56ba..d196b55 100644 --- a/src/PSTextMate/Rendering/QuoteRenderer.cs +++ b/src/PSTextMate/Rendering/QuoteRenderer.cs @@ -1,8 +1,3 @@ -using Markdig.Syntax; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Themes; - namespace PSTextMate.Rendering; /// diff --git a/src/PSTextMate/Rendering/TableRenderer.cs b/src/PSTextMate/Rendering/TableRenderer.cs index 6feae9e..4e50c0d 100644 --- a/src/PSTextMate/Rendering/TableRenderer.cs +++ b/src/PSTextMate/Rendering/TableRenderer.cs @@ -1,13 +1,3 @@ -using System.Text; -using Markdig.Extensions.Tables; -using Markdig.Helpers; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using PSTextMate.Core; -using PSTextMate.Utilities; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Themes; using TCell = Markdig.Extensions.Tables.TableCell; namespace PSTextMate.Rendering; @@ -25,7 +15,7 @@ internal static class TableRenderer { /// Theme for styling /// Rendered table with proper styling public static IRenderable? Render(Markdig.Extensions.Tables.Table table, Theme theme) { - var spectreTable = new Spectre.Console.Table { + var spectreTable = new Table { ShowFooters = false, // Configure table appearance diff --git a/src/PSTextMate/Sixel/CellSize.cs b/src/PSTextMate/Sixel/CellSize.cs new file mode 100644 index 0000000..9f6f82a --- /dev/null +++ b/src/PSTextMate/Sixel/CellSize.cs @@ -0,0 +1,10 @@ +namespace PSTextMate.Sixel; + +/// +/// Represents terminal cell dimensions in pixels. +/// +public sealed class CellSize { + public int PixelWidth { get; init; } + + public int PixelHeight { get; init; } +} diff --git a/src/PSTextMate/Sixel/Compatibility.cs b/src/PSTextMate/Sixel/Compatibility.cs new file mode 100644 index 0000000..d0a583d --- /dev/null +++ b/src/PSTextMate/Sixel/Compatibility.cs @@ -0,0 +1,232 @@ +namespace PSTextMate.Sixel; + +/// +/// Sixel terminal compatibility helpers. +/// +public static class Compatibility { + private static readonly string DA1 = "[c"; + public const char ESC = '\u001b'; + + /// + /// Memory-caches the result of the terminal supporting sixel graphics. + /// + private static bool? _terminalSupportsSixel; + /// + /// Memory-caches the result of the terminal cell size, sending the control code is slow. + /// + private static CellSize? _cellSize; + + /// + /// Get the cell size of the terminal in pixel-sixel size. + /// The response to the command will look like [6;20;10t where the 20 is height and 10 is width. + /// I think the 6 is the terminal class, which is not used here. + /// + /// The number of pixel sixels that will fit in a single character cell. + public static CellSize GetCellSize() { + if (_cellSize is not null) { + return _cellSize; + } + string response = GetControlSequenceResponse("[16t"); + + try { + string[] parts = response.Split(';', 't'); + if (parts.Length >= 3) { + int width = int.Parse(parts[2], NumberStyles.Number, CultureInfo.InvariantCulture); + int height = int.Parse(parts[1], NumberStyles.Number, CultureInfo.InvariantCulture); + + // Validate the parsed values are reasonable + if (IsValidCellSize(width, height)) { + _cellSize = new CellSize { + PixelWidth = width, + PixelHeight = height + }; + return _cellSize; + } + } + } + catch { + // Fall through to platform-specific fallback + } + + // Platform-specific fallback values + _cellSize = GetPlatformDefaultCellSize(); + return _cellSize; + } + + /// + /// Check if the terminal supports sixel graphics. + /// This is done by sending the terminal a Device Attributes request. + /// If the terminal responds with a response that contains ";4;" then it supports sixel graphics. + /// https://vt100.net/docs/vt510-rm/DA1.html. + /// + /// True if the terminal supports sixel graphics, false otherwise. + public static bool TerminalSupportsSixel() { + if (_terminalSupportsSixel.HasValue) { + return _terminalSupportsSixel.Value; + } + + string response = GetControlSequenceResponse(DA1); + _terminalSupportsSixel = response.Contains(";4;") || response.Contains(";4c"); + return _terminalSupportsSixel.Value; + } + + /// + /// Send a control sequence to the terminal and read back the response from STDIN. + /// + /// The control sequence to send to the terminal. + /// The response from the terminal. + public static string GetControlSequenceResponse(string controlSequence) { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return string.Empty; + } + + const int timeoutMs = 500; + const int maxRetries = 3; + + for (int retry = 0; retry < maxRetries; retry++) { + try { + var response = new StringBuilder(); + + // Send the control sequence + Console.Write($"{ESC}{controlSequence}"); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < timeoutMs) { + if (!Console.KeyAvailable) { + Thread.Sleep(1); + continue; + } + + ConsoleKeyInfo keyInfo = Console.ReadKey(true); + char key = keyInfo.KeyChar; + response.Append(key); + + // Check if we have a complete response + if (IsCompleteResponse(response)) { + return response.ToString(); + } + } + + // If we got a partial response, return it + if (response.Length > 0) { + return response.ToString(); + } + } + catch (Exception) { + if (retry == maxRetries - 1) { + return string.Empty; + } + } + } + + return string.Empty; + } + /// + /// Check for complete terminal responses + /// + private static bool IsCompleteResponse(StringBuilder response) { + int length = response.Length; + if (length < 2) return false; + + // Look for common terminal response endings + char lastChar = response[length - 1]; + + // Most VT terminal responses end with specific letters + switch (lastChar) { + case 'c': // Device Attributes (ESC[...c) + case 'R': // Cursor Position Report (ESC[row;columnR) + case 't': // Window manipulation (ESC[...t) + case 'n': // Device Status Report (ESC[...n) + case 'y': // DECRPM response (ESC[?...y) + // Make sure it's actually a CSI sequence (ESC[) + return length >= 3 && response[0] == '\x1b' && response[1] == '['; + + case '\\': // String Terminator (ESC\) + return length >= 2 && response[length - 2] == '\x1b'; + + case (char)7: // BEL character + return true; + + default: + // Check for Kitty graphics protocol: ends with ";OK" followed by ST and then another response + if (length >= 7) // Minimum for ";OK" + ESC\ + ESC[...c + { + // Look for ";OK" pattern + bool hasOK = false; + for (int i = 0; i <= length - 3; i++) { + if (response[i] == ';' && i + 2 < length && + response[i + 1] == 'O' && response[i + 2] == 'K') { + hasOK = true; + break; + } + } + + if (hasOK) { + // Look for ESC\ (String Terminator) + int stIndex = -1; + for (int i = 0; i < length - 1; i++) { + if (response[i] == '\x1b' && response[i + 1] == '\\') { + stIndex = i; + break; + } + } + + if (stIndex >= 0 && stIndex + 2 < length) { + // Check if there's a complete response after the ST + int afterSTStart = stIndex + 2; + int afterSTLength = length - afterSTStart; + if (afterSTLength >= 3 && + response[afterSTStart] == '\x1b' && + response[afterSTStart + 1] == '[') { + char afterSTLast = response[length - 1]; + return afterSTLast is 'c' or + 'R' or + 't' or + 'n' or + 'y'; + } + } + } + } + return false; + } + } + /// + /// Minimal validation: only ensures positive integer values. + /// Terminal-reported cell sizes are treated as ground truth. + /// + private static bool IsValidCellSize(int width, int height) + => width > 0 && height > 0; + /// + /// Returns platform-specific default cell size as fallback. + /// + private static CellSize GetPlatformDefaultCellSize() { + // Common terminal default sizes by platform + // macOS terminals (especially with Retina) often use 10x20 + // Windows Terminal: 10x20 + // Linux varies: 8x16 to 10x20 + + // expand this in the future. + + return new CellSize { + PixelWidth = 10, + PixelHeight = 20 + }; + } + + /// + /// Gets the terminal height in cells. Returns 0 if the height cannot be determined. + /// + /// The terminal height in cells, or 0 if unavailable. + public static int GetTerminalHeight() { + try { + if (!Console.IsOutputRedirected) { + return Console.WindowHeight; + } + } + catch { + // Terminal height is unavailable (e.g. no console attached). + } + return 0; + } +} diff --git a/src/PSTextMate/Sixel/ImageCanvas.cs b/src/PSTextMate/Sixel/ImageCanvas.cs new file mode 100644 index 0000000..1aadffc --- /dev/null +++ b/src/PSTextMate/Sixel/ImageCanvas.cs @@ -0,0 +1,197 @@ +namespace PSTextMate.Sixel; + +/// +/// Represents a renderable canvas. +/// +public sealed class ImageCanvas : Renderable { + private struct Cell { + public char Glyph; + public Color? Foreground; + public Color? Background; + } + + private readonly Cell[,] _cells; + + /// + /// Gets the width of the canvas. + /// + public int Width { get; } + + /// + /// Gets the height of the canvas. + /// + public int Height { get; } + + /// + /// Gets or sets the render width of the canvas. + /// + public int? MaxWidth { get; set; } + + /// + /// Gets or sets a value indicating whether or not + /// to scale the canvas when rendering. + /// + public bool Scale { get; set; } = true; + + /// + /// Gets or sets the pixel width. + /// + public int PixelWidth { get; set; } = 2; + + /// + /// Initializes a new instance of the class. + /// + /// The canvas width. + /// The canvas height. + public ImageCanvas(int width, int height) { + if (width < 1) { + throw new ArgumentException("Must be > 1", nameof(width)); + } + + if (height < 1) { + throw new ArgumentException("Must be > 1", nameof(height)); + } + + Width = width; + Height = height; + + _cells = new Cell[Width, Height]; + } + + /// + /// Sets a cell with the specified color in the canvas at the specified location. + /// + /// The X coordinate for the pixel. + /// The Y coordinate for the pixel. + /// The pixel color. + /// The same instance so that multiple calls can be chained. + /// + public ImageCanvas SetCell(int x, int y, Color cellColor) { + if (x < 0 || x >= Width || y < 0 || y >= Height) { + throw new ArgumentOutOfRangeException($"SetCell x/y out of bounds: ({x},{y}) for canvas {Width}x{Height}"); + } + + _cells[x, y].Foreground = cellColor; + return this; + } + public ImageCanvas SetCell(int x, int y, char glyph, Color? cellColor) { + if (x < 0 || x >= Width || y < 0 || y >= Height) { + throw new ArgumentOutOfRangeException($"SetCell x/y out of bounds: ({x},{y}) for canvas {Width}x{Height}"); + } + + _cells[x, y].Glyph = glyph; + _cells[x, y].Foreground = cellColor; + return this; + } + public ImageCanvas SetCell(int x, int y, char glyph, Color? foreground, Color? background) { + if (x < 0 || x >= Width || y < 0 || y >= Height) { + throw new ArgumentOutOfRangeException($"SetCell x/y out of bounds: ({x},{y}) for canvas {Width}x{Height}"); + } + + _cells[x, y].Glyph = glyph; + _cells[x, y].Foreground = foreground; + _cells[x, y].Background = background; + return this; + } + + /// + protected override Measurement Measure(RenderOptions options, int maxWidth) { + if (PixelWidth < 0) { + throw new InvalidOperationException("Pixel width must be greater than zero."); + } + + int width = MaxWidth ?? Width; + + return maxWidth < width * PixelWidth ? new Measurement(maxWidth, maxWidth) : new Measurement(width * PixelWidth, width * PixelWidth); + } + + /// + protected override IEnumerable Render(RenderOptions options, int maxWidth) { + if (PixelWidth < 0) { + throw new InvalidOperationException("Pixel width must be greater than zero."); + } + + IEnumerable DoRender() { + Cell[,] pixels = _cells; + int width = Width; + int height = Height; + + // Got a max width? + if (MaxWidth != null) { + height = (int)(height * ((float)MaxWidth.Value) / Width); + width = MaxWidth.Value; + } + + // Exceed the max width when we take pixel width into account? + if (width * PixelWidth > maxWidth) { + height = (int)(height * (maxWidth / (float)(width * PixelWidth))); + width = maxWidth / PixelWidth; + + // If it's not possible to scale the canvas sufficiently, it's too small to render. + if (height == 0) { + yield break; + } + } + + // Need to rescale the pixel buffer? + if (Scale && (width != Width || height != Height)) { + pixels = ScaleDown(width, height); + } + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + Cell cell = pixels[x, y]; + + // Transparent cell + if (cell.Foreground == null && cell.Background == null && cell.Glyph == '\0') { + yield return ImageSegment.Transparent(PixelWidth); + continue; + } + + string content; + if (cell.Glyph != '\0') { + content = cell.Glyph.ToString(); + if (PixelWidth > content.Length) content = content.PadRight(PixelWidth); + } + else { + content = new string(' ', PixelWidth); + } + + // Treat Color.Default as absence so we don't emit default-bg/fg SGR codes. + Color? fg = cell.Foreground.HasValue && cell.Foreground.Value != Color.Default ? cell.Foreground : null; + Color? bg = cell.Background.HasValue && cell.Background.Value != Color.Default ? cell.Background : null; + + var style = new Style(foreground: fg, background: bg); + yield return ImageSegment.Create(content, style); + } + + yield return Segment.LineBreak; + } + } + + // Materialize the iterator and return segments. + return [.. DoRender()]; + } + + private Cell[,] ScaleDown(int newWidth, int newHeight) { + var buffer = new Cell[newWidth, newHeight]; + int xRatio = ((Width << 16) / newWidth) + 1; + int yRatio = ((Height << 16) / newHeight) + 1; + + for (int i = 0; i < newHeight; i++) { + for (int j = 0; j < newWidth; j++) { + int srcX = (j * xRatio) >> 16; + int srcY = (i * yRatio) >> 16; + + if (srcX < 0) srcX = 0; + if (srcY < 0) srcY = 0; + if (srcX >= Width) srcX = Width - 1; + if (srcY >= Height) srcY = Height - 1; + + buffer[j, i] = _cells[srcX, srcY]; + } + } + + return buffer; + } +} diff --git a/src/PSTextMate/Sixel/ImageSegment.cs b/src/PSTextMate/Sixel/ImageSegment.cs new file mode 100644 index 0000000..85d624a --- /dev/null +++ b/src/PSTextMate/Sixel/ImageSegment.cs @@ -0,0 +1,29 @@ +namespace PSTextMate.Sixel; + +/// +/// Helper methods for creating and working with sixel-related segments. +/// +public static class ImageSegment { + /// + /// Gets a transparent segment. + /// + /// The size of the transparent segment. + /// A transparent segment. + public static Segment Transparent(int size) => Segment.Padding(size); + + /// + /// Creates a new segment with the specified text. + /// + public static Segment Create(string text) => new(text); + + /// + /// Creates a new segment with the specified text and style. + /// + public static Segment Create(string text, Style style) => new(text, style); + + /// + /// Wrapper around . + /// + public static List SplitOverflow(Segment segment, Overflow? overflow, int maxWidth) + => Segment.SplitOverflow(segment, overflow, maxWidth); +} diff --git a/src/PSTextMate/Sixel/PixelImage.cs b/src/PSTextMate/Sixel/PixelImage.cs new file mode 100644 index 0000000..51e696c --- /dev/null +++ b/src/PSTextMate/Sixel/PixelImage.cs @@ -0,0 +1,133 @@ +namespace PSTextMate.Sixel; + +/// +/// Represents a renderable image, with pixel rendering (ie sub-cell). +/// +/// +/// Initializes a new instance of the class. +/// +public sealed class PixelImage : Renderable { + private const char ESC = '\u001b'; + /// + /// Gets the image width in pixels. + /// + public int Width => Image.Width; + + /// + /// Gets the image height in pixels. + /// + public int Height => Image.Height; + + /// + /// Gets or sets the render width of the canvas in terminal cells. + /// + public int? MaxWidth { get; set; } + + /// + /// Gets the render width of the canvas. This is hard coded to 1 for sixel images. + /// + public int PixelWidth { get; } = 1; + + /// + /// Gets a value indicating whether the image should be animated. + /// + public bool AnimationDisabled { get; init; } + + /// + /// Gets or sets the current frame of the image. + /// + public int FrameToRender { + get => _frameToRender; + set { + if (value < 0) { + throw new InvalidOperationException("Frame to render must be greater than zero."); + } + + if (value >= Image.Frames.Count) { + throw new InvalidOperationException("Frame to render must be less than the total number of frames in the image."); + } + + _frameToRender = value; + } + } + + internal SixLabors.ImageSharp.Image Image { get; private set; } + private readonly Dictionary _cachedSixels = []; + private int _frameToRender; + + public PixelImage(string filename, bool animationDisabled = false) { + AnimationDisabled = animationDisabled; + Image = SixImage.Load(filename); + } + + /// + protected override Measurement Measure(RenderOptions options, int maxWidth) { + if (PixelWidth < 0) { + throw new InvalidOperationException("Pixel width must be greater than zero."); + } + + int width = MaxWidth ?? Width; + return maxWidth < width * PixelWidth ? new Measurement(maxWidth, maxWidth) : new Measurement(width * PixelWidth, width * PixelWidth); + } + + /// + protected override IEnumerable Render(RenderOptions options, int maxWidth) { + // Got a max width smaller than the render max width? + // When MaxWidth is explicitly set by the user, use it and don't constrain height. + // When MaxWidth is not set, constrain the image to the terminal height so tall images + // don't cause sixel scrolling artifacts. + int? maxCellHeight = null; + if (MaxWidth != null && MaxWidth < maxWidth) { + maxWidth = MaxWidth.Value; + } + else { + int terminalHeight = Compatibility.GetTerminalHeight(); + if (terminalHeight > 0) { + maxCellHeight = terminalHeight - 4; // Leave some room for the prompt and avoid triggering terminal scroll when rendering images that are close to the terminal height. + } + } + + // Write the sixel data as a control segment. + // Parsing is expensive, cache the result for the current width. + if (!_cachedSixels.TryGetValue(maxWidth, out Sixel sixel)) { + sixel = SixelRender.ImageToSixel(Image, maxWidth, AnimationDisabled, maxCellHeight); + _cachedSixels.Add(maxWidth, sixel); + } + + // Draw a transparent renderable to take up the space the sixel is drawn in. + // This allows Spectre.Console to render the image and not write overtop of it with space characters while padding panel borders etc. + var canvas = new ImageCanvas(sixel.CellWidth, sixel.CellHeight) { + MaxWidth = sixel.CellWidth, + PixelWidth = PixelWidth, + Scale = false, + }; + + // The segment list is a transparent canvas followed by a couple of zero-width control segments for sixel data output. + // Rendering the sixel data after the canvas allows the canvas to be truncated in a layout without destroying the layout. + var segments = ((IRenderable)canvas).Render(options, maxWidth).ToList(); + + // Remove the final line break from the canvas so the sixel data can be rendered relative to the top left of the canvas. + // Leaving the line break in means when this is rendered with IAlignable the cursor position after the canvas is in the wrong location. + Segment finalSegment = segments.TakeLast(1).First(); + if (finalSegment.IsLineBreak) { + segments.RemoveAt(segments.Count - 1); + } + + // After rendering the canvas, send the cursor to the top left of the canvas to render the sixel data. + segments.Add(Segment.Control($"{ESC}[{sixel.CellHeight - 1}A{ESC}[{sixel.CellWidth}D")); + + // Render the sixel data. + segments.Add(Segment.Control(sixel.SixelStrings[FrameToRender])); + + // Reposition the cursor to the bottom right of the canvas after the sixel rendering leaves it at the bottom left. + segments.Add(Segment.Control($"{ESC}[1A{ESC}[{sixel.CellWidth}C")); + + // Add the line break stolen from the canvas. + segments.Add(Segment.LineBreak); + + // Update animation frame. + FrameToRender = (FrameToRender + 1) % sixel.SixelStrings.Length; + + return segments; + } +} diff --git a/src/PSTextMate/Sixel/Sixel.cs b/src/PSTextMate/Sixel/Sixel.cs new file mode 100644 index 0000000..038ad6f --- /dev/null +++ b/src/PSTextMate/Sixel/Sixel.cs @@ -0,0 +1,40 @@ +namespace PSTextMate.Sixel; + +/// +/// Represents the size of a cell in pixels for sixel rendering. +/// +/// +/// Initializes a new instance of the class. +/// +/// The width of a sixel image in pixels. +/// The height of a sixel image in pixels. +/// The height of a sixel image in terminal cells. +/// The width of a sixel image in terminal cells. +/// The Sixel strings representing each frame of the image. +public readonly struct Sixel(int pixelWidth, int pixelHeight, int cellHeight, int cellWidth, string[] sixelStrings) { + /// + /// Gets the width of a sixel image in pixels. + /// + public int PixelWidth { get; init; } = pixelWidth; + + /// + /// Gets the height of a sixel image in pixels. + /// + public int PixelHeight { get; init; } = pixelHeight; + + /// + /// Gets the height of a sixel image in terminal cells. + /// + public int CellHeight { get; init; } = cellHeight; + + /// + /// Gets the width of a sixel image in terminal cells. + /// + public int CellWidth { get; init; } = cellWidth; + + /// + /// Gets the Sixel string. + /// + /// The Sixel string. + public string[] SixelStrings { get; init; } = sixelStrings; +} diff --git a/src/PSTextMate/Sixel/SixelRender.cs b/src/PSTextMate/Sixel/SixelRender.cs new file mode 100644 index 0000000..c18e7d8 --- /dev/null +++ b/src/PSTextMate/Sixel/SixelRender.cs @@ -0,0 +1,311 @@ +using SixLabors.ImageSharp; +using Size = SixLabors.ImageSharp.Size; + +namespace PSTextMate.Sixel; + +/// +/// Contains methods for converting an image to a Sixel format. +/// +public static class SixelRender { + /// + /// The character to use when entering a terminal escape code sequence. + /// + public const char ESC = '\u001b'; + + /// + /// The character to indicate the start of a sixel color palette entry or to switch to a new color. + /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.3. + /// + public const char SIXELCOLOR = '#'; + + /// + /// The character to use when a sixel is empty/transparent. + /// ? (hex 3F) represents the binary value 000000. + /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1. + /// + public const char SIXELEMPTY = '?'; + + /// + /// The character to use when entering a repeated sequence of a color in a sixel. + /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.1. + /// + public const char SIXELREPEAT = '!'; + + /// + /// The character to use when moving to the next line in a sixel. + /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.5. + /// + public const char SIXELDECGNL = '-'; + + /// + /// The character to use when going back to the start of the current line in a sixel to write more data over it. + /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.4. + /// + public const char SIXELDECGCR = '$'; + + /// + /// The start of a sixel sequence. + /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1. + /// + public static readonly string SIXELSTART = $"{ESC}P0;1q"; + + /// + /// The raster settings for setting the sixel pixel ratio to 1:1 so images are square when rendered instead of the 2:1 double height default. + /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.2. + /// + public const string SIXELRASTERATTRIBUTES = "\"1;1;"; + + /// + /// The end of a sixel sequence. + /// + public static readonly string SIXELEND = $"{ESC}\\"; + + /// + /// The transparent color for the sixel, this is black but the sixel should be transparent so this is not visible. + /// + public const string SIXELTRANSPARENTCOLOR = "#0;2;0;0;0"; + + /// + /// explicit space char for clarity + /// + public const char Space = ' '; + + /// + /// Converts an image to a Sixel object. + /// This uses a copy of the c# sixel codec from @trackd and @ShaunLawrie in https://github.com/trackd/Sixel. + /// + /// The image to convert. + /// The width of the cell in terminal cells. + /// Whether to disable animation for the image and only load the first frame. + /// Optional maximum height in terminal cells. When set, the image will be scaled down + /// to fit within this height while maintaining its aspect ratio. This prevents sixel images from scrolling the + /// terminal during rendering which misaligns with Spectre Console's cursor position tracking. + /// The Sixel object. + public static Sixel ImageToSixel(Image image, int cellWidth, bool disableAnimation = false, int? maxCellHeight = null) { + // We're going to resize the image when it's rendered, so use a copy to leave the original untouched. + Image imageClone = image.Clone(); + + // Convert to pixel sizes. + CellSize cellSize = Compatibility.GetCellSize(); + int pixelWidth = cellWidth * cellSize.PixelWidth; + int pixelHeight = (int)Math.Round((double)imageClone.Height / imageClone.Width * pixelWidth); + + // Cap the height to prevent sixel scrolling artifacts. + // When a sixel image is taller than the terminal, it scrolls the terminal during + // rendering which misaligns with Spectre Console's cursor position tracking. + if (maxCellHeight.HasValue && maxCellHeight.Value > 0) { + int maxPixelHeight = maxCellHeight.Value * cellSize.PixelHeight; + if (pixelHeight > maxPixelHeight) { + pixelHeight = maxPixelHeight; + pixelWidth = (int)Math.Round((double)imageClone.Width / imageClone.Height * pixelHeight); + cellWidth = (int)Math.Ceiling((double)pixelWidth / cellSize.PixelWidth); + } + } + + imageClone.Mutate(ctx => { + // Resize the image to the target size + ctx.Resize(new ResizeOptions() { + Sampler = KnownResamplers.Bicubic, + Size = new Size(pixelWidth, pixelHeight), + PremultiplyAlpha = false, + }); + + // Sixel supports 256 colors max + ctx.Quantize(new OctreeQuantizer(new() { + MaxColors = 256, + })); + }); + + int cellPixelHeight = cellSize.PixelHeight; + int cellHeight = (int)Math.Ceiling((double)pixelHeight / cellPixelHeight); + var sixelStrings = new List(); + + for (int i = 0; i < imageClone.Frames.Count; i++) { + sixelStrings.Add( + FrameToSixelString( + imageClone.Frames[i], + cellHeight, + cellPixelHeight)); + + if (disableAnimation) { + break; + } + } + + return new Sixel( + pixelWidth, + pixelHeight, + cellHeight, + cellWidth, + [.. sixelStrings] + ); + } + + /// + /// Converts an image frame to a Sixel string. + /// + /// The image frame to convert. + /// The height of the cell in terminal cells. + /// The height of in individual cell in pixels. + /// The Sixel string. + private static string FrameToSixelString(ImageFrame frame, int cellHeight, int cellPixelHeight) { + var sixelBuilder = new StringBuilder(); + var palette = new Dictionary(); + int colorCounter = 1; + int y = 0; + sixelBuilder.StartSixel(frame.Width, cellHeight * cellPixelHeight); + frame.ProcessPixelRows(accessor => { + for (y = 0; y < accessor.Height; y++) { + Span pixelRow = accessor.GetRowSpan(y); + + // The value of 1 left-shifted by the remainder of the current row divided by 6 gives the correct sixel character offset from the empty sixel char for each row. + // See the description of s...s for more detail on the sixel format https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1 + char c = (char)(SIXELEMPTY + (1 << (y % 6))); + int lastColor = -1; + int repeatCounter = 0; + + foreach (ref Rgba32 pixel in pixelRow) { + // The colors can be added to the palette and interleaved with the sixel data so long as the color is defined before it is used. + if (!palette.TryGetValue(pixel, out int colorIndex)) { + colorIndex = colorCounter++; + palette[pixel] = colorIndex; + sixelBuilder.AddColorToPalette(pixel, colorIndex); + } + + // Transparency is a special color index of 0 that exists in our sixel palette. + int colorId = pixel.A == 0 ? 0 : colorIndex; + + // Sixel data will use a repeat entry if the color is the same as the last one. + // https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.1 + if (colorId == lastColor || repeatCounter == 0) { + // If the color was repeated go to the next loop iteration to check the next pixel. + lastColor = colorId; + repeatCounter++; + continue; + } + + // Every time the color is not repeated the previous color is written to the string. + sixelBuilder.AppendSixel(lastColor, repeatCounter, c); + + // Remember the current color and reset the repeat counter. + lastColor = colorId; + repeatCounter = 1; + } + + // Write the last color and repeat counter to the string for the current row. + sixelBuilder.AppendSixel(lastColor, repeatCounter, c); + + // Add a carriage return at the end of each row and a new line every 6 pixel rows. + sixelBuilder.AppendCarriageReturn(); + if (y % 6 == 5) { + sixelBuilder.AppendNextLine(); + } + } + + // Padding to ensure the cursor finishes below the image not halfway through the rendered pixels. + for (int padding = y; padding <= (cellHeight * cellPixelHeight); padding++) { + if (padding % 6 == 5) { + sixelBuilder.AppendNextLine(); + } + } + + // And a final newline to position the cursor under the image. + sixelBuilder.AppendNextLine(); + }); + + sixelBuilder.AppendExitSixel(); + + return sixelBuilder.ToString(); + } + + /// + /// Adds a color to the sixel palette. + /// + /// The string builder to write to. + /// The pixel to add to the palette. + /// The index of the color in the palette. + private static void AddColorToPalette(this StringBuilder sixelBuilder, Rgba32 pixel, int colorIndex) { + // rgb 0-255 needs to be translated to 0-100 for sixel. + (int r, int g, int b) = ( + pixel.R * 100 / 255, + pixel.G * 100 / 255, + pixel.B * 100 / 255 + ); + + sixelBuilder.Append(SIXELCOLOR) + .Append(colorIndex) + .Append(";2;") + .Append(r) + .Append(';') + .Append(g) + .Append(';') + .Append(b); + } + + /// + /// Writes a repeated sixel entry to the string builder. + /// + /// The string builder to write to. + /// The index of the color in the palette. + /// The number of times the color is repeated. + /// The sixel character to write. + private static void AppendSixel(this StringBuilder sixelBuilder, int colorIndex, int repeatCounter, char sixel) { + if (colorIndex == 0) { + // Transparent pixels are a special case and are always 0 in the palette. + sixel = SIXELEMPTY; + } + if (repeatCounter <= 1) { + // single entry + sixelBuilder + .Append(SIXELCOLOR) + .Append(colorIndex) + .Append(sixel); + } + else { + // add repeats + sixelBuilder + .Append(SIXELCOLOR) + .Append(colorIndex) + .Append(SIXELREPEAT) + .Append(repeatCounter) + .Append(sixel); + } + } + + /// + /// Writes the Sixel carriage return sequence to the string builder. + /// + /// The string builder to write to. + private static void AppendCarriageReturn(this StringBuilder sixelBuilder) + => sixelBuilder.Append(SIXELDECGCR); + + /// + /// Writes the Sixel next line sequence to the string builder. + /// + /// The string builder to write to. + private static void AppendNextLine(this StringBuilder sixelBuilder) + => sixelBuilder.Append(SIXELDECGNL); + + /// + /// Writes the Sixel exit sequence to the string builder. + /// + /// The string builder to write to. + private static void AppendExitSixel(this StringBuilder sixelBuilder) + => sixelBuilder.Append(SIXELEND); + + /// + /// Writes the Sixel start sequence to the string builder. + /// + /// The string builder to write to. + /// The width of the image in pixels. + /// The height of the image in pixels. + private static void StartSixel(this StringBuilder sixelBuilder, int width, int height) { + sixelBuilder + .Append(SIXELSTART) + .Append(SIXELRASTERATTRIBUTES) + .Append(width) + .Append(';') + .Append(height) + .Append(SIXELTRANSPARENTCOLOR); + } +} diff --git a/src/PSTextMate/Utilities/AssemblyInfo.cs b/src/PSTextMate/Utilities/AssemblyInfo.cs index 1febb66..e3b4415 100644 --- a/src/PSTextMate/Utilities/AssemblyInfo.cs +++ b/src/PSTextMate/Utilities/AssemblyInfo.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("PSTextMate.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/PSTextMate/Utilities/Completers.cs b/src/PSTextMate/Utilities/Completers.cs index d70283e..5eeaa10 100644 --- a/src/PSTextMate/Utilities/Completers.cs +++ b/src/PSTextMate/Utilities/Completers.cs @@ -1,11 +1,3 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Management.Automation; -using System.Management.Automation.Language; -using System.Text.RegularExpressions; - namespace PSTextMate; /// diff --git a/src/PSTextMate/Utilities/Helpers.cs b/src/PSTextMate/Utilities/Helpers.cs index 7bf78a8..8ab522f 100644 --- a/src/PSTextMate/Utilities/Helpers.cs +++ b/src/PSTextMate/Utilities/Helpers.cs @@ -1,5 +1,3 @@ -using TextMateSharp.Grammars; - namespace PSTextMate; /// @@ -41,11 +39,7 @@ internal static string[] SplitToLines(string input) { return [string.Empty]; } - if (input.Contains('\n') || input.Contains('\r')) { - return input.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); - } - - return [input]; + return input.Contains('\n') || input.Contains('\r') ? input.Split(["\r\n", "\n", "\r"], StringSplitOptions.None) : [input]; } internal static string[] NormalizeToLines(List buffer) { diff --git a/src/PSTextMate/Utilities/ITextMateStyler.cs b/src/PSTextMate/Utilities/ITextMateStyler.cs index 47c9cca..21930b7 100644 --- a/src/PSTextMate/Utilities/ITextMateStyler.cs +++ b/src/PSTextMate/Utilities/ITextMateStyler.cs @@ -1,6 +1,3 @@ -using Spectre.Console; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Utilities/ImageFile.cs b/src/PSTextMate/Utilities/ImageFile.cs index 8b58502..9dcd9ae 100644 --- a/src/PSTextMate/Utilities/ImageFile.cs +++ b/src/PSTextMate/Utilities/ImageFile.cs @@ -1,13 +1,6 @@ // class to normalize image file path/url/base64, basically any image source that is allowed in markdown. // if it is something Spectre.Console.SixelImage(string filename, bool animations) cannot handle we need to fix that, like downloading to a temporary file or converting the base64 to a file.. -using System; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text.RegularExpressions; -using System.Threading.Tasks; - namespace PSTextMate.Utilities; /// diff --git a/src/PSTextMate/Utilities/InlineTextExtractor.cs b/src/PSTextMate/Utilities/InlineTextExtractor.cs index e4edabb..1671cd5 100644 --- a/src/PSTextMate/Utilities/InlineTextExtractor.cs +++ b/src/PSTextMate/Utilities/InlineTextExtractor.cs @@ -1,6 +1,3 @@ -using System.Text; -using Markdig.Syntax.Inlines; - namespace PSTextMate.Utilities; /// diff --git a/src/PSTextMate/Utilities/MarkdownPatterns.cs b/src/PSTextMate/Utilities/MarkdownPatterns.cs index 8dc175e..45c7036 100644 --- a/src/PSTextMate/Utilities/MarkdownPatterns.cs +++ b/src/PSTextMate/Utilities/MarkdownPatterns.cs @@ -1,6 +1,3 @@ -using Markdig.Syntax; -using Markdig.Syntax.Inlines; - namespace PSTextMate.Utilities; /// diff --git a/src/PSTextMate/Utilities/Pager.cs b/src/PSTextMate/Utilities/Pager.cs index b89c173..b77f81b 100644 --- a/src/PSTextMate/Utilities/Pager.cs +++ b/src/PSTextMate/Utilities/Pager.cs @@ -1,9 +1,3 @@ -using System.Globalization; -using Spectre.Console; -using Spectre.Console.Rendering; -using PSTextMate.Core; -using System.Reflection; - namespace PSTextMate.Utilities; /// diff --git a/src/PSTextMate/Utilities/PagerNormal.cs b/src/PSTextMate/Utilities/PagerNormal.cs index 0510584..437c9dc 100644 --- a/src/PSTextMate/Utilities/PagerNormal.cs +++ b/src/PSTextMate/Utilities/PagerNormal.cs @@ -1,9 +1,3 @@ -using System.Globalization; -using Spectre.Console; -using Spectre.Console.Rendering; -using System.Linq; -using PSTextMate.Core; - namespace PSTextMate.Utilities; public class PagerNormal : IDisposable { diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs index a0a44e2..92aa773 100644 --- a/src/PSTextMate/Utilities/SpectreRenderBridge.cs +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -1,9 +1,3 @@ -using System; -using System.IO; -using System.Management.Automation.Host; -using Spectre.Console; -using Spectre.Console.Rendering; - namespace PSTextMate.Utilities; /// diff --git a/src/PSTextMate/Utilities/SpectreStyleCompat.cs b/src/PSTextMate/Utilities/SpectreStyleCompat.cs index 58e9662..80a4d44 100644 --- a/src/PSTextMate/Utilities/SpectreStyleCompat.cs +++ b/src/PSTextMate/Utilities/SpectreStyleCompat.cs @@ -1,6 +1,3 @@ -using System.Reflection; -using Spectre.Console; - namespace PSTextMate.Utilities; internal static class SpectreStyleCompat { diff --git a/src/PSTextMate/Utilities/SpectreTextMateStyler.cs b/src/PSTextMate/Utilities/SpectreTextMateStyler.cs index f091585..24d184a 100644 --- a/src/PSTextMate/Utilities/SpectreTextMateStyler.cs +++ b/src/PSTextMate/Utilities/SpectreTextMateStyler.cs @@ -1,8 +1,3 @@ -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using Spectre.Console; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Utilities/StringBuilderExtensions.cs b/src/PSTextMate/Utilities/StringBuilderExtensions.cs index 75c08f9..7f25ce6 100644 --- a/src/PSTextMate/Utilities/StringBuilderExtensions.cs +++ b/src/PSTextMate/Utilities/StringBuilderExtensions.cs @@ -1,7 +1,3 @@ -using System.Globalization; -using System.Text; -using Spectre.Console; - namespace PSTextMate.Utilities; /// diff --git a/src/PSTextMate/Utilities/StringBuilderPool.cs b/src/PSTextMate/Utilities/StringBuilderPool.cs index 2317a2e..3ede0b1 100644 --- a/src/PSTextMate/Utilities/StringBuilderPool.cs +++ b/src/PSTextMate/Utilities/StringBuilderPool.cs @@ -1,6 +1,3 @@ -using System.Collections.Concurrent; -using System.Text; - namespace PSTextMate.Utilities; internal static class StringBuilderPool { diff --git a/src/PSTextMate/Utilities/StringExtensions.cs b/src/PSTextMate/Utilities/StringExtensions.cs index 89c200d..9ce5fec 100644 --- a/src/PSTextMate/Utilities/StringExtensions.cs +++ b/src/PSTextMate/Utilities/StringExtensions.cs @@ -1,5 +1,3 @@ -using System.Text; - namespace PSTextMate.Utilities; /// diff --git a/src/PSTextMate/Utilities/TMConsole.cs b/src/PSTextMate/Utilities/TMConsole.cs deleted file mode 100644 index 1887777..0000000 --- a/src/PSTextMate/Utilities/TMConsole.cs +++ /dev/null @@ -1,96 +0,0 @@ -#if netstandard2_0 -// just a reference implementation -using PSTextMate.Core; -using Spectre.Console; -using System.Text; -using Spectre.Console.Rendering; - -namespace PSTextMate.Utilities; - -public abstract class TMConsole : IAnsiConsole { - public Profile Profile { get; } - public IAnsiConsoleCursor Cursor { get; } - public IAnsiConsoleInput Input { get; } - public IExclusivityMode ExclusivityMode { get; } - public RenderPipeline Pipeline { get; } - - public int Width { get; set; } = 80; - public int Height { get; set; } = 25; - - public TMConsole() { - var writer = new NoopWriter(); - var output = new SimpleOutput(writer, () => Width, () => Height); - - Profile = new Profile(output, Encoding.Unicode) { - Capabilities = - { - ColorSystem = ColorSystem.TrueColor, - Unicode = true, - Ansi = false, - Links = false, - Legacy = false, - Interactive = false, - AlternateBuffer = false - }, - }; - - Cursor = new NoopConsoleCursor(); - Input = new NoopConsoleInput(); - ExclusivityMode = new ExclusivityMode(); - Pipeline = new RenderPipeline(); - } - - public abstract void Clear(bool home); - public abstract void Write(IRenderable renderable); -} - -public sealed class NoopWriter : TextWriter { - public override Encoding Encoding { get; } = Encoding.Unicode; -} - -public sealed class SimpleOutput : IAnsiConsoleOutput { - private readonly Func _width; - private readonly Func _height; - - public TextWriter Writer { get; } - public bool IsTerminal { get; } = true; - public int Width => _width(); - public int Height => _height(); - - public SimpleOutput(NoopWriter writer, Func width, Func height) { - _width = width ?? throw new ArgumentNullException(nameof(width)); - _height = height ?? throw new ArgumentNullException(nameof(height)); - - Writer = writer; - } - - public void SetEncoding(Encoding encoding) { - } -} - -internal sealed class NoopConsoleCursor : IAnsiConsoleCursor { - public void Show(bool show) { - } - - public void SetPosition(int column, int line) { - } - - public void Move(CursorDirection direction, int steps) { - } -} - -internal sealed class NoopConsoleInput : IAnsiConsoleInput { - public bool IsKeyAvailable() => false; - - public ConsoleKeyInfo? ReadKey(bool intercept) => null; - - public Task ReadKeyAsync(bool intercept, CancellationToken cancellationToken) - => Task.FromResult(null); -} - -internal sealed class ExclusivityMode : IExclusivityMode { - public T Run(Func func) => func(); - - public Task RunAsync(Func> func) => func(); -} -#endif diff --git a/src/PSTextMate/Utilities/TextMateResolver.cs b/src/PSTextMate/Utilities/TextMateResolver.cs index 5bdb41c..06fe6ac 100644 --- a/src/PSTextMate/Utilities/TextMateResolver.cs +++ b/src/PSTextMate/Utilities/TextMateResolver.cs @@ -1,5 +1,3 @@ -using System; - namespace PSTextMate; /// diff --git a/src/PSTextMate/Utilities/ThemeExtensions.cs b/src/PSTextMate/Utilities/ThemeExtensions.cs index f908a47..61f4862 100644 --- a/src/PSTextMate/Utilities/ThemeExtensions.cs +++ b/src/PSTextMate/Utilities/ThemeExtensions.cs @@ -1,7 +1,3 @@ -using PSTextMate.Core; -using Spectre.Console; -using TextMateSharp.Themes; - namespace PSTextMate.Utilities; /// diff --git a/src/PSTextMate/Utilities/TokenStyleProcessor.cs b/src/PSTextMate/Utilities/TokenStyleProcessor.cs index 1010fe0..b2180d2 100644 --- a/src/PSTextMate/Utilities/TokenStyleProcessor.cs +++ b/src/PSTextMate/Utilities/TokenStyleProcessor.cs @@ -1,9 +1,3 @@ -using System.Collections.Generic; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - namespace PSTextMate.Core; /// diff --git a/src/PSTextMate/Utilities/VTConversion.cs b/src/PSTextMate/Utilities/VTConversion.cs index d6517e3..63328f9 100644 --- a/src/PSTextMate/Utilities/VTConversion.cs +++ b/src/PSTextMate/Utilities/VTConversion.cs @@ -1,8 +1,3 @@ -using System.Runtime.CompilerServices; -using System.Text; -using PSTextMate.Utilities; -using Spectre.Console; - namespace PSTextMate.Helpers; /// diff --git a/src/PSTextMate/Utilities/VTHelpers.cs b/src/PSTextMate/Utilities/VTHelpers.cs index 21009ae..770f93d 100644 --- a/src/PSTextMate/Utilities/VTHelpers.cs +++ b/src/PSTextMate/Utilities/VTHelpers.cs @@ -1,5 +1,3 @@ -using PSTextMate.Core; - namespace PSTextMate.Utilities; public static class VTHelpers { diff --git a/src/PSTextMate/Utilities/Writer.cs b/src/PSTextMate/Utilities/Writer.cs index 5d9846e..8c3d7a6 100644 --- a/src/PSTextMate/Utilities/Writer.cs +++ b/src/PSTextMate/Utilities/Writer.cs @@ -1,9 +1,3 @@ -using System.IO; -using System.Runtime.CompilerServices; -using PSTextMate.Core; -using Spectre.Console; -using Spectre.Console.Rendering; - namespace PSTextMate.Utilities; /// @@ -31,6 +25,13 @@ public static string Write(IRenderable renderable) { public static string Write(HighlightedText highlightedText, bool autoPage = true, bool alternatePager = false) { ArgumentNullException.ThrowIfNull(highlightedText); + // Sixel payload must be written as raw control sequences. Converting to a string + // and flowing through host formatting can strip DCS wrappers and print payload text. + if (ContainsImageRenderables(highlightedText.Renderables)) { + AnsiConsole.Write(highlightedText); + return string.Empty; + } + // Keep compatibility with prior callsites while remaining side-effect free. _ = autoPage; _ = alternatePager; @@ -96,4 +97,14 @@ private static int GetConsoleWidth() { return 80; } } + + private static bool ContainsImageRenderables(IEnumerable renderables) + => renderables.Any(IsImageRenderable); + + private static bool IsImageRenderable(IRenderable renderable) { + string name = renderable.GetType().Name; + return name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Image", StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/PSTextMate/Utilities/using.cs b/src/PSTextMate/Utilities/using.cs new file mode 100644 index 0000000..06d0e90 --- /dev/null +++ b/src/PSTextMate/Utilities/using.cs @@ -0,0 +1,43 @@ +global using System; +global using System.Buffers; +global using System.Collections; +global using System.Collections.Concurrent; +global using System.Collections.Generic; +global using System.Diagnostics; +global using System.Globalization; +global using System.IO; +global using System.Linq; +global using System.Management.Automation; +global using System.Management.Automation.Language; +global using System.Net.Http; +global using System.Reflection; +global using System.Runtime.CompilerServices; +global using System.Text; +global using System.Text.RegularExpressions; +global using System.Threading; +global using System.Threading.Tasks; +global using Markdig; +global using Markdig.Extensions; +global using Markdig.Extensions.AutoLinks; +global using Markdig.Extensions.TaskLists; +global using Markdig.Helpers; +global using Markdig.Syntax; +global using Markdig.Syntax.Inlines; +global using PSTextMate; +global using PSTextMate.Core; +global using PSTextMate.Sixel; +global using PSTextMate.Utilities; +global using SixLabors.ImageSharp.PixelFormats; +global using SixLabors.ImageSharp.Processing; +global using SixLabors.ImageSharp.Processing.Processors.Quantization; +global using SixLabors.ImageSharp.Processing.Processors.Transforms; +global using Spectre.Console; +global using Spectre.Console.Rendering; +global using TextMateSharp.Grammars; +global using TextMateSharp.Registry; +global using TextMateSharp.Themes; +global using Color = Spectre.Console.Color; +global using PSHostUserInterface = System.Management.Automation.Host.PSHostUserInterface; +global using SixColor = SixLabors.ImageSharp.Color; +global using SixImage = SixLabors.ImageSharp.Image; +global using TableColumnAlign = Markdig.Extensions.Tables.TableColumnAlign; From 22a214f712ab5408ce17a65a8caad26b215c31a9 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:53:03 +0100 Subject: [PATCH 04/17] =?UTF-8?q?feat(pager):=20=E2=9C=A8=20Implement=20in?= =?UTF-8?q?teractive=20pager=20with=20improved=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced a new `Pager` class for displaying renderables in an interactive pager format. * Removed the deprecated `PagerNormal` class to streamline the codebase. * Enhanced terminal compatibility with support for alternate buffers and synchronized output. * Added `OutPageCmdlet` to facilitate sending renderables or VT-formatted strings to the pager. * Improved image rendering estimation and handling within the pager. * Refactored VT conversion utilities for better performance and maintainability. --- Module/TextMate.psd1 | 8 +- README.md | 7 +- harness.ps1 | 2 - src/PSTextMate/Cmdlets/OutPage.cs | 205 ++++++++++ src/PSTextMate/Cmdlets/TextMateCmdletBase.cs | 13 +- src/PSTextMate/Core/HighlightedText.cs | 28 +- src/PSTextMate/PSTextMate.csproj | 2 +- src/PSTextMate/Sixel/CellSize.cs | 2 +- src/PSTextMate/Sixel/Compatibility.cs | 372 +++++++++++++----- src/PSTextMate/Sixel/ImageCanvas.cs | 2 +- src/PSTextMate/Sixel/ImageSegment.cs | 2 +- src/PSTextMate/Sixel/PixelImage.cs | 2 +- src/PSTextMate/Sixel/Sixel.cs | 2 +- src/PSTextMate/Sixel/SixelRender.cs | 121 +++--- src/PSTextMate/Utilities/Pager.cs | 260 +++++------- src/PSTextMate/Utilities/PagerNormal.cs | 233 ----------- .../Utilities/SpectreStyleCompat.cs | 24 +- src/PSTextMate/Utilities/VTConversion.cs | 41 +- src/PSTextMate/Utilities/VTHelpers.cs | 89 ++++- src/PSTextMate/Utilities/Writer.cs | 41 +- src/PSTextMate/Utilities/using.cs | 1 + 21 files changed, 790 insertions(+), 667 deletions(-) create mode 100644 src/PSTextMate/Cmdlets/OutPage.cs delete mode 100644 src/PSTextMate/Utilities/PagerNormal.cs diff --git a/Module/TextMate.psd1 b/Module/TextMate.psd1 index 890aa52..8a56f9d 100644 --- a/Module/TextMate.psd1 +++ b/Module/TextMate.psd1 @@ -5,7 +5,7 @@ Author = 'trackd' CompanyName = 'trackd' Copyright = '(c) trackd. All rights reserved.' - Description = 'A PowerShell module for syntax highlighting using TextMate grammars. Using PwshSpectreConsole for rendering.' + Description = 'A PowerShell module for syntax highlighting using TextMate grammars with built-in Spectre rendering.' PowerShellVersion = '7.4' CompatiblePSEditions = 'Core' CmdletsToExport = @( @@ -13,6 +13,7 @@ 'Format-CSharp' 'Format-Markdown' 'Format-PowerShell' + 'Out-Page' 'Test-TextMate' 'Get-TextMateGrammar' ) @@ -25,11 +26,6 @@ ) FormatsToProcess = 'TextMate.format.ps1xml' RequiredModules = @() - # @{ - # ModuleName = 'PwshSpectreConsole' - # ModuleVersion = '2.5.0' - # MaximumVersion = '2.99.99' - # } PrivateData = @{ PSData = @{ Tags = 'Windows', 'Linux', 'OSX', 'TextMate', 'Markdown', 'SyntaxHighlighting' diff --git a/README.md b/README.md index 178190f..49ac083 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # TextMate -TextMate delivers syntax-aware highlighting for PowerShell on top of TextMate grammars. It exposes a focused set of cmdlets that emit tokenized, theme-styled `HighlightedText` renderables you can write with PwshSpectreConsole or feed into any Spectre-based pipeline. Helper cmdlets make it easy to discover grammars and validate support for files, extensions, or language IDs before formatting. +TextMate delivers syntax-aware highlighting for PowerShell on top of TextMate grammars. It exposes a focused set of cmdlets that emit tokenized, theme-styled `HighlightedText` renderables you can write directly or feed into any Spectre-based pipeline. Helper cmdlets make it easy to discover grammars and validate support for files, extensions, or language IDs before formatting. What it does - Highlights source text using TextMate grammars such as PowerShell, C#, Markdown, and Python. -- Returns `HighlightedText` renderables that implement Spectre.Console's contract, so they can be written through PwshSpectreConsole or other Spectre hosts. +- Returns `HighlightedText` renderables that implement Spectre.Console's contract, so they can be written directly or through other Spectre hosts. - Provides discovery and testing helpers for installed grammars, extensions, or language IDs. - Does inline Sixel images in markdown @@ -77,8 +77,7 @@ Import-Module .\output\TextMate.psd1 - [TextMateSharp](https://github.com/danipen/TextMateSharp) - [OnigWrap](https://github.com/aikawayataro/Onigwrap) -- [PwshSpectreConsole](https://github.com/ShaunLawrie/PwshSpectreConsole) - - [SpectreConsole](https://github.com/spectreconsole/spectre.console) +- [SpectreConsole](https://github.com/spectreconsole/spectre.console) --- diff --git a/harness.ps1 b/harness.ps1 index 1827bf6..02b08c8 100644 --- a/harness.ps1 +++ b/harness.ps1 @@ -2,8 +2,6 @@ param([switch]$Load) $s = { param([string]$Path, [switch]$LoadOnly) - $Parent = Split-Path $Path -Parent - Import-Module (Join-Path $Parent 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') Import-Module (Join-Path $Path 'output' 'TextMate.psd1') if (-not $LoadOnly) { Format-Markdown (Join-Path $Path 'tests' 'test-markdown.md') diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs new file mode 100644 index 0000000..898ead1 --- /dev/null +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -0,0 +1,205 @@ +namespace PSTextMate.Commands; + +/// +/// Sends renderables or VT-formatted strings to the interactive pager. +/// +[Cmdlet(VerbsData.Out, "Page")] +[OutputType(typeof(void))] +public sealed class OutPageCmdlet : PSCmdlet { + private readonly List _renderables = []; + private readonly List _outStringInputs = []; + + /// + /// Pipeline input to page. + /// Accepts values directly, or strings that are + /// converted through . + /// + [Parameter(Mandatory = true, ValueFromPipeline = true, Position = 0)] + [AllowNull] + public PSObject? InputObject { get; set; } + + /// + /// Processes one input object from the pipeline. + /// + protected override void ProcessRecord() { + if (InputObject?.BaseObject is null) { + return; + } + + object value = InputObject.BaseObject; + + if (value is IRenderable renderable) { + _renderables.Add(renderable); + return; + } + + if (value is string text) { + _outStringInputs.Add(text); + return; + } + + if (TryConvertForeignSpectreRenderable(value, out Paragraph paragraph)) { + _renderables.Add(paragraph); + return; + } + + _outStringInputs.Add(value); + } + + /// + /// Runs the pager when all pipeline input has been collected. + /// + protected override void EndProcessing() { + if (_outStringInputs.Count > 0) { + string formatted = ConvertWithOutString(_outStringInputs); + if (!string.IsNullOrEmpty(formatted)) { + _renderables.Add(Helpers.VTConversion.ToParagraph(formatted)); + } + else { + foreach (object value in _outStringInputs) { + _renderables.Add(new Text(LanguagePrimitives.ConvertTo(value))); + } + } + } + + if (_renderables.Count == 0) { + return; + } + + using var pager = new Pager(_renderables); + pager.Show(); + } + + private static string ConvertWithOutString(List values) { + if (values.Count == 0) { + return string.Empty; + } + + OutputRendering previousOutputRendering = PSStyle.Instance.OutputRendering; + try { + PSStyle.Instance.OutputRendering = OutputRendering.Ansi; + + using var ps = PowerShell.Create(RunspaceMode.CurrentRunspace); + ps.AddCommand("Out-String") + .AddParameter("Width", GetConsoleWidth()); + + Collection results = ps.Invoke(values); + if (ps.HadErrors || results.Count == 0) { + return string.Empty; + } + + var builder = new StringBuilder(); + foreach (PSObject? result in results) { + if (result?.BaseObject is string text) { + builder.Append(text); + } + else { + builder.Append(result?.ToString()); + } + } + + return builder.ToString(); + } + catch { + return string.Empty; + } + finally { + PSStyle.Instance.OutputRendering = previousOutputRendering; + } + } + + private static int GetConsoleWidth() { + try { + return Console.WindowWidth > 0 ? Console.WindowWidth : 120; + } + catch { + return 120; + } + } + + private static bool TryConvertForeignSpectreRenderable(object value, out Paragraph paragraph) { + paragraph = new Paragraph(); + + Type valueType = value.GetType(); + string? fullName = valueType.FullName; + if (string.IsNullOrWhiteSpace(fullName) + || !fullName.StartsWith("Spectre.Console.", StringComparison.Ordinal) + || value is IRenderable) { + return false; + } + + string ansi = RenderForeignSpectreToAnsi(value); + if (string.IsNullOrEmpty(ansi)) { + return false; + } + + paragraph = Helpers.VTConversion.ToParagraph(ansi); + return true; + } + + private static string RenderForeignSpectreToAnsi(object value) { + try { + Assembly assembly = value.GetType().Assembly; + Type? ansiConsoleType = assembly.GetType("Spectre.Console.AnsiConsole"); + Type? ansiConsoleSettingsType = assembly.GetType("Spectre.Console.AnsiConsoleSettings"); + Type? ansiConsoleOutputType = assembly.GetType("Spectre.Console.AnsiConsoleOutput"); + Type? renderableType = assembly.GetType("Spectre.Console.Rendering.IRenderable") + ?? assembly.GetType("Spectre.Console.IRenderable"); + + if (ansiConsoleType is null + || ansiConsoleSettingsType is null + || ansiConsoleOutputType is null + || renderableType is null + || !renderableType.IsInstanceOfType(value)) { + return string.Empty; + } + + using var writer = new StringWriter(); + object? output = Activator.CreateInstance(ansiConsoleOutputType, writer); + object? settings = Activator.CreateInstance(ansiConsoleSettingsType); + PropertyInfo? outProperty = ansiConsoleSettingsType.GetProperty("Out"); + + if (output is null || settings is null || outProperty is null || !outProperty.CanWrite) { + return string.Empty; + } + + outProperty.SetValue(settings, output); + + MethodInfo? createMethod = ansiConsoleType + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => method.Name == "Create" + && method.GetParameters() is { Length: 1 } parameters + && parameters[0].ParameterType == ansiConsoleSettingsType); + + object? console = createMethod?.Invoke(null, [settings]); + if (console is null) { + return string.Empty; + } + + MethodInfo? writeMethod = console.GetType().GetMethod("Write", [renderableType]); + if (writeMethod is not null) { + _ = writeMethod.Invoke(console, [value]); + } + else { + Type? extType = assembly.GetType("Spectre.Console.AnsiConsoleExtensions"); + MethodInfo? extWriteMethod = extType? + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => method.Name == "Write" + && method.GetParameters() is { Length: 2 } parameters + && parameters[1].ParameterType == renderableType); + + if (extWriteMethod is null) { + return string.Empty; + } + + _ = extWriteMethod.Invoke(null, [console, value]); + } + + return writer.ToString(); + } + catch { + return string.Empty; + } + } + +} diff --git a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs index 7a959c9..85e1363 100644 --- a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs +++ b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs @@ -39,8 +39,11 @@ public abstract class TextMateCmdletBase : PSCmdlet { [Parameter] public SwitchParameter Panel { get; set; } - // [Parameter] - // public SwitchParameter Page { get; set; } + /// + /// When present, always render through the interactive pager. + /// + [Parameter] + public SwitchParameter Page { get; set; } /// /// Fixed language or extension token used for rendering. @@ -161,7 +164,8 @@ protected override void EndProcessing() { Renderables = renderables, ShowLineNumbers = LineNumbers.IsPresent, Language = token, - WrapInPanel = Panel.IsPresent + WrapInPanel = Panel.IsPresent, + Page = Page.IsPresent }; } @@ -187,7 +191,8 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { Renderables = renderables, ShowLineNumbers = LineNumbers.IsPresent, Language = token, - WrapInPanel = Panel.IsPresent + WrapInPanel = Panel.IsPresent, + Page = Page.IsPresent }; } } diff --git a/src/PSTextMate/Core/HighlightedText.cs b/src/PSTextMate/Core/HighlightedText.cs index dec871a..da440b2 100644 --- a/src/PSTextMate/Core/HighlightedText.cs +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -84,6 +84,11 @@ private IEnumerable GetRenderablesEnumerable() => /// public bool WrapInPanel { get; set; } + /// + /// When true, writing this renderable should use the interactive pager. + /// + public bool Page { get; set; } + /// /// Renders the highlighted text by combining all renderables into a single output. /// @@ -368,26 +373,19 @@ public HighlightedText Slice(int start, int count, int? overrideLineNumberWidth LineNumberWidth = overrideLineNumberWidth ?? LineNumberWidth, GutterSeparator = GutterSeparator, Language = Language, - WrapInPanel = WrapInPanel + WrapInPanel = WrapInPanel, + Page = Page }; } - - public void ShowAlternateBufferPager() { - if (LineCount <= 0) return; - - using var pager = new Pager(this); - pager.Show(); - } public void ShowPager() { if (LineCount <= 0) return; using var pager = new Pager(this); - pager.Show(useAlternateBuffer: true); + pager.Show(); } - public IRenderable? AutoPage(bool alternate = true) { + public IRenderable? AutoPage() { if (LineCount > Console.WindowHeight - 2) { - if (alternate) ShowAlternateBufferPager(); - else ShowPager(); + ShowPager(); return null; } return this; @@ -396,9 +394,9 @@ public void ShowPager() { /// /// Renders this highlighted text to a string. /// - public string Write(bool autoPage = true, bool alternatePager = true) - => Writer.Write(this, autoPage, alternatePager); + public string Write() + => Writer.Write(this, Page); public override string ToString() - => Writer.WriteToString(this, customItemFormatter: true); + => Writer.WriteToString(this); } diff --git a/src/PSTextMate/PSTextMate.csproj b/src/PSTextMate/PSTextMate.csproj index af7068e..97e218d 100644 --- a/src/PSTextMate/PSTextMate.csproj +++ b/src/PSTextMate/PSTextMate.csproj @@ -13,7 +13,7 @@ latest-Recommended - + diff --git a/src/PSTextMate/Sixel/CellSize.cs b/src/PSTextMate/Sixel/CellSize.cs index 9f6f82a..1435147 100644 --- a/src/PSTextMate/Sixel/CellSize.cs +++ b/src/PSTextMate/Sixel/CellSize.cs @@ -3,7 +3,7 @@ namespace PSTextMate.Sixel; /// /// Represents terminal cell dimensions in pixels. /// -public sealed class CellSize { +internal sealed class CellSize { public int PixelWidth { get; init; } public int PixelHeight { get; init; } diff --git a/src/PSTextMate/Sixel/Compatibility.cs b/src/PSTextMate/Sixel/Compatibility.cs index d0a583d..acb1735 100644 --- a/src/PSTextMate/Sixel/Compatibility.cs +++ b/src/PSTextMate/Sixel/Compatibility.cs @@ -1,126 +1,150 @@ namespace PSTextMate.Sixel; /// -/// Sixel terminal compatibility helpers. +/// Provides methods and cached properties for detecting terminal compatibility, supported protocols, and cell/window sizes. /// -public static class Compatibility { - private static readonly string DA1 = "[c"; - public const char ESC = '\u001b'; - +public static partial class Compatibility { + private static readonly object s_controlSequenceLock = new(); /// /// Memory-caches the result of the terminal supporting sixel graphics. /// - private static bool? _terminalSupportsSixel; + internal static bool? _terminalSupportsSixel; /// - /// Memory-caches the result of the terminal cell size, sending the control code is slow. + /// Memory-caches the result of the terminal cell size. /// private static CellSize? _cellSize; + private static int? _lastWindowWidth; + private static int? _lastWindowHeight; + private static readonly Version MinVSCodeSixelVersion = new(1, 102, 0); + private static readonly DateTime MinWezTermSixelBuildDate = new(2025, 3, 20); + private static bool? _environmentSupportsSixel; + /// - /// Get the cell size of the terminal in pixel-sixel size. - /// The response to the command will look like [6;20;10t where the 20 is height and 10 is width. - /// I think the 6 is the terminal class, which is not used here. + /// Get the response to a control sequence. + /// Only queries when it's safe to do so (no pending input, not redirected). + /// Retries up to 2 times with 500ms timeout each. /// - /// The number of pixel sixels that will fit in a single character cell. - public static CellSize GetCellSize() { - if (_cellSize is not null) { - return _cellSize; + internal static string GetControlSequenceResponse(string controlSequence) { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return string.Empty; } - string response = GetControlSequenceResponse("[16t"); - try { - string[] parts = response.Split(';', 't'); - if (parts.Length >= 3) { - int width = int.Parse(parts[2], NumberStyles.Number, CultureInfo.InvariantCulture); - int height = int.Parse(parts[1], NumberStyles.Number, CultureInfo.InvariantCulture); + const int timeoutMs = 500; + const int maxRetries = 2; - // Validate the parsed values are reasonable - if (IsValidCellSize(width, height)) { - _cellSize = new CellSize { - PixelWidth = width, - PixelHeight = height - }; - return _cellSize; + lock (s_controlSequenceLock) { + // Drain any stale bytes that may have leaked from prior VT interactions. + DrainPendingInput(); + + for (int retry = 0; retry < maxRetries; retry++) { + try { + var response = new StringBuilder(); + bool capturing = false; + + // Send the control sequence + Console.Write($"\e{controlSequence}"); + Console.Out.Flush(); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < timeoutMs) { + if (!TryReadAvailableKey(out char key)) { + Thread.Sleep(1); + continue; + } + + if (!capturing) { + if (key != '\x1b') { + continue; + } + capturing = true; + } + + response.Append(key); + + // Check if we have a complete response + if (IsCompleteResponse(response)) { + DrainPendingInput(); + return response.ToString(); + } + } + + // If we got a partial response, return it + if (response.Length > 0) { + DrainPendingInput(); + return response.ToString(); + } + } + catch (Exception) { + if (retry == maxRetries - 1) { + DrainPendingInput(); + return string.Empty; + } } } - } - catch { - // Fall through to platform-specific fallback + + DrainPendingInput(); } - // Platform-specific fallback values - _cellSize = GetPlatformDefaultCellSize(); - return _cellSize; + return string.Empty; } /// - /// Check if the terminal supports sixel graphics. - /// This is done by sending the terminal a Device Attributes request. - /// If the terminal responds with a response that contains ";4;" then it supports sixel graphics. - /// https://vt100.net/docs/vt510-rm/DA1.html. + /// Attempts to read a key if one is available. /// - /// True if the terminal supports sixel graphics, false otherwise. - public static bool TerminalSupportsSixel() { - if (_terminalSupportsSixel.HasValue) { - return _terminalSupportsSixel.Value; - } + /// The key read from stdin. + /// True when a key was read, otherwise false. + private static bool TryReadAvailableKey(out char key) { + key = default; - string response = GetControlSequenceResponse(DA1); - _terminalSupportsSixel = response.Contains(";4;") || response.Contains(";4c"); - return _terminalSupportsSixel.Value; + try { + if (!Console.KeyAvailable) { + return false; + } + + key = Console.ReadKey(true).KeyChar; + return true; + } + catch { + return false; + } } /// - /// Send a control sequence to the terminal and read back the response from STDIN. + /// Drains any pending stdin bytes to prevent VT probe responses from leaking into user input. /// - /// The control sequence to send to the terminal. - /// The response from the terminal. - public static string GetControlSequenceResponse(string controlSequence) { + private static void DrainPendingInput() { if (Console.IsOutputRedirected || Console.IsInputRedirected) { - return string.Empty; + return; } - const int timeoutMs = 500; - const int maxRetries = 3; - - for (int retry = 0; retry < maxRetries; retry++) { - try { - var response = new StringBuilder(); + try { + const int quietPeriodMs = 20; + const int maxDrainMs = 250; - // Send the control sequence - Console.Write($"{ESC}{controlSequence}"); - var stopwatch = Stopwatch.StartNew(); + var stopwatch = Stopwatch.StartNew(); + long lastReadAt = stopwatch.ElapsedMilliseconds; - while (stopwatch.ElapsedMilliseconds < timeoutMs) { - if (!Console.KeyAvailable) { - Thread.Sleep(1); - continue; + while (stopwatch.ElapsedMilliseconds < maxDrainMs) { + if (!Console.KeyAvailable) { + if (stopwatch.ElapsedMilliseconds - lastReadAt >= quietPeriodMs) { + break; } - ConsoleKeyInfo keyInfo = Console.ReadKey(true); - char key = keyInfo.KeyChar; - response.Append(key); - - // Check if we have a complete response - if (IsCompleteResponse(response)) { - return response.ToString(); - } + Thread.Sleep(1); + continue; } - // If we got a partial response, return it - if (response.Length > 0) { - return response.ToString(); - } - } - catch (Exception) { - if (retry == maxRetries - 1) { - return string.Empty; - } + _ = Console.ReadKey(true); + lastReadAt = stopwatch.ElapsedMilliseconds; } } - - return string.Empty; + catch { + // Best effort only. + } } + + /// /// Check for complete terminal responses /// @@ -128,29 +152,32 @@ private static bool IsCompleteResponse(StringBuilder response) { int length = response.Length; if (length < 2) return false; - // Look for common terminal response endings - char lastChar = response[length - 1]; // Most VT terminal responses end with specific letters - switch (lastChar) { - case 'c': // Device Attributes (ESC[...c) - case 'R': // Cursor Position Report (ESC[row;columnR) - case 't': // Window manipulation (ESC[...t) - case 'n': // Device Status Report (ESC[...n) - case 'y': // DECRPM response (ESC[?...y) - // Make sure it's actually a CSI sequence (ESC[) + switch (response[length - 1]) { + // Device Attributes (ESC[...c) + case 'c': + // Cursor Position Report (ESC[row;columnR) + case 'R': + // Window manipulation (ESC[...t) + case 't': + // Device Status Report (ESC[...n) + case 'n': + // DECRPM response (ESC[?...y) + case 'y': + // Make sure it's actually a CSI sequence (ESC[) return length >= 3 && response[0] == '\x1b' && response[1] == '['; - - case '\\': // String Terminator (ESC\) + // String Terminator (ESC\) + case '\\': return length >= 2 && response[length - 2] == '\x1b'; - - case (char)7: // BEL character + // BEL character + case (char)7: return true; default: // Check for Kitty graphics protocol: ends with ";OK" followed by ST and then another response - if (length >= 7) // Minimum for ";OK" + ESC\ + ESC[...c - { + // Minimum for ";OK" + ESC\ + ESC[...c + if (length >= 7) { // Look for ";OK" pattern bool hasOK = false; for (int i = 0; i <= length - 3; i++) { @@ -191,12 +218,56 @@ private static bool IsCompleteResponse(StringBuilder response) { return false; } } + + /// + /// Get the cell size of the terminal in pixel-sixel size. + /// The response to the command will look like [6;20;10t where the 20 is height and 10 is width. + /// I think the 6 is the terminal class, which is not used here. + /// + /// The number of pixel sixels that will fit in a single character cell. + internal static CellSize GetCellSize() { + if (_cellSize is not null && !HasWindowSizeChanged()) { + return _cellSize; + } + + _cellSize = null; + string response = GetControlSequenceResponse("[16t"); + + try { + string[] parts = response.Split(';', 't'); + if (parts.Length >= 3) { + int width = int.Parse(parts[2], NumberStyles.Number, CultureInfo.InvariantCulture); + int height = int.Parse(parts[1], NumberStyles.Number, CultureInfo.InvariantCulture); + + // Validate the parsed values are reasonable + if (IsValidCellSize(width, height)) { + _cellSize = new CellSize { + PixelWidth = width, + PixelHeight = height + }; + UpdateWindowSizeSnapshot(); + return _cellSize; + } + } + } + catch { + // Fall through to platform-specific fallback + } + + // Platform-specific fallback values + _cellSize = GetPlatformDefaultCellSize(); + UpdateWindowSizeSnapshot(); + return _cellSize; + } + /// /// Minimal validation: only ensures positive integer values. /// Terminal-reported cell sizes are treated as ground truth. /// private static bool IsValidCellSize(int width, int height) => width > 0 && height > 0; + + /// /// Returns platform-specific default cell size as fallback. /// @@ -206,19 +277,49 @@ private static CellSize GetPlatformDefaultCellSize() { // Windows Terminal: 10x20 // Linux varies: 8x16 to 10x20 - // expand this in the future. - return new CellSize { PixelWidth = 10, PixelHeight = 20 }; } + private static bool HasWindowSizeChanged() { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return false; + } + + try { + int currentWidth = Console.WindowWidth; + int currentHeight = Console.WindowHeight; + + return _lastWindowWidth.HasValue && + _lastWindowHeight.HasValue && + (_lastWindowWidth.Value != currentWidth || _lastWindowHeight.Value != currentHeight); + } + catch { + return false; + } + } + + private static void UpdateWindowSizeSnapshot() { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return; + } + + try { + _lastWindowWidth = Console.WindowWidth; + _lastWindowHeight = Console.WindowHeight; + } + catch { + _lastWindowWidth = null; + _lastWindowHeight = null; + } + } /// /// Gets the terminal height in cells. Returns 0 if the height cannot be determined. /// /// The terminal height in cells, or 0 if unavailable. - public static int GetTerminalHeight() { + internal static int GetTerminalHeight() { try { if (!Console.IsOutputRedirected) { return Console.WindowHeight; @@ -229,4 +330,75 @@ public static int GetTerminalHeight() { } return 0; } + + /// + /// Check if the terminal supports sixel graphics. + /// This is done by sending the terminal a Device Attributes request. + /// If the terminal responds with a response that contains ";4;" then it supports sixel graphics. + /// https://vt100.net/docs/vt510-rm/DA1.html + /// + /// True if the terminal supports sixel graphics, false otherwise. + public static bool TerminalSupportsSixel() { + if (_terminalSupportsSixel.HasValue) { + return _terminalSupportsSixel.Value; + } + + string response = GetControlSequenceResponse("[c"); + bool supportsSixelByDa1 = response.Contains(";4;", StringComparison.Ordinal) + || response.Contains(";4c", StringComparison.Ordinal); + + _terminalSupportsSixel = supportsSixelByDa1 || DetectSixelSupportFromEnvironment(); + return _terminalSupportsSixel.Value; + } + + [GeneratedRegex(@"^data:image/\w+;base64,", RegexOptions.IgnoreCase, 1000)] + internal static partial Regex Base64Image(); + + internal static string TrimBase64(string b64) + => Base64Image().Replace(b64, string.Empty); + + /// This fallback is used only when DA1 probing does not positively identify sixel support. + /// + /// True when environment metadata indicates sixel support. + private static bool DetectSixelSupportFromEnvironment() { + if (_environmentSupportsSixel.HasValue) { + return _environmentSupportsSixel.Value; + } + + IDictionary env = Environment.GetEnvironmentVariables(); + bool supportsSixel = false; + + if (env["TERM_PROGRAM"] is string termProgram + && env["TERM_PROGRAM_VERSION"] is string termProgramVersion) { + if (termProgram.Equals("vscode", StringComparison.OrdinalIgnoreCase)) { + supportsSixel = IsVSCodeVersionAtLeast(termProgramVersion, MinVSCodeSixelVersion); + } + else if (termProgram.Equals("wezterm", StringComparison.OrdinalIgnoreCase)) { + supportsSixel = IsWezTermBuildDateAtLeast(termProgramVersion, MinWezTermSixelBuildDate); + } + } + + _environmentSupportsSixel = supportsSixel; + return supportsSixel; + } + + private static bool IsVSCodeVersionAtLeast(string termProgramVersion, Version minimumVersion) { + int dashIdx = termProgramVersion.IndexOf('-', StringComparison.Ordinal); + string versionPart = dashIdx > 0 ? termProgramVersion[..dashIdx] : termProgramVersion; + + return Version.TryParse(versionPart, out Version? parsedVersion) + && parsedVersion >= minimumVersion; + } + + private static bool IsWezTermBuildDateAtLeast(string termProgramVersion, DateTime minimumBuildDate) { + string[] parts = termProgramVersion.Split('-', StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 0 + && DateTime.TryParseExact( + parts[0], + "yyyyMMdd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTime buildDate) + && buildDate >= minimumBuildDate; + } } diff --git a/src/PSTextMate/Sixel/ImageCanvas.cs b/src/PSTextMate/Sixel/ImageCanvas.cs index 1aadffc..828f4a4 100644 --- a/src/PSTextMate/Sixel/ImageCanvas.cs +++ b/src/PSTextMate/Sixel/ImageCanvas.cs @@ -3,7 +3,7 @@ namespace PSTextMate.Sixel; /// /// Represents a renderable canvas. /// -public sealed class ImageCanvas : Renderable { +internal sealed class ImageCanvas : Renderable { private struct Cell { public char Glyph; public Color? Foreground; diff --git a/src/PSTextMate/Sixel/ImageSegment.cs b/src/PSTextMate/Sixel/ImageSegment.cs index 85d624a..00c2e5a 100644 --- a/src/PSTextMate/Sixel/ImageSegment.cs +++ b/src/PSTextMate/Sixel/ImageSegment.cs @@ -3,7 +3,7 @@ namespace PSTextMate.Sixel; /// /// Helper methods for creating and working with sixel-related segments. /// -public static class ImageSegment { +internal static class ImageSegment { /// /// Gets a transparent segment. /// diff --git a/src/PSTextMate/Sixel/PixelImage.cs b/src/PSTextMate/Sixel/PixelImage.cs index 51e696c..a0fe139 100644 --- a/src/PSTextMate/Sixel/PixelImage.cs +++ b/src/PSTextMate/Sixel/PixelImage.cs @@ -6,7 +6,7 @@ namespace PSTextMate.Sixel; /// /// Initializes a new instance of the class. /// -public sealed class PixelImage : Renderable { +internal sealed class PixelImage : Renderable { private const char ESC = '\u001b'; /// /// Gets the image width in pixels. diff --git a/src/PSTextMate/Sixel/Sixel.cs b/src/PSTextMate/Sixel/Sixel.cs index 038ad6f..b7f07f5 100644 --- a/src/PSTextMate/Sixel/Sixel.cs +++ b/src/PSTextMate/Sixel/Sixel.cs @@ -11,7 +11,7 @@ namespace PSTextMate.Sixel; /// The height of a sixel image in terminal cells. /// The width of a sixel image in terminal cells. /// The Sixel strings representing each frame of the image. -public readonly struct Sixel(int pixelWidth, int pixelHeight, int cellHeight, int cellWidth, string[] sixelStrings) { +internal readonly struct Sixel(int pixelWidth, int pixelHeight, int cellHeight, int cellWidth, string[] sixelStrings) { /// /// Gets the width of a sixel image in pixels. /// diff --git a/src/PSTextMate/Sixel/SixelRender.cs b/src/PSTextMate/Sixel/SixelRender.cs index c18e7d8..69ce3a2 100644 --- a/src/PSTextMate/Sixel/SixelRender.cs +++ b/src/PSTextMate/Sixel/SixelRender.cs @@ -6,69 +6,68 @@ namespace PSTextMate.Sixel; /// /// Contains methods for converting an image to a Sixel format. /// -public static class SixelRender { +internal static class SixelRender { /// /// The character to use when entering a terminal escape code sequence. /// - public const char ESC = '\u001b'; + internal const char ESC = '\u001b'; /// /// The character to indicate the start of a sixel color palette entry or to switch to a new color. /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.3. /// - public const char SIXELCOLOR = '#'; + internal const char SixelColorStart = '#'; /// /// The character to use when a sixel is empty/transparent. /// ? (hex 3F) represents the binary value 000000. /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1. /// - public const char SIXELEMPTY = '?'; + internal const char SixelTransparent = '?'; /// /// The character to use when entering a repeated sequence of a color in a sixel. /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.1. /// - public const char SIXELREPEAT = '!'; + internal const char SixelRepeat = '!'; /// /// The character to use when moving to the next line in a sixel. /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.5. /// - public const char SIXELDECGNL = '-'; + internal const char SixelDECGNL = '-'; /// /// The character to use when going back to the start of the current line in a sixel to write more data over it. /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.4. /// - public const char SIXELDECGCR = '$'; - + internal const char SixelDECGCR = '$'; /// /// The start of a sixel sequence. /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1. /// - public static readonly string SIXELSTART = $"{ESC}P0;1q"; - + internal static readonly string SixelStart = $"{ESC}P0;1q"; /// /// The raster settings for setting the sixel pixel ratio to 1:1 so images are square when rendered instead of the 2:1 double height default. /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.2. /// - public const string SIXELRASTERATTRIBUTES = "\"1;1;"; - + internal const string SixelRaster = "\"1;1;"; /// /// The end of a sixel sequence. /// - public static readonly string SIXELEND = $"{ESC}\\"; + internal static readonly string ST = $"{ESC}\\"; + internal const string SixelColorParam = ";2;"; /// /// The transparent color for the sixel, this is black but the sixel should be transparent so this is not visible. /// - public const string SIXELTRANSPARENTCOLOR = "#0;2;0;0;0"; + internal const string SixelTransparentColor = "#0;2;0;0;0"; /// /// explicit space char for clarity /// - public const char Space = ' '; + internal const char Space = ' '; + internal const char Divider = ';'; /// /// Converts an image to a Sixel object. @@ -160,7 +159,7 @@ private static string FrameToSixelString(ImageFrame frame, int cellHeigh // The value of 1 left-shifted by the remainder of the current row divided by 6 gives the correct sixel character offset from the empty sixel char for each row. // See the description of s...s for more detail on the sixel format https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1 - char c = (char)(SIXELEMPTY + (1 << (y % 6))); + char c = (char)('?' + (1 << (y % 6))); int lastColor = -1; int repeatCounter = 0; @@ -218,12 +217,6 @@ private static string FrameToSixelString(ImageFrame frame, int cellHeigh return sixelBuilder.ToString(); } - /// - /// Adds a color to the sixel palette. - /// - /// The string builder to write to. - /// The pixel to add to the palette. - /// The index of the color in the palette. private static void AddColorToPalette(this StringBuilder sixelBuilder, Rgba32 pixel, int colorIndex) { // rgb 0-255 needs to be translated to 0-100 for sixel. (int r, int g, int b) = ( @@ -232,80 +225,60 @@ private static void AddColorToPalette(this StringBuilder sixelBuilder, Rgba32 pi pixel.B * 100 / 255 ); - sixelBuilder.Append(SIXELCOLOR) - .Append(colorIndex) - .Append(";2;") - .Append(r) - .Append(';') - .Append(g) - .Append(';') - .Append(b); + _ = sixelBuilder + .Append(SixelColorStart) + .Append(colorIndex) + .Append(SixelColorParam) + .Append(r) + .Append(Divider) + .Append(g) + .Append(Divider) + .Append(b); } - - /// - /// Writes a repeated sixel entry to the string builder. - /// - /// The string builder to write to. - /// The index of the color in the palette. - /// The number of times the color is repeated. - /// The sixel character to write. private static void AppendSixel(this StringBuilder sixelBuilder, int colorIndex, int repeatCounter, char sixel) { if (colorIndex == 0) { // Transparent pixels are a special case and are always 0 in the palette. - sixel = SIXELEMPTY; + sixel = SixelTransparent; } if (repeatCounter <= 1) { // single entry - sixelBuilder - .Append(SIXELCOLOR) + _ = sixelBuilder + .Append(SixelColorStart) .Append(colorIndex) .Append(sixel); } else { // add repeats - sixelBuilder - .Append(SIXELCOLOR) + _ = sixelBuilder + .Append(SixelColorStart) .Append(colorIndex) - .Append(SIXELREPEAT) + .Append(SixelRepeat) .Append(repeatCounter) .Append(sixel); } } + private static void AppendCarriageReturn(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(SixelDECGCR); + } - /// - /// Writes the Sixel carriage return sequence to the string builder. - /// - /// The string builder to write to. - private static void AppendCarriageReturn(this StringBuilder sixelBuilder) - => sixelBuilder.Append(SIXELDECGCR); - - /// - /// Writes the Sixel next line sequence to the string builder. - /// - /// The string builder to write to. - private static void AppendNextLine(this StringBuilder sixelBuilder) - => sixelBuilder.Append(SIXELDECGNL); + private static void AppendNextLine(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(SixelDECGNL); + } - /// - /// Writes the Sixel exit sequence to the string builder. - /// - /// The string builder to write to. - private static void AppendExitSixel(this StringBuilder sixelBuilder) - => sixelBuilder.Append(SIXELEND); + private static void AppendExitSixel(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(ST); + } - /// - /// Writes the Sixel start sequence to the string builder. - /// - /// The string builder to write to. - /// The width of the image in pixels. - /// The height of the image in pixels. private static void StartSixel(this StringBuilder sixelBuilder, int width, int height) { - sixelBuilder - .Append(SIXELSTART) - .Append(SIXELRASTERATTRIBUTES) + _ = sixelBuilder + .Append(SixelStart) + .Append(SixelRaster) .Append(width) - .Append(';') + .Append(Divider) .Append(height) - .Append(SIXELTRANSPARENTCOLOR); + .Append(SixelTransparentColor); } } diff --git a/src/PSTextMate/Utilities/Pager.cs b/src/PSTextMate/Utilities/Pager.cs index b77f81b..6e1a228 100644 --- a/src/PSTextMate/Utilities/Pager.cs +++ b/src/PSTextMate/Utilities/Pager.cs @@ -9,6 +9,7 @@ namespace PSTextMate.Utilities; /// - q or Escape: quit /// public sealed class Pager : IDisposable { + private static readonly PagerExclusivityMode s_pagerExclusivityMode = new(); private readonly IReadOnlyList _renderables; private readonly HighlightedText? _sourceHighlightedText; private readonly int? _originalLineNumberStart; @@ -24,48 +25,52 @@ public sealed class Pager : IDisposable { private readonly record struct ViewportWindow(int Top, int Count, int EndExclusive, bool HasImages); - private bool ContainsImageRenderables() => _renderables.Any(IsImageRenderable); + private sealed class PagerExclusivityMode : IExclusivityMode { + private readonly object _syncRoot = new(); - private static int? GetIntPropertyValue(object instance, string propertyName) { - PropertyInfo? property = instance.GetType().GetProperty(propertyName); - if (property is null || !property.CanRead) { - return null; + public T Run(Func func) { + ArgumentNullException.ThrowIfNull(func); + + lock (_syncRoot) { + return func(); + } } - object? value = property.GetValue(instance); - return value is int i ? i : null; - } + public async Task RunAsync(Func> func) { + ArgumentNullException.ThrowIfNull(func); - private static double GetTerminalCellAspectRatio() { - try { - var compatibility = Type.GetType("PwshSpectreConsole.Terminal.Compatibility, PwshSpectreConsole"); - MethodInfo? getCellSize = compatibility?.GetMethod("GetCellSize", Type.EmptyTypes); - object? cellSize = getCellSize?.Invoke(null, null); - if (cellSize is null) { - return 0.5d; + Task task; + lock (_syncRoot) { + task = func(); } - PropertyInfo? pixelWidthProperty = cellSize.GetType().GetProperty("PixelWidth"); - PropertyInfo? pixelHeightProperty = cellSize.GetType().GetProperty("PixelHeight"); - int pixelWidth = (int?)pixelWidthProperty?.GetValue(cellSize) ?? 0; - int pixelHeight = (int?)pixelHeightProperty?.GetValue(cellSize) ?? 0; - return pixelWidth <= 0 || pixelHeight <= 0 ? 0.5d : (double)pixelWidth / pixelHeight; - } - catch { - return 0.5d; + return await task.ConfigureAwait(false); } } + private static double GetTerminalCellAspectRatio() { + CellSize cellSize = Compatibility.GetCellSize(); + return cellSize.PixelWidth <= 0 || cellSize.PixelHeight <= 0 + ? 0.5d + : (double)cellSize.PixelWidth / cellSize.PixelHeight; + } + private static int EstimateImageHeight(IRenderable renderable, int width, int contentRows, RenderOptions options) { - // If an explicit max height exists, it is the strongest signal. - int? explicitMaxHeight = GetIntPropertyValue(renderable, "MaxHeight"); - if (explicitMaxHeight.HasValue && explicitMaxHeight.Value > 0) { - return Math.Clamp(explicitMaxHeight.Value, 1, contentRows); + if (renderable is PixelImage pixelImage) { + int imagePixelWidth = pixelImage.Width; + int imagePixelHeight = pixelImage.Height; + int cellWidth = pixelImage.MaxWidth is int maxWidth && maxWidth > 0 + ? Math.Min(width, maxWidth) + : width; + + if (imagePixelWidth > 0 && imagePixelHeight > 0) { + double imageAspect = (double)imagePixelHeight / imagePixelWidth; + double cellAspectRatio = GetTerminalCellAspectRatio(); + int estimatedRows = (int)Math.Ceiling(imageAspect * Math.Max(1, cellWidth) * cellAspectRatio); + return Math.Clamp(Math.Max(1, estimatedRows), 1, contentRows); + } } - int? imagePixelWidth = GetIntPropertyValue(renderable, "Width"); - int? imagePixelHeight = GetIntPropertyValue(renderable, "Height"); - Measurement measure; try { measure = renderable.Measure(options, width); @@ -74,16 +79,10 @@ private static int EstimateImageHeight(IRenderable renderable, int width, int co return Math.Clamp(contentRows, 1, contentRows); } - int cellWidth = Math.Max(1, Math.Min(width, measure.Max)); - if (imagePixelWidth.HasValue && imagePixelWidth.Value > 0 && imagePixelHeight.HasValue && imagePixelHeight.Value > 0) { - double imageAspect = (double)imagePixelHeight.Value / imagePixelWidth.Value; - double cellAspectRatio = GetTerminalCellAspectRatio(); - int estimatedRows = (int)Math.Ceiling(imageAspect * cellWidth * cellAspectRatio); - return Math.Clamp(Math.Max(1, estimatedRows), 1, contentRows); - } + int cellWidthFallback = Math.Max(1, Math.Min(width, measure.Max)); // Last fallback: keep as atomic item, but estimate from measured width. - return Math.Clamp(Math.Max(1, (int)Math.Ceiling((double)measure.Max / width)), 1, contentRows); + return Math.Clamp(Math.Max(1, (int)Math.Ceiling((double)cellWidthFallback / Math.Max(1, width))), 1, contentRows); } private bool IsMarkdownSource() @@ -159,7 +158,7 @@ public Pager(IEnumerable renderables) { _renderables = list is null ? [] : (IReadOnlyList)list; _top = 0; } - private void Navigate(LiveDisplayContext ctx) { + private void Navigate(LiveDisplayContext ctx, bool useAlternateBuffer) { bool running = true; (WindowWidth, WindowHeight) = GetPagerSize(); bool forceRedraw = true; @@ -175,42 +174,52 @@ private void Navigate(LiveDisplayContext ctx) { WindowWidth = width; WindowHeight = pageHeight; - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + if (useAlternateBuffer) { + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + } forceRedraw = true; } // Redraw if needed (initial, resize, or after navigation) if (resized || forceRedraw) { - RecalculateRenderableHeights(width, contentRows); - _top = Math.Clamp(_top, 0, GetMaxTop(contentRows)); - ViewportWindow viewport = BuildViewport(_top, contentRows); - _top = viewport.Top; - - bool fullClear = resized || viewport.HasImages || _lastPageHadImages; - if (fullClear) { - VTHelpers.ClearScreen(); - VTHelpers.ReserveRow(contentRows); - } - else { - VTHelpers.SetCursorPosition(1, 1); - } + VTHelpers.BeginSynchronizedOutput(); + try { + RecalculateRenderableHeights(width, contentRows); + _top = Math.Clamp(_top, 0, GetMaxTop(contentRows)); + ViewportWindow viewport = BuildViewport(_top, contentRows); + _top = viewport.Top; + + bool fullClear = resized || viewport.HasImages || _lastPageHadImages; + if (fullClear) { + VTHelpers.ClearScreen(); + if (useAlternateBuffer) { + VTHelpers.ReserveRow(contentRows); + } + } + else { + VTHelpers.SetCursorPosition(1, 1); + } - IRenderable target = BuildRenderable(viewport); - ctx.UpdateTarget(target); - ctx.Refresh(); + IRenderable target = BuildRenderable(viewport); + ctx.UpdateTarget(target); + ctx.Refresh(); - DrawFooter(width, contentRows, viewport); + DrawFooter(width, contentRows, viewport); - // Clear any previously-rendered lines that are now beyond contentRows. - if (_lastRenderedRows > contentRows) { - for (int r = contentRows + 1; r <= _lastRenderedRows; r++) { - VTHelpers.ClearRow(r); + // Clear any previously-rendered lines that are now beyond contentRows. + if (_lastRenderedRows > contentRows) { + for (int r = contentRows + 1; r <= _lastRenderedRows; r++) { + VTHelpers.ClearRow(r); + } } - } - _lastRenderedRows = contentRows; - _lastPageHadImages = viewport.HasImages; - forceRedraw = false; + _lastRenderedRows = contentRows; + _lastPageHadImages = viewport.HasImages; + forceRedraw = false; + } + finally { + VTHelpers.EndSynchronizedOutput(); + } } // Wait for input, checking for resize while idle @@ -326,6 +335,13 @@ private void RecalculateRenderableHeights(int width, int contentRows) { } if (IsImageRenderable(r)) { + if (r is PixelImage pixelImage) { + // In pager mode, clamp image width to the viewport so frames stay within screen bounds. + pixelImage.MaxWidth = pixelImage.MaxWidth is int existingWidth && existingWidth > 0 + ? Math.Min(existingWidth, width) + : width; + } + _renderableHeights.Add(EstimateImageHeight(r, width, contentRows, options)); continue; } @@ -390,7 +406,7 @@ private void DrawFooter(int width, int contentRows, ViewportWindow viewport) { int pos = total == 0 ? 0 : viewport.Top + 1; int end = viewport.EndExclusive; - string keys = "Up/Down: ↑↓ PgUp/PgDn: PgUp/PgDn/Spacebar Home/End: Home/End q/Esc: Quit"; + string keys = "Up/Down: ↑↓ PgUp/PgDn/Spacebar Home/End q/Esc: Quit"; string status = $" {pos}-{end}/{total} "; int remaining = Math.Max(0, width - keys.Length - status.Length - 2); string spacer = new(' ', remaining); @@ -403,97 +419,24 @@ private void DrawFooter(int width, int contentRows, ViewportWindow viewport) { Console.Write(line.PadRight(width)); } - private void NavigateDirect(bool useAlternateBuffer) { - bool running = true; - (WindowWidth, WindowHeight) = GetPagerSize(); - bool forceRedraw = true; - - while (running) { - (int width, int pageHeight) = GetPagerSize(); - int contentRows = Math.Max(1, pageHeight - 1); + public void Show() { + bool resolvedUseAlternateBuffer = VTHelpers.SupportsAlternateBuffer(); - bool resized = width != WindowWidth || pageHeight != WindowHeight; - if (resized) { - AnsiConsole.Console.Profile.Width = width; - WindowWidth = width; - WindowHeight = pageHeight; - forceRedraw = true; + s_pagerExclusivityMode.Run(() => { + if (resolvedUseAlternateBuffer) { + AnsiConsole.Console.AlternateScreen(() => ShowCore(useAlternateBuffer: true)); } - - if (resized || forceRedraw) { - RecalculateRenderableHeights(width, contentRows); - _top = Math.Clamp(_top, 0, GetMaxTop(contentRows)); - ViewportWindow viewport = BuildViewport(_top, contentRows); - _top = viewport.Top; - - VTHelpers.ClearScreen(); - if (useAlternateBuffer) { - VTHelpers.ReserveRow(contentRows); - } - - IRenderable target = BuildRenderable(viewport); - AnsiConsole.Write(target); - DrawFooter(width, contentRows, viewport); - forceRedraw = false; - } - - if (!Console.KeyAvailable) { - Thread.Sleep(50); - continue; + else { + ShowCore(useAlternateBuffer: false); } - ConsoleKeyInfo key = Console.ReadKey(true); - lock (_lock) { - switch (key.Key) { - case ConsoleKey.DownArrow: - ScrollRenderable(1); - forceRedraw = true; - break; - case ConsoleKey.UpArrow: - ScrollRenderable(-1); - forceRedraw = true; - break; - case ConsoleKey.Spacebar: - case ConsoleKey.PageDown: - PageDown(contentRows); - forceRedraw = true; - break; - case ConsoleKey.PageUp: - PageUp(contentRows); - forceRedraw = true; - break; - case ConsoleKey.Home: - GoToTop(); - forceRedraw = true; - break; - case ConsoleKey.End: - GoToEnd(contentRows); - forceRedraw = true; - break; - case ConsoleKey.Q: - case ConsoleKey.Escape: - running = false; - break; - } - } - } + return 0; + }); } - public void Show() => Show(useAlternateBuffer: true); - - public void Show(bool useAlternateBuffer) { - if (useAlternateBuffer) { - VTHelpers.EnterAlternateBuffer(); - } + private void ShowCore(bool useAlternateBuffer) { VTHelpers.HideCursor(); try { - // Sixel/pixel renderables are safest when written directly because - // Live's diff/crop pass can interfere with terminal image sequences. - if (ContainsImageRenderables()) { - NavigateDirect(useAlternateBuffer); - return; - } - (int width, int pageHeight) = GetPagerSize(); int contentRows = Math.Max(1, pageHeight - 1); @@ -513,12 +456,18 @@ public void Show(bool useAlternateBuffer) { // If the initial page contains images, clear appropriately to ensure safe image rendering if (initialViewport.HasImages) { - if (useAlternateBuffer) { - VTHelpers.ClearScreen(); - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + VTHelpers.BeginSynchronizedOutput(); + try { + if (useAlternateBuffer) { + VTHelpers.ClearScreen(); + VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); + } + else { + VTHelpers.ClearScreen(); + } } - else { - VTHelpers.ClearScreen(); + finally { + VTHelpers.EndSynchronizedOutput(); } } @@ -530,7 +479,7 @@ public void Show(bool useAlternateBuffer) { // Draw footer once before entering the interactive loop DrawFooter(width, contentRows, initialViewport); // Enter interactive loop using the live display context - Navigate(ctx); + Navigate(ctx, useAlternateBuffer); }); } finally { @@ -545,7 +494,6 @@ public void Show(bool useAlternateBuffer) { // Reset scroll region and restore normal screen buffer if used if (useAlternateBuffer) { VTHelpers.ResetScrollRegion(); - VTHelpers.ExitAlternateBuffer(); } VTHelpers.ShowCursor(); } diff --git a/src/PSTextMate/Utilities/PagerNormal.cs b/src/PSTextMate/Utilities/PagerNormal.cs deleted file mode 100644 index 437c9dc..0000000 --- a/src/PSTextMate/Utilities/PagerNormal.cs +++ /dev/null @@ -1,233 +0,0 @@ -namespace PSTextMate.Utilities; - -public class PagerNormal : IDisposable { - private readonly IReadOnlyList _renderables; - private readonly HighlightedText? _sourceHighlightedText; - private readonly Measurement[] _measurements; - // private readonly int? _originalLineNumberStart; - // private readonly int? _originalLineNumberWidth; - private readonly object _lock = new(); - - public PagerNormal(HighlightedText highlightedText) { - _sourceHighlightedText = highlightedText; - // _originalLineNumberStart = highlightedText.LineNumberStart; - // _originalLineNumberWidth = highlightedText.LineNumberWidth; - _renderables = highlightedText.Renderables; - _measurements = ComputeMeasurements(GetPagerSize().width); - } - - public PagerNormal(IEnumerable renderables) { - var list = renderables?.ToList(); - _renderables = list is null ? [] : (IReadOnlyList)list; - _measurements = ComputeMeasurements(GetPagerSize().width); - } - - private Measurement[] ComputeMeasurements(int width) { - if (_sourceHighlightedText is not null) { - return _sourceHighlightedText.MeasureRenderablesFull(width); - } - - Capabilities caps = AnsiConsole.Console.Profile.Capabilities; - var size = new Size(width, Math.Max(1, Console.WindowHeight)); - var options = new RenderOptions(caps, size); - - IReadOnlyList source = _renderables; - var list = new List(source.Count); - foreach (IRenderable? r in source) { - if (r is null) { - list.Add(new Measurement(1, 1)); - continue; - } - - try { - Measurement m = r.Measure(options, width); - list.Add(m); - } - catch { - list.Add(new Measurement(1, 1)); - } - } - - return [.. list]; - } - - private int GetRenderableRowCount(int index, int width) { - if (_measurements == null || index < 0 || index >= _measurements.Length) return 1; - Measurement m = _measurements[index]; - int maxWidth = Math.Max(1, m.Max); - return maxWidth <= width ? 1 : (int)Math.Ceiling((double)maxWidth / width); - } - - private int CountRenderablesForHeight(int startIndex, int availableRows, int width) { - int sum = 0; - int count = 0; - if (_measurements == null) return 1; - for (int i = startIndex; i < _measurements.Length; i++) { - int r = GetRenderableRowCount(i, width); - if (sum + r > availableRows) break; - sum += r; - count++; - } - - // Always show at least one renderable - return Math.Max(1, count); - } - - private static (int width, int height) GetPagerSize() { - int width = Console.WindowWidth > 0 ? Console.WindowWidth : 80; - int height = Console.WindowHeight > 0 ? Console.WindowHeight : 40; - return (width, height); - } - - // private static void ScrollUp(int lines) => Console.Write($"\x1b[{lines}S"); - // private static void ScrollDown(int lines) => Console.Write($"\x1b[{lines}T"); - private IRenderable BuildContent(int top, int contentRows) { - if (_sourceHighlightedText is not null) { - return _sourceHighlightedText.Slice(top, contentRows, _sourceHighlightedText.LineNumberWidth); - } - - var slice = _renderables.Skip(top).Take(contentRows).ToList(); - return new Rows(slice); - } - private static int ProcessKey(ConsoleKey key, int top, int availableRows, int totalCount, out bool running) { - running = true; - switch (key) { - case ConsoleKey.DownArrow: - if (top < Math.Max(0, totalCount - 1)) top++; - break; - case ConsoleKey.UpArrow: - if (top > 0) top--; - break; - case ConsoleKey.PageDown: - case ConsoleKey.Spacebar: - top = Math.Min(Math.Max(0, totalCount - availableRows), top + availableRows); - break; - case ConsoleKey.PageUp: - top = Math.Max(0, top - availableRows); - break; - case ConsoleKey.Home: - top = 0; - break; - case ConsoleKey.End: - top = Math.Max(0, totalCount - availableRows); - break; - case ConsoleKey.Q: - case ConsoleKey.Escape: - running = false; - break; - } - - return top; - } - private string BuildFooterLine(int top, int contentRows, int width) { - string keys = "Up/Down: ↑↓ PgUp/PgDn: PgUp/PgDn/Spacebar Home/End: Home/End q/Esc: Quit"; - string status = $" {Math.Min(top + 1, _renderables.Count)}-{Math.Min(top + contentRows, _renderables.Count)}/{_renderables.Count} "; - int remaining = Math.Max(0, width - keys.Length - status.Length - 2); - string spacer = new(' ', remaining); - string footerLine = keys + spacer + status; - if (footerLine.Length > width) footerLine = footerLine[..width]; - return footerLine; - } - public void Show() { - // VTHelpers.HideCursor(); - (int width, int height) = GetPagerSize(); - AnsiConsole.Console.Profile.Width = width; - int availableRows = Math.Max(1, Console.WindowHeight - 1); - int top = 0; - - int totalCount = _sourceHighlightedText is not null ? _sourceHighlightedText.Renderables.Length : _renderables.Count; - - // compute how many renderables fit in the availableRows starting at top - int initialCount = CountRenderablesForHeight(top, availableRows, width); - var composite = new CompositeRenderable(_sourceHighlightedText, _renderables, top, initialCount, width, BuildFooterLine); - - AnsiConsole.Live(composite) - .AutoClear(true) - .Overflow(VerticalOverflow.Ellipsis) - .Start(ctx => { - bool running = true; - while (running) { - if (!Console.KeyAvailable) { - Thread.Sleep(50); - continue; - } - - ConsoleKeyInfo key = Console.ReadKey(true); - int newTop = ProcessKey(key.Key, top, availableRows, totalCount, out running); - - lock (_lock) { - top = newTop; - int count = CountRenderablesForHeight(top, availableRows, width); - composite.Update(top, count, width); - if (!running) { - return; - } - ctx.Refresh(); - } - } - }); - - } - public void Dispose() => GC.SuppressFinalize(this); -} - - -// Composite renderable that presents content and a footer line as a single -// Spectre `IRenderable`. Navigation updates modify its internal state and -// calling `ctx.Refresh()` will cause Live to re-render using the current -// content/top values. -internal sealed class CompositeRenderable : IRenderable { - private readonly HighlightedText? _source; - private readonly IReadOnlyList? _list; - private readonly Func _footerBuilder; - private int _top; - private int _contentRows; - private int _width; - - public CompositeRenderable(HighlightedText? source, IReadOnlyList list, int top, int contentRows, int width, Func footerBuilder) { - _source = source; - _list = list; - _top = top; - _contentRows = contentRows; - _width = width; - _footerBuilder = footerBuilder; - } - - public void Update(int top, int contentRows, int width) { - _top = top; - _contentRows = contentRows; - _width = width; - } - - public IEnumerable Render(RenderOptions options, int maxWidth) { - IRenderable content; - if (_source is not null) { - content = _source.Slice(_top, _contentRows, _source.LineNumberWidth); - } - else { - IReadOnlyList sourceList = _list ?? []; - var slice = sourceList.Skip(_top).Take(_contentRows).ToList(); - content = new Rows(slice); - } - - var footer = new Panel(new Text(_footerBuilder(_top, _contentRows, _width))) { Padding = new Padding(0, 0) }; - var rows = new Rows(content, footer); - return ((IRenderable)rows).Render(options, maxWidth); - } - - public Measurement Measure(RenderOptions options, int maxWidth) { - IRenderable content; - if (_source is not null) { - content = _source.Slice(_top, _contentRows, _source.LineNumberWidth); - } - else { - IReadOnlyList sourceList = _list ?? []; - var slice = sourceList.Skip(_top).Take(_contentRows).ToList(); - content = new Rows(slice); - } - - var footer = new Panel(new Text(_footerBuilder(_top, _contentRows, _width))) { Padding = new Padding(0, 0) }; - var rows = new Rows(content, footer); - return ((IRenderable)rows).Measure(options, maxWidth); - } -} diff --git a/src/PSTextMate/Utilities/SpectreStyleCompat.cs b/src/PSTextMate/Utilities/SpectreStyleCompat.cs index 80a4d44..e509c12 100644 --- a/src/PSTextMate/Utilities/SpectreStyleCompat.cs +++ b/src/PSTextMate/Utilities/SpectreStyleCompat.cs @@ -1,21 +1,13 @@ namespace PSTextMate.Utilities; internal static class SpectreStyleCompat { - private static readonly ConstructorInfo? LinkStyleCtor = typeof(Style).GetConstructor([typeof(Color?), typeof(Color?), typeof(Decoration?), typeof(string)]); - private static readonly Type? LinkType = Type.GetType("Spectre.Console.Link, Spectre.Console.Ansi") - ?? Type.GetType("Spectre.Console.Link, Spectre.Console"); - private static readonly ConstructorInfo? LinkCtor = LinkType?.GetConstructor([typeof(string)]); - private static readonly MethodInfo? ParagraphAppendWithLink = FindParagraphAppendWithLink(); - public static Style Create(Color? foreground = null, Color? background = null, Decoration? decoration = null) => new(foreground, background, decoration); public static Style CreateWithLink(Color? foreground, Color? background, Decoration? decoration, string? link) { return string.IsNullOrWhiteSpace(link) ? new Style(foreground, background, decoration) - : LinkStyleCtor is not null - ? (Style)LinkStyleCtor.Invoke([foreground, background, decoration, link]) - : new Style(foreground, background, decoration); + : new Style(foreground, background, decoration, link); } public static string ToMarkup(Style? style) { @@ -37,22 +29,8 @@ public static void Append(Paragraph paragraph, string text, Style? style = null, return; } - if (ParagraphAppendWithLink is not null && LinkCtor is not null) { - object? linkObject = LinkCtor.Invoke([link]); - ParagraphAppendWithLink.Invoke(paragraph, [text, style, linkObject]); - return; - } - Style baseStyle = Resolve(style); Style linked = CreateWithLink(baseStyle.Foreground, baseStyle.Background, baseStyle.Decoration, link); paragraph.Append(text, linked); } - - private static MethodInfo? FindParagraphAppendWithLink() - => typeof(Paragraph) - .GetMethods(BindingFlags.Public | BindingFlags.Instance) - .FirstOrDefault(method - => method.Name == nameof(Paragraph.Append) - && method.GetParameters() is { Length: 3 } parameters - && parameters[2].ParameterType.Name == "Link"); } diff --git a/src/PSTextMate/Utilities/VTConversion.cs b/src/PSTextMate/Utilities/VTConversion.cs index 63328f9..9aaae67 100644 --- a/src/PSTextMate/Utilities/VTConversion.cs +++ b/src/PSTextMate/Utilities/VTConversion.cs @@ -4,7 +4,7 @@ namespace PSTextMate.Helpers; /// Efficient parser for VT (Virtual Terminal) escape sequences that converts them to Spectre.Console objects. /// Supports RGB colors, 256-color palette, 3-bit colors, and text decorations. /// -public static class VTParser { +public static class VTConversion { private const char ESC = '\x1B'; private const char CSI_START = '['; private const char OSC_START = ']'; @@ -224,8 +224,8 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re int linkTextEnd = -1; // Look for the closing OSC sequence: ESC]8;;ESC\ - while (i < span.Length - 6 && oscLength < MaxOscLength) // Need at least 6 chars for ESC]8;;ESC\ - { + while (i < span.Length - 6 && oscLength < MaxOscLength) { + // Need at least 6 chars for ESC]8;;ESC\ if (span[i] == ESC && span[i + 1] == OSC_START && span[i + 2] == '8' && span[i + 3] == ';' && span[i + 4] == ';' && span[i + 5] == ESC && @@ -240,7 +240,8 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re if (linkTextEnd > linkTextStart) { string linkText = span[linkTextStart..linkTextEnd].ToString(); style.Link = url; - return new OscResult(linkTextEnd + 7, linkText); // Skip ESC]8;;ESC\ + // Skip ESC]8;;ESC\ + return new OscResult(linkTextEnd + 7, linkText); } } else { @@ -260,8 +261,8 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re i++; oscLength++; } - - return new OscResult(start + 1); // Failed to parse, advance by 1 + // Failed to parse, advance by 1 + return new OscResult(start + 1); } /// @@ -377,16 +378,16 @@ private static void ApplySgrParameters(ReadOnlySpan parameters, ref StyleSt // Extended background color if (i + 1 < parameters.Length) { int colorType = parameters[i + 1]; - if (colorType == 2 && i + 4 < parameters.Length) // RGB - { + // RGB + if (colorType == 2 && i + 4 < parameters.Length) { byte r = (byte)Math.Clamp(parameters[i + 2], 0, 255); byte g = (byte)Math.Clamp(parameters[i + 3], 0, 255); byte b = (byte)Math.Clamp(parameters[i + 4], 0, 255); style.Background = new Color(r, g, b); i += 4; } - else if (colorType == 5 && i + 2 < parameters.Length) // 256-color - { + // 256-color + else if (colorType == 5 && i + 2 < parameters.Length) { int colorIndex = parameters[i + 2]; style.Background = Get256Color(colorIndex); i += 2; @@ -434,24 +435,6 @@ private static void ApplySgrParameters(ReadOnlySpan parameters, ref StyleSt 96 or 106 => Color.Aqua, 97 or 107 => Color.White, _ => Color.Default - // 30 or 40 => Color.Black, - // 31 or 41 => Color.Red, - // 32 or 42 => Color.Green, - // 33 or 43 => Color.Yellow, - // 34 or 44 => Color.Blue, - // 35 or 45 => Color.Purple, - // 36 or 46 => Color.Teal, - // 37 or 47 => Color.White, - // 90 or 100 => Color.Grey, - // 91 or 101 => Color.Red1, - // 92 or 102 => Color.Green1, - // 93 or 103 => Color.Yellow1, - // 94 or 104 => Color.Blue1, - // 95 or 105 => Color.Fuchsia, - // 96 or 106 => Color.Aqua, - // 97 or 107 => Color.White, - // _ => Color.Default - // From ConvertFrom-ConsoleColor.ps1 }; /// @@ -514,11 +497,9 @@ private struct StyleState { public Color? Background; public Decoration Decoration; public string? Link; - public readonly bool HasAnyStyle => Foreground.HasValue || Background.HasValue || Decoration != Decoration.None || Link is not null; - public void Reset() { Foreground = null; Background = null; diff --git a/src/PSTextMate/Utilities/VTHelpers.cs b/src/PSTextMate/Utilities/VTHelpers.cs index 770f93d..564e829 100644 --- a/src/PSTextMate/Utilities/VTHelpers.cs +++ b/src/PSTextMate/Utilities/VTHelpers.cs @@ -1,8 +1,16 @@ namespace PSTextMate.Utilities; public static class VTHelpers { - public static void EnterAlternateBuffer() => Console.Write("\x1b[?1049h"); - public static void ExitAlternateBuffer() => Console.Write("\x1b[?1049l"); + private static bool? _supportsAlternateBuffer; + private static bool? _supportsSynchronizedOutput; + private const string AlternateBufferModeQuery = "[?1049$p"; + private const string AlternateBufferReply = "[?1049;1$y"; + private const string MainBufferReply = "[?1049;2$y"; + private const string SynchronizedOutputModeQuery = "[?2026$p"; + private const string SynchronizedOutputActiveReply = "[?2026;1$y"; + private const string SynchronizedOutputInactiveReply = "[?2026;2$y"; + private const string BeginSynchronizedOutputSequence = "\x1b[?2026h"; + private const string EndSynchronizedOutputSequence = "\x1b[?2026l"; public static void HideCursor() => Console.Write("\x1b[?25l"); public static void ShowCursor() => Console.Write("\x1b[?25h"); public static void ClearScreen() => Console.Write("\x1b[2J\x1b[H"); @@ -15,4 +23,81 @@ public static class VTHelpers { // Reset scroll region to full height (CSI r) public static void ResetScrollRegion() => Console.Write("\x1b[r"); + /// + /// Begins synchronized output mode (DEC private mode 2026). + /// Unsupported terminals ignore this sequence. + /// + public static void BeginSynchronizedOutput() { + if (!SupportsSynchronizedOutput()) { + return; + } + + Console.Write(BeginSynchronizedOutputSequence); + Console.Out.Flush(); + } + + /// + /// Ends synchronized output mode (DEC private mode 2026). + /// + public static void EndSynchronizedOutput() { + if (!SupportsSynchronizedOutput()) { + return; + } + + Console.Write(EndSynchronizedOutputSequence); + Console.Out.Flush(); + } + + /// + /// Determines whether the terminal supports synchronized output mode 2026 using DECRQM. + /// + public static bool SupportsSynchronizedOutput() { + if (_supportsSynchronizedOutput.HasValue) { + return _supportsSynchronizedOutput.Value; + } + + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + _supportsSynchronizedOutput = false; + return false; + } + + try { + string response = Compatibility.GetControlSequenceResponse(SynchronizedOutputModeQuery); + bool supported = response.Contains(SynchronizedOutputActiveReply, StringComparison.Ordinal) + || response.Contains(SynchronizedOutputInactiveReply, StringComparison.Ordinal); + _supportsSynchronizedOutput = supported; + return supported; + } + catch { + _supportsSynchronizedOutput = false; + return false; + } + } + + /// + /// Determines whether the terminal supports mode 1049 using DECRQM. + /// + public static bool SupportsAlternateBuffer() { + if (_supportsAlternateBuffer.HasValue) { + return _supportsAlternateBuffer.Value; + } + + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + _supportsAlternateBuffer = false; + return false; + } + + try { + string response = Compatibility.GetControlSequenceResponse(AlternateBufferModeQuery); + bool supported = response.Contains(AlternateBufferReply, StringComparison.Ordinal) + || response.Contains(MainBufferReply, StringComparison.Ordinal); + _supportsAlternateBuffer = supported; + return supported; + } + catch { + _supportsAlternateBuffer = false; + return false; + } + } + } diff --git a/src/PSTextMate/Utilities/Writer.cs b/src/PSTextMate/Utilities/Writer.cs index 8c3d7a6..86585ef 100644 --- a/src/PSTextMate/Utilities/Writer.cs +++ b/src/PSTextMate/Utilities/Writer.cs @@ -22,9 +22,15 @@ public static string Write(IRenderable renderable) { /// Renders highlighted text to string. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string Write(HighlightedText highlightedText, bool autoPage = true, bool alternatePager = false) { + public static string Write(HighlightedText highlightedText, bool autoPage = true) { ArgumentNullException.ThrowIfNull(highlightedText); + if (highlightedText.Page || (autoPage && ShouldPage(highlightedText))) { + using var pager = new Pager(highlightedText); + pager.Show(); + return string.Empty; + } + // Sixel payload must be written as raw control sequences. Converting to a string // and flowing through host formatting can strip DCS wrappers and print payload text. if (ContainsImageRenderables(highlightedText.Renderables)) { @@ -32,10 +38,7 @@ public static string Write(HighlightedText highlightedText, bool autoPage = true return string.Empty; } - // Keep compatibility with prior callsites while remaining side-effect free. - _ = autoPage; - _ = alternatePager; - return WriteToString(highlightedText, customItemFormatter: true); + return WriteToString(highlightedText); } /// @@ -53,14 +56,14 @@ public static string Write(IEnumerable renderables) { /// /// Renders a Spectre renderable to a reusable in-memory writer. - /// This mirrors the PwshSpectreConsole approach so the output can be streamed + /// Uses a stable in-memory rendering path so the output can be streamed /// as plain text, redirected, or post-processed by custom formatters. /// - public static string WriteToString(IRenderable renderable, bool customItemFormatter = false, int? width = null) { + public static string WriteToString(IRenderable renderable, int? width = null) { ArgumentNullException.ThrowIfNull(renderable); lock (SyncRoot) { - StringConsole.Profile.Width = ResolveWidth(customItemFormatter, width); + StringConsole.Profile.Width = ResolveWidth(width); StringConsole.Write(renderable); string output = StringConsoleWriter.ToString().TrimEnd(); @@ -73,8 +76,8 @@ public static string WriteToString(IRenderable renderable, bool customItemFormat /// Compatibility wrapper for previous API shape. /// No host-direct output is performed; this returns the rendered string only. /// - public static string WriteToStringWithHostFallback(IRenderable renderable, bool customItemFormatter = false, int? width = null) - => WriteToString(renderable, customItemFormatter, width); + public static string WriteToStringWithHostFallback(IRenderable renderable, int? width = null) + => WriteToString(renderable, width); private static IAnsiConsole CreateStringConsole(StringWriter writer) { var settings = new AnsiConsoleSettings { @@ -84,9 +87,9 @@ private static IAnsiConsole CreateStringConsole(StringWriter writer) { return AnsiConsole.Create(settings); } - private static int ResolveWidth(bool customItemFormatter, int? widthOverride) { + private static int ResolveWidth(int? widthOverride) { int width = widthOverride ?? GetConsoleWidth(); - return customItemFormatter && width > 1 ? width - 1 : Math.Max(1, width); + return Math.Max(1, width); } private static int GetConsoleWidth() { @@ -101,6 +104,20 @@ private static int GetConsoleWidth() { private static bool ContainsImageRenderables(IEnumerable renderables) => renderables.Any(IsImageRenderable); + private static bool ShouldPage(HighlightedText highlightedText) { + int windowHeight = GetConsoleHeight(); + return highlightedText.LineCount > Math.Max(1, windowHeight - 2); + } + + private static int GetConsoleHeight() { + try { + return Console.WindowHeight > 0 ? Console.WindowHeight : 40; + } + catch { + return 40; + } + } + private static bool IsImageRenderable(IRenderable renderable) { string name = renderable.GetType().Name; return name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) diff --git a/src/PSTextMate/Utilities/using.cs b/src/PSTextMate/Utilities/using.cs index 06d0e90..c637824 100644 --- a/src/PSTextMate/Utilities/using.cs +++ b/src/PSTextMate/Utilities/using.cs @@ -3,6 +3,7 @@ global using System.Collections; global using System.Collections.Concurrent; global using System.Collections.Generic; +global using System.Collections.ObjectModel; global using System.Diagnostics; global using System.Globalization; global using System.IO; From c6b0a9133c955c431d50966b98b76b59b50aa858 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:18:34 +0100 Subject: [PATCH 05/17] =?UTF-8?q?feat(pager):=20=E2=9C=A8=20Enhance=20`Out?= =?UTF-8?q?PageCmdlet`=20and=20`HighlightedText`=20for=20improved=20render?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added support for handling `HighlightedText` in `OutPageCmdlet`. * Updated rendering logic to accommodate highlighted text and non-highlighted input. * Modified `Pager` to disable panel wrapping during paging for stable layout. * Refactored `Writer` to utilize a `RenderContext` for better performance and memory management. * Improved `SpectreRenderBridge` to use a `StringBuilder` for optimized string handling. --- src/PSTextMate/Cmdlets/OutPage.cs | 102 ++++++++++++++---- src/PSTextMate/Core/HighlightedText.cs | 9 +- src/PSTextMate/Utilities/Pager.cs | 9 +- .../Utilities/SpectreRenderBridge.cs | 2 +- src/PSTextMate/Utilities/Writer.cs | 41 +++++-- 5 files changed, 127 insertions(+), 36 deletions(-) diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index 898ead1..d4cce56 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -8,6 +8,8 @@ public sealed class OutPageCmdlet : PSCmdlet { private readonly List _renderables = []; private readonly List _outStringInputs = []; + private HighlightedText? _singleHighlightedText; + private bool _sawNonHighlightedInput; /// /// Pipeline input to page. @@ -28,8 +30,27 @@ protected override void ProcessRecord() { object value = InputObject.BaseObject; + if (value is HighlightedText highlightedText) { + if (_singleHighlightedText is null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { + _singleHighlightedText = highlightedText; + return; + } + + _sawNonHighlightedInput = true; + _renderables.AddRange(highlightedText.Renderables); + return; + } + + _sawNonHighlightedInput = true; + if (value is IRenderable renderable) { - _renderables.Add(renderable); + string rendered = Writer.WriteToString(renderable, width: GetConsoleWidth()); + if (!string.IsNullOrEmpty(rendered)) { + AddParagraphLines(_renderables, rendered); + } + else { + _renderables.Add(renderable); + } return; } @@ -38,8 +59,8 @@ protected override void ProcessRecord() { return; } - if (TryConvertForeignSpectreRenderable(value, out Paragraph paragraph)) { - _renderables.Add(paragraph); + if (TryConvertForeignSpectreRenderable(value, out List convertedRenderables)) { + _renderables.AddRange(convertedRenderables); return; } @@ -50,10 +71,18 @@ protected override void ProcessRecord() { /// Runs the pager when all pipeline input has been collected. /// protected override void EndProcessing() { + if (_singleHighlightedText is not null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { + using var highlightedPager = new Pager(_singleHighlightedText); + highlightedPager.Show(); + return; + } + if (_outStringInputs.Count > 0) { - string formatted = ConvertWithOutString(_outStringInputs); - if (!string.IsNullOrEmpty(formatted)) { - _renderables.Add(Helpers.VTConversion.ToParagraph(formatted)); + List formattedLines = ConvertWithOutStringLines(_outStringInputs); + if (formattedLines.Count > 0) { + foreach (string line in formattedLines) { + _renderables.Add(Helpers.VTConversion.ToParagraph(line)); + } } else { foreach (object value in _outStringInputs) { @@ -70,9 +99,9 @@ protected override void EndProcessing() { pager.Show(); } - private static string ConvertWithOutString(List values) { + private static List ConvertWithOutStringLines(List values) { if (values.Count == 0) { - return string.Empty; + return []; } OutputRendering previousOutputRendering = PSStyle.Instance.OutputRendering; @@ -81,33 +110,44 @@ private static string ConvertWithOutString(List values) { using var ps = PowerShell.Create(RunspaceMode.CurrentRunspace); ps.AddCommand("Out-String") + .AddParameter("Stream") .AddParameter("Width", GetConsoleWidth()); Collection results = ps.Invoke(values); if (ps.HadErrors || results.Count == 0) { - return string.Empty; + return []; } - var builder = new StringBuilder(); + var lines = new List(results.Count); foreach (PSObject? result in results) { if (result?.BaseObject is string text) { - builder.Append(text); + AddLines(lines, text); } else { - builder.Append(result?.ToString()); + AddLines(lines, result?.ToString() ?? string.Empty); } } - return builder.ToString(); + return lines; } catch { - return string.Empty; + return []; } finally { PSStyle.Instance.OutputRendering = previousOutputRendering; } } + private static void AddLines(List lines, string text) { + string normalized = text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + + string[] split = normalized.Split('\n'); + foreach (string line in split) { + lines.Add(line); + } + } + private static int GetConsoleWidth() { try { return Console.WindowWidth > 0 ? Console.WindowWidth : 120; @@ -117,8 +157,8 @@ private static int GetConsoleWidth() { } } - private static bool TryConvertForeignSpectreRenderable(object value, out Paragraph paragraph) { - paragraph = new Paragraph(); + private static bool TryConvertForeignSpectreRenderable(object value, out List renderables) { + renderables = []; Type valueType = value.GetType(); string? fullName = valueType.FullName; @@ -133,8 +173,32 @@ private static bool TryConvertForeignSpectreRenderable(object value, out Paragra return false; } - paragraph = Helpers.VTConversion.ToParagraph(ansi); - return true; + renderables = ConvertAnsiToLineRenderables(ansi); + return renderables.Count != 0; + } + + private static List ConvertAnsiToLineRenderables(string ansi) { + string[] lines = ansi.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + var renderables = new List(lines.Length); + + foreach (string line in lines) { + renderables.Add(Helpers.VTConversion.ToParagraph(line)); + } + + return renderables; + } + + private static void AddParagraphLines(List destination, string ansi) { + if (string.IsNullOrEmpty(ansi)) { + return; + } + + List lines = ConvertAnsiToLineRenderables(ansi); + if (lines.Count == 0) { + return; + } + + destination.AddRange(lines); } private static string RenderForeignSpectreToAnsi(object value) { @@ -154,7 +218,7 @@ private static string RenderForeignSpectreToAnsi(object value) { return string.Empty; } - using var writer = new StringWriter(); + using StringWriter writer = new(new StringBuilder(1024), CultureInfo.InvariantCulture); object? output = Activator.CreateInstance(ansiConsoleOutputType, writer); object? settings = Activator.CreateInstance(ansiConsoleSettingsType); PropertyInfo? outProperty = ansiConsoleSettingsType.GetProperty("Out"); diff --git a/src/PSTextMate/Core/HighlightedText.cs b/src/PSTextMate/Core/HighlightedText.cs index da440b2..8449bf4 100644 --- a/src/PSTextMate/Core/HighlightedText.cs +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -100,12 +100,12 @@ protected override IEnumerable Render(RenderOptions options, int maxWid // to avoid creating the InnerContentRenderable wrapper. if (!ShowLineNumbers) { var rowsInner = new Rows(GetRenderablesEnumerable()); - var panelInner = new Panel(rowsInner) { Padding = new Padding(0, 0) }; + var panelInner = new Panel(rowsInner) { Padding = new Padding(0, 0), Expand = true }; return ((IRenderable)panelInner).Render(options, maxWidth); } var inner = new InnerContentRenderable(this); - var panel = new Panel(inner) { Padding = new Padding(0, 0) }; + var panel = new Panel(inner) { Padding = new Padding(0, 0), Expand = true }; return ((IRenderable)panel).Render(options, maxWidth); } @@ -121,12 +121,12 @@ protected override Measurement Measure(RenderOptions options, int maxWidth) { if (WrapInPanel) { if (!ShowLineNumbers) { var rowsInner = new Rows(GetRenderablesEnumerable()); - var panelInner = new Panel(rowsInner) { Padding = new Padding(0, 0) }; + var panelInner = new Panel(rowsInner) { Padding = new Padding(0, 0), Expand = true }; return ((IRenderable)panelInner).Measure(options, maxWidth); } var inner = new InnerContentRenderable(this); - var panel = new Panel(inner) { Padding = new Padding(0, 0) }; + var panel = new Panel(inner) { Padding = new Padding(0, 0), Expand = true }; return ((IRenderable)panel).Measure(options, maxWidth); } @@ -329,6 +329,7 @@ public Panel ToPanel(string? title = null, BoxBorder? border = null) { var panel = new Panel(content); panel.Padding(0, 0); + panel.Expand(); if (!string.IsNullOrEmpty(title)) { panel.Header(title); diff --git a/src/PSTextMate/Utilities/Pager.cs b/src/PSTextMate/Utilities/Pager.cs index 6e1a228..fc65252 100644 --- a/src/PSTextMate/Utilities/Pager.cs +++ b/src/PSTextMate/Utilities/Pager.cs @@ -14,6 +14,7 @@ public sealed class Pager : IDisposable { private readonly HighlightedText? _sourceHighlightedText; private readonly int? _originalLineNumberStart; private readonly int? _originalLineNumberWidth; + private readonly bool? _originalWrapInPanel; private readonly int? _stableLineNumberWidth; private int _top; private int WindowHeight; @@ -22,7 +23,6 @@ public sealed class Pager : IDisposable { private int _lastRenderedRows; private List _renderableHeights = []; private bool _lastPageHadImages; - private readonly record struct ViewportWindow(int Top, int Count, int EndExclusive, bool HasImages); private sealed class PagerExclusivityMode : IExclusivityMode { @@ -141,6 +141,7 @@ private ViewportWindow BuildViewport(int proposedTop, int contentRows) { public Pager(HighlightedText highlightedText) { _sourceHighlightedText = highlightedText; + _originalWrapInPanel = highlightedText.WrapInPanel; int totalLines = highlightedText.LineCount; int lastLineNumber = highlightedText.LineNumberStart + Math.Max(0, totalLines - 1); @@ -148,6 +149,9 @@ public Pager(HighlightedText highlightedText) { _originalLineNumberStart = highlightedText.LineNumberStart; _originalLineNumberWidth = highlightedText.LineNumberWidth; + // Panel rendering in pager mode causes unstable layout; disable it for the paging session. + highlightedText.WrapInPanel = false; + // Reference the underlying renderable array directly to avoid copying. _renderables = highlightedText.Renderables; _top = 0; @@ -439,6 +443,8 @@ private void ShowCore(bool useAlternateBuffer) { try { (int width, int pageHeight) = GetPagerSize(); int contentRows = Math.Max(1, pageHeight - 1); + WindowWidth = width; + WindowHeight = pageHeight; // Start with a clean screen then reserve the last row as a non-scrolling footer region if (useAlternateBuffer) { @@ -490,6 +496,7 @@ private void ShowCore(bool useAlternateBuffer) { _sourceHighlightedText.ClearView(); _sourceHighlightedText.LineNumberStart = _originalLineNumberStart ?? 1; _sourceHighlightedText.LineNumberWidth = _originalLineNumberWidth; + _sourceHighlightedText.WrapInPanel = _originalWrapInPanel ?? false; } // Reset scroll region and restore normal screen buffer if used if (useAlternateBuffer) { diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs index 92aa773..522191a 100644 --- a/src/PSTextMate/Utilities/SpectreRenderBridge.cs +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -22,7 +22,7 @@ public static string RenderToString(object renderableObject, bool escapeAnsi = f ); } - using StringWriter writer = new(); + using StringWriter writer = new(new StringBuilder(1024), CultureInfo.InvariantCulture); var output = new AnsiConsoleOutput(writer); var settings = new AnsiConsoleSettings { Out = output }; IAnsiConsole console = AnsiConsole.Create(settings); diff --git a/src/PSTextMate/Utilities/Writer.cs b/src/PSTextMate/Utilities/Writer.cs index 86585ef..ab01c57 100644 --- a/src/PSTextMate/Utilities/Writer.cs +++ b/src/PSTextMate/Utilities/Writer.cs @@ -5,9 +5,20 @@ namespace PSTextMate.Utilities; /// Uses a cached in-memory Spectre console and returns rendered strings. /// public static class Writer { - private static readonly StringWriter StringConsoleWriter = new(); - private static readonly IAnsiConsole StringConsole = CreateStringConsole(StringConsoleWriter); - private static readonly object SyncRoot = new(); + private sealed class RenderContext { + public StringBuilder Buffer { get; } + public StringWriter Writer { get; } + public IAnsiConsole Console { get; } + + public RenderContext() { + Buffer = new StringBuilder(2048); + Writer = new StringWriter(Buffer, CultureInfo.InvariantCulture); + Console = CreateStringConsole(Writer); + } + } + + [ThreadStatic] + private static RenderContext? _threadContext; /// /// Renders a single renderable to string. @@ -22,7 +33,7 @@ public static string Write(IRenderable renderable) { /// Renders highlighted text to string. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string Write(HighlightedText highlightedText, bool autoPage = true) { + public static string Write(HighlightedText highlightedText, bool autoPage = false) { ArgumentNullException.ThrowIfNull(highlightedText); if (highlightedText.Page || (autoPage && ShouldPage(highlightedText))) { @@ -62,14 +73,11 @@ public static string Write(IEnumerable renderables) { public static string WriteToString(IRenderable renderable, int? width = null) { ArgumentNullException.ThrowIfNull(renderable); - lock (SyncRoot) { - StringConsole.Profile.Width = ResolveWidth(width); + RenderContext context = _threadContext ??= new RenderContext(); + context.Console.Profile.Width = ResolveWidth(width); - StringConsole.Write(renderable); - string output = StringConsoleWriter.ToString().TrimEnd(); - StringConsoleWriter.GetStringBuilder().Clear(); - return output; - } + context.Console.Write(renderable); + return GetTrimmedOutputAndReset(context.Buffer); } /// @@ -79,6 +87,17 @@ public static string WriteToString(IRenderable renderable, int? width = null) { public static string WriteToStringWithHostFallback(IRenderable renderable, int? width = null) => WriteToString(renderable, width); + private static string GetTrimmedOutputAndReset(StringBuilder buffer) { + int end = buffer.Length; + while (end > 0 && char.IsWhiteSpace(buffer[end - 1])) { + end--; + } + + string output = end == 0 ? string.Empty : buffer.ToString(0, end); + buffer.Clear(); + return output; + } + private static IAnsiConsole CreateStringConsole(StringWriter writer) { var settings = new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer) From dba245896a562040887b5d86d637c619f6d56ce6 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:57:59 +0100 Subject: [PATCH 06/17] =?UTF-8?q?feat(rendering):=20=E2=9C=A8=20Enhance=20?= =?UTF-8?q?`RenderToString`=20for=20improved=20handling=20of=20renderable?= =?UTF-8?q?=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored `RenderToString` to separate local and foreign rendering logic. * Added detailed error messages for unsupported types. * Improved handling of foreign renderables using reflection for dynamic type resolution. --- src/PSTextMate/Cmdlets/OutPage.cs | 42 +++++++++-- .../Utilities/SpectreRenderBridge.cs | 72 +++++++++++++++++-- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index d4cce56..0d5c0d5 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -25,19 +25,23 @@ public sealed class OutPageCmdlet : PSCmdlet { /// protected override void ProcessRecord() { if (InputObject?.BaseObject is null) { + WriteVerbose("ProcessRecord: InputObject is null; skipping item."); return; } object value = InputObject.BaseObject; + WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}'."); if (value is HighlightedText highlightedText) { if (_singleHighlightedText is null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { _singleHighlightedText = highlightedText; + WriteVerbose("ProcessRecord: took HighlightedText fast path and deferred to highlighted pager."); return; } _sawNonHighlightedInput = true; _renderables.AddRange(highlightedText.Renderables); + WriteVerbose("ProcessRecord: merged HighlightedText renderables into the main renderable list."); return; } @@ -47,24 +51,48 @@ protected override void ProcessRecord() { string rendered = Writer.WriteToString(renderable, width: GetConsoleWidth()); if (!string.IsNullOrEmpty(rendered)) { AddParagraphLines(_renderables, rendered); + WriteVerbose("ProcessRecord: input matched IRenderable and was expanded into paragraph lines."); } else { _renderables.Add(renderable); + WriteVerbose("ProcessRecord: input matched IRenderable and was added as-is."); } + return; } if (value is string text) { _outStringInputs.Add(text); + WriteVerbose("ProcessRecord: input matched string; queued for Out-String conversion."); + return; + } + + if (value is Renderable renderable1) { + _renderables.Add(renderable1); + WriteVerbose("ProcessRecord: input matched Renderable concrete type; added directly."); return; } + try { + var r = (Renderable)value; + if (r is not null) { + _renderables.Add(r); + WriteVerbose("ProcessRecord: input cast to Renderable successfully; added directly."); + return; + } + } + catch { + WriteVerbose("ProcessRecord: direct Renderable cast failed; continuing with foreign conversion attempt."); + } + if (TryConvertForeignSpectreRenderable(value, out List convertedRenderables)) { _renderables.AddRange(convertedRenderables); + WriteVerbose($"ProcessRecord: converted foreign Spectre renderable into {convertedRenderables.Count} line renderables."); return; } _outStringInputs.Add(value); + WriteVerbose("ProcessRecord: no renderable conversion path matched; queued object for Out-String conversion."); } /// @@ -72,29 +100,37 @@ protected override void ProcessRecord() { /// protected override void EndProcessing() { if (_singleHighlightedText is not null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { + WriteVerbose("EndProcessing: using single HighlightedText pager path."); using var highlightedPager = new Pager(_singleHighlightedText); highlightedPager.Show(); return; } if (_outStringInputs.Count > 0) { + WriteVerbose($"EndProcessing: converting {_outStringInputs.Count} queued value(s) through Out-String."); List formattedLines = ConvertWithOutStringLines(_outStringInputs); if (formattedLines.Count > 0) { foreach (string line in formattedLines) { _renderables.Add(Helpers.VTConversion.ToParagraph(line)); } + + WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); } else { foreach (object value in _outStringInputs) { _renderables.Add(new Text(LanguagePrimitives.ConvertTo(value))); } + + WriteVerbose("EndProcessing: Out-String returned no lines; used string conversion fallback."); } } if (_renderables.Count == 0) { + WriteVerbose("EndProcessing: no renderables collected; nothing to page."); return; } + WriteVerbose($"EndProcessing: launching pager with {_renderables.Count} renderable(s)."); using var pager = new Pager(_renderables); pager.Show(); } @@ -142,8 +178,7 @@ private static void AddLines(List lines, string text) { string normalized = text.Replace("\r\n", "\n", StringComparison.Ordinal) .Replace('\r', '\n'); - string[] split = normalized.Split('\n'); - foreach (string line in split) { + foreach (string line in normalized.Split('\n')) { lines.Add(line); } } @@ -213,8 +248,7 @@ private static string RenderForeignSpectreToAnsi(object value) { if (ansiConsoleType is null || ansiConsoleSettingsType is null || ansiConsoleOutputType is null - || renderableType is null - || !renderableType.IsInstanceOfType(value)) { + || renderableType?.IsInstanceOfType(value) != true) { return string.Empty; } diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs index 522191a..288eb6f 100644 --- a/src/PSTextMate/Utilities/SpectreRenderBridge.cs +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -15,20 +15,84 @@ public static class SpectreRenderBridge { public static string RenderToString(object renderableObject, bool escapeAnsi = false) { ArgumentNullException.ThrowIfNull(renderableObject); - if (renderableObject is not IRenderable renderable) { + string rendered = renderableObject is IRenderable localRenderable + ? RenderLocal(localRenderable) + : RenderForeign(renderableObject); + + if (string.IsNullOrEmpty(rendered)) { throw new ArgumentException( - $"Object of type '{renderableObject.GetType().FullName}' does not implement {nameof(IRenderable)}.", + $"Object of type '{renderableObject.GetType().FullName}' does not implement a supported Spectre IRenderable shape.", nameof(renderableObject) ); } + return escapeAnsi ? PSHostUserInterface.GetOutputString(rendered, false) : rendered; + } + + private static string RenderLocal(IRenderable renderable) { using StringWriter writer = new(new StringBuilder(1024), CultureInfo.InvariantCulture); var output = new AnsiConsoleOutput(writer); var settings = new AnsiConsoleSettings { Out = output }; IAnsiConsole console = AnsiConsole.Create(settings); console.Write(renderable); + return writer.ToString(); + } - string rendered = writer.ToString(); - return escapeAnsi ? PSHostUserInterface.GetOutputString(rendered, false) : rendered; + private static string RenderForeign(object renderableObject) { + Type valueType = renderableObject.GetType(); + Assembly assembly = valueType.Assembly; + + Type? ansiConsoleType = assembly.GetType("Spectre.Console.AnsiConsole"); + Type? ansiConsoleSettingsType = assembly.GetType("Spectre.Console.AnsiConsoleSettings"); + Type? ansiConsoleOutputType = assembly.GetType("Spectre.Console.AnsiConsoleOutput"); + Type? foreignRenderableType = assembly.GetType("Spectre.Console.Rendering.IRenderable") + ?? assembly.GetType("Spectre.Console.IRenderable"); + + if (ansiConsoleType is null + || ansiConsoleSettingsType is null + || ansiConsoleOutputType is null + || foreignRenderableType is null + || !foreignRenderableType.IsInstanceOfType(renderableObject)) { + return string.Empty; + } + + using StringWriter writer = new(new StringBuilder(1024), CultureInfo.InvariantCulture); + object? output = Activator.CreateInstance(ansiConsoleOutputType, writer); + object? settings = Activator.CreateInstance(ansiConsoleSettingsType); + PropertyInfo? outProperty = ansiConsoleSettingsType.GetProperty("Out"); + if (output is null || settings is null || outProperty is null || !outProperty.CanWrite) { + return string.Empty; + } + + outProperty.SetValue(settings, output); + + MethodInfo? createMethod = ansiConsoleType + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => method.Name == "Create" + && method.GetParameters() is { Length: 1 } parameters + && parameters[0].ParameterType == ansiConsoleSettingsType); + object? console = createMethod?.Invoke(null, [settings]); + if (console is null) { + return string.Empty; + } + + MethodInfo? writeMethod = console.GetType().GetMethod("Write", [foreignRenderableType]); + if (writeMethod is not null) { + _ = writeMethod.Invoke(console, [renderableObject]); + return writer.ToString(); + } + + Type? extType = assembly.GetType("Spectre.Console.AnsiConsoleExtensions"); + MethodInfo? extWriteMethod = extType? + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => method.Name == "Write" + && method.GetParameters() is { Length: 2 } parameters + && parameters[1].ParameterType == foreignRenderableType); + if (extWriteMethod is null) { + return string.Empty; + } + + _ = extWriteMethod.Invoke(null, [console, renderableObject]); + return writer.ToString(); } } From 7c95641eb46e6a8615dfb0fb69314e0c4d46f7d6 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:50:14 +0100 Subject: [PATCH 07/17] =?UTF-8?q?feat(pager):=20=E2=9C=A8=20Implement=20in?= =?UTF-8?q?teractive=20pager=20with=20enhanced=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated `OutPageCmdlet` to support interactive paging of renderable objects. * Enhanced rendering logic to handle various input types, including `HighlightedText` and foreign Spectre renderables. * Introduced a new `Out-Page` cmdlet with comprehensive documentation. * Improved footer display in the pager for better user experience. * Added support for dynamic width adjustments in rendering. * Updated module version to `0.2.0` and included new required modules. --- Module/TextMate.psd1 | 10 +- README.md | 1 + docs/en-us/Out-Page.md | 127 +++++++++++ src/PSTextMate/Cmdlets/OutPage.cs | 200 +++++------------- src/PSTextMate/Cmdlets/TestTextMate.cs | 9 +- src/PSTextMate/Cmdlets/TextMateCmdletBase.cs | 2 +- src/PSTextMate/PSTextMate.csproj | 2 +- src/PSTextMate/Utilities/Helpers.cs | 8 +- src/PSTextMate/Utilities/Pager.cs | 136 ++++++++---- .../Utilities/SpectreRenderBridge.cs | 158 +++++++++++++- src/PSTextMate/Utilities/VTConversion.cs | 5 + src/PSTextMate/Utilities/Writer.cs | 1 - src/PSTextMate/Utilities/using.cs | 3 + 13 files changed, 449 insertions(+), 213 deletions(-) create mode 100644 docs/en-us/Out-Page.md diff --git a/Module/TextMate.psd1 b/Module/TextMate.psd1 index 8a56f9d..50e39f3 100644 --- a/Module/TextMate.psd1 +++ b/Module/TextMate.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'TextMate.psm1' - ModuleVersion = '0.1.0' + ModuleVersion = '0.2.0' GUID = 'fe78d2cb-2418-4308-9309-a0850e504cd6' Author = 'trackd' CompanyName = 'trackd' @@ -25,7 +25,13 @@ 'Show-TextMate' ) FormatsToProcess = 'TextMate.format.ps1xml' - RequiredModules = @() + RequiredModules = @( + @{ + ModuleName = 'PwshSpectreConsole' + ModuleVersion = '2.6.3' + MaximumVersion = '2.99.99' + } + ) PrivateData = @{ PSData = @{ Tags = 'Windows', 'Linux', 'OSX', 'TextMate', 'Markdown', 'SyntaxHighlighting' diff --git a/README.md b/README.md index 49ac083..cd06137 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ What it does | [Format-PowerShell](docs/en-us/Format-PowerShell.md) | Highlight PowerShell code | | [Get-TextMateGrammar](docs/en-us/Get-TextMateGrammar.md) | List available grammars and file extensions. | | [Test-TextMate](docs/en-us/Test-TextMate.md) | Check support for a file, extension, or language ID. | +| [Out-Page](docs/en-us/Out-Page.md) | Builtin terminal pager | ```note Format-CSharp/Markdown/Powershell is just sugar for Format-TextMate -Language CSharp/PowerShell/Markdown diff --git a/docs/en-us/Out-Page.md b/docs/en-us/Out-Page.md new file mode 100644 index 0000000..cdc97a1 --- /dev/null +++ b/docs/en-us/Out-Page.md @@ -0,0 +1,127 @@ +--- +external help file: PSTextMate.dll-Help.xml +Module Name: TextMate +online version: https://github.com/trackd/TextMate/blob/main/docs/en-us +schema: 2.0.0 +--- + +# Out-Page + +## SYNOPSIS + +Displays pipeline content in the interactive pager. + +## SYNTAX + +### (All) + +```powershell +Out-Page [-InputObject] [] +``` + +## ALIASES + +This cmdlet has the following aliases, + None + +## DESCRIPTION + +Out-Page collects pipeline input and opens an interactive pager view. +Renderable values are shown directly; other values are formatted through `Out-String -Stream` +and displayed line-by-line. + +The pager supports keyboard navigation for scrolling and paging through large output. + +## EXAMPLES + +### Example 1 + +Example: page output from a TextMate formatter cmdlet + +```powershell +Get-Content .\src\PSTextMate\Cmdlets\OutPage.cs -Raw | Format-CSharp | Out-Page +``` + +### Example 2 + +Example: capture and pipe a `HighlightedText` object directly + +```powershell +$highlighted = Get-Content .\README.md -Raw | Format-Markdown +$highlighted | Out-Page +``` + +### Example 3 + +Example: page PwshSpectreConsole renderables + +```powershell +Import-Module PwshSpectreConsole +$num = $host.ui.RawUI.WindowSize.Height - 5 +1..$num | + ForEach-Object { + $randomColor = [Spectre.Console.Color].GetProperties().Name | Get-Random + $value = Get-Random -Minimum 10 -Maximum 100 + New-SpectreChartItem -Label "Item $_" -Value $value -Color $randomColor + } | + Format-SpectreBarChart | + Out-Page +``` + +### Example 4 + +Example: page regular `Out-String` content + +```powershell +Get-ChildItem -Recurse | Out-String -Stream | Out-Page +``` + +## PARAMETERS + +### -InputObject + +Input to display in the pager. Accepts renderables, strings, or general objects from the pipeline. + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSObject + +Accepts any pipeline object. Renderables are used directly; non-renderables are formatted into text lines for paging. + +## OUTPUTS + +### System.Void + +This cmdlet writes to the interactive pager and does not emit pipeline output. + +## NOTES + +Use `q` or `Esc` to exit the pager. Arrow keys, PageUp/PageDown, Spacebar, Home, and End are supported for navigation. + +## RELATED LINKS + +See also `Format-TextMate`, `Format-CSharp`, `Format-Markdown`, and `Format-PowerShell`. diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index 0d5c0d5..fd10fa4 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -1,4 +1,4 @@ -namespace PSTextMate.Commands; +namespace PSTextMate.Commands; /// /// Sends renderables or VT-formatted strings to the interactive pager. @@ -6,23 +6,16 @@ [Cmdlet(VerbsData.Out, "Page")] [OutputType(typeof(void))] public sealed class OutPageCmdlet : PSCmdlet { + private const char Escape = '\x1B'; private readonly List _renderables = []; private readonly List _outStringInputs = []; private HighlightedText? _singleHighlightedText; private bool _sawNonHighlightedInput; - /// - /// Pipeline input to page. - /// Accepts values directly, or strings that are - /// converted through . - /// [Parameter(Mandatory = true, ValueFromPipeline = true, Position = 0)] - [AllowNull] + [System.Management.Automation.AllowNull] public PSObject? InputObject { get; set; } - /// - /// Processes one input object from the pipeline. - /// protected override void ProcessRecord() { if (InputObject?.BaseObject is null) { WriteVerbose("ProcessRecord: InputObject is null; skipping item."); @@ -30,7 +23,7 @@ protected override void ProcessRecord() { } object value = InputObject.BaseObject; - WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}'."); + WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}' BaseType: '{value.GetType().BaseType}'."); if (value is HighlightedText highlightedText) { if (_singleHighlightedText is null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { @@ -48,16 +41,8 @@ protected override void ProcessRecord() { _sawNonHighlightedInput = true; if (value is IRenderable renderable) { - string rendered = Writer.WriteToString(renderable, width: GetConsoleWidth()); - if (!string.IsNullOrEmpty(rendered)) { - AddParagraphLines(_renderables, rendered); - WriteVerbose("ProcessRecord: input matched IRenderable and was expanded into paragraph lines."); - } - else { - _renderables.Add(renderable); - WriteVerbose("ProcessRecord: input matched IRenderable and was added as-is."); - } - + _renderables.Add(renderable); + WriteVerbose("ProcessRecord: input matched IRenderable and was added directly."); return; } @@ -67,37 +52,25 @@ protected override void ProcessRecord() { return; } - if (value is Renderable renderable1) { - _renderables.Add(renderable1); - WriteVerbose("ProcessRecord: input matched Renderable concrete type; added directly."); + if (TryConvertForeignSpectreRenderable(value, out IRenderable? convertedRenderable)) { + _renderables.Add(convertedRenderable); + WriteVerbose("ProcessRecord: converted foreign Spectre renderable to local IRenderable."); return; } - try { - var r = (Renderable)value; - if (r is not null) { - _renderables.Add(r); - WriteVerbose("ProcessRecord: input cast to Renderable successfully; added directly."); - return; - } - } - catch { - WriteVerbose("ProcessRecord: direct Renderable cast failed; continuing with foreign conversion attempt."); - } - - if (TryConvertForeignSpectreRenderable(value, out List convertedRenderables)) { - _renderables.AddRange(convertedRenderables); - WriteVerbose($"ProcessRecord: converted foreign Spectre renderable into {convertedRenderables.Count} line renderables."); - return; + if (IsSpectreObject(value)) { + string localAssembly = typeof(IRenderable).Assembly.FullName ?? ""; + string foreignAssembly = value.GetType().Assembly.FullName ?? ""; + WriteVerbose( + $"ProcessRecord: Spectre object conversion failed (local='{localAssembly}', foreign='{foreignAssembly}'). " + + "This usually indicates duplicate Spectre.Console assemblies loaded in different contexts; restart the session after updating the module." + ); } _outStringInputs.Add(value); WriteVerbose("ProcessRecord: no renderable conversion path matched; queued object for Out-String conversion."); } - /// - /// Runs the pager when all pipeline input has been collected. - /// protected override void EndProcessing() { if (_singleHighlightedText is not null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { WriteVerbose("EndProcessing: using single HighlightedText pager path."); @@ -111,7 +84,9 @@ protected override void EndProcessing() { List formattedLines = ConvertWithOutStringLines(_outStringInputs); if (formattedLines.Count > 0) { foreach (string line in formattedLines) { - _renderables.Add(Helpers.VTConversion.ToParagraph(line)); + _renderables.Add(line.Length == 0 + ? new Text(string.Empty) + : Helpers.VTConversion.ToParagraph(line)); } WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); @@ -147,7 +122,7 @@ private static List ConvertWithOutStringLines(List values) { using var ps = PowerShell.Create(RunspaceMode.CurrentRunspace); ps.AddCommand("Out-String") .AddParameter("Stream") - .AddParameter("Width", GetConsoleWidth()); + .AddParameter("Width", GetOutStringWidth()); Collection results = ps.Invoke(values); if (ps.HadErrors || results.Count == 0) { @@ -164,6 +139,7 @@ private static List ConvertWithOutStringLines(List values) { } } + // TrimBoundaryEmptyLines(lines); return lines; } catch { @@ -178,8 +154,18 @@ private static void AddLines(List lines, string text) { string normalized = text.Replace("\r\n", "\n", StringComparison.Ordinal) .Replace('\r', '\n'); - foreach (string line in normalized.Split('\n')) { - lines.Add(line); + string[] split = normalized.Split('\n'); + int count = split.Length; + + // Out-String -Stream commonly returns one chunk per logical line with a + // trailing newline terminator. Ignore only that terminator-induced empty + // entry so pager row counts match what is actually rendered. + if (count > 0 && split[^1].Length == 0 && normalized.EndsWith('\n')) { + count--; + } + + for (int i = 0; i < count; i++) { + lines.Add(split[i]); } } @@ -192,112 +178,30 @@ private static int GetConsoleWidth() { } } - private static bool TryConvertForeignSpectreRenderable(object value, out List renderables) { - renderables = []; + // Keep one-column slack so width-bound lines from Out-String do not + // wrap in the live pager viewport and skew row-height calculations. + private static int GetOutStringWidth() => Math.Max(20, GetConsoleWidth() - 1); + + private static bool TryConvertForeignSpectreRenderable( + object value, + [NotNullWhen(true)] out IRenderable? renderable + ) { + renderable = null; Type valueType = value.GetType(); string? fullName = valueType.FullName; - if (string.IsNullOrWhiteSpace(fullName) - || !fullName.StartsWith("Spectre.Console.", StringComparison.Ordinal) - || value is IRenderable) { - return false; - } - - string ansi = RenderForeignSpectreToAnsi(value); - if (string.IsNullOrEmpty(ansi)) { - return false; - } - - renderables = ConvertAnsiToLineRenderables(ansi); - return renderables.Count != 0; + return IsSpectreObject(fullName) + && value is not IRenderable + && SpectreRenderBridge.TryConvertToLocalRenderable(value, out renderable); } - private static List ConvertAnsiToLineRenderables(string ansi) { - string[] lines = ansi.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); - var renderables = new List(lines.Length); - - foreach (string line in lines) { - renderables.Add(Helpers.VTConversion.ToParagraph(line)); - } - - return renderables; - } - - private static void AddParagraphLines(List destination, string ansi) { - if (string.IsNullOrEmpty(ansi)) { - return; - } - - List lines = ConvertAnsiToLineRenderables(ansi); - if (lines.Count == 0) { - return; - } - - destination.AddRange(lines); + private static bool IsSpectreObject(object value) { + string? fullName = value.GetType().FullName; + return IsSpectreObject(fullName); } - - private static string RenderForeignSpectreToAnsi(object value) { - try { - Assembly assembly = value.GetType().Assembly; - Type? ansiConsoleType = assembly.GetType("Spectre.Console.AnsiConsole"); - Type? ansiConsoleSettingsType = assembly.GetType("Spectre.Console.AnsiConsoleSettings"); - Type? ansiConsoleOutputType = assembly.GetType("Spectre.Console.AnsiConsoleOutput"); - Type? renderableType = assembly.GetType("Spectre.Console.Rendering.IRenderable") - ?? assembly.GetType("Spectre.Console.IRenderable"); - - if (ansiConsoleType is null - || ansiConsoleSettingsType is null - || ansiConsoleOutputType is null - || renderableType?.IsInstanceOfType(value) != true) { - return string.Empty; - } - - using StringWriter writer = new(new StringBuilder(1024), CultureInfo.InvariantCulture); - object? output = Activator.CreateInstance(ansiConsoleOutputType, writer); - object? settings = Activator.CreateInstance(ansiConsoleSettingsType); - PropertyInfo? outProperty = ansiConsoleSettingsType.GetProperty("Out"); - - if (output is null || settings is null || outProperty is null || !outProperty.CanWrite) { - return string.Empty; - } - - outProperty.SetValue(settings, output); - - MethodInfo? createMethod = ansiConsoleType - .GetMethods(BindingFlags.Public | BindingFlags.Static) - .FirstOrDefault(method => method.Name == "Create" - && method.GetParameters() is { Length: 1 } parameters - && parameters[0].ParameterType == ansiConsoleSettingsType); - - object? console = createMethod?.Invoke(null, [settings]); - if (console is null) { - return string.Empty; - } - - MethodInfo? writeMethod = console.GetType().GetMethod("Write", [renderableType]); - if (writeMethod is not null) { - _ = writeMethod.Invoke(console, [value]); - } - else { - Type? extType = assembly.GetType("Spectre.Console.AnsiConsoleExtensions"); - MethodInfo? extWriteMethod = extType? - .GetMethods(BindingFlags.Public | BindingFlags.Static) - .FirstOrDefault(method => method.Name == "Write" - && method.GetParameters() is { Length: 2 } parameters - && parameters[1].ParameterType == renderableType); - - if (extWriteMethod is null) { - return string.Empty; - } - - _ = extWriteMethod.Invoke(null, [console, value]); - } - - return writer.ToString(); - } - catch { - return string.Empty; - } + private static bool IsSpectreObject(string? str) { + return !string.IsNullOrWhiteSpace(str) + && (str.StartsWith("Spectre.Console.", StringComparison.Ordinal) || + str.StartsWith("PwshSpectreConsole.", StringComparison.Ordinal)); } - } diff --git a/src/PSTextMate/Cmdlets/TestTextMate.cs b/src/PSTextMate/Cmdlets/TestTextMate.cs index 63ee757..669f827 100644 --- a/src/PSTextMate/Cmdlets/TestTextMate.cs +++ b/src/PSTextMate/Cmdlets/TestTextMate.cs @@ -13,6 +13,7 @@ public sealed class TestTextMateCmdlet : PSCmdlet { /// [Parameter( ParameterSetName = "ExtensionSet", + ValueFromPipelineByPropertyName = true, Mandatory = true )] [ValidateNotNullOrEmpty] @@ -23,6 +24,7 @@ public sealed class TestTextMateCmdlet : PSCmdlet { /// [Parameter( ParameterSetName = "LanguageSet", + ValueFromPipelineByPropertyName = true, Mandatory = true )] [ValidateNotNullOrEmpty] @@ -33,15 +35,18 @@ public sealed class TestTextMateCmdlet : PSCmdlet { /// [Parameter( ParameterSetName = "FileSet", - Mandatory = true + ValueFromPipelineByPropertyName = true, + Mandatory = true, + Position = 0 )] + [Alias("Path")] [ValidateNotNullOrEmpty] public string? File { get; set; } /// /// Finalizes processing and outputs support check results. /// - protected override void EndProcessing() { + protected override void ProcessRecord() { switch (ParameterSetName) { case "FileSet": FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(File!)); diff --git a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs index 85e1363..e525366 100644 --- a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs +++ b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs @@ -17,7 +17,7 @@ public abstract class TextMateCmdletBase : PSCmdlet { Position = 0 )] [AllowEmptyString] - [AllowNull] + [System.Management.Automation.AllowNull] [Alias("FullName", "Path")] public PSObject? InputObject { get; set; } diff --git a/src/PSTextMate/PSTextMate.csproj b/src/PSTextMate/PSTextMate.csproj index 97e218d..9395b9f 100644 --- a/src/PSTextMate/PSTextMate.csproj +++ b/src/PSTextMate/PSTextMate.csproj @@ -13,8 +13,8 @@ latest-Recommended + - diff --git a/src/PSTextMate/Utilities/Helpers.cs b/src/PSTextMate/Utilities/Helpers.cs index 8ab522f..364ff98 100644 --- a/src/PSTextMate/Utilities/Helpers.cs +++ b/src/PSTextMate/Utilities/Helpers.cs @@ -35,11 +35,9 @@ static TextMateHelper() { } } internal static string[] SplitToLines(string input) { - if (input.Length == 0) { - return [string.Empty]; - } - - return input.Contains('\n') || input.Contains('\r') ? input.Split(["\r\n", "\n", "\r"], StringSplitOptions.None) : [input]; + return input.Length == 0 + ? [string.Empty] + : input.Contains('\n') || input.Contains('\r') ? input.Split(["\r\n", "\n", "\r"], StringSplitOptions.None) : [input]; } internal static string[] NormalizeToLines(List buffer) { diff --git a/src/PSTextMate/Utilities/Pager.cs b/src/PSTextMate/Utilities/Pager.cs index fc65252..35a48bb 100644 --- a/src/PSTextMate/Utilities/Pager.cs +++ b/src/PSTextMate/Utilities/Pager.cs @@ -16,6 +16,7 @@ public sealed class Pager : IDisposable { private readonly int? _originalLineNumberWidth; private readonly bool? _originalWrapInPanel; private readonly int? _stableLineNumberWidth; + private readonly int _statusColumnWidth; private int _top; private int WindowHeight; private int WindowWidth; @@ -25,6 +26,24 @@ public sealed class Pager : IDisposable { private bool _lastPageHadImages; private readonly record struct ViewportWindow(int Top, int Count, int EndExclusive, bool HasImages); + private bool UseRichFooter(int footerWidth) + => footerWidth >= GetMinimumRichFooterWidth(); + + private int GetFooterHeight(int footerWidth) + => UseRichFooter(footerWidth) ? 3 : 1; + + private int GetMinimumRichFooterWidth() { + const int keySectionMinWidth = 38; + const int chartSectionMinWidth = 12; + const int layoutOverhead = 10; + return keySectionMinWidth + _statusColumnWidth + chartSectionMinWidth + layoutOverhead; + } + + private static int GetStatusColumnWidth(int totalItems) { + int digits = Math.Max(1, totalItems.ToString(CultureInfo.InvariantCulture).Length); + return (digits * 3) + 4; + } + private sealed class PagerExclusivityMode : IExclusivityMode { private readonly object _syncRoot = new(); @@ -154,12 +173,14 @@ public Pager(HighlightedText highlightedText) { // Reference the underlying renderable array directly to avoid copying. _renderables = highlightedText.Renderables; + _statusColumnWidth = GetStatusColumnWidth(_renderables.Count); _top = 0; } public Pager(IEnumerable renderables) { var list = renderables?.ToList(); _renderables = list is null ? [] : (IReadOnlyList)list; + _statusColumnWidth = GetStatusColumnWidth(_renderables.Count); _top = 0; } private void Navigate(LiveDisplayContext ctx, bool useAlternateBuffer) { @@ -169,8 +190,8 @@ private void Navigate(LiveDisplayContext ctx, bool useAlternateBuffer) { while (running) { (int width, int pageHeight) = GetPagerSize(); - // Reserve last row for footer - int contentRows = Math.Max(1, pageHeight - 1); + int footerHeight = GetFooterHeight(width); + int contentRows = Math.Max(1, pageHeight - footerHeight); bool resized = width != WindowWidth || pageHeight != WindowHeight; if (resized) { @@ -178,9 +199,6 @@ private void Navigate(LiveDisplayContext ctx, bool useAlternateBuffer) { WindowWidth = width; WindowHeight = pageHeight; - if (useAlternateBuffer) { - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); - } forceRedraw = true; } @@ -196,28 +214,23 @@ private void Navigate(LiveDisplayContext ctx, bool useAlternateBuffer) { bool fullClear = resized || viewport.HasImages || _lastPageHadImages; if (fullClear) { VTHelpers.ClearScreen(); - if (useAlternateBuffer) { - VTHelpers.ReserveRow(contentRows); - } } else { VTHelpers.SetCursorPosition(1, 1); } - IRenderable target = BuildRenderable(viewport); + IRenderable target = BuildRenderable(viewport, width); ctx.UpdateTarget(target); ctx.Refresh(); - DrawFooter(width, contentRows, viewport); - - // Clear any previously-rendered lines that are now beyond contentRows. - if (_lastRenderedRows > contentRows) { - for (int r = contentRows + 1; r <= _lastRenderedRows; r++) { + // Clear any stale lines after a terminal shrink. + if (_lastRenderedRows > pageHeight) { + for (int r = pageHeight + 1; r <= _lastRenderedRows; r++) { VTHelpers.ClearRow(r); } } - _lastRenderedRows = contentRows; + _lastRenderedRows = pageHeight; _lastPageHadImages = viewport.HasImages; forceRedraw = false; } @@ -389,38 +402,78 @@ private int GetMaxTop(int contentRows) { private void GoToEnd(int contentRows) => _top = GetMaxTop(contentRows); - private IRenderable BuildRenderable(ViewportWindow viewport) { - if (viewport.Count <= 0) { - return new Rows([]); - } + private Layout BuildRenderable(ViewportWindow viewport, int width) { + int footerHeight = GetFooterHeight(width); + IRenderable content = viewport.Count <= 0 + ? Text.Empty + : BuildContentRenderable(viewport); + + IRenderable footer = BuildFooter(width, viewport); + var root = new Layout("root"); + root.SplitRows( + new Layout("body").Update(content), + new Layout("footer").Size(footerHeight).Update(footer) + ); + return root; + } + + private IRenderable BuildContentRenderable(ViewportWindow viewport) { if (_sourceHighlightedText is not null) { _sourceHighlightedText.SetView(_renderables, viewport.Top, viewport.Count); _sourceHighlightedText.LineNumberStart = (_originalLineNumberStart ?? 1) + viewport.Top; _sourceHighlightedText.LineNumberWidth = _stableLineNumberWidth; - return _sourceHighlightedText; } return new Rows(_renderables.Skip(viewport.Top).Take(viewport.Count)); } - private void DrawFooter(int width, int contentRows, ViewportWindow viewport) { + private IRenderable BuildFooter(int width, ViewportWindow viewport) + => UseRichFooter(width) + ? BuildRichFooter(width, viewport) + : BuildSimpleFooter(viewport); + + private Text BuildSimpleFooter(ViewportWindow viewport) { int total = _renderables.Count; - int pos = total == 0 ? 0 : viewport.Top + 1; + int start = total == 0 ? 0 : viewport.Top + 1; int end = viewport.EndExclusive; + return new Text($"↑↓ Scroll PgUp/PgDn Page Home/End Jump q/Esc Quit {start}-{end}/{total}", new Style(Color.Grey)); + } - string keys = "Up/Down: ↑↓ PgUp/PgDn/Spacebar Home/End q/Esc: Quit"; - string status = $" {pos}-{end}/{total} "; - int remaining = Math.Max(0, width - keys.Length - status.Length - 2); - string spacer = new(' ', remaining); - string line = keys + spacer + status; - if (line.Length > width) line = line[..width]; - - // Write footer directly to reserved row (contentRows + 1) - int footerRow = contentRows + 1; - VTHelpers.SetCursorPosition(footerRow, 1); - Console.Write(line.PadRight(width)); + private Panel BuildRichFooter(int width, ViewportWindow viewport) { + int total = _renderables.Count; + int start = total == 0 ? 0 : viewport.Top + 1; + int end = viewport.EndExclusive; + int safeTotal = Math.Max(1, total); + int digits = Math.Max(1, safeTotal.ToString(CultureInfo.InvariantCulture).Length); + + string keyText = "↑↓ Scroll PgUp/PgDn Page Home/End Jump q/Esc Quit"; + string statusText = $"{start.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}-{end.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}/{total.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}".PadLeft(_statusColumnWidth); + + int chartWidth = Math.Clamp(width / 5, 12, 28); + double progressUnits = total == 0 ? 0d : (double)end / safeTotal * chartWidth; + double chartValue = end <= 0 ? 0d : Math.Clamp(Math.Ceiling(progressUnits), Math.Min(4d, chartWidth), chartWidth); + BarChart chart = new BarChart() + .Width(chartWidth) + .WithMaxValue(chartWidth) + .HideValues() + .AddItem(" ", chartValue, Color.Lime); + + Columns columns = new([ + new Text(keyText, new Style(Color.Grey)), + new Markup($"[bold]{statusText}[/]"), + chart + ]) { + Expand = true, + Padding = new Padding(2, 0, 2, 0) + }; + + return new Panel(columns) { + Border = BoxBorder.Rounded, + Padding = new Padding(0, 0, 0, 0), + Expand = true + }; } public void Show() { @@ -442,22 +495,18 @@ private void ShowCore(bool useAlternateBuffer) { VTHelpers.HideCursor(); try { (int width, int pageHeight) = GetPagerSize(); - int contentRows = Math.Max(1, pageHeight - 1); + int footerHeight = GetFooterHeight(width); + int contentRows = Math.Max(1, pageHeight - footerHeight); WindowWidth = width; WindowHeight = pageHeight; - // Start with a clean screen then reserve the last row as a non-scrolling footer region - if (useAlternateBuffer) { - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); - } - - // Initial target for Spectre Live (footer is drawn manually) + // Initial target for Spectre Live (footer included in target renderable) AnsiConsole.Console.Profile.Width = width; RecalculateRenderableHeights(width, contentRows); ViewportWindow initialViewport = BuildViewport(_top, contentRows); _top = initialViewport.Top; - IRenderable initial = BuildRenderable(initialViewport); - _lastRenderedRows = contentRows; + IRenderable initial = BuildRenderable(initialViewport, width); + _lastRenderedRows = pageHeight; _lastPageHadImages = initialViewport.HasImages; // If the initial page contains images, clear appropriately to ensure safe image rendering @@ -466,7 +515,6 @@ private void ShowCore(bool useAlternateBuffer) { try { if (useAlternateBuffer) { VTHelpers.ClearScreen(); - VTHelpers.ReserveRow(Math.Max(1, pageHeight - 1)); } else { VTHelpers.ClearScreen(); @@ -482,8 +530,6 @@ private void ShowCore(bool useAlternateBuffer) { .Overflow(VerticalOverflow.Crop) .Cropping(VerticalOverflowCropping.Bottom) .Start(ctx => { - // Draw footer once before entering the interactive loop - DrawFooter(width, contentRows, initialViewport); // Enter interactive loop using the live display context Navigate(ctx, useAlternateBuffer); }); diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs index 288eb6f..6060913 100644 --- a/src/PSTextMate/Utilities/SpectreRenderBridge.cs +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -4,6 +4,9 @@ namespace PSTextMate.Utilities; /// Provides an ALC-safe bridge for rendering Spectre objects to plain text. /// public static class SpectreRenderBridge { + private static readonly CallSite> s_convertToRenderableCallSite = + CreateConvertToRenderableCallSite(); + /// /// Renders a Spectre renderable object to a string. /// @@ -12,33 +15,79 @@ public static class SpectreRenderBridge { /// The rendered string output. /// Thrown when is null. /// Thrown when does not implement . - public static string RenderToString(object renderableObject, bool escapeAnsi = false) { + public static string RenderToString(object renderableObject, bool escapeAnsi = false, int? width = null) { ArgumentNullException.ThrowIfNull(renderableObject); string rendered = renderableObject is IRenderable localRenderable - ? RenderLocal(localRenderable) - : RenderForeign(renderableObject); + ? RenderLocal(localRenderable, width) + : RenderForeign(renderableObject, width); - if (string.IsNullOrEmpty(rendered)) { - throw new ArgumentException( + return rendered.Length != 0 + ? escapeAnsi ? PSHostUserInterface.GetOutputString(rendered, false) : rendered + : throw new ArgumentException( $"Object of type '{renderableObject.GetType().FullName}' does not implement a supported Spectre IRenderable shape.", nameof(renderableObject) ); + } + + /// + /// Attempts to convert a foreign Spectre renderable object to the local type. + /// + /// The candidate renderable object. + /// Converted renderable when conversion succeeds. + /// when conversion succeeds; otherwise . + public static bool TryConvertToLocalRenderable( + object value, + [NotNullWhen(true)] out IRenderable? renderable + ) { + ArgumentNullException.ThrowIfNull(value); + + if (value is IRenderable local) { + renderable = local; + return true; } - return escapeAnsi ? PSHostUserInterface.GetOutputString(rendered, false) : rendered; + string? fullName = value.GetType().FullName; + if (string.IsNullOrWhiteSpace(fullName) + || !fullName.StartsWith("Spectre.Console.", StringComparison.Ordinal)) { + renderable = null; + return false; + } + + try { + renderable = s_convertToRenderableCallSite.Target(s_convertToRenderableCallSite, value); + return renderable is not null; + } + catch (RuntimeBinderException) { + renderable = null; + return false; + } + catch (InvalidCastException) { + renderable = null; + } + + if (TryCreateForeignRenderableAdapter(value, out IRenderable? adaptedRenderable)) { + renderable = adaptedRenderable; + return true; + } + + return false; } - private static string RenderLocal(IRenderable renderable) { + private static string RenderLocal(IRenderable renderable, int? width) { using StringWriter writer = new(new StringBuilder(1024), CultureInfo.InvariantCulture); var output = new AnsiConsoleOutput(writer); var settings = new AnsiConsoleSettings { Out = output }; IAnsiConsole console = AnsiConsole.Create(settings); + if (width is int targetWidth && targetWidth > 0) { + console.Profile.Width = targetWidth; + } + console.Write(renderable); return writer.ToString(); } - private static string RenderForeign(object renderableObject) { + private static string RenderForeign(object renderableObject, int? width) { Type valueType = renderableObject.GetType(); Assembly assembly = valueType.Assembly; @@ -76,6 +125,15 @@ private static string RenderForeign(object renderableObject) { return string.Empty; } + if (width is int targetWidth && targetWidth > 0) { + PropertyInfo? profileProperty = console.GetType().GetProperty("Profile"); + object? profile = profileProperty?.GetValue(console); + PropertyInfo? widthProperty = profile?.GetType().GetProperty("Width"); + if (widthProperty is not null && widthProperty.CanWrite) { + widthProperty.SetValue(profile, targetWidth); + } + } + MethodInfo? writeMethod = console.GetType().GetMethod("Write", [foreignRenderableType]); if (writeMethod is not null) { _ = writeMethod.Invoke(console, [renderableObject]); @@ -95,4 +153,88 @@ private static string RenderForeign(object renderableObject) { _ = extWriteMethod.Invoke(null, [console, renderableObject]); return writer.ToString(); } + + private static CallSite> CreateConvertToRenderableCallSite() { + // Dynamic conversion is only used at runtime for cross-ALC Spectre values. +#pragma warning disable IL2026 + // Call-site generation requires dynamic code in JIT scenarios. +#pragma warning disable IL3050 + return CallSite>.Create( + Microsoft.CSharp.RuntimeBinder.Binder.Convert( + CSharpBinderFlags.ConvertExplicit, + typeof(IRenderable), + typeof(SpectreRenderBridge) + ) + ); +#pragma warning restore IL3050 +#pragma warning restore IL2026 + } + + private static bool TryCreateForeignRenderableAdapter( + object value, + [NotNullWhen(true)] out IRenderable? renderable + ) { + Type valueType = value.GetType(); + string? fullName = valueType.FullName; + if (string.IsNullOrWhiteSpace(fullName) + || !fullName.StartsWith("Spectre.Console.", StringComparison.Ordinal)) { + renderable = null; + return false; + } + + Type? foreignRenderableType = valueType.Assembly.GetType("Spectre.Console.Rendering.IRenderable") + ?? valueType.Assembly.GetType("Spectre.Console.IRenderable"); + if (foreignRenderableType is null || !foreignRenderableType.IsInstanceOfType(value)) { + renderable = null; + return false; + } + + renderable = new ForeignRenderableAdapter(value); + return true; + } + + private static IRenderable ConvertAnsiToRenderable(string ansi) { + string[] lines = ansi.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); + if (lines.Length <= 1) { + return Helpers.VTConversion.ToParagraph(lines[0]); + } + + var renderables = new IRenderable[lines.Length]; + for (int i = 0; i < lines.Length; i++) { + renderables[i] = Helpers.VTConversion.ToParagraph(lines[i]); + } + + return new Rows(renderables); + } + + private sealed class ForeignRenderableAdapter : IRenderable { + private readonly object _foreignRenderable; + private int _cachedWidth; + private IRenderable? _cachedRenderable; + + public ForeignRenderableAdapter(object foreignRenderable) { + _foreignRenderable = foreignRenderable; + _cachedWidth = -1; + } + + public Measurement Measure(RenderOptions options, int maxWidth) + => GetOrCreate(maxWidth).Measure(options, maxWidth); + + public IEnumerable Render(RenderOptions options, int maxWidth) + => GetOrCreate(maxWidth).Render(options, maxWidth); + + private IRenderable GetOrCreate(int maxWidth) { + int width = Math.Max(1, maxWidth); + if (_cachedRenderable is not null && _cachedWidth == width) { + return _cachedRenderable; + } + + string rendered = RenderToString(_foreignRenderable, width: width); + _cachedRenderable = ConvertAnsiToRenderable(rendered); + _cachedWidth = width; + return _cachedRenderable; + } + } } diff --git a/src/PSTextMate/Utilities/VTConversion.cs b/src/PSTextMate/Utilities/VTConversion.cs index 9aaae67..e78ea2f 100644 --- a/src/PSTextMate/Utilities/VTConversion.cs +++ b/src/PSTextMate/Utilities/VTConversion.cs @@ -9,6 +9,11 @@ public static class VTConversion { private const char CSI_START = '['; private const char OSC_START = ']'; private const char SGR_END = 'm'; + private enum ForeignImageType { + Sixel, + IIP, + Kitty + } /// /// Parses a string containing VT escape sequences and returns a Paragraph object. diff --git a/src/PSTextMate/Utilities/Writer.cs b/src/PSTextMate/Utilities/Writer.cs index ab01c57..d45d7c9 100644 --- a/src/PSTextMate/Utilities/Writer.cs +++ b/src/PSTextMate/Utilities/Writer.cs @@ -9,7 +9,6 @@ private sealed class RenderContext { public StringBuilder Buffer { get; } public StringWriter Writer { get; } public IAnsiConsole Console { get; } - public RenderContext() { Buffer = new StringBuilder(2048); Writer = new StringWriter(Buffer, CultureInfo.InvariantCulture); diff --git a/src/PSTextMate/Utilities/using.cs b/src/PSTextMate/Utilities/using.cs index c637824..2f7dcd5 100644 --- a/src/PSTextMate/Utilities/using.cs +++ b/src/PSTextMate/Utilities/using.cs @@ -5,6 +5,7 @@ global using System.Collections.Generic; global using System.Collections.ObjectModel; global using System.Diagnostics; +global using System.Diagnostics.CodeAnalysis; global using System.Globalization; global using System.IO; global using System.Linq; @@ -13,6 +14,7 @@ global using System.Net.Http; global using System.Reflection; global using System.Runtime.CompilerServices; +global using System.Runtime.InteropServices; global using System.Text; global using System.Text.RegularExpressions; global using System.Threading; @@ -24,6 +26,7 @@ global using Markdig.Helpers; global using Markdig.Syntax; global using Markdig.Syntax.Inlines; +global using Microsoft.CSharp.RuntimeBinder; global using PSTextMate; global using PSTextMate.Core; global using PSTextMate.Sixel; From 0982f67488fd1d14a3018fbf7df7bcb454036fe4 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:45:55 +0100 Subject: [PATCH 08/17] =?UTF-8?q?feat(pager):=20=E2=9C=A8=20Implement=20pa?= =?UTF-8?q?ger=20functionality=20with=20search=20and=20highlighting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced `PagerDocument`, `PagerSearchSession`, and `PagerViewportEngine` to manage document entries, search hits, and viewport rendering. * Added `PagerHighlighting` for structured highlighting of search results. * Implemented tests for pager functionality, ensuring correct behavior for search queries and rendering. * Enhanced `Out-Page` command with metadata and help documentation. * Integrated Spectre.Console for rendering and testing capabilities. --- TextMate.build.ps1 | 26 +- TextMate.slnx | 1 + src/PSTextMate/Cmdlets/OutPage.cs | 34 +- src/PSTextMate/Core/HighlightedText.cs | 2 +- src/PSTextMate/PSTextMate.csproj | 3 +- src/PSTextMate/Pager/Pager.cs | 663 ++++++++++++++++++ src/PSTextMate/Pager/PagerDocument.cs | 88 +++ src/PSTextMate/Pager/PagerHighlighting.cs | 220 ++++++ src/PSTextMate/Pager/PagerSearchSession.cs | 134 ++++ src/PSTextMate/Pager/PagerViewport.cs | 221 ++++++ src/PSTextMate/Utilities/Pager.cs | 558 --------------- .../Utilities/SpectreRenderBridge.cs | 15 +- src/PSTextMate/Utilities/VTHelpers.cs | 39 +- src/PSTextMate/Utilities/Writer.cs | 6 +- src/PSTextMate/Utilities/using.cs | 1 + tests/Out-Page.tests.ps1 | 18 + .../PSTextMate.InteractiveTests.csproj | 25 + .../PagerCoreTests.cs | 47 ++ .../SpectreConsoleTestingTests.cs | 39 ++ .../SpectreLiveTestingTests.cs | 63 ++ tests/testhelper.psm1 | 31 +- 21 files changed, 1630 insertions(+), 604 deletions(-) create mode 100644 src/PSTextMate/Pager/Pager.cs create mode 100644 src/PSTextMate/Pager/PagerDocument.cs create mode 100644 src/PSTextMate/Pager/PagerHighlighting.cs create mode 100644 src/PSTextMate/Pager/PagerSearchSession.cs create mode 100644 src/PSTextMate/Pager/PagerViewport.cs delete mode 100644 src/PSTextMate/Utilities/Pager.cs create mode 100644 tests/Out-Page.tests.ps1 create mode 100644 tests/PSTextMate.InteractiveTests/PSTextMate.InteractiveTests.csproj create mode 100644 tests/PSTextMate.InteractiveTests/PagerCoreTests.cs create mode 100644 tests/PSTextMate.InteractiveTests/SpectreConsoleTestingTests.cs create mode 100644 tests/PSTextMate.InteractiveTests/SpectreLiveTestingTests.cs diff --git a/TextMate.build.ps1 b/TextMate.build.ps1 index 552b720..e8aa524 100644 --- a/TextMate.build.ps1 +++ b/TextMate.build.ps1 @@ -45,7 +45,23 @@ task Build { $ridDest = Join-Path $folders.DestinationPath $rid $null = New-Item -Path $ridDest -ItemType Directory -Force $nativePath = Join-Path $folders.TempLib 'runtimes' $rid 'native' - Get-ChildItem -Path $nativePath -File | Move-Item -Destination $ridDest -Force + if (-not (Test-Path $nativePath -PathType Container)) { + continue + } + + foreach ($nativeFile in Get-ChildItem -Path $nativePath -File) { + $destinationFile = Join-Path $ridDest $nativeFile.Name + try { + if (Test-Path $destinationFile -PathType Leaf) { + Remove-Item -Path $destinationFile -Force + } + + Move-Item -Path $nativeFile.FullName -Destination $ridDest -Force + } + catch { + Write-Warning "Skipping native file update for '$destinationFile': $($_.Exception.Message)" + } + } } Get-ChildItem -Path $folders.TempLib -File | Move-Item -Destination $folders.DestinationPath -Force if (Test-Path -Path $folders.TempLib -PathType Container) { @@ -118,6 +134,12 @@ task Test -if (-not $SkipTests) { Invoke-Pester -Configuration $pesterConfig } +task DotNetTest -if (-not $SkipTests) { + exec { + dotnet test (Join-Path $PSScriptRoot 'tests' 'PSTextMate.InteractiveTests' 'PSTextMate.InteractiveTests.csproj') --configuration $Configuration --nologo + } +} + task CleanAfter { if ($script:folders.DestinationPath -and (Test-Path $script:folders.DestinationPath)) { Get-ChildItem $script:folders.DestinationPath -File -Recurse | Where-Object { $_.Extension -in '.pdb', '.json' } | Remove-Item -Force -ErrorAction Ignore @@ -125,5 +147,5 @@ task CleanAfter { } -task All -Jobs Clean, Build, ModuleFiles, GenerateHelp, CleanAfter , Test +task All -Jobs Clean, Build, ModuleFiles, GenerateHelp, CleanAfter, Test, DotNetTest task BuildAndTest -Jobs Clean, Build, ModuleFiles, CleanAfter #, Test diff --git a/TextMate.slnx b/TextMate.slnx index 934a824..2040db3 100644 --- a/TextMate.slnx +++ b/TextMate.slnx @@ -1,4 +1,5 @@ + diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index fd10fa4..8af41d8 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -23,18 +23,18 @@ protected override void ProcessRecord() { } object value = InputObject.BaseObject; - WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}' BaseType: '{value.GetType().BaseType}'."); + // WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}' BaseType: '{value.GetType().BaseType}'."); if (value is HighlightedText highlightedText) { if (_singleHighlightedText is null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { _singleHighlightedText = highlightedText; - WriteVerbose("ProcessRecord: took HighlightedText fast path and deferred to highlighted pager."); + // WriteVerbose("ProcessRecord: took HighlightedText fast path and deferred to highlighted pager."); return; } _sawNonHighlightedInput = true; _renderables.AddRange(highlightedText.Renderables); - WriteVerbose("ProcessRecord: merged HighlightedText renderables into the main renderable list."); + // WriteVerbose("ProcessRecord: merged HighlightedText renderables into the main renderable list."); return; } @@ -42,45 +42,34 @@ protected override void ProcessRecord() { if (value is IRenderable renderable) { _renderables.Add(renderable); - WriteVerbose("ProcessRecord: input matched IRenderable and was added directly."); + // WriteVerbose("ProcessRecord: input matched IRenderable and was added directly."); return; } if (value is string text) { _outStringInputs.Add(text); - WriteVerbose("ProcessRecord: input matched string; queued for Out-String conversion."); return; } if (TryConvertForeignSpectreRenderable(value, out IRenderable? convertedRenderable)) { _renderables.Add(convertedRenderable); - WriteVerbose("ProcessRecord: converted foreign Spectre renderable to local IRenderable."); + // WriteVerbose("ProcessRecord: converted foreign Spectre renderable to local IRenderable."); return; } - if (IsSpectreObject(value)) { - string localAssembly = typeof(IRenderable).Assembly.FullName ?? ""; - string foreignAssembly = value.GetType().Assembly.FullName ?? ""; - WriteVerbose( - $"ProcessRecord: Spectre object conversion failed (local='{localAssembly}', foreign='{foreignAssembly}'). " - + "This usually indicates duplicate Spectre.Console assemblies loaded in different contexts; restart the session after updating the module." - ); - } - _outStringInputs.Add(value); - WriteVerbose("ProcessRecord: no renderable conversion path matched; queued object for Out-String conversion."); } protected override void EndProcessing() { if (_singleHighlightedText is not null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { - WriteVerbose("EndProcessing: using single HighlightedText pager path."); - using var highlightedPager = new Pager(_singleHighlightedText); + // WriteVerbose("EndProcessing: using single HighlightedText pager path."); + var highlightedPager = new Pager(_singleHighlightedText); highlightedPager.Show(); return; } if (_outStringInputs.Count > 0) { - WriteVerbose($"EndProcessing: converting {_outStringInputs.Count} queued value(s) through Out-String."); + // WriteVerbose($"EndProcessing: converting {_outStringInputs.Count} queued value(s) through Out-String."); List formattedLines = ConvertWithOutStringLines(_outStringInputs); if (formattedLines.Count > 0) { foreach (string line in formattedLines) { @@ -89,14 +78,14 @@ protected override void EndProcessing() { : Helpers.VTConversion.ToParagraph(line)); } - WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); + // WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); } else { foreach (object value in _outStringInputs) { _renderables.Add(new Text(LanguagePrimitives.ConvertTo(value))); } - WriteVerbose("EndProcessing: Out-String returned no lines; used string conversion fallback."); + // WriteVerbose("EndProcessing: Out-String returned no lines; used string conversion fallback."); } } @@ -106,7 +95,7 @@ protected override void EndProcessing() { } WriteVerbose($"EndProcessing: launching pager with {_renderables.Count} renderable(s)."); - using var pager = new Pager(_renderables); + var pager = new Pager(_renderables); pager.Show(); } @@ -139,7 +128,6 @@ private static List ConvertWithOutStringLines(List values) { } } - // TrimBoundaryEmptyLines(lines); return lines; } catch { diff --git a/src/PSTextMate/Core/HighlightedText.cs b/src/PSTextMate/Core/HighlightedText.cs index 8449bf4..a2f6847 100644 --- a/src/PSTextMate/Core/HighlightedText.cs +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -381,7 +381,7 @@ public HighlightedText Slice(int start, int count, int? overrideLineNumberWidth public void ShowPager() { if (LineCount <= 0) return; - using var pager = new Pager(this); + var pager = new Pager(this); pager.Show(); } public IRenderable? AutoPage() { diff --git a/src/PSTextMate/PSTextMate.csproj b/src/PSTextMate/PSTextMate.csproj index 9395b9f..0c5ebd5 100644 --- a/src/PSTextMate/PSTextMate.csproj +++ b/src/PSTextMate/PSTextMate.csproj @@ -31,8 +31,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - + diff --git a/src/PSTextMate/Pager/Pager.cs b/src/PSTextMate/Pager/Pager.cs new file mode 100644 index 0000000..a53bc13 --- /dev/null +++ b/src/PSTextMate/Pager/Pager.cs @@ -0,0 +1,663 @@ +namespace PSTextMate.Terminal; + +/// +/// Simple pager implemented with Spectre.Console Live display. +/// Interaction keys: +/// - Up/Down: move one renderable item +/// - PageUp/PageDown: move by one viewport of items +/// - Home/End: go to start/end +/// - / or Ctrl+F: prompt for search query +/// - n / N: next / previous match +/// - q or Escape: quit +/// +public sealed class Pager { + private static readonly PagerExclusivityMode s_pagerExclusivityMode = new(); + private readonly IAnsiConsole _console; + private readonly Func? _tryReadKeyOverride; + private readonly bool _suppressTerminalControlSequences; + private readonly IReadOnlyList _renderables; + private readonly PagerDocument _document; + private readonly PagerSearchSession _search; + private readonly PagerViewportEngine _viewportEngine; + private readonly HighlightedText? _sourceHighlightedText; + private readonly int? _originalLineNumberStart; + private readonly int? _originalLineNumberWidth; + private readonly bool? _originalWrapInPanel; + private readonly int? _stableLineNumberWidth; + private readonly int _statusColumnWidth; + private int _top; + private int WindowHeight; + private int WindowWidth; + private readonly object _lock = new(); + private int _lastRenderedRows; + private bool _lastPageHadImages; + private string _searchStatusText = string.Empty; + private bool _isSearchInputActive; + private readonly StringBuilder _searchInputBuffer = new(64); + private const string SearchRowStyle = "white on grey"; + private const string SearchMatchStyle = "black on orange1"; + + private bool TryReadKey(out ConsoleKeyInfo key) { + if (_tryReadKeyOverride is not null) { + ConsoleKeyInfo? injected = _tryReadKeyOverride(); + if (injected.HasValue) { + key = injected.Value; + return true; + } + + key = default; + return false; + } + + return TryReadKeyFromConsole(out key); + } + + private static bool TryReadKeyFromConsole(out ConsoleKeyInfo key) { + try { + if (!Console.KeyAvailable) { + key = default; + return false; + } + + key = Console.ReadKey(true); + return true; + } + catch (IOException) { + key = default; + return false; + } + catch (InvalidOperationException) { + key = default; + return false; + } + } + + private bool UseRichFooter(int footerWidth) + => footerWidth >= GetMinimumRichFooterWidth(); + + private int GetFooterHeight(int footerWidth) + => UseRichFooter(footerWidth) ? 3 : 1; + + private int GetSearchInputHeight() + => _isSearchInputActive ? 3 : 0; + + private int GetMinimumRichFooterWidth() { + const int keySectionMinWidth = 38; + const int chartSectionMinWidth = 12; + const int layoutOverhead = 10; + return keySectionMinWidth + _statusColumnWidth + chartSectionMinWidth + layoutOverhead; + } + + private static int GetStatusColumnWidth(int totalItems) { + int digits = Math.Max(1, totalItems.ToString(CultureInfo.InvariantCulture).Length); + return (digits * 3) + 4; + } + + private sealed class PagerExclusivityMode : IExclusivityMode { + private readonly object _syncRoot = new(); + + public T Run(Func func) { + ArgumentNullException.ThrowIfNull(func); + + lock (_syncRoot) { + return func(); + } + } + + public async Task RunAsync(Func> func) { + ArgumentNullException.ThrowIfNull(func); + + Task task; + lock (_syncRoot) { + task = func(); + } + return await task.ConfigureAwait(false); + } + } + + + public Pager(HighlightedText highlightedText) { + _console = AnsiConsole.Console; + _tryReadKeyOverride = null; + _suppressTerminalControlSequences = false; + _sourceHighlightedText = highlightedText; + _originalWrapInPanel = highlightedText.WrapInPanel; + + int totalLines = highlightedText.LineCount; + int lastLineNumber = highlightedText.LineNumberStart + Math.Max(0, totalLines - 1); + _stableLineNumberWidth = highlightedText.LineNumberWidth ?? lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; + _originalLineNumberStart = highlightedText.LineNumberStart; + _originalLineNumberWidth = highlightedText.LineNumberWidth; + + // Panel rendering in pager mode causes unstable layout; disable it for the paging session. + highlightedText.WrapInPanel = false; + + _document = PagerDocument.FromHighlightedText(highlightedText); + _renderables = _document.Renderables; + _search = new PagerSearchSession(_document); + _viewportEngine = new PagerViewportEngine(_renderables, _sourceHighlightedText); + _statusColumnWidth = GetStatusColumnWidth(_renderables.Count); + _top = 0; + } + + public Pager(IEnumerable renderables) + : this(renderables, AnsiConsole.Console, null, suppressTerminalControlSequences: false) { + } + + internal Pager( + IEnumerable renderables, + IAnsiConsole console, + Func? tryReadKeyOverride, + bool suppressTerminalControlSequences = false + ) { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _tryReadKeyOverride = tryReadKeyOverride; + _suppressTerminalControlSequences = suppressTerminalControlSequences; + _document = new PagerDocument(renderables ?? []); + _renderables = _document.Renderables; + _search = new PagerSearchSession(_document); + _viewportEngine = new PagerViewportEngine(_renderables, _sourceHighlightedText); + _statusColumnWidth = GetStatusColumnWidth(_renderables.Count); + _top = 0; + } + private void Navigate(LiveDisplayContext ctx) { + bool running = true; + (WindowWidth, WindowHeight) = GetPagerSize(); + bool forceRedraw = true; + + while (running) { + (int width, int pageHeight) = GetPagerSize(); + int footerHeight = GetFooterHeight(width); + int searchInputHeight = GetSearchInputHeight(); + int contentRows = Math.Max(1, pageHeight - footerHeight - searchInputHeight); + + bool resized = width != WindowWidth || pageHeight != WindowHeight; + if (resized) { + _console.Profile.Width = width; + + WindowWidth = width; + WindowHeight = pageHeight; + forceRedraw = true; + } + + // Redraw if needed (initial, resize, or after navigation) + if (resized || forceRedraw) { + if (!_suppressTerminalControlSequences) { + VTHelpers.BeginSynchronizedOutput(); + } + + try { + _viewportEngine.RecalculateHeights(width, contentRows, WindowHeight, _console); + _top = Math.Clamp(_top, 0, _viewportEngine.GetMaxTop(contentRows)); + PagerViewportWindow viewport = _viewportEngine.BuildViewport(_top, contentRows); + _top = viewport.Top; + + bool fullClear = resized || viewport.HasImages || _lastPageHadImages; + if (!_suppressTerminalControlSequences) { + if (fullClear) { + VTHelpers.ClearScreen(); + } + else { + VTHelpers.SetCursorPosition(1, 1); + } + } + + IRenderable target = BuildRenderable(viewport, width); + ctx.UpdateTarget(target); + ctx.Refresh(); + + // Clear any stale lines after a terminal shrink. + if (!_suppressTerminalControlSequences && _lastRenderedRows > pageHeight) { + for (int r = pageHeight + 1; r <= _lastRenderedRows; r++) { + VTHelpers.ClearRow(r); + } + } + + _lastRenderedRows = pageHeight; + _lastPageHadImages = viewport.HasImages; + forceRedraw = false; + } + finally { + if (!_suppressTerminalControlSequences) { + VTHelpers.EndSynchronizedOutput(); + } + } + } + + // Wait for input, checking for resize while idle. + if (!TryReadKey(out ConsoleKeyInfo key)) { + Thread.Sleep(50); + continue; + } + + lock (_lock) { + if (_isSearchInputActive) { + HandleSearchInputKey(key, ref forceRedraw); + continue; + } + + bool isCtrlF = key.Key == ConsoleKey.F && (key.Modifiers & ConsoleModifiers.Control) != 0; + if (key.KeyChar == '/' || isCtrlF) { + BeginSearchInput(); + forceRedraw = true; + continue; + } + + switch (key.Key) { + case ConsoleKey.DownArrow: + ScrollRenderable(1); + forceRedraw = true; + break; + case ConsoleKey.UpArrow: + ScrollRenderable(-1); + forceRedraw = true; + break; + case ConsoleKey.Spacebar: + case ConsoleKey.PageDown: + PageDown(contentRows); + forceRedraw = true; + break; + case ConsoleKey.PageUp: + PageUp(contentRows); + forceRedraw = true; + break; + case ConsoleKey.Home: + GoToTop(); + forceRedraw = true; + break; + case ConsoleKey.End: + GoToEnd(contentRows); + forceRedraw = true; + break; + case ConsoleKey.N: + if ((key.Modifiers & ConsoleModifiers.Shift) != 0) { + JumpToPreviousMatch(); + } + else { + JumpToNextMatch(); + } + + forceRedraw = true; + break; + case ConsoleKey.Q: + case ConsoleKey.Escape: + running = false; + break; + } + } + } + } + + private static (int width, int height) GetPagerSize() { + try { + int width = Console.WindowWidth > 0 ? Console.WindowWidth : 80; + int height = Console.WindowHeight > 0 ? Console.WindowHeight : 40; + return (width, height); + } + catch (IOException) { + return (80, 40); + } + catch (InvalidOperationException) { + return (80, 40); + } + } + + private void ScrollRenderable(int delta) => _top = _viewportEngine.ScrollTop(_top, delta, WindowHeight); + + private void PageDown(int contentRows) => _top = _viewportEngine.PageDownTop(_top, contentRows); + + private void PageUp(int contentRows) => _top = _viewportEngine.PageUpTop(_top, contentRows); + + private void GoToTop() => _top = 0; + + private void BeginSearchInput() { + _isSearchInputActive = true; + _searchInputBuffer.Clear(); + if (!string.IsNullOrEmpty(_search.Query)) { + _searchInputBuffer.Append(_search.Query); + } + } + + private void HandleSearchInputKey(ConsoleKeyInfo key, ref bool forceRedraw) { + switch (key.Key) { + case ConsoleKey.Enter: + _isSearchInputActive = false; + ApplySearchQuery(_searchInputBuffer.ToString()); + forceRedraw = true; + return; + case ConsoleKey.Escape: + _isSearchInputActive = false; + forceRedraw = true; + return; + case ConsoleKey.Backspace: + if (_searchInputBuffer.Length > 0) { + _searchInputBuffer.Length--; + forceRedraw = true; + } + + return; + } + + if (!char.IsControl(key.KeyChar)) { + _searchInputBuffer.Append(key.KeyChar); + forceRedraw = true; + } + } + + private void ApplySearchQuery(string query) { + _search.SetQuery(query); + if (!_search.HasQuery) { + _searchStatusText = string.Empty; + return; + } + + PagerSearchHit? hit = _search.MoveNext(_top); + if (hit is null) { + _searchStatusText = $"/{_search.Query} (no matches)"; + return; + } + + _top = hit.RenderableIndex; + _searchStatusText = BuildSearchStatus(); + } + + private void JumpToNextMatch() { + if (!_search.HasQuery) { + _searchStatusText = "No active search. Press / to search."; + return; + } + + PagerSearchHit? hit = _search.MoveNext(_top); + if (hit is null) { + _searchStatusText = $"/{_search.Query} (no matches)"; + return; + } + + _top = hit.RenderableIndex; + _searchStatusText = BuildSearchStatus(); + } + + private void JumpToPreviousMatch() { + if (!_search.HasQuery) { + _searchStatusText = "No active search. Press / to search."; + return; + } + + PagerSearchHit? hit = _search.MovePrevious(_top); + if (hit is null) { + _searchStatusText = $"/{_search.Query} (no matches)"; + return; + } + + _top = hit.RenderableIndex; + _searchStatusText = BuildSearchStatus(); + } + + private string BuildSearchStatus() { + PagerSearchHit? hit = _search.CurrentHit; + if (hit is null) { + return $"/{_search.Query} (0 matches)"; + } + + int current = _search.CurrentHitIndex + 1; + int line = hit.Line + 1; + int column = hit.Column + 1; + return $"/{_search.Query} [{current}/{_search.HitCount}] line {line}, col {column}"; + } + + private void GoToEnd(int contentRows) => _top = _viewportEngine.GetMaxTop(contentRows); + + private Layout BuildRenderable(PagerViewportWindow viewport, int width) { + int footerHeight = GetFooterHeight(width); + int searchInputHeight = GetSearchInputHeight(); + IRenderable content = viewport.Count <= 0 + ? Text.Empty + : BuildContentRenderable(viewport); + + IRenderable footer = BuildFooter(width, viewport); + var root = new Layout("root"); + Layout bodyLayout = new Layout("body").Ratio(1).Update(content); + if (_isSearchInputActive) { + root.SplitRows( + new Layout("search").Size(searchInputHeight).Update(BuildSearchInputPanel()), + bodyLayout, + new Layout("footer").Size(footerHeight).Update(footer) + ); + } + else { + root.SplitRows( + bodyLayout, + new Layout("footer").Size(footerHeight).Update(footer) + ); + } + + return root; + } + + private Panel BuildSearchInputPanel() { + string inputText = Markup.Escape(_searchInputBuffer.ToString()); + string prompt = $"[bold]/[/]{inputText}[grey]_[/]"; + var content = new Markup(prompt); + return new Panel(content) { + Header = new PanelHeader("Search", Justify.Left), + Border = BoxBorder.Rounded, + Padding = new Padding(1, 0, 1, 0), + Expand = true + }; + } + + private IRenderable BuildContentRenderable(PagerViewportWindow viewport) { + if (_sourceHighlightedText is not null && !_search.HasQuery) { + _sourceHighlightedText.SetView(_renderables, viewport.Top, viewport.Count); + _sourceHighlightedText.LineNumberStart = (_originalLineNumberStart ?? 1) + viewport.Top; + _sourceHighlightedText.LineNumberWidth = _stableLineNumberWidth; + return _sourceHighlightedText; + } + + return _search.HasQuery ? BuildSearchAwareContent(viewport) : new Rows(_renderables.Skip(viewport.Top).Take(viewport.Count)); + } + + private Rows BuildSearchAwareContent(PagerViewportWindow viewport) { + var items = new List(viewport.Count); + for (int i = 0; i < viewport.Count; i++) { + int renderableIndex = viewport.Top + i; + items.Add(ApplySearchHighlight(renderableIndex, _renderables[renderableIndex])); + } + + return new Rows(items); + } + + private IRenderable ApplySearchHighlight(int renderableIndex, IRenderable renderable) { + IReadOnlyList hits = _search.GetHitsForRenderable(renderableIndex); + if (hits.Count == 0) { + return renderable; + } + + if (PagerHighlighting.TryBuildStructuredHighlightRenderable(renderable, _search.Query, SearchRowStyle, SearchMatchStyle, out IRenderable structuredHighlight)) { + return structuredHighlight; + } + + string plainText = GetSearchTextForHighlight(renderableIndex, renderable); + if (plainText.Length == 0) { + return renderable; + } + + string highlighted = PagerHighlighting.BuildHighlightedMarkup(plainText, hits, SearchMatchStyle); + return new Markup($"[{SearchRowStyle}]{highlighted}[/]"); + } + + private string GetSearchTextForHighlight(int renderableIndex, IRenderable renderable) { + string normalizedEntryText = PagerHighlighting.NormalizeText(_document.GetEntry(renderableIndex)?.SearchText); + return normalizedEntryText.Length > 0 + ? normalizedEntryText + : ExtractPlainTextForSearchHighlight(renderable); + } + + private string ExtractPlainTextForSearchHighlight(IRenderable renderable) { + if (renderable is Text text) { + return PagerHighlighting.NormalizeText(text.ToString()); + } + + try { + int width = Math.Max(20, WindowWidth - 2); + string rendered = Writer.WriteToString(renderable, width); + return PagerHighlighting.NormalizeText(VTHelpers.StripAnsi(rendered)); + } + catch { + return string.Empty; + } + } + + private IRenderable BuildFooter(int width, PagerViewportWindow viewport) + => UseRichFooter(width) + ? BuildRichFooter(width, viewport) + : BuildSimpleFooter(viewport); + + private Text BuildSimpleFooter(PagerViewportWindow viewport) { + int total = _renderables.Count; + int start = total == 0 ? 0 : viewport.Top + 1; + int end = viewport.EndExclusive; + string baseText = $"↑↓ Scroll PgUp/PgDn Page Home/End Jump / or Ctrl+F Search n/N Match q/Esc Quit {start}-{end}/{total}"; + string inputHelp = _isSearchInputActive ? " Enter Apply Esc Cancel" : string.Empty; + return string.IsNullOrEmpty(_searchStatusText) + ? new Text(baseText + inputHelp, new Style(Color.Grey)) + : new Text($"{baseText}{inputHelp} {_searchStatusText}", new Style(Color.Grey)); + } + + private Panel BuildRichFooter(int width, PagerViewportWindow viewport) { + int total = _renderables.Count; + int start = total == 0 ? 0 : viewport.Top + 1; + int end = viewport.EndExclusive; + int safeTotal = Math.Max(1, total); + int digits = Math.Max(1, safeTotal.ToString(CultureInfo.InvariantCulture).Length); + + string keyText = "↑↓ Scroll PgUp/PgDn Page Home/End Jump / or Ctrl+F Search n/N Match q/Esc Quit"; + string statusText = $"{start.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}-{end.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}/{total.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}".PadLeft(_statusColumnWidth); + if (_isSearchInputActive) { + keyText = $"{keyText} Enter Apply Esc Cancel"; + } + + if (!string.IsNullOrEmpty(_searchStatusText)) { + keyText = $"{keyText} {_searchStatusText}"; + } + + int chartWidth = Math.Clamp(width / 5, 12, 28); + double progressUnits = total == 0 ? 0d : (double)end / safeTotal * chartWidth; + double chartValue = end <= 0 ? 0d : Math.Clamp(Math.Ceiling(progressUnits), Math.Min(4d, chartWidth), chartWidth); + BarChart chart = new BarChart() + .Width(chartWidth) + .WithMaxValue(chartWidth) + .HideValues() + .AddItem(" ", chartValue, Color.Lime); + + Columns columns = new([ + new Text(keyText, new Style(Color.Grey)), + new Markup($"[bold]{statusText}[/]"), + chart + ]) { + Expand = true, + Padding = new Padding(0, 0, 0, 0) + }; + + return new Panel(columns) { + Border = BoxBorder.Rounded, + Padding = new Padding(0, 0, 0, 0), + Expand = true + }; + } + + public void Show() { + s_pagerExclusivityMode.Run(() => { + if (_suppressTerminalControlSequences) { + ShowCore(); + return 0; + } + + try { + _console.AlternateScreen(ShowCore); + } + catch (NotSupportedException) { + // Some hosts report no alternate-buffer/ANSI capability. + // Keep pager functional by running on the main screen. + ShowCore(); + } + catch (IOException) { + // Certain PTY hosts report invalid console handles for alternate screen. + // Fall back to normal screen rendering so pager still works. + ShowCore(); + } + catch (InvalidOperationException) { + // Console state can be partially unavailable in test/PTY environments. + ShowCore(); + } + + return 0; + }); + } + + private void ShowCore() { + if (!_suppressTerminalControlSequences) { + VTHelpers.HideCursor(); + VTHelpers.EnableAlternateScroll(); + } + + try { + (int width, int pageHeight) = GetPagerSize(); + int footerHeight = GetFooterHeight(width); + int searchInputHeight = GetSearchInputHeight(); + int contentRows = Math.Max(1, pageHeight - footerHeight - searchInputHeight); + WindowWidth = width; + WindowHeight = pageHeight; + + // Initial target for Spectre Live (footer included in target renderable) + _console.Profile.Width = width; + _viewportEngine.RecalculateHeights(width, contentRows, WindowHeight, _console); + PagerViewportWindow initialViewport = _viewportEngine.BuildViewport(_top, contentRows); + _top = initialViewport.Top; + IRenderable initial = BuildRenderable(initialViewport, width); + _lastRenderedRows = pageHeight; + _lastPageHadImages = initialViewport.HasImages; + + // If the initial page contains images, clear appropriately to ensure safe image rendering + if (initialViewport.HasImages) { + if (!_suppressTerminalControlSequences) { + VTHelpers.BeginSynchronizedOutput(); + } + + try { + if (!_suppressTerminalControlSequences) { + VTHelpers.ClearScreen(); + } + } + finally { + if (!_suppressTerminalControlSequences) { + VTHelpers.EndSynchronizedOutput(); + } + } + } + // Enter interactive loop using the live display context + _console.Live(initial) + .AutoClear(true) + .Overflow(VerticalOverflow.Crop) + .Cropping(VerticalOverflowCropping.Bottom) + .Start(Navigate); + } + finally { + // Clear any active view on the source highlighted text to avoid + // leaving its state mutated after the pager exits, and restore + // original line-number settings. + if (_sourceHighlightedText is not null) { + _sourceHighlightedText.ClearView(); + _sourceHighlightedText.LineNumberStart = _originalLineNumberStart ?? 1; + _sourceHighlightedText.LineNumberWidth = _originalLineNumberWidth; + _sourceHighlightedText.WrapInPanel = _originalWrapInPanel ?? false; + } + + if (!_suppressTerminalControlSequences) { + VTHelpers.DisableAlternateScroll(); + VTHelpers.ShowCursor(); + } + } + } + +} diff --git a/src/PSTextMate/Pager/PagerDocument.cs b/src/PSTextMate/Pager/PagerDocument.cs new file mode 100644 index 0000000..9dcaf42 --- /dev/null +++ b/src/PSTextMate/Pager/PagerDocument.cs @@ -0,0 +1,88 @@ +namespace PSTextMate.Terminal; + +internal sealed record PagerDocumentEntry( + int RenderableIndex, + IRenderable Renderable, + string SearchText, + int[] LineStarts, + bool IsImage +); + +internal sealed class PagerDocument { + private readonly List _entries = []; + + public IReadOnlyList Entries => _entries; + + public IReadOnlyList Renderables { get; } + + public PagerDocument(IEnumerable renderables) { + ArgumentNullException.ThrowIfNull(renderables); + + var renderableList = new List(); + int index = 0; + foreach (IRenderable renderable in renderables) { + bool isImage = IsImageRenderable(renderable); + string searchText = isImage ? string.Empty : ExtractSearchText(renderable); + int[] lineStarts = BuildLineStarts(searchText); + _entries.Add(new PagerDocumentEntry(index, renderable, searchText, lineStarts, isImage)); + renderableList.Add(renderable); + index++; + } + + Renderables = renderableList; + } + + public static PagerDocument FromHighlightedText(HighlightedText highlightedText) { + ArgumentNullException.ThrowIfNull(highlightedText); + return new PagerDocument(highlightedText.Renderables); + } + + public PagerDocumentEntry? GetEntry(int renderableIndex) { + return renderableIndex < 0 || renderableIndex >= _entries.Count + ? null + : _entries[renderableIndex]; + } + + private static string ExtractSearchText(IRenderable renderable) { + if (renderable is Text text) { + return Normalize(text.ToString()); + } + + try { + string rendered = Writer.WriteToString(renderable, width: 200); + return Normalize(VTHelpers.StripAnsi(rendered)); + } + catch { + return string.Empty; + } + } + + private static string Normalize(string? value) { + return string.IsNullOrEmpty(value) + ? string.Empty + : value.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + } + + private static int[] BuildLineStarts(string value) { + if (string.IsNullOrEmpty(value)) { + return [0]; + } + + var starts = new List { 0 }; + for (int i = 0; i < value.Length; i++) { + if (value[i] == '\n' && i + 1 < value.Length) { + starts.Add(i + 1); + } + } + + return [.. starts]; + } + + private static bool IsImageRenderable(IRenderable renderable) { + string name = renderable.GetType().Name; + return name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Image", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/PSTextMate/Pager/PagerHighlighting.cs b/src/PSTextMate/Pager/PagerHighlighting.cs new file mode 100644 index 0000000..4330340 --- /dev/null +++ b/src/PSTextMate/Pager/PagerHighlighting.cs @@ -0,0 +1,220 @@ +namespace PSTextMate.Terminal; + +internal static class PagerHighlighting { + internal static bool TryBuildStructuredHighlightRenderable( + IRenderable renderable, + string query, + string rowStyle, + string matchStyle, + out IRenderable highlighted + ) { + highlighted = renderable; + if (string.IsNullOrEmpty(query) || renderable is not Table table) { + return false; + } + + highlighted = CloneTableWithHighlight(table, query, rowStyle, matchStyle); + return true; + } + + internal static string BuildHighlightedMarkup(string plainText, IReadOnlyList hits, string matchStyle) { + if (plainText.Length == 0) { + return string.Empty; + } + + int position = 0; + var builder = new StringBuilder(plainText.Length + Math.Max(32, hits.Count * 24)); + foreach (PagerSearchHit hit in hits.OrderBy(static h => h.Offset)) { + int start = Math.Clamp(hit.Offset, 0, plainText.Length); + int length = Math.Clamp(hit.Length, 0, plainText.Length - start); + if (length <= 0 || start < position) { + continue; + } + + if (start > position) { + builder.Append(Markup.Escape(plainText[position..start])); + } + + string matchPart = plainText.Substring(start, length); + builder.Append('[') + .Append(matchStyle) + .Append(']') + .Append(Markup.Escape(matchPart)) + .Append("[/]"); + + position = start + length; + } + + if (position < plainText.Length) { + builder.Append(Markup.Escape(plainText[position..])); + } + + return builder.ToString(); + } + + internal static string NormalizeText(string? text) { + return string.IsNullOrEmpty(text) + ? string.Empty + : text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .TrimEnd('\n'); + } + + private static Table CloneTableWithHighlight(Table source, string query, string rowStyle, string matchStyle) { + var clone = new Table { + Border = source.Border, + BorderStyle = source.BorderStyle, + UseSafeBorder = source.UseSafeBorder, + ShowHeaders = source.ShowHeaders, + ShowRowSeparators = source.ShowRowSeparators, + ShowFooters = source.ShowFooters, + Expand = source.Expand, + Width = source.Width, + Title = source.Title is null ? null : new TableTitle(source.Title.Text, source.Title.Style), + Caption = source.Caption is null ? null : new TableTitle(source.Caption.Text, source.Caption.Style) + }; + + foreach (TableColumn sourceColumn in source.Columns) { + IRenderable header = HighlightRenderableNode(sourceColumn.Header, query, applyRowStyle: false, rowStyle, matchStyle); + IRenderable? footer = sourceColumn.Footer is null + ? null + : HighlightRenderableNode(sourceColumn.Footer, query, applyRowStyle: false, rowStyle, matchStyle); + var column = new TableColumn(header) { + Width = sourceColumn.Width, + Padding = sourceColumn.Padding, + NoWrap = sourceColumn.NoWrap, + Alignment = sourceColumn.Alignment, + Footer = footer + }; + + clone.AddColumn(column); + } + + foreach (TableRow sourceRow in source.Rows) { + bool rowHasMatch = sourceRow.Any(cell => RenderableContainsQuery(cell, query)); + var rowItems = new List(); + foreach (IRenderable sourceCell in sourceRow) { + rowItems.Add(HighlightRenderableNode(sourceCell, query, rowHasMatch, rowStyle, matchStyle)); + } + + clone.AddRow(rowItems); + } + + return clone; + } + + private static bool RenderableContainsQuery(IRenderable renderable, string query) { + if (string.IsNullOrEmpty(query)) { + return false; + } + + string plainText = NormalizeText(ExtractRenderableText(renderable)); + return plainText.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + private static IRenderable HighlightRenderableNode( + IRenderable renderable, + string query, + bool applyRowStyle, + string rowStyle, + string matchStyle + ) { + return renderable switch { + Text or Markup or Paragraph => HighlightLeafRenderable(renderable, query, applyRowStyle, rowStyle, matchStyle), + _ => renderable, + }; + } + + private static IRenderable HighlightLeafRenderable( + IRenderable renderable, + string query, + bool applyRowStyle, + string rowStyle, + string matchStyle + ) { + string plainText = NormalizeText(ExtractRenderableText(renderable)); + if (plainText.Length == 0) { + return renderable; + } + + string highlighted = BuildHighlightedMarkupFromQuery(plainText, query, applyRowStyle, rowStyle, matchStyle); + string baseline = applyRowStyle + ? $"[{rowStyle}]{Markup.Escape(plainText)}[/]" + : Markup.Escape(plainText); + + return string.Equals(highlighted, baseline, StringComparison.Ordinal) + ? renderable + : new Markup(highlighted); + } + + private static string ExtractRenderableText(IRenderable renderable) { + if (renderable is Text text) { + return text.ToString() ?? string.Empty; + } + + try { + string rendered = Writer.WriteToString(renderable, width: 200); + return VTHelpers.StripAnsi(rendered); + } + catch { + return string.Empty; + } + } + + private static string BuildHighlightedMarkupFromQuery( + string plainText, + string query, + bool applyRowStyle, + string rowStyle, + string matchStyle + ) { + if (string.IsNullOrEmpty(plainText) || string.IsNullOrEmpty(query)) { + string escaped = Markup.Escape(plainText); + return applyRowStyle ? $"[{rowStyle}]{escaped}[/]" : escaped; + } + + static void AppendNormalText(StringBuilder builder, string text, bool applyRowStyle, string rowStyle) { + if (text.Length == 0) { + return; + } + + string escaped = Markup.Escape(text); + if (applyRowStyle) { + builder.Append('[') + .Append(rowStyle) + .Append(']') + .Append(escaped) + .Append("[/]"); + } + else { + builder.Append(escaped); + } + } + + int position = 0; + int queryLength = query.Length; + var builder = new StringBuilder(plainText.Length + 32); + while (position < plainText.Length) { + int hitOffset = plainText.IndexOf(query, position, StringComparison.OrdinalIgnoreCase); + if (hitOffset < 0) { + AppendNormalText(builder, plainText[position..], applyRowStyle, rowStyle); + break; + } + + if (hitOffset > position) { + AppendNormalText(builder, plainText[position..hitOffset], applyRowStyle, rowStyle); + } + + int hitLength = Math.Min(queryLength, plainText.Length - hitOffset); + builder.Append('[') + .Append(matchStyle) + .Append(']') + .Append(Markup.Escape(plainText.Substring(hitOffset, hitLength))) + .Append("[/]"); + + position = hitOffset + Math.Max(1, hitLength); + } + + return builder.ToString(); + } +} diff --git a/src/PSTextMate/Pager/PagerSearchSession.cs b/src/PSTextMate/Pager/PagerSearchSession.cs new file mode 100644 index 0000000..ff27e5b --- /dev/null +++ b/src/PSTextMate/Pager/PagerSearchSession.cs @@ -0,0 +1,134 @@ +namespace PSTextMate.Terminal; + +internal sealed record PagerSearchHit( + int RenderableIndex, + int Offset, + int Length, + int Line, + int Column +); + +internal sealed class PagerSearchSession { + private readonly PagerDocument _document; + private readonly List _hits = []; + private static readonly IReadOnlyList s_noHits = []; + public string Query { get; private set; } = string.Empty; + public int CurrentHitIndex { get; private set; } = -1; + public int HitCount => _hits.Count; + public bool HasQuery => !string.IsNullOrWhiteSpace(Query); + public PagerSearchHit? CurrentHit + => CurrentHitIndex >= 0 && CurrentHitIndex < _hits.Count + ? _hits[CurrentHitIndex] + : null; + + public PagerSearchSession(PagerDocument document) { + _document = document ?? throw new ArgumentNullException(nameof(document)); + } + + public void SetQuery(string query) { + Query = query?.Trim() ?? string.Empty; + RebuildHits(); + } + + public PagerSearchHit? MoveNext(int topIndex) { + if (_hits.Count == 0) { + CurrentHitIndex = -1; + return null; + } + + if (CurrentHitIndex >= 0) { + CurrentHitIndex = (CurrentHitIndex + 1) % _hits.Count; + return _hits[CurrentHitIndex]; + } + + int nearest = _hits.FindIndex(hit => hit.RenderableIndex >= topIndex); + CurrentHitIndex = nearest >= 0 ? nearest : 0; + return _hits[CurrentHitIndex]; + } + + public PagerSearchHit? MovePrevious(int topIndex) { + if (_hits.Count == 0) { + CurrentHitIndex = -1; + return null; + } + + if (CurrentHitIndex >= 0) { + CurrentHitIndex = CurrentHitIndex == 0 ? _hits.Count - 1 : CurrentHitIndex - 1; + return _hits[CurrentHitIndex]; + } + + int nearest = _hits.FindLastIndex(hit => hit.RenderableIndex <= topIndex); + CurrentHitIndex = nearest >= 0 ? nearest : _hits.Count - 1; + return _hits[CurrentHitIndex]; + } + + public IReadOnlyList GetHitsForRenderable(int renderableIndex) { + if (_hits.Count == 0) { + return s_noHits; + } + + List? matches = null; + foreach (PagerSearchHit hit in _hits) { + if (hit.RenderableIndex != renderableIndex) { + continue; + } + + matches ??= []; + matches.Add(hit); + } + + return matches ?? s_noHits; + } + + public bool IsCurrentHit(PagerSearchHit hit) + => CurrentHit is PagerSearchHit current + && current.RenderableIndex == hit.RenderableIndex + && current.Offset == hit.Offset + && current.Length == hit.Length; + + private void RebuildHits() { + _hits.Clear(); + CurrentHitIndex = -1; + + if (!HasQuery) { + return; + } + + foreach (PagerDocumentEntry entry in _document.Entries) { + if (entry.IsImage || string.IsNullOrEmpty(entry.SearchText)) { + continue; + } + + int searchStart = 0; + while (searchStart <= entry.SearchText.Length - Query.Length) { + int hitOffset = entry.SearchText.IndexOf(Query, searchStart, StringComparison.OrdinalIgnoreCase); + if (hitOffset < 0) { + break; + } + + (int line, int column) = ResolveLineColumn(entry.LineStarts, hitOffset); + _hits.Add(new PagerSearchHit(entry.RenderableIndex, hitOffset, Query.Length, line, column)); + + searchStart = hitOffset + Math.Max(1, Query.Length); + } + } + } + + private static (int line, int column) ResolveLineColumn(int[] lineStarts, int offset) { + if (lineStarts.Length == 0) { + return (0, offset); + } + + int line = 0; + for (int i = 1; i < lineStarts.Length; i++) { + if (lineStarts[i] > offset) { + break; + } + + line = i; + } + + int column = offset - lineStarts[line]; + return (line, Math.Max(0, column)); + } +} diff --git a/src/PSTextMate/Pager/PagerViewport.cs b/src/PSTextMate/Pager/PagerViewport.cs new file mode 100644 index 0000000..94ef181 --- /dev/null +++ b/src/PSTextMate/Pager/PagerViewport.cs @@ -0,0 +1,221 @@ +namespace PSTextMate.Terminal; + +internal readonly record struct PagerViewportWindow(int Top, int Count, int EndExclusive, bool HasImages); + +internal sealed class PagerViewportEngine { + private readonly IReadOnlyList _renderables; + private readonly HighlightedText? _sourceHighlightedText; + private List _renderableHeights = []; + + public PagerViewportEngine(IReadOnlyList renderables, HighlightedText? sourceHighlightedText) { + _renderables = renderables ?? throw new ArgumentNullException(nameof(renderables)); + _sourceHighlightedText = sourceHighlightedText; + } + + public void RecalculateHeights(int width, int contentRows, int windowHeight, IAnsiConsole console) { + ArgumentNullException.ThrowIfNull(console); + + _renderableHeights = new List(_renderables.Count); + Capabilities capabilities = console.Profile.Capabilities; + int measurementHeight = windowHeight > 0 ? windowHeight : Math.Max(1, contentRows + 3); + var size = new Size(width, measurementHeight); + var options = new RenderOptions(capabilities, size); + + for (int i = 0; i < _renderables.Count; i++) { + IRenderable? renderable = _renderables[i]; + if (renderable is null) { + _renderableHeights.Add(1); + continue; + } + + if (IsImageRenderable(renderable)) { + if (renderable is PixelImage pixelImage) { + // In pager mode, clamp image width to the viewport so frames stay within screen bounds. + pixelImage.MaxWidth = pixelImage.MaxWidth is int existingWidth && existingWidth > 0 + ? Math.Min(existingWidth, width) + : width; + } + + _renderableHeights.Add(EstimateImageHeight(renderable, width, contentRows, options)); + continue; + } + + try { + // For non-image renderables, render to segments to get accurate row count. + // This avoids overflow/cropping artifacts when wrapped text spans many rows. + var segments = renderable.Render(options, width).ToList(); + int lines = CountLinesSegments(segments); + _renderableHeights.Add(Math.Max(1, lines)); + } + catch { + // Fallback: assume single-line if measurement fails. + _renderableHeights.Add(1); + } + } + } + + public PagerViewportWindow BuildViewport(int proposedTop, int contentRows) { + if (_renderables.Count == 0) { + return new PagerViewportWindow(0, 0, 0, false); + } + + int clampedTop = Math.Clamp(proposedTop, 0, _renderables.Count - 1); + int rowsUsed = 0; + int count = 0; + bool hasImages = false; + + for (int i = clampedTop; i < _renderables.Count; i++) { + bool isImage = IsImageRenderable(_renderables[i]); + int height = Math.Clamp(GetRenderableHeight(i), 1, contentRows); + + if (count > 0 && rowsUsed + height > contentRows) { + break; + } + + rowsUsed += height; + count++; + hasImages |= isImage; + + if (rowsUsed >= contentRows) { + break; + } + } + + if (count == 0) { + count = 1; + hasImages = IsImageRenderable(_renderables[clampedTop]); + } + + return new PagerViewportWindow(clampedTop, count, clampedTop + count, hasImages); + } + + public int GetMaxTop(int contentRows) { + if (_renderables.Count == 0) { + return 0; + } + + int top = _renderables.Count - 1; + int rows = Math.Clamp(GetRenderableHeight(top), 1, contentRows); + + while (top > 0) { + int previousHeight = Math.Clamp(GetRenderableHeight(top - 1), 1, contentRows); + if (rows + previousHeight > contentRows) { + break; + } + + rows += previousHeight; + top--; + } + + return top; + } + + public int ScrollTop(int currentTop, int delta, int windowHeight) { + if (_renderables.Count == 0) { + return currentTop; + } + + int direction = Math.Sign(delta); + if (direction == 0) { + return currentTop; + } + + int maxTop = GetMaxTop(Math.Max(1, windowHeight - 1)); + return Math.Clamp(currentTop + direction, 0, maxTop); + } + + public int PageDownTop(int currentTop, int contentRows) { + if (_renderables.Count == 0) { + return currentTop; + } + + PagerViewportWindow viewport = BuildViewport(currentTop, contentRows); + int maxTop = GetMaxTop(contentRows); + return viewport.EndExclusive >= _renderables.Count ? maxTop : Math.Min(viewport.EndExclusive, maxTop); + } + + public int PageUpTop(int currentTop, int contentRows) { + if (_renderables.Count == 0) { + return currentTop; + } + + int rowsSkipped = 0; + int idx = currentTop - 1; + int nextTop = currentTop; + while (idx >= 0 && rowsSkipped < contentRows) { + rowsSkipped += Math.Clamp(GetRenderableHeight(idx), 1, contentRows); + nextTop = idx; + idx--; + } + + return Math.Clamp(nextTop, 0, _renderables.Count - 1); + } + + private int GetRenderableHeight(int index) + => index < 0 || index >= _renderableHeights.Count ? 1 : Math.Max(1, _renderableHeights[index]); + + private bool IsImageRenderable(IRenderable? renderable) { + if (renderable is null) { + return false; + } + + if (_sourceHighlightedText is not null && !IsMarkdownSource()) { + return false; + } + + string name = renderable.GetType().Name; + return name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase) + || name.Contains("Image", StringComparison.OrdinalIgnoreCase); + } + + private bool IsMarkdownSource() + => _sourceHighlightedText is not null + && _sourceHighlightedText.Language.Contains("markdown", StringComparison.OrdinalIgnoreCase); + + private static int CountLinesSegments(List segments) { + if (segments.Count == 0) { + return 0; + } + + int lineBreaks = segments.Count(segment => segment.IsLineBreak); + return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; + } + + private static int EstimateImageHeight(IRenderable renderable, int width, int contentRows, RenderOptions options) { + if (renderable is PixelImage pixelImage) { + int imagePixelWidth = pixelImage.Width; + int imagePixelHeight = pixelImage.Height; + int cellWidth = pixelImage.MaxWidth is int maxWidth && maxWidth > 0 + ? Math.Min(width, maxWidth) + : width; + + if (imagePixelWidth > 0 && imagePixelHeight > 0) { + double imageAspect = (double)imagePixelHeight / imagePixelWidth; + double cellAspectRatio = GetTerminalCellAspectRatio(); + int estimatedRows = (int)Math.Ceiling(imageAspect * Math.Max(1, cellWidth) * cellAspectRatio); + return Math.Clamp(Math.Max(1, estimatedRows), 1, contentRows); + } + } + + Measurement measure; + try { + measure = renderable.Measure(options, width); + } + catch { + return Math.Clamp(contentRows, 1, contentRows); + } + + int cellWidthFallback = Math.Max(1, Math.Min(width, measure.Max)); + + // Last fallback: keep as atomic item, but estimate from measured width. + return Math.Clamp(Math.Max(1, (int)Math.Ceiling((double)cellWidthFallback / Math.Max(1, width))), 1, contentRows); + } + + private static double GetTerminalCellAspectRatio() { + CellSize cellSize = Compatibility.GetCellSize(); + return cellSize.PixelWidth <= 0 || cellSize.PixelHeight <= 0 + ? 0.5d + : (double)cellSize.PixelWidth / cellSize.PixelHeight; + } +} diff --git a/src/PSTextMate/Utilities/Pager.cs b/src/PSTextMate/Utilities/Pager.cs deleted file mode 100644 index 35a48bb..0000000 --- a/src/PSTextMate/Utilities/Pager.cs +++ /dev/null @@ -1,558 +0,0 @@ -namespace PSTextMate.Utilities; - -/// -/// Simple pager implemented with Spectre.Console Live display. -/// Interaction keys: -/// - Up/Down: move one renderable item -/// - PageUp/PageDown: move by one viewport of items -/// - Home/End: go to start/end -/// - q or Escape: quit -/// -public sealed class Pager : IDisposable { - private static readonly PagerExclusivityMode s_pagerExclusivityMode = new(); - private readonly IReadOnlyList _renderables; - private readonly HighlightedText? _sourceHighlightedText; - private readonly int? _originalLineNumberStart; - private readonly int? _originalLineNumberWidth; - private readonly bool? _originalWrapInPanel; - private readonly int? _stableLineNumberWidth; - private readonly int _statusColumnWidth; - private int _top; - private int WindowHeight; - private int WindowWidth; - private readonly object _lock = new(); - private int _lastRenderedRows; - private List _renderableHeights = []; - private bool _lastPageHadImages; - private readonly record struct ViewportWindow(int Top, int Count, int EndExclusive, bool HasImages); - - private bool UseRichFooter(int footerWidth) - => footerWidth >= GetMinimumRichFooterWidth(); - - private int GetFooterHeight(int footerWidth) - => UseRichFooter(footerWidth) ? 3 : 1; - - private int GetMinimumRichFooterWidth() { - const int keySectionMinWidth = 38; - const int chartSectionMinWidth = 12; - const int layoutOverhead = 10; - return keySectionMinWidth + _statusColumnWidth + chartSectionMinWidth + layoutOverhead; - } - - private static int GetStatusColumnWidth(int totalItems) { - int digits = Math.Max(1, totalItems.ToString(CultureInfo.InvariantCulture).Length); - return (digits * 3) + 4; - } - - private sealed class PagerExclusivityMode : IExclusivityMode { - private readonly object _syncRoot = new(); - - public T Run(Func func) { - ArgumentNullException.ThrowIfNull(func); - - lock (_syncRoot) { - return func(); - } - } - - public async Task RunAsync(Func> func) { - ArgumentNullException.ThrowIfNull(func); - - Task task; - lock (_syncRoot) { - task = func(); - } - - return await task.ConfigureAwait(false); - } - } - - private static double GetTerminalCellAspectRatio() { - CellSize cellSize = Compatibility.GetCellSize(); - return cellSize.PixelWidth <= 0 || cellSize.PixelHeight <= 0 - ? 0.5d - : (double)cellSize.PixelWidth / cellSize.PixelHeight; - } - - private static int EstimateImageHeight(IRenderable renderable, int width, int contentRows, RenderOptions options) { - if (renderable is PixelImage pixelImage) { - int imagePixelWidth = pixelImage.Width; - int imagePixelHeight = pixelImage.Height; - int cellWidth = pixelImage.MaxWidth is int maxWidth && maxWidth > 0 - ? Math.Min(width, maxWidth) - : width; - - if (imagePixelWidth > 0 && imagePixelHeight > 0) { - double imageAspect = (double)imagePixelHeight / imagePixelWidth; - double cellAspectRatio = GetTerminalCellAspectRatio(); - int estimatedRows = (int)Math.Ceiling(imageAspect * Math.Max(1, cellWidth) * cellAspectRatio); - return Math.Clamp(Math.Max(1, estimatedRows), 1, contentRows); - } - } - - Measurement measure; - try { - measure = renderable.Measure(options, width); - } - catch { - return Math.Clamp(contentRows, 1, contentRows); - } - - int cellWidthFallback = Math.Max(1, Math.Min(width, measure.Max)); - - // Last fallback: keep as atomic item, but estimate from measured width. - return Math.Clamp(Math.Max(1, (int)Math.Ceiling((double)cellWidthFallback / Math.Max(1, width))), 1, contentRows); - } - - private bool IsMarkdownSource() - => _sourceHighlightedText is not null - && _sourceHighlightedText.Language.Contains("markdown", StringComparison.OrdinalIgnoreCase); - - private bool IsImageRenderable(IRenderable? renderable) { - if (renderable is null) { - return false; - } - - if (_sourceHighlightedText is not null && !IsMarkdownSource()) { - return false; - } - - string name = renderable.GetType().Name; - return name.Contains("Sixel", StringComparison.OrdinalIgnoreCase) - || name.Contains("Pixel", StringComparison.OrdinalIgnoreCase) - || name.Contains("Image", StringComparison.OrdinalIgnoreCase); - } - - private ViewportWindow BuildViewport(int proposedTop, int contentRows) { - if (_renderables.Count == 0) { - return new ViewportWindow(0, 0, 0, false); - } - - int clampedTop = Math.Clamp(proposedTop, 0, _renderables.Count - 1); - int rowsUsed = 0; - int count = 0; - bool hasImages = false; - - for (int i = clampedTop; i < _renderables.Count; i++) { - bool isImage = IsImageRenderable(_renderables[i]); - int height = Math.Clamp(GetRenderableHeight(i), 1, contentRows); - - if (count > 0 && rowsUsed + height > contentRows) { - break; - } - - rowsUsed += height; - count++; - hasImages |= isImage; - - if (rowsUsed >= contentRows) { - break; - } - } - - if (count == 0) { - count = 1; - hasImages = IsImageRenderable(_renderables[clampedTop]); - } - - return new ViewportWindow(clampedTop, count, clampedTop + count, hasImages); - } - - public Pager(HighlightedText highlightedText) { - _sourceHighlightedText = highlightedText; - _originalWrapInPanel = highlightedText.WrapInPanel; - - int totalLines = highlightedText.LineCount; - int lastLineNumber = highlightedText.LineNumberStart + Math.Max(0, totalLines - 1); - _stableLineNumberWidth = highlightedText.LineNumberWidth ?? lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; - _originalLineNumberStart = highlightedText.LineNumberStart; - _originalLineNumberWidth = highlightedText.LineNumberWidth; - - // Panel rendering in pager mode causes unstable layout; disable it for the paging session. - highlightedText.WrapInPanel = false; - - // Reference the underlying renderable array directly to avoid copying. - _renderables = highlightedText.Renderables; - _statusColumnWidth = GetStatusColumnWidth(_renderables.Count); - _top = 0; - } - - public Pager(IEnumerable renderables) { - var list = renderables?.ToList(); - _renderables = list is null ? [] : (IReadOnlyList)list; - _statusColumnWidth = GetStatusColumnWidth(_renderables.Count); - _top = 0; - } - private void Navigate(LiveDisplayContext ctx, bool useAlternateBuffer) { - bool running = true; - (WindowWidth, WindowHeight) = GetPagerSize(); - bool forceRedraw = true; - - while (running) { - (int width, int pageHeight) = GetPagerSize(); - int footerHeight = GetFooterHeight(width); - int contentRows = Math.Max(1, pageHeight - footerHeight); - - bool resized = width != WindowWidth || pageHeight != WindowHeight; - if (resized) { - AnsiConsole.Console.Profile.Width = width; - - WindowWidth = width; - WindowHeight = pageHeight; - forceRedraw = true; - } - - // Redraw if needed (initial, resize, or after navigation) - if (resized || forceRedraw) { - VTHelpers.BeginSynchronizedOutput(); - try { - RecalculateRenderableHeights(width, contentRows); - _top = Math.Clamp(_top, 0, GetMaxTop(contentRows)); - ViewportWindow viewport = BuildViewport(_top, contentRows); - _top = viewport.Top; - - bool fullClear = resized || viewport.HasImages || _lastPageHadImages; - if (fullClear) { - VTHelpers.ClearScreen(); - } - else { - VTHelpers.SetCursorPosition(1, 1); - } - - IRenderable target = BuildRenderable(viewport, width); - ctx.UpdateTarget(target); - ctx.Refresh(); - - // Clear any stale lines after a terminal shrink. - if (_lastRenderedRows > pageHeight) { - for (int r = pageHeight + 1; r <= _lastRenderedRows; r++) { - VTHelpers.ClearRow(r); - } - } - - _lastRenderedRows = pageHeight; - _lastPageHadImages = viewport.HasImages; - forceRedraw = false; - } - finally { - VTHelpers.EndSynchronizedOutput(); - } - } - - // Wait for input, checking for resize while idle - if (!Console.KeyAvailable) { - Thread.Sleep(50); - continue; - } - - ConsoleKeyInfo key = Console.ReadKey(true); - lock (_lock) { - switch (key.Key) { - case ConsoleKey.DownArrow: - ScrollRenderable(1); - forceRedraw = true; - break; - case ConsoleKey.UpArrow: - ScrollRenderable(-1); - forceRedraw = true; - break; - case ConsoleKey.Spacebar: - case ConsoleKey.PageDown: - PageDown(contentRows); - forceRedraw = true; - break; - case ConsoleKey.PageUp: - PageUp(contentRows); - forceRedraw = true; - break; - case ConsoleKey.Home: - GoToTop(); - forceRedraw = true; - break; - case ConsoleKey.End: - GoToEnd(contentRows); - forceRedraw = true; - break; - case ConsoleKey.Q: - case ConsoleKey.Escape: - running = false; - break; - } - } - } - } - - private static (int width, int height) GetPagerSize() { - int width = Console.WindowWidth > 0 ? Console.WindowWidth : 80; - int height = Console.WindowHeight > 0 ? Console.WindowHeight : 40; - return (width, height); - } - - private void ScrollRenderable(int delta) { - if (_renderables.Count == 0) return; - - int direction = Math.Sign(delta); - if (direction == 0) return; - - int maxTop = GetMaxTop(Math.Max(1, WindowHeight - 1)); - _top = Math.Clamp(_top + direction, 0, maxTop); - } - - private void PageDown(int contentRows) { - if (_renderables.Count == 0) return; - - ViewportWindow viewport = BuildViewport(_top, contentRows); - int maxTop = GetMaxTop(contentRows); - if (viewport.EndExclusive >= _renderables.Count) { - _top = maxTop; - return; - } - - _top = Math.Min(viewport.EndExclusive, maxTop); - } - - private void PageUp(int contentRows) { - if (_renderables.Count == 0) return; - - int rowsSkipped = 0; - int idx = _top - 1; - int nextTop = _top; - while (idx >= 0 && rowsSkipped < contentRows) { - rowsSkipped += Math.Clamp(GetRenderableHeight(idx), 1, contentRows); - nextTop = idx; - idx--; - } - - _top = Math.Clamp(nextTop, 0, _renderables.Count - 1); - } - - private int GetRenderableHeight(int index) - => index < 0 || index >= _renderableHeights.Count ? 1 : Math.Max(1, _renderableHeights[index]); - - private static int CountLinesSegments(List segments) { - if (segments.Count == 0) { - return 0; - } - - int lineBreaks = segments.Count(segment => segment.IsLineBreak); - return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; - } - - private void RecalculateRenderableHeights(int width, int contentRows) { - _renderableHeights = new List(_renderables.Count); - Capabilities capabilities = AnsiConsole.Console.Profile.Capabilities; - var size = new Size(width, Math.Max(1, Console.WindowHeight)); - var options = new RenderOptions(capabilities, size); - - for (int i = 0; i < _renderables.Count; i++) { - IRenderable? r = _renderables[i]; - if (r is null) { - _renderableHeights.Add(1); - continue; - } - - if (IsImageRenderable(r)) { - if (r is PixelImage pixelImage) { - // In pager mode, clamp image width to the viewport so frames stay within screen bounds. - pixelImage.MaxWidth = pixelImage.MaxWidth is int existingWidth && existingWidth > 0 - ? Math.Min(existingWidth, width) - : width; - } - - _renderableHeights.Add(EstimateImageHeight(r, width, contentRows, options)); - continue; - } - - try { - // For non-image renderables, render to segments to get accurate row count. - // This avoids overflow/cropping artifacts when wrapped text spans many rows. - var segments = r.Render(options, width).ToList(); - int lines = CountLinesSegments(segments); - _renderableHeights.Add(Math.Max(1, lines)); - } - catch { - // Fallback: assume single-line if measurement fails - _renderableHeights.Add(1); - } - } - } - - private void GoToTop() => _top = 0; - - private int GetMaxTop(int contentRows) { - if (_renderables.Count == 0) { - return 0; - } - - int top = _renderables.Count - 1; - int rows = Math.Clamp(GetRenderableHeight(top), 1, contentRows); - - while (top > 0) { - int previousHeight = Math.Clamp(GetRenderableHeight(top - 1), 1, contentRows); - if (rows + previousHeight > contentRows) { - break; - } - - rows += previousHeight; - top--; - } - - return top; - } - - private void GoToEnd(int contentRows) => _top = GetMaxTop(contentRows); - - private Layout BuildRenderable(ViewportWindow viewport, int width) { - int footerHeight = GetFooterHeight(width); - IRenderable content = viewport.Count <= 0 - ? Text.Empty - : BuildContentRenderable(viewport); - - IRenderable footer = BuildFooter(width, viewport); - var root = new Layout("root"); - root.SplitRows( - new Layout("body").Update(content), - new Layout("footer").Size(footerHeight).Update(footer) - ); - - return root; - } - - private IRenderable BuildContentRenderable(ViewportWindow viewport) { - if (_sourceHighlightedText is not null) { - _sourceHighlightedText.SetView(_renderables, viewport.Top, viewport.Count); - _sourceHighlightedText.LineNumberStart = (_originalLineNumberStart ?? 1) + viewport.Top; - _sourceHighlightedText.LineNumberWidth = _stableLineNumberWidth; - return _sourceHighlightedText; - } - - return new Rows(_renderables.Skip(viewport.Top).Take(viewport.Count)); - } - - private IRenderable BuildFooter(int width, ViewportWindow viewport) - => UseRichFooter(width) - ? BuildRichFooter(width, viewport) - : BuildSimpleFooter(viewport); - - private Text BuildSimpleFooter(ViewportWindow viewport) { - int total = _renderables.Count; - int start = total == 0 ? 0 : viewport.Top + 1; - int end = viewport.EndExclusive; - return new Text($"↑↓ Scroll PgUp/PgDn Page Home/End Jump q/Esc Quit {start}-{end}/{total}", new Style(Color.Grey)); - } - - private Panel BuildRichFooter(int width, ViewportWindow viewport) { - int total = _renderables.Count; - int start = total == 0 ? 0 : viewport.Top + 1; - int end = viewport.EndExclusive; - int safeTotal = Math.Max(1, total); - int digits = Math.Max(1, safeTotal.ToString(CultureInfo.InvariantCulture).Length); - - string keyText = "↑↓ Scroll PgUp/PgDn Page Home/End Jump q/Esc Quit"; - string statusText = $"{start.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}-{end.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}/{total.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}".PadLeft(_statusColumnWidth); - - int chartWidth = Math.Clamp(width / 5, 12, 28); - double progressUnits = total == 0 ? 0d : (double)end / safeTotal * chartWidth; - double chartValue = end <= 0 ? 0d : Math.Clamp(Math.Ceiling(progressUnits), Math.Min(4d, chartWidth), chartWidth); - BarChart chart = new BarChart() - .Width(chartWidth) - .WithMaxValue(chartWidth) - .HideValues() - .AddItem(" ", chartValue, Color.Lime); - - Columns columns = new([ - new Text(keyText, new Style(Color.Grey)), - new Markup($"[bold]{statusText}[/]"), - chart - ]) { - Expand = true, - Padding = new Padding(2, 0, 2, 0) - }; - - return new Panel(columns) { - Border = BoxBorder.Rounded, - Padding = new Padding(0, 0, 0, 0), - Expand = true - }; - } - - public void Show() { - bool resolvedUseAlternateBuffer = VTHelpers.SupportsAlternateBuffer(); - - s_pagerExclusivityMode.Run(() => { - if (resolvedUseAlternateBuffer) { - AnsiConsole.Console.AlternateScreen(() => ShowCore(useAlternateBuffer: true)); - } - else { - ShowCore(useAlternateBuffer: false); - } - - return 0; - }); - } - - private void ShowCore(bool useAlternateBuffer) { - VTHelpers.HideCursor(); - try { - (int width, int pageHeight) = GetPagerSize(); - int footerHeight = GetFooterHeight(width); - int contentRows = Math.Max(1, pageHeight - footerHeight); - WindowWidth = width; - WindowHeight = pageHeight; - - // Initial target for Spectre Live (footer included in target renderable) - AnsiConsole.Console.Profile.Width = width; - RecalculateRenderableHeights(width, contentRows); - ViewportWindow initialViewport = BuildViewport(_top, contentRows); - _top = initialViewport.Top; - IRenderable initial = BuildRenderable(initialViewport, width); - _lastRenderedRows = pageHeight; - _lastPageHadImages = initialViewport.HasImages; - - // If the initial page contains images, clear appropriately to ensure safe image rendering - if (initialViewport.HasImages) { - VTHelpers.BeginSynchronizedOutput(); - try { - if (useAlternateBuffer) { - VTHelpers.ClearScreen(); - } - else { - VTHelpers.ClearScreen(); - } - } - finally { - VTHelpers.EndSynchronizedOutput(); - } - } - - AnsiConsole.Live(initial) - .AutoClear(true) - .Overflow(VerticalOverflow.Crop) - .Cropping(VerticalOverflowCropping.Bottom) - .Start(ctx => { - // Enter interactive loop using the live display context - Navigate(ctx, useAlternateBuffer); - }); - } - finally { - // Clear any active view on the source highlighted text to avoid - // leaving its state mutated after the pager exits, and restore - // original line-number settings. - if (_sourceHighlightedText is not null) { - _sourceHighlightedText.ClearView(); - _sourceHighlightedText.LineNumberStart = _originalLineNumberStart ?? 1; - _sourceHighlightedText.LineNumberWidth = _originalLineNumberWidth; - _sourceHighlightedText.WrapInPanel = _originalWrapInPanel ?? false; - } - // Reset scroll region and restore normal screen buffer if used - if (useAlternateBuffer) { - VTHelpers.ResetScrollRegion(); - } - VTHelpers.ShowCursor(); - } - } - - public void Dispose() { - // No resources to dispose, but required for IDisposable - } -} diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs index 6060913..2ec19ee 100644 --- a/src/PSTextMate/Utilities/SpectreRenderBridge.cs +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -23,7 +23,7 @@ public static string RenderToString(object renderableObject, bool escapeAnsi = f : RenderForeign(renderableObject, width); return rendered.Length != 0 - ? escapeAnsi ? PSHostUserInterface.GetOutputString(rendered, false) : rendered + ? escapeAnsi ? VTHelpers.StripAnsi(rendered) : rendered : throw new ArgumentException( $"Object of type '{renderableObject.GetType().FullName}' does not implement a supported Spectre IRenderable shape.", nameof(renderableObject) @@ -100,8 +100,7 @@ private static string RenderForeign(object renderableObject, int? width) { if (ansiConsoleType is null || ansiConsoleSettingsType is null || ansiConsoleOutputType is null - || foreignRenderableType is null - || !foreignRenderableType.IsInstanceOfType(renderableObject)) { + || foreignRenderableType?.IsInstanceOfType(renderableObject) != true) { return string.Empty; } @@ -129,7 +128,7 @@ private static string RenderForeign(object renderableObject, int? width) { PropertyInfo? profileProperty = console.GetType().GetProperty("Profile"); object? profile = profileProperty?.GetValue(console); PropertyInfo? widthProperty = profile?.GetType().GetProperty("Width"); - if (widthProperty is not null && widthProperty.CanWrite) { + if (widthProperty?.CanWrite == true) { widthProperty.SetValue(profile, targetWidth); } } @@ -155,10 +154,6 @@ private static string RenderForeign(object renderableObject, int? width) { } private static CallSite> CreateConvertToRenderableCallSite() { - // Dynamic conversion is only used at runtime for cross-ALC Spectre values. -#pragma warning disable IL2026 - // Call-site generation requires dynamic code in JIT scenarios. -#pragma warning disable IL3050 return CallSite>.Create( Microsoft.CSharp.RuntimeBinder.Binder.Convert( CSharpBinderFlags.ConvertExplicit, @@ -166,8 +161,6 @@ private static CallSite> CreateConvertToRend typeof(SpectreRenderBridge) ) ); -#pragma warning restore IL3050 -#pragma warning restore IL2026 } private static bool TryCreateForeignRenderableAdapter( @@ -184,7 +177,7 @@ private static bool TryCreateForeignRenderableAdapter( Type? foreignRenderableType = valueType.Assembly.GetType("Spectre.Console.Rendering.IRenderable") ?? valueType.Assembly.GetType("Spectre.Console.IRenderable"); - if (foreignRenderableType is null || !foreignRenderableType.IsInstanceOfType(value)) { + if (foreignRenderableType?.IsInstanceOfType(value) != true) { renderable = null; return false; } diff --git a/src/PSTextMate/Utilities/VTHelpers.cs b/src/PSTextMate/Utilities/VTHelpers.cs index 564e829..01556df 100644 --- a/src/PSTextMate/Utilities/VTHelpers.cs +++ b/src/PSTextMate/Utilities/VTHelpers.cs @@ -1,6 +1,6 @@ namespace PSTextMate.Utilities; -public static class VTHelpers { +public static partial class VTHelpers { private static bool? _supportsAlternateBuffer; private static bool? _supportsSynchronizedOutput; private const string AlternateBufferModeQuery = "[?1049$p"; @@ -11,6 +11,8 @@ public static class VTHelpers { private const string SynchronizedOutputInactiveReply = "[?2026;2$y"; private const string BeginSynchronizedOutputSequence = "\x1b[?2026h"; private const string EndSynchronizedOutputSequence = "\x1b[?2026l"; + private const string EnableAlternateScrollSequence = "\x1b[?1007h"; + private const string DisableAlternateScrollSequence = "\x1b[?1007l"; public static void HideCursor() => Console.Write("\x1b[?25l"); public static void ShowCursor() => Console.Write("\x1b[?25h"); public static void ClearScreen() => Console.Write("\x1b[2J\x1b[H"); @@ -23,6 +25,32 @@ public static class VTHelpers { // Reset scroll region to full height (CSI r) public static void ResetScrollRegion() => Console.Write("\x1b[r"); + /// + /// Enables xterm alternate-scroll mode (1007) so mouse wheel can map to + /// scroll/navigation in alternate screen based pager sessions. + /// Unsupported terminals ignore this sequence. + /// + public static void EnableAlternateScroll() { + if (Console.IsOutputRedirected) { + return; + } + + Console.Write(EnableAlternateScrollSequence); + Console.Out.Flush(); + } + + /// + /// Disables xterm alternate-scroll mode (1007). + /// + public static void DisableAlternateScroll() { + if (Console.IsOutputRedirected) { + return; + } + + Console.Write(DisableAlternateScrollSequence); + Console.Out.Flush(); + } + /// /// Begins synchronized output mode (DEC private mode 2026). /// Unsupported terminals ignore this sequence. @@ -100,4 +128,13 @@ public static bool SupportsAlternateBuffer() { } } + + [GeneratedRegex(@"(\x1b\[\d*(;\d+)*m)|(\x1b\[\?\d+[hl])|(\x1b\]8;;.*?\x1b\\)", RegexOptions.Compiled, 1000)] + private static partial Regex AnsiRegex(); + + public static string StripAnsi(string? value) { + return string.IsNullOrEmpty(value) + ? string.Empty + : AnsiRegex().Replace(value, string.Empty); + } } diff --git a/src/PSTextMate/Utilities/Writer.cs b/src/PSTextMate/Utilities/Writer.cs index d45d7c9..d9f0bb7 100644 --- a/src/PSTextMate/Utilities/Writer.cs +++ b/src/PSTextMate/Utilities/Writer.cs @@ -36,7 +36,7 @@ public static string Write(HighlightedText highlightedText, bool autoPage = fals ArgumentNullException.ThrowIfNull(highlightedText); if (highlightedText.Page || (autoPage && ShouldPage(highlightedText))) { - using var pager = new Pager(highlightedText); + var pager = new Pager(highlightedText); pager.Show(); return string.Empty; } @@ -69,7 +69,7 @@ public static string Write(IEnumerable renderables) { /// Uses a stable in-memory rendering path so the output can be streamed /// as plain text, redirected, or post-processed by custom formatters. /// - public static string WriteToString(IRenderable renderable, int? width = null) { + internal static string WriteToString(IRenderable renderable, int? width = null) { ArgumentNullException.ThrowIfNull(renderable); RenderContext context = _threadContext ??= new RenderContext(); @@ -83,7 +83,7 @@ public static string WriteToString(IRenderable renderable, int? width = null) { /// Compatibility wrapper for previous API shape. /// No host-direct output is performed; this returns the rendered string only. /// - public static string WriteToStringWithHostFallback(IRenderable renderable, int? width = null) + internal static string WriteToStringWithHostFallback(IRenderable renderable, int? width = null) => WriteToString(renderable, width); private static string GetTrimmedOutputAndReset(StringBuilder buffer) { diff --git a/src/PSTextMate/Utilities/using.cs b/src/PSTextMate/Utilities/using.cs index 2f7dcd5..3e984bf 100644 --- a/src/PSTextMate/Utilities/using.cs +++ b/src/PSTextMate/Utilities/using.cs @@ -30,6 +30,7 @@ global using PSTextMate; global using PSTextMate.Core; global using PSTextMate.Sixel; +global using PSTextMate.Terminal; global using PSTextMate.Utilities; global using SixLabors.ImageSharp.PixelFormats; global using SixLabors.ImageSharp.Processing; diff --git a/tests/Out-Page.tests.ps1 b/tests/Out-Page.tests.ps1 new file mode 100644 index 0000000..6a1006a --- /dev/null +++ b/tests/Out-Page.tests.ps1 @@ -0,0 +1,18 @@ +BeforeAll { + if (-not (Get-Module 'TextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'TextMate.psd1') -ErrorAction Stop + } + + Import-Module (Join-Path $PSScriptRoot 'testhelper.psm1') -Force + +} + +Describe 'Out-Page' { + It 'Has command metadata and help' { + $cmd = Get-Command Out-Page -ErrorAction Stop + $cmd | Should -Not -BeNullOrEmpty + + $help = Get-Help Out-Page -Full + $help.Synopsis | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/PSTextMate.InteractiveTests/PSTextMate.InteractiveTests.csproj b/tests/PSTextMate.InteractiveTests/PSTextMate.InteractiveTests.csproj new file mode 100644 index 0000000..a0f2f4e --- /dev/null +++ b/tests/PSTextMate.InteractiveTests/PSTextMate.InteractiveTests.csproj @@ -0,0 +1,25 @@ + + + net8.0 + enable + enable + true + 13.0 + $(NoWarn);SYSLIB1054 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs new file mode 100644 index 0000000..ff0046d --- /dev/null +++ b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs @@ -0,0 +1,47 @@ +using PSTextMate.Terminal; +using PSTextMate.Utilities; +using Spectre.Console; +using Xunit; + +namespace PSTextMate.InteractiveTests; + +public sealed class PagerCoreTests { + [Fact] + public void SetQuery_WithMultipleMatches_BuildsHitIndex() { + PagerDocument document = new([ + new Markup("alpha beta"), + new Markup("beta gamma") + ]); + + PagerSearchSession session = new(document); + session.SetQuery("beta"); + + Assert.Equal(2, session.HitCount); + + PagerSearchHit? first = session.MoveNext(topIndex: 0); + Assert.NotNull(first); + Assert.Equal(0, first.RenderableIndex); + + PagerSearchHit? second = session.MoveNext(topIndex: 0); + Assert.NotNull(second); + Assert.Equal(1, second.RenderableIndex); + } + + [Fact] + public void SetQuery_EmptyValue_ClearsHitsAndCurrentSelection() { + PagerDocument document = new([ + new Markup("alpha beta"), + new Markup("beta gamma") + ]); + + PagerSearchSession session = new(document); + session.SetQuery("beta"); + Assert.Equal(2, session.HitCount); + + session.SetQuery(string.Empty); + + Assert.False(session.HasQuery); + Assert.Equal(0, session.HitCount); + Assert.Null(session.CurrentHit); + } +} diff --git a/tests/PSTextMate.InteractiveTests/SpectreConsoleTestingTests.cs b/tests/PSTextMate.InteractiveTests/SpectreConsoleTestingTests.cs new file mode 100644 index 0000000..5483f61 --- /dev/null +++ b/tests/PSTextMate.InteractiveTests/SpectreConsoleTestingTests.cs @@ -0,0 +1,39 @@ +using PSTextMate.Terminal; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Testing; +using Xunit; + +namespace PSTextMate.InteractiveTests; + +public sealed class SpectreConsoleTestingTests { + [Fact] + public void WriterWriteToString_MatchesTestConsole_ForSimpleRenderable() { + const int width = 48; + var renderable = new Markup("[green]Hello[/] [bold]Pager[/]"); + + var console = new TestConsole(); + console.Profile.Width = width; + console.Write(renderable); + + string expected = console.Output.TrimEnd(); + string actual = Writer.WriteToString(renderable, width); + + Assert.Equal(expected, actual); + } + + [Fact] + public void SpectreRenderBridge_RenderToString_StripsAnsiWhenRequested() { + const int width = 48; + var renderable = new Markup("[red]Error[/] [yellow]Warning[/]"); + + string rendered = SpectreRenderBridge.RenderToString(renderable, escapeAnsi: false, width: width); + string escaped = SpectreRenderBridge.RenderToString(renderable, escapeAnsi: true, width: width); + + Assert.Contains("Error", rendered); + Assert.Contains("Warning", rendered); + Assert.DoesNotContain(escaped, static c => c == '\u001b'); + Assert.Contains("Error", escaped); + Assert.Contains("Warning", escaped); + } +} diff --git a/tests/PSTextMate.InteractiveTests/SpectreLiveTestingTests.cs b/tests/PSTextMate.InteractiveTests/SpectreLiveTestingTests.cs new file mode 100644 index 0000000..2510b42 --- /dev/null +++ b/tests/PSTextMate.InteractiveTests/SpectreLiveTestingTests.cs @@ -0,0 +1,63 @@ +using PSTextMate.Terminal; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Testing; +using Xunit; + +namespace PSTextMate.InteractiveTests; + +public sealed class SpectreLiveTestingTests { + [Fact] + public void LiveDisplay_Start_ReturnsScriptBlockResult() { + var console = new TestConsole(); + + var table = new Table(); + table.AddColumn("Name"); + table.AddColumn("Value"); + table.AddRow("Test", "Value"); + + int result = console.Live(table) + .AutoClear(true) + .Start(_ => 1); + + Assert.Equal(1, result); + } + + [Fact] + public void LiveDisplay_CanUpdateTargetDuringExecution() { + var console = new TestConsole(); + + _ = console.Live(new Markup("start")) + .AutoClear(true) + .Start(ctx => { + ctx.UpdateTarget(new Markup("end")); + ctx.Refresh(); + return 0; + }); + + Assert.Contains("end", console.Output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Pager_Show_WithTestConsoleAndQuitKey_ExitsAndRendersContent() { + var console = new TestConsole(); + var keys = new Queue([ + new ConsoleKeyInfo('q', ConsoleKey.Q, false, false, false) + ]); + + Markup[] renderables = [ + new Markup("alpha"), + new Markup("beta") + ]; + + var pager = new Pager( + renderables, + console, + () => keys.Count > 0 ? keys.Dequeue() : null, + suppressTerminalControlSequences: true + ); + pager.Show(); + + Assert.Contains("alpha", console.Output, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/testhelper.psm1 b/tests/testhelper.psm1 index 03162f6..d6e5372 100644 --- a/tests/testhelper.psm1 +++ b/tests/testhelper.psm1 @@ -11,8 +11,33 @@ function _GetSpectreRenderable { $EscapeAnsi.IsPresent ) } -filter _EscapeAnsi { - [Host.PSHostUserInterface]::GetOutputString($_, $false) + + + +function Get-HostBuffer { + <# + Applications that use the GetConsoleScreenBufferInfo family of APIs to retrieve the active console colors in Win32 format and then attempt to transform them into cross-platform VT sequences (for example, by transforming BACKGROUND_RED to \x1b[41m) may interfere with Terminal's ability to detect what background color the application is attempting to use. + + Application developers are encouraged to choose either Windows API functions or VT sequences for adjusting colors and not attempt to mix them. + https://learn.microsoft.com/en-us/windows/terminal/troubleshooting#technical-notes + https://learn.microsoft.com/en-us/windows/console/getconsolescreenbufferinfoex + #> + $windowSize = $host.UI.RawUI.WindowSize + $windowPosition = $host.UI.RawUI.WindowPosition + $windowWidth = $windowSize.Width + $windowHeight = $windowSize.Height + $windowRect = [System.Management.Automation.Host.Rectangle]::new( + $windowPosition.X, + $windowPosition.Y, + ($windowPosition.X + $windowWidth), + ($windowPosition.Y + $windowHeight)) + $windowBuffer = $host.UI.RawUI.GetBufferContents($windowRect) + foreach ($x in 0..($windowHeight - 1)) { + $row = foreach ($y in 0..($windowWidth - 1)) { + $windowBuffer[$x, $y].Character + } + -join $row + } } -Export-ModuleMember -Function _GetSpectreRenderable, _EscapeAnsi +Export-ModuleMember -Function _GetSpectreRenderable, Get-HostBuffer From 99e873a7f6f840f934125aea5b6bdc3c925bda15 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:28:58 +0100 Subject: [PATCH 09/17] =?UTF-8?q?feat(pager):=20=E2=9C=A8=20Enhance=20sear?= =?UTF-8?q?ch=20highlighting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improved search highlighting behavior across various renderable types. * Added `Streams` utility to facilitate output forwarding to PSCmdlet streams. * Introduced tests for custom renderables and their interaction with the pager. * Created a showcase document for validating search highlight behavior. * Refactored highlighting logic to ensure scoped background colors for matches. --- src/PSTextMate/Cmdlets/OutPage.cs | 25 +- src/PSTextMate/Pager/Pager.cs | 86 ++- src/PSTextMate/Pager/PagerDocument.cs | 7 +- src/PSTextMate/Pager/PagerHighlighting.cs | 591 +++++++++++++++--- src/PSTextMate/Utilities/Streams.cs | 77 +++ .../PagerCoreTests.cs | 87 +++ tests/pager-highlight-showcase.md | 56 ++ 7 files changed, 799 insertions(+), 130 deletions(-) create mode 100644 src/PSTextMate/Utilities/Streams.cs create mode 100644 tests/pager-highlight-showcase.md diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index 8af41d8..ba6822e 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -23,18 +23,18 @@ protected override void ProcessRecord() { } object value = InputObject.BaseObject; - // WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}' BaseType: '{value.GetType().BaseType}'."); + WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}' baseType '{value.GetType().BaseType}'."); if (value is HighlightedText highlightedText) { if (_singleHighlightedText is null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { _singleHighlightedText = highlightedText; - // WriteVerbose("ProcessRecord: took HighlightedText fast path and deferred to highlighted pager."); + WriteVerbose($"ProcessRecord: HighlightedText fast path renderables={highlightedText.Renderables.Length} lineCount={highlightedText.LineCount}."); return; } _sawNonHighlightedInput = true; _renderables.AddRange(highlightedText.Renderables); - // WriteVerbose("ProcessRecord: merged HighlightedText renderables into the main renderable list."); + WriteVerbose($"ProcessRecord: merged HighlightedText renderables count={highlightedText.Renderables.Length} totalRenderables={_renderables.Count}."); return; } @@ -42,7 +42,7 @@ protected override void ProcessRecord() { if (value is IRenderable renderable) { _renderables.Add(renderable); - // WriteVerbose("ProcessRecord: input matched IRenderable and was added directly."); + WriteVerbose($"ProcessRecord: input matched IRenderable type='{renderable.GetType().FullName}' totalRenderables={_renderables.Count}."); return; } @@ -53,7 +53,7 @@ protected override void ProcessRecord() { if (TryConvertForeignSpectreRenderable(value, out IRenderable? convertedRenderable)) { _renderables.Add(convertedRenderable); - // WriteVerbose("ProcessRecord: converted foreign Spectre renderable to local IRenderable."); + WriteVerbose($"ProcessRecord: converted foreign Spectre renderable to local type='{convertedRenderable.GetType().FullName}' totalRenderables={_renderables.Count}."); return; } @@ -62,14 +62,14 @@ protected override void ProcessRecord() { protected override void EndProcessing() { if (_singleHighlightedText is not null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { - // WriteVerbose("EndProcessing: using single HighlightedText pager path."); + WriteVerbose("EndProcessing: using single HighlightedText pager path."); var highlightedPager = new Pager(_singleHighlightedText); highlightedPager.Show(); return; } if (_outStringInputs.Count > 0) { - // WriteVerbose($"EndProcessing: converting {_outStringInputs.Count} queued value(s) through Out-String."); + WriteVerbose($"EndProcessing: converting {_outStringInputs.Count} queued value(s) through Out-String."); List formattedLines = ConvertWithOutStringLines(_outStringInputs); if (formattedLines.Count > 0) { foreach (string line in formattedLines) { @@ -78,14 +78,14 @@ protected override void EndProcessing() { : Helpers.VTConversion.ToParagraph(line)); } - // WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); + WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); } else { foreach (object value in _outStringInputs) { _renderables.Add(new Text(LanguagePrimitives.ConvertTo(value))); } - // WriteVerbose("EndProcessing: Out-String returned no lines; used string conversion fallback."); + WriteVerbose("EndProcessing: Out-String returned no lines; used string conversion fallback."); } } @@ -95,7 +95,7 @@ protected override void EndProcessing() { } WriteVerbose($"EndProcessing: launching pager with {_renderables.Count} renderable(s)."); - var pager = new Pager(_renderables); + var pager = new Pager(_renderables, AnsiConsole.Console, null, suppressTerminalControlSequences: false); pager.Show(); } @@ -182,11 +182,6 @@ private static bool TryConvertForeignSpectreRenderable( && value is not IRenderable && SpectreRenderBridge.TryConvertToLocalRenderable(value, out renderable); } - - private static bool IsSpectreObject(object value) { - string? fullName = value.GetType().FullName; - return IsSpectreObject(fullName); - } private static bool IsSpectreObject(string? str) { return !string.IsNullOrWhiteSpace(str) && (str.StartsWith("Spectre.Console.", StringComparison.Ordinal) || diff --git a/src/PSTextMate/Pager/Pager.cs b/src/PSTextMate/Pager/Pager.cs index a53bc13..05aa6ad 100644 --- a/src/PSTextMate/Pager/Pager.cs +++ b/src/PSTextMate/Pager/Pager.cs @@ -34,6 +34,8 @@ public sealed class Pager { private string _searchStatusText = string.Empty; private bool _isSearchInputActive; private readonly StringBuilder _searchInputBuffer = new(64); + private static readonly Style SearchRowTextStyle = new(Color.White, Color.Grey); + private static readonly Style SearchMatchTextStyle = new(Color.Black, Color.Orange1); private const string SearchRowStyle = "white on grey"; private const string SearchMatchStyle = "black on orange1"; @@ -147,7 +149,7 @@ public Pager(IEnumerable renderables) internal Pager( IEnumerable renderables, IAnsiConsole console, - Func? tryReadKeyOverride, + Func? tryReadKeyOverride = null, bool suppressTerminalControlSequences = false ) { _console = console ?? throw new ArgumentNullException(nameof(console)); @@ -461,7 +463,8 @@ private Rows BuildSearchAwareContent(PagerViewportWindow viewport) { var items = new List(viewport.Count); for (int i = 0; i < viewport.Count; i++) { int renderableIndex = viewport.Top + i; - items.Add(ApplySearchHighlight(renderableIndex, _renderables[renderableIndex])); + IRenderable highlighted = ApplySearchHighlight(renderableIndex, _renderables[renderableIndex]); + items.Add(highlighted); } return new Rows(items); @@ -469,21 +472,61 @@ private Rows BuildSearchAwareContent(PagerViewportWindow viewport) { private IRenderable ApplySearchHighlight(int renderableIndex, IRenderable renderable) { IReadOnlyList hits = _search.GetHitsForRenderable(renderableIndex); - if (hits.Count == 0) { - return renderable; - } - if (PagerHighlighting.TryBuildStructuredHighlightRenderable(renderable, _search.Query, SearchRowStyle, SearchMatchStyle, out IRenderable structuredHighlight)) { + bool isStructuredRenderable = PagerHighlighting.IsStructuredRowHighlightCandidate(renderable); + + // Keep table-specific highlighting in the structured path so row styling + // is scoped to matching rows rather than the whole rendered table block. + if ((isStructuredRenderable || hits.Count == 0) + && PagerHighlighting.TryBuildStructuredHighlightRenderable( + renderable, + _search.Query, + SearchRowStyle, + SearchMatchStyle, + hits, + out IRenderable structuredHighlight + )) { return structuredHighlight; } string plainText = GetSearchTextForHighlight(renderableIndex, renderable); - if (plainText.Length == 0) { + if (plainText.Length == 0 || !_search.HasQuery) { return renderable; } - string highlighted = PagerHighlighting.BuildHighlightedMarkup(plainText, hits, SearchMatchStyle); - return new Markup($"[{SearchRowStyle}]{highlighted}[/]"); + if (hits.Count == 0) { + hits = BuildQueryHits(plainText, _search.Query, renderableIndex); + if (hits.Count == 0) { + return renderable; + } + } + + return PagerHighlighting.BuildSegmentHighlightRenderable(renderable, _search.Query, SearchRowTextStyle, SearchMatchTextStyle); + } + + private static List BuildQueryHits(string plainText, string query, int renderableIndex) { + if (string.IsNullOrEmpty(plainText) || string.IsNullOrWhiteSpace(query)) { + return []; + } + + string normalizedQuery = query.Trim(); + if (normalizedQuery.Length == 0) { + return []; + } + + var hits = new List(); + int searchStart = 0; + while (searchStart <= plainText.Length - normalizedQuery.Length) { + int hitOffset = plainText.IndexOf(normalizedQuery, searchStart, StringComparison.OrdinalIgnoreCase); + if (hitOffset < 0) { + break; + } + + hits.Add(new PagerSearchHit(renderableIndex, hitOffset, normalizedQuery.Length, 0, hitOffset)); + searchStart = hitOffset + Math.Max(1, normalizedQuery.Length); + } + + return hits; } private string GetSearchTextForHighlight(int renderableIndex, IRenderable renderable) { @@ -501,10 +544,13 @@ private string ExtractPlainTextForSearchHighlight(IRenderable renderable) { try { int width = Math.Max(20, WindowWidth - 2); string rendered = Writer.WriteToString(renderable, width); - return PagerHighlighting.NormalizeText(VTHelpers.StripAnsi(rendered)); + string normalized = PagerHighlighting.NormalizeText(VTHelpers.StripAnsi(rendered)); + return normalized.Length > 0 + ? normalized + : PagerHighlighting.NormalizeText(renderable.ToString()); } catch { - return string.Empty; + return PagerHighlighting.NormalizeText(renderable.ToString()); } } @@ -541,7 +587,7 @@ private Panel BuildRichFooter(int width, PagerViewportWindow viewport) { keyText = $"{keyText} {_searchStatusText}"; } - int chartWidth = Math.Clamp(width / 5, 12, 28); + int chartWidth = Math.Clamp(width / 4, 14, 40); double progressUnits = total == 0 ? 0d : (double)end / safeTotal * chartWidth; double chartValue = end <= 0 ? 0d : Math.Clamp(Math.Ceiling(progressUnits), Math.Min(4d, chartWidth), chartWidth); BarChart chart = new BarChart() @@ -550,16 +596,14 @@ private Panel BuildRichFooter(int width, PagerViewportWindow viewport) { .HideValues() .AddItem(" ", chartValue, Color.Lime); - Columns columns = new([ - new Text(keyText, new Style(Color.Grey)), - new Markup($"[bold]{statusText}[/]"), - chart - ]) { - Expand = true, - Padding = new Padding(0, 0, 0, 0) - }; + var footerBody = new Layout("footer-body"); + footerBody.SplitColumns( + new Layout("keys").Ratio(1).Update(new Text(keyText, new Style(Color.Grey))), + new Layout("status").Size(_statusColumnWidth).Update(new Align(new Markup($"[bold]{statusText}[/]"), HorizontalAlignment.Right)), + new Layout("chart").Size(chartWidth).Update(chart) + ); - return new Panel(columns) { + return new Panel(footerBody) { Border = BoxBorder.Rounded, Padding = new Padding(0, 0, 0, 0), Expand = true diff --git a/src/PSTextMate/Pager/PagerDocument.cs b/src/PSTextMate/Pager/PagerDocument.cs index 9dcaf42..dfeb376 100644 --- a/src/PSTextMate/Pager/PagerDocument.cs +++ b/src/PSTextMate/Pager/PagerDocument.cs @@ -50,10 +50,13 @@ private static string ExtractSearchText(IRenderable renderable) { try { string rendered = Writer.WriteToString(renderable, width: 200); - return Normalize(VTHelpers.StripAnsi(rendered)); + string normalized = Normalize(VTHelpers.StripAnsi(rendered)); + return !string.IsNullOrEmpty(normalized) + ? normalized + : Normalize(renderable.ToString()); } catch { - return string.Empty; + return Normalize(renderable.ToString()); } } diff --git a/src/PSTextMate/Pager/PagerHighlighting.cs b/src/PSTextMate/Pager/PagerHighlighting.cs index 4330340..d4b0cd2 100644 --- a/src/PSTextMate/Pager/PagerHighlighting.cs +++ b/src/PSTextMate/Pager/PagerHighlighting.cs @@ -1,29 +1,25 @@ namespace PSTextMate.Terminal; internal static class PagerHighlighting { - internal static bool TryBuildStructuredHighlightRenderable( - IRenderable renderable, - string query, - string rowStyle, - string matchStyle, - out IRenderable highlighted + private static readonly FieldInfo? s_panelChildField = typeof(Panel).GetField("_child", BindingFlags.Instance | BindingFlags.NonPublic); + + internal static Paragraph BuildHighlightedTextRenderable( + string plainText, + IReadOnlyList hits, + Style rowStyle, + Style matchStyle ) { - highlighted = renderable; - if (string.IsNullOrEmpty(query) || renderable is not Table table) { - return false; + var result = new Paragraph(); + if (plainText.Length == 0) { + return result; } - highlighted = CloneTableWithHighlight(table, query, rowStyle, matchStyle); - return true; - } - - internal static string BuildHighlightedMarkup(string plainText, IReadOnlyList hits, string matchStyle) { - if (plainText.Length == 0) { - return string.Empty; + if (hits.Count == 0) { + result.Append(plainText, rowStyle); + return result; } int position = 0; - var builder = new StringBuilder(plainText.Length + Math.Max(32, hits.Count * 24)); foreach (PagerSearchHit hit in hits.OrderBy(static h => h.Offset)) { int start = Math.Clamp(hit.Offset, 0, plainText.Length); int length = Math.Clamp(hit.Length, 0, plainText.Length - start); @@ -32,26 +28,62 @@ internal static string BuildHighlightedMarkup(string plainText, IReadOnlyList position) { - builder.Append(Markup.Escape(plainText[position..start])); + result.Append(plainText[position..start], rowStyle); } - string matchPart = plainText.Substring(start, length); - builder.Append('[') - .Append(matchStyle) - .Append(']') - .Append(Markup.Escape(matchPart)) - .Append("[/]"); - + result.Append(plainText.Substring(start, length), matchStyle); position = start + length; } if (position < plainText.Length) { - builder.Append(Markup.Escape(plainText[position..])); + result.Append(plainText[position..], rowStyle); + } + + return result; + } + + internal static bool TryBuildStructuredHighlightRenderable( + IRenderable renderable, + string query, + string rowStyle, + string matchStyle, + IReadOnlyList indexedHits, + out IRenderable highlighted + ) { + highlighted = renderable; + if (string.IsNullOrEmpty(query)) { + return false; + } + + if (renderable is Table table) { + highlighted = CloneTableWithHighlight(table, query, rowStyle, matchStyle, indexedHits); + return true; + } + + if (renderable is Grid grid) { + highlighted = CloneGridWithHighlight(grid, query, rowStyle, matchStyle, indexedHits); + return true; + } + + if (renderable is Panel panel) { + highlighted = ClonePanelWithHighlight(panel, query, rowStyle, matchStyle); + return true; } - return builder.ToString(); + return false; } + internal static bool IsStructuredRowHighlightCandidate(IRenderable renderable) + => renderable is Table or Grid or Panel; + + internal static IRenderable BuildSegmentHighlightRenderable( + IRenderable renderable, + string query, + Style rowStyle, + Style matchStyle + ) => new SegmentHighlightRenderable(renderable, query, rowStyle, matchStyle); + + internal static string NormalizeText(string? text) { return string.IsNullOrEmpty(text) ? string.Empty @@ -60,7 +92,17 @@ internal static string NormalizeText(string? text) { .TrimEnd('\n'); } - private static Table CloneTableWithHighlight(Table source, string query, string rowStyle, string matchStyle) { + private static Table CloneTableWithHighlight( + Table source, + string query, + string rowStyle, + string matchStyle, + IReadOnlyList indexedHits + ) { + HashSet matchedRowsFromHits = ResolveTableRowsFromHitLines(source, indexedHits); + bool[] rowCellMatches = [.. source.Rows.Select(row => row.Any(cell => RenderableContainsQuery(cell, query)))]; + bool hasAnyRowCellMatch = rowCellMatches.Any(static match => match); + var clone = new Table { Border = source.Border, BorderStyle = source.BorderStyle, @@ -90,14 +132,20 @@ private static Table CloneTableWithHighlight(Table source, string query, string clone.AddColumn(column); } + int rowIndex = 0; foreach (TableRow sourceRow in source.Rows) { - bool rowHasMatch = sourceRow.Any(cell => RenderableContainsQuery(cell, query)); + string[] cellTexts = [.. sourceRow.Select(ExtractRenderableText).Select(NormalizeText)]; + bool rowHasCellMatch = rowCellMatches[rowIndex]; + bool rowHasIndexedContextMatch = !hasAnyRowCellMatch && matchedRowsFromHits.Contains(rowIndex); + bool rowHasMatch = rowHasCellMatch || rowHasIndexedContextMatch; + var rowItems = new List(); foreach (IRenderable sourceCell in sourceRow) { rowItems.Add(HighlightRenderableNode(sourceCell, query, rowHasMatch, rowStyle, matchStyle)); } clone.AddRow(rowItems); + rowIndex++; } return clone; @@ -112,109 +160,468 @@ private static bool RenderableContainsQuery(IRenderable renderable, string query return plainText.Contains(query, StringComparison.OrdinalIgnoreCase); } - private static IRenderable HighlightRenderableNode( - IRenderable renderable, + private static Grid CloneGridWithHighlight( + Grid source, string query, - bool applyRowStyle, string rowStyle, - string matchStyle + string matchStyle, + IReadOnlyList indexedHits ) { - return renderable switch { - Text or Markup or Paragraph => HighlightLeafRenderable(renderable, query, applyRowStyle, rowStyle, matchStyle), - _ => renderable, + HashSet matchedRowsFromHits = ResolveGridRowsFromHitLines(source, indexedHits); + bool[] rowCellMatches = [.. source.Rows.Select(row => row.Any(cell => RenderableContainsQuery(cell, query)))]; + bool hasAnyRowCellMatch = rowCellMatches.Any(static match => match); + + var clone = new Grid { + Expand = source.Expand, + Width = source.Width }; + + foreach (GridColumn sourceColumn in source.Columns) { + clone.AddColumn(new GridColumn { + Width = sourceColumn.Width, + NoWrap = sourceColumn.NoWrap, + Padding = sourceColumn.Padding, + Alignment = sourceColumn.Alignment + }); + } + + int rowIndex = 0; + foreach (GridRow sourceRow in source.Rows) { + string[] cellTexts = [.. sourceRow.Select(ExtractRenderableText).Select(NormalizeText)]; + bool rowHasCellMatch = rowCellMatches[rowIndex]; + bool rowHasIndexedContextMatch = !hasAnyRowCellMatch && matchedRowsFromHits.Contains(rowIndex); + bool rowHasMatch = rowHasCellMatch || rowHasIndexedContextMatch; + + IRenderable[] rowItems = [.. sourceRow.Select(cell => HighlightRenderableNode(cell, query, rowHasMatch, rowStyle, matchStyle))]; + clone.AddRow(rowItems); + rowIndex++; + } + + return clone; } - private static IRenderable HighlightLeafRenderable( - IRenderable renderable, + private static HashSet ResolveTableRowsFromHitLines(Table source, IReadOnlyList indexedHits) { + var matchedRows = new HashSet(); + if (indexedHits.Count == 0 || source.Rows.Count == 0) { + return matchedRows; + } + + Table probe = BuildTableSkeleton(source); + int previousLineCount = CountRenderableLines(probe); + + int rowIndex = 0; + foreach (TableRow sourceRow in source.Rows) { + probe.AddRow([.. sourceRow]); + int currentLineCount = CountRenderableLines(probe); + int rowStartLine = previousLineCount; + int rowEndLine = Math.Max(previousLineCount, currentLineCount - 1); + + bool hitInRow = indexedHits.Any(hit => hit.Line >= rowStartLine && hit.Line <= rowEndLine); + if (hitInRow) { + matchedRows.Add(rowIndex); + } + + previousLineCount = currentLineCount; + rowIndex++; + } + + return matchedRows; + } + + private static HashSet ResolveGridRowsFromHitLines(Grid source, IReadOnlyList indexedHits) { + var matchedRows = new HashSet(); + if (indexedHits.Count == 0 || source.Rows.Count == 0) { + return matchedRows; + } + + var probe = new Grid { + Expand = source.Expand, + Width = source.Width + }; + + foreach (GridColumn sourceColumn in source.Columns) { + probe.AddColumn(new GridColumn { + Width = sourceColumn.Width, + NoWrap = sourceColumn.NoWrap, + Padding = sourceColumn.Padding, + Alignment = sourceColumn.Alignment + }); + } + + int previousLineCount = CountRenderableLines(probe); + int rowIndex = 0; + foreach (GridRow sourceRow in source.Rows) { + probe.AddRow([.. sourceRow]); + int currentLineCount = CountRenderableLines(probe); + int rowStartLine = previousLineCount; + int rowEndLine = Math.Max(previousLineCount, currentLineCount - 1); + + bool hitInRow = indexedHits.Any(hit => hit.Line >= rowStartLine && hit.Line <= rowEndLine); + if (hitInRow) { + matchedRows.Add(rowIndex); + } + + previousLineCount = currentLineCount; + rowIndex++; + } + + return matchedRows; + } + + private static Table BuildTableSkeleton(Table source) { + var probe = new Table { + Border = source.Border, + BorderStyle = source.BorderStyle, + UseSafeBorder = source.UseSafeBorder, + ShowHeaders = source.ShowHeaders, + ShowRowSeparators = source.ShowRowSeparators, + ShowFooters = source.ShowFooters, + Expand = source.Expand, + Width = source.Width, + Title = source.Title is null ? null : new TableTitle(source.Title.Text, source.Title.Style), + Caption = source.Caption is null ? null : new TableTitle(source.Caption.Text, source.Caption.Style) + }; + + foreach (TableColumn sourceColumn in source.Columns) { + probe.AddColumn(new TableColumn(sourceColumn.Header) { + Width = sourceColumn.Width, + Padding = sourceColumn.Padding, + NoWrap = sourceColumn.NoWrap, + Alignment = sourceColumn.Alignment, + Footer = sourceColumn.Footer + }); + } + + return probe; + } + + private static Panel ClonePanelWithHighlight( + Panel source, string query, - bool applyRowStyle, string rowStyle, string matchStyle ) { - string plainText = NormalizeText(ExtractRenderableText(renderable)); - if (plainText.Length == 0) { - return renderable; + if (!TryGetPanelChild(source, out IRenderable child)) { + return source; } - string highlighted = BuildHighlightedMarkupFromQuery(plainText, query, applyRowStyle, rowStyle, matchStyle); - string baseline = applyRowStyle - ? $"[{rowStyle}]{Markup.Escape(plainText)}[/]" - : Markup.Escape(plainText); + var parsedRowStyle = Style.Parse(rowStyle); + var parsedMatchStyle = Style.Parse(matchStyle); + IRenderable highlightedChild = IsStructuredRowHighlightCandidate(child) + && TryBuildStructuredHighlightRenderable(child, query, rowStyle, matchStyle, [], out IRenderable structuredChild) + ? structuredChild + : new SegmentHighlightRenderable(child, query, parsedRowStyle, parsedMatchStyle); - return string.Equals(highlighted, baseline, StringComparison.Ordinal) - ? renderable - : new Markup(highlighted); + // Preserve structured semantics for nested tables/grids inside panels. + + return new Panel(highlightedChild) { + Border = source.Border, + UseSafeBorder = source.UseSafeBorder, + BorderStyle = source.BorderStyle, + Expand = source.Expand, + Padding = source.Padding, + Header = source.Header, + Width = source.Width, + Height = source.Height + }; } - private static string ExtractRenderableText(IRenderable renderable) { - if (renderable is Text text) { - return text.ToString() ?? string.Empty; + private static bool TryGetPanelChild(Panel panel, out IRenderable child) { + if (s_panelChildField?.GetValue(panel) is IRenderable renderable) { + child = renderable; + return true; + } + + child = Text.Empty; + return false; + } + + private static List BuildQueryHits(string plainText, string query) { + if (string.IsNullOrEmpty(plainText) || string.IsNullOrEmpty(query)) { + return []; + } + + var hits = new List(); + int searchStart = 0; + while (searchStart <= plainText.Length - query.Length) { + int hitOffset = plainText.IndexOf(query, searchStart, StringComparison.OrdinalIgnoreCase); + if (hitOffset < 0) { + break; + } + + hits.Add(new PagerSearchHit(0, hitOffset, query.Length, 0, hitOffset)); + searchStart = hitOffset + Math.Max(1, query.Length); + } + + return hits; + } + + private sealed class SegmentHighlightRenderable : Renderable { + private readonly IRenderable _inner; + private readonly string _query; + private readonly Style _rowStyle; + private readonly Style _matchStyle; + + public SegmentHighlightRenderable(IRenderable inner, string query, Style rowStyle, Style matchStyle) { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _query = query ?? string.Empty; + _rowStyle = rowStyle; + _matchStyle = matchStyle; + } + + protected override Measurement Measure(RenderOptions options, int maxWidth) + => _inner.Measure(options, maxWidth); + + protected override IEnumerable Render(RenderOptions options, int maxWidth) { + List source = [.. _inner.Render(options, maxWidth)]; + if (source.Count == 0 || string.IsNullOrEmpty(_query)) { + return source; + } + + string plainText = BuildPlainText(source); + if (plainText.Length == 0) { + return source; + } + + List hits = BuildQueryHits(plainText, _query); + if (hits.Count == 0) { + return source; + } + + bool[] matchMask = new bool[plainText.Length]; + foreach (PagerSearchHit hit in hits) { + int start = Math.Clamp(hit.Offset, 0, plainText.Length); + int length = Math.Clamp(hit.Length, 0, plainText.Length - start); + for (int i = 0; i < length; i++) { + matchMask[start + i] = true; + } + } + + bool[] lineHasMatch = BuildLineMatchMask(plainText, hits); + return RebuildSegmentsWithHighlights(source, matchMask, lineHasMatch); + } + + private static string BuildPlainText(IEnumerable segments) { + var builder = new StringBuilder(); + foreach (Segment segment in segments) { + if (segment.IsControlCode) { + continue; + } + + if (segment.IsLineBreak) { + builder.Append('\n'); + continue; + } + + builder.Append(segment.Text); + } + + return builder.ToString(); + } + + private static bool[] BuildLineMatchMask(string plainText, IReadOnlyList hits) { + var lineStarts = new List { 0 }; + for (int i = 0; i < plainText.Length; i++) { + if (plainText[i] == '\n' && i + 1 < plainText.Length) { + lineStarts.Add(i + 1); + } + } + + bool[] lineMatches = new bool[lineStarts.Count == 0 ? 1 : lineStarts.Count]; + foreach (PagerSearchHit hit in hits) { + int line = ResolveLine(lineStarts, hit.Offset); + lineMatches[line] = true; + } + + return lineMatches; + } + + private static int ResolveLine(List lineStarts, int offset) { + if (lineStarts.Count == 0) { + return 0; + } + + int line = 0; + for (int i = 1; i < lineStarts.Count; i++) { + if (lineStarts[i] > offset) { + break; + } + + line = i; + } + + return line; } + private List RebuildSegmentsWithHighlights(List source, bool[] matchMask, bool[] lineHasMatch) { + var output = new List(source.Count * 2); + int absolute = 0; + int line = 0; + + foreach (Segment segment in source) { + if (segment.IsControlCode) { + output.Add(segment); + continue; + } + + if (segment.IsLineBreak) { + output.Add(segment); + if (absolute < matchMask.Length) { + absolute++; + } + + line = Math.Min(line + 1, lineHasMatch.Length - 1); + continue; + } + + if (segment.Text.Length == 0) { + continue; + } + + var chunk = new StringBuilder(); + Style? chunkStyle = null; + + foreach (char ch in segment.Text) { + if (ch == '\n') { + FlushChunk(output, chunk, chunkStyle); + output.Add(Segment.LineBreak); + if (absolute < matchMask.Length) { + absolute++; + } + + line = Math.Min(line + 1, lineHasMatch.Length - 1); + continue; + } + + bool inMatch = absolute >= 0 && absolute < matchMask.Length && matchMask[absolute]; + bool inMatchedLine = line >= 0 && line < lineHasMatch.Length && lineHasMatch[line]; + Style style = inMatch ? _matchStyle : inMatchedLine ? _rowStyle : segment.Style; + + if (chunkStyle is null || !chunkStyle.Equals(style)) { + FlushChunk(output, chunk, chunkStyle); + chunkStyle = style; + } + + chunk.Append(ch); + absolute++; + } + + FlushChunk(output, chunk, chunkStyle); + } + + return output; + } + + private static void FlushChunk(List output, StringBuilder chunk, Style? style) { + if (chunk.Length == 0 || style is null) { + return; + } + + output.Add(new Segment(chunk.ToString(), style)); + chunk.Clear(); + } + } + + private static int CountRenderableLines(IRenderable renderable) { try { string rendered = Writer.WriteToString(renderable, width: 200); - return VTHelpers.StripAnsi(rendered); + if (string.IsNullOrEmpty(rendered)) { + return 0; + } + + int lines = 1; + foreach (char ch in rendered) { + if (ch == '\n') { + lines++; + } + } + + return lines; } catch { - return string.Empty; + return 0; } } - private static string BuildHighlightedMarkupFromQuery( - string plainText, + private static IRenderable HighlightRenderableNode( + IRenderable renderable, + string query, + bool applyRowStyle, + string rowStyle, + string matchStyle + ) => HighlightLeafRenderable(renderable, query, applyRowStyle, rowStyle, matchStyle); + + private static IRenderable HighlightLeafRenderable( + IRenderable renderable, string query, bool applyRowStyle, string rowStyle, string matchStyle ) { - if (string.IsNullOrEmpty(plainText) || string.IsNullOrEmpty(query)) { - string escaped = Markup.Escape(plainText); - return applyRowStyle ? $"[{rowStyle}]{escaped}[/]" : escaped; + string plainText = NormalizeText(ExtractRenderableText(renderable)); + if (plainText.Length == 0) { + return renderable; } - static void AppendNormalText(StringBuilder builder, string text, bool applyRowStyle, string rowStyle) { - if (text.Length == 0) { - return; - } - - string escaped = Markup.Escape(text); - if (applyRowStyle) { - builder.Append('[') - .Append(rowStyle) - .Append(']') - .Append(escaped) - .Append("[/]"); - } - else { - builder.Append(escaped); - } + if (string.IsNullOrEmpty(query)) { + return applyRowStyle + ? BuildHighlightedTextRenderable(plainText, [], Style.Parse(rowStyle), Style.Parse(matchStyle)) + : renderable; } - int position = 0; - int queryLength = query.Length; - var builder = new StringBuilder(plainText.Length + 32); - while (position < plainText.Length) { - int hitOffset = plainText.IndexOf(query, position, StringComparison.OrdinalIgnoreCase); + var hits = new List(); + int searchStart = 0; + while (searchStart <= plainText.Length - query.Length) { + int hitOffset = plainText.IndexOf(query, searchStart, StringComparison.OrdinalIgnoreCase); if (hitOffset < 0) { - AppendNormalText(builder, plainText[position..], applyRowStyle, rowStyle); break; } - if (hitOffset > position) { - AppendNormalText(builder, plainText[position..hitOffset], applyRowStyle, rowStyle); - } + hits.Add(new PagerSearchHit(0, hitOffset, query.Length, 0, hitOffset)); + searchStart = hitOffset + Math.Max(1, query.Length); + } + + return hits.Count == 0 && !applyRowStyle + ? renderable + : BuildHighlightedTextRenderable(plainText, hits, Style.Parse(rowStyle), Style.Parse(matchStyle)); + } + + private static string ExtractRenderableText(IRenderable renderable) { + try { + var options = RenderOptions.Create(AnsiConsole.Console); + IEnumerable segments = renderable.Render(options, maxWidth: 200); + var builder = new StringBuilder(); + + foreach (Segment segment in segments) { + if (segment.IsControlCode) { + continue; + } - int hitLength = Math.Min(queryLength, plainText.Length - hitOffset); - builder.Append('[') - .Append(matchStyle) - .Append(']') - .Append(Markup.Escape(plainText.Substring(hitOffset, hitLength))) - .Append("[/]"); + if (segment.IsLineBreak) { + builder.Append('\n'); + continue; + } + + builder.Append(segment.Text); + } - position = hitOffset + Math.Max(1, hitLength); + string extracted = builder.ToString(); + if (!string.IsNullOrEmpty(extracted)) { + return extracted; + } + } + catch { } - return builder.ToString(); + try { + string rendered = Writer.WriteToString(renderable, width: 200); + return string.IsNullOrEmpty(rendered) + ? string.Empty + : rendered; + } + catch { + return string.Empty; + } } + } diff --git a/src/PSTextMate/Utilities/Streams.cs b/src/PSTextMate/Utilities/Streams.cs new file mode 100644 index 0000000..63b0c21 --- /dev/null +++ b/src/PSTextMate/Utilities/Streams.cs @@ -0,0 +1,77 @@ +namespace PSTextMate.Utilities; + +/// +/// Forwards output to PSCmdlet streams. Pass this to internal classes +/// so they can write to verbose/debug/warning/error streams. +/// +/// +/// +/// // In cmdlet: +/// var streams = new Streams(this); +/// var processor = new DataProcessor(streams); +/// +/// // In internal class: +/// public class DataProcessor { +/// private readonly Streams _ps; +/// public DataProcessor(Streams ps) => _ps = ps; +/// public void Process() => _ps.WriteVerbose("Processing..."); +/// } +/// +/// +internal readonly struct Streams { + private readonly PSCmdlet? _cmdlet; + + /// + /// Creates a Streams wrapper for the given cmdlet. + /// + public Streams(PSCmdlet cmdlet) { + _cmdlet = cmdlet; + } + + /// + /// Creates a null/no-op streams instance (for testing or when no cmdlet available). + /// + public static Streams Null => default; + + /// + /// Returns true if this instance is connected to a cmdlet. + /// + public bool IsConnected => _cmdlet is not null; + + public void WriteVerbose(string message) => _cmdlet?.WriteVerbose(message); + public void WriteDebug(string message) => _cmdlet?.WriteDebug(message); + public void WriteWarning(string message) => _cmdlet?.WriteWarning(message); + + public void WriteError(ErrorRecord errorRecord) => _cmdlet?.WriteError(errorRecord); + + public void WriteError(Exception exception, string errorId, ErrorCategory errorCategory = ErrorCategory.NotSpecified, object? targetObject = null) => + _cmdlet?.WriteError(new ErrorRecord(exception, errorId, errorCategory, targetObject)); + + public void WriteError(string message, ErrorCategory errorCategory = ErrorCategory.NotSpecified, object? targetObject = null) => + _cmdlet?.WriteError(new ErrorRecord(new InvalidOperationException(message), "TextMateError", errorCategory, targetObject)); + + public void WriteInformation(string message, string[]? tags = null, string? source = null) => + _cmdlet?.WriteInformation(new InformationRecord(message, source), tags ?? []); + public void WriteInformation(object MessageData, string[]? tags = null, string? source = null) => + _cmdlet?.WriteInformation(new InformationRecord(MessageData, source), tags ?? []); + + public void WriteObject(object? obj) => _cmdlet?.WriteObject(obj); + public void WriteObject(object? obj, bool enumerateCollection) => _cmdlet?.WriteObject(obj, enumerateCollection); + + /// + /// Writes progress information. + /// + public void WriteProgress(ProgressRecord progress) => _cmdlet?.WriteProgress(progress); + + /// + /// Writes progress with simplified parameters. + /// + public void WriteProgress(string activity, string status, int percentComplete, int activityId = 0) => + _cmdlet?.WriteProgress(new ProgressRecord(activityId, activity, status) { PercentComplete = percentComplete }); + + /// + /// Completes a progress bar. + /// + public void CompleteProgress(int activityId = 0) => + _cmdlet?.WriteProgress(new ProgressRecord(activityId, "Complete", "Done") { RecordType = ProgressRecordType.Completed }); +} diff --git a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs index ff0046d..a114efe 100644 --- a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs +++ b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs @@ -1,6 +1,8 @@ using PSTextMate.Terminal; +using PSTextMate.Core; using PSTextMate.Utilities; using Spectre.Console; +using Spectre.Console.Rendering; using Xunit; namespace PSTextMate.InteractiveTests; @@ -44,4 +46,89 @@ public void SetQuery_EmptyValue_ClearsHitsAndCurrentSelection() { Assert.Equal(0, session.HitCount); Assert.Null(session.CurrentHit); } + + [Fact] + public void SetQuery_CustomRenderableWithToStringFallback_FindsMatch() { + PagerDocument document = new([ + new ThrowingRenderable("alpha beta"), + new ThrowingRenderable("gamma") + ]); + + PagerSearchSession session = new(document); + session.SetQuery("beta"); + + Assert.True(session.HasQuery); + Assert.Equal(1, session.HitCount); + + PagerSearchHit? hit = session.MoveNext(topIndex: 0); + Assert.NotNull(hit); + Assert.Equal(0, hit.RenderableIndex); + } + + [Fact] + public void SetQuery_FromHighlightedTextWithCustomRenderable_FindsMatch() { + HighlightedText highlighted = new() { + Renderables = [new ThrowingRenderable("search target")] + }; + + PagerDocument document = PagerDocument.FromHighlightedText(highlighted); + PagerSearchSession session = new(document); + session.SetQuery("target"); + + Assert.Equal(1, session.HitCount); + Assert.NotNull(session.MoveNext(topIndex: 0)); + } + + [Fact] + public void SetQuery_RenderableWithEmptyWriterOutput_UsesToStringFallback() { + PagerDocument document = new([ + new EmptyRenderable("delta epsilon") + ]); + + PagerSearchSession session = new(document); + session.SetQuery("epsilon"); + + Assert.Equal(1, session.HitCount); + Assert.NotNull(session.MoveNext(topIndex: 0)); + } + + private sealed class ThrowingRenderable : IRenderable { + private readonly string _text; + + public ThrowingRenderable(string text) { + _text = text; + } + + public Measurement Measure(RenderOptions options, int maxWidth) { + throw new InvalidOperationException("test render failure"); + } + + public IEnumerable Render(RenderOptions options, int maxWidth) { + throw new InvalidOperationException("test render failure"); + } + + public override string ToString() { + return _text; + } + } + + private sealed class EmptyRenderable : IRenderable { + private readonly string _text; + + public EmptyRenderable(string text) { + _text = text; + } + + public Measurement Measure(RenderOptions options, int maxWidth) { + return new Measurement(1, 1); + } + + public IEnumerable Render(RenderOptions options, int maxWidth) { + return []; + } + + public override string ToString() { + return _text; + } + } } diff --git a/tests/pager-highlight-showcase.md b/tests/pager-highlight-showcase.md new file mode 100644 index 0000000..3f5f4c2 --- /dev/null +++ b/tests/pager-highlight-showcase.md @@ -0,0 +1,56 @@ +# Pager Highlight Showcase + +This file is for validating search highlight behavior across different renderable shapes. +Search queries to try: `Out-Page`, `Format-Markdown`, `Panel`, `Sixel`. + +## Plain Paragraph + +`Out-Page` should highlight only the matching line text, not unrelated lines. + +## Bullet List + +- Render markdown with `Format-Markdown`. +- Pipe output to `Out-Page`. +- Validate row background is scoped to matching lines only. + +## Numbered List + +1. Search for `Out-Page`. +2. Press `n` repeatedly. +3. Confirm highlight stays on the expected row. + +## Block Quote + +> The pager should highlight matches in quoted text. +> Query target: `Out-Page` appears here. + +## Table + +| Command | Description | +|------------------|---------------------------------------------------------------| +| Test-TextMate | Check support for a file, extension, or language ID. | +| Out-Page | Builtin terminal pager. | +| Format-Markdown | Highlight Markdown content and return a HighlightedText object. | + +## Fenced Code + +```powershell +Get-Content README.md -Raw | + Format-Markdown -Theme SolarizedLight | + Out-Page +``` + +## Inline HTML Block + +
+

Panel-like renderers should not tint everything when only one line matches.

+

Query target: Out-Page

+
+ +## Mixed Emphasis + +Use **Out-Page** for paging and _Format-Markdown_ for markdown highlighting. + +## Long Paragraph + +When a long line wraps in the terminal, the behavior should still be stable: only the matching row (or wrapped visual line) gets row background, and the exact matched span gets match style. This sentence includes Out-Page once for verification. From 3264e6c5ddc472b3d6f9174668c13ddc1772e617 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:30:39 +0100 Subject: [PATCH 10/17] disabled excessive verbose --- src/PSTextMate/Cmdlets/OutPage.cs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index ba6822e..bd62287 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -18,23 +18,23 @@ public sealed class OutPageCmdlet : PSCmdlet { protected override void ProcessRecord() { if (InputObject?.BaseObject is null) { - WriteVerbose("ProcessRecord: InputObject is null; skipping item."); + // WriteVerbose("ProcessRecord: InputObject is null; skipping item."); return; } object value = InputObject.BaseObject; - WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}' baseType '{value.GetType().BaseType}'."); + // WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}' baseType '{value.GetType().BaseType}'."); if (value is HighlightedText highlightedText) { if (_singleHighlightedText is null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { _singleHighlightedText = highlightedText; - WriteVerbose($"ProcessRecord: HighlightedText fast path renderables={highlightedText.Renderables.Length} lineCount={highlightedText.LineCount}."); + // WriteVerbose($"ProcessRecord: HighlightedText fast path renderables={highlightedText.Renderables.Length} lineCount={highlightedText.LineCount}."); return; } _sawNonHighlightedInput = true; _renderables.AddRange(highlightedText.Renderables); - WriteVerbose($"ProcessRecord: merged HighlightedText renderables count={highlightedText.Renderables.Length} totalRenderables={_renderables.Count}."); + // WriteVerbose($"ProcessRecord: merged HighlightedText renderables count={highlightedText.Renderables.Length} totalRenderables={_renderables.Count}."); return; } @@ -42,7 +42,7 @@ protected override void ProcessRecord() { if (value is IRenderable renderable) { _renderables.Add(renderable); - WriteVerbose($"ProcessRecord: input matched IRenderable type='{renderable.GetType().FullName}' totalRenderables={_renderables.Count}."); + // WriteVerbose($"ProcessRecord: input matched IRenderable type='{renderable.GetType().FullName}' totalRenderables={_renderables.Count}."); return; } @@ -62,14 +62,14 @@ protected override void ProcessRecord() { protected override void EndProcessing() { if (_singleHighlightedText is not null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { - WriteVerbose("EndProcessing: using single HighlightedText pager path."); + // WriteVerbose("EndProcessing: using single HighlightedText pager path."); var highlightedPager = new Pager(_singleHighlightedText); highlightedPager.Show(); return; } if (_outStringInputs.Count > 0) { - WriteVerbose($"EndProcessing: converting {_outStringInputs.Count} queued value(s) through Out-String."); + // WriteVerbose($"EndProcessing: converting {_outStringInputs.Count} queued value(s) through Out-String."); List formattedLines = ConvertWithOutStringLines(_outStringInputs); if (formattedLines.Count > 0) { foreach (string line in formattedLines) { @@ -78,19 +78,19 @@ protected override void EndProcessing() { : Helpers.VTConversion.ToParagraph(line)); } - WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); + // WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); } else { foreach (object value in _outStringInputs) { _renderables.Add(new Text(LanguagePrimitives.ConvertTo(value))); } - WriteVerbose("EndProcessing: Out-String returned no lines; used string conversion fallback."); + // WriteVerbose("EndProcessing: Out-String returned no lines; used string conversion fallback."); } } if (_renderables.Count == 0) { - WriteVerbose("EndProcessing: no renderables collected; nothing to page."); + // WriteVerbose("EndProcessing: no renderables collected; nothing to page."); return; } From a67664668f159d436f604a5707ca276749bd412a Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:34:00 +0100 Subject: [PATCH 11/17] =?UTF-8?q?feat(pager):=20=E2=9C=A8=20Add=20help=20o?= =?UTF-8?q?verlay=20and=20enhance=20search=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced a help overlay that displays keybindings when '?' is pressed. * Added functionality to clear active search with the 'c' key. * Updated `ScrollTop` method to accept `contentRows` instead of `windowHeight`. * Enhanced the rendering logic to support the new help overlay. --- Module/TextMate.format.ps1xml | 2 +- src/PSTextMate/Cmdlets/OutPage.cs | 19 +-- src/PSTextMate/Cmdlets/TextMateCmdletBase.cs | 15 ++- src/PSTextMate/Core/HighlightedText.cs | 2 +- src/PSTextMate/Pager/Pager.cs | 111 +++++++++++++++--- src/PSTextMate/Pager/PagerViewport.cs | 4 +- .../Utilities/SpectreRenderBridge.cs | 4 +- src/PSTextMate/Utilities/VTConversion.cs | 2 +- src/PSTextMate/Utilities/VTHelpers.cs | 3 + src/PSTextMate/Utilities/Writer.cs | 8 +- src/PSTextMate/Utilities/using.cs | 1 - tests/pager-highlight-showcase.md | 26 +++- 12 files changed, 148 insertions(+), 49 deletions(-) diff --git a/Module/TextMate.format.ps1xml b/Module/TextMate.format.ps1xml index 3892dab..c19315e 100644 --- a/Module/TextMate.format.ps1xml +++ b/Module/TextMate.format.ps1xml @@ -52,7 +52,7 @@ - [PSTextMate.Utilities.Writer]::Write($_) + [PSTextMate.Utilities.Writer]::Write($_, $false, $true) diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index bd62287..78bdcb1 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -6,7 +6,6 @@ namespace PSTextMate.Commands; [Cmdlet(VerbsData.Out, "Page")] [OutputType(typeof(void))] public sealed class OutPageCmdlet : PSCmdlet { - private const char Escape = '\x1B'; private readonly List _renderables = []; private readonly List _outStringInputs = []; private HighlightedText? _singleHighlightedText; @@ -18,23 +17,19 @@ public sealed class OutPageCmdlet : PSCmdlet { protected override void ProcessRecord() { if (InputObject?.BaseObject is null) { - // WriteVerbose("ProcessRecord: InputObject is null; skipping item."); return; } object value = InputObject.BaseObject; - // WriteVerbose($"ProcessRecord: received input type '{value.GetType().FullName}' baseType '{value.GetType().BaseType}'."); if (value is HighlightedText highlightedText) { if (_singleHighlightedText is null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { _singleHighlightedText = highlightedText; - // WriteVerbose($"ProcessRecord: HighlightedText fast path renderables={highlightedText.Renderables.Length} lineCount={highlightedText.LineCount}."); return; } _sawNonHighlightedInput = true; _renderables.AddRange(highlightedText.Renderables); - // WriteVerbose($"ProcessRecord: merged HighlightedText renderables count={highlightedText.Renderables.Length} totalRenderables={_renderables.Count}."); return; } @@ -42,7 +37,6 @@ protected override void ProcessRecord() { if (value is IRenderable renderable) { _renderables.Add(renderable); - // WriteVerbose($"ProcessRecord: input matched IRenderable type='{renderable.GetType().FullName}' totalRenderables={_renderables.Count}."); return; } @@ -53,48 +47,39 @@ protected override void ProcessRecord() { if (TryConvertForeignSpectreRenderable(value, out IRenderable? convertedRenderable)) { _renderables.Add(convertedRenderable); - WriteVerbose($"ProcessRecord: converted foreign Spectre renderable to local type='{convertedRenderable.GetType().FullName}' totalRenderables={_renderables.Count}."); return; } - _outStringInputs.Add(value); + _outStringInputs.Add(InputObject); } protected override void EndProcessing() { if (_singleHighlightedText is not null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { - // WriteVerbose("EndProcessing: using single HighlightedText pager path."); var highlightedPager = new Pager(_singleHighlightedText); highlightedPager.Show(); return; } if (_outStringInputs.Count > 0) { - // WriteVerbose($"EndProcessing: converting {_outStringInputs.Count} queued value(s) through Out-String."); List formattedLines = ConvertWithOutStringLines(_outStringInputs); if (formattedLines.Count > 0) { foreach (string line in formattedLines) { - _renderables.Add(line.Length == 0 - ? new Text(string.Empty) - : Helpers.VTConversion.ToParagraph(line)); + _renderables.Add(line.Length == 0 ? Text.Empty : VTConversion.ToParagraph(line)); } - // WriteVerbose($"EndProcessing: Out-String produced {formattedLines.Count} line(s) for paging."); } else { foreach (object value in _outStringInputs) { _renderables.Add(new Text(LanguagePrimitives.ConvertTo(value))); } - // WriteVerbose("EndProcessing: Out-String returned no lines; used string conversion fallback."); } } if (_renderables.Count == 0) { - // WriteVerbose("EndProcessing: no renderables collected; nothing to page."); return; } - WriteVerbose($"EndProcessing: launching pager with {_renderables.Count} renderable(s)."); var pager = new Pager(_renderables, AnsiConsole.Console, null, suppressTerminalControlSequences: false); pager.Show(); } diff --git a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs index e525366..5c0e094 100644 --- a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs +++ b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs @@ -95,7 +95,7 @@ protected override void ProcessRecord() { if (InputObject?.BaseObject is FileInfo file) { try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result); + EmitHighlightedResult(result); } } catch (Exception ex) { @@ -118,7 +118,7 @@ protected override void ProcessRecord() { try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result); + EmitHighlightedResult(result); } } catch (Exception ex) { @@ -139,7 +139,7 @@ protected override void EndProcessing() { HighlightedText? result = ProcessStringInput(); if (result is not null) { - WriteObject(result); + EmitHighlightedResult(result); } } catch (Exception ex) { @@ -169,6 +169,15 @@ protected override void EndProcessing() { }; } + private void EmitHighlightedResult(HighlightedText result) { + if (Page.IsPresent) { + result.ShowPager(); + return; + } + + WriteObject(result); + } + private IEnumerable ProcessPathInput(FileInfo filePath) { if (!filePath.Exists) { throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); diff --git a/src/PSTextMate/Core/HighlightedText.cs b/src/PSTextMate/Core/HighlightedText.cs index a2f6847..78a4c48 100644 --- a/src/PSTextMate/Core/HighlightedText.cs +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -395,7 +395,7 @@ public void ShowPager() { /// /// Renders this highlighted text to a string. /// - public string Write() + public string? Write() => Writer.Write(this, Page); public override string ToString() diff --git a/src/PSTextMate/Pager/Pager.cs b/src/PSTextMate/Pager/Pager.cs index 05aa6ad..dadff19 100644 --- a/src/PSTextMate/Pager/Pager.cs +++ b/src/PSTextMate/Pager/Pager.cs @@ -8,6 +8,8 @@ namespace PSTextMate.Terminal; /// - Home/End: go to start/end /// - / or Ctrl+F: prompt for search query /// - n / N: next / previous match +/// - c: clear active search +/// - ?: show/hide keybindings /// - q or Escape: quit /// public sealed class Pager { @@ -33,6 +35,7 @@ public sealed class Pager { private bool _lastPageHadImages; private string _searchStatusText = string.Empty; private bool _isSearchInputActive; + private bool _isHelpOverlayActive; private readonly StringBuilder _searchInputBuffer = new(64); private static readonly Style SearchRowTextStyle = new(Color.White, Color.Grey); private static readonly Style SearchMatchTextStyle = new(Color.Black, Color.Orange1); @@ -238,6 +241,22 @@ private void Navigate(LiveDisplayContext ctx) { continue; } + if (_isHelpOverlayActive) { + if (key.Key == ConsoleKey.Q) { + running = false; + continue; + } + + _isHelpOverlayActive = false; + forceRedraw = true; + + if (key.Key == ConsoleKey.Escape || key.KeyChar == '?') { + continue; + } + + continue; + } + bool isCtrlF = key.Key == ConsoleKey.F && (key.Modifiers & ConsoleModifiers.Control) != 0; if (key.KeyChar == '/' || isCtrlF) { BeginSearchInput(); @@ -245,13 +264,19 @@ private void Navigate(LiveDisplayContext ctx) { continue; } + if (key.KeyChar == '?') { + _isHelpOverlayActive = true; + forceRedraw = true; + continue; + } + switch (key.Key) { case ConsoleKey.DownArrow: - ScrollRenderable(1); + ScrollRenderable(1, contentRows); forceRedraw = true; break; case ConsoleKey.UpArrow: - ScrollRenderable(-1); + ScrollRenderable(-1, contentRows); forceRedraw = true; break; case ConsoleKey.Spacebar: @@ -280,6 +305,13 @@ private void Navigate(LiveDisplayContext ctx) { } forceRedraw = true; + break; + case ConsoleKey.C: + if (_search.HasQuery) { + ClearSearch(); + forceRedraw = true; + } + break; case ConsoleKey.Q: case ConsoleKey.Escape: @@ -304,7 +336,7 @@ private static (int width, int height) GetPagerSize() { } } - private void ScrollRenderable(int delta) => _top = _viewportEngine.ScrollTop(_top, delta, WindowHeight); + private void ScrollRenderable(int delta, int contentRows) => _top = _viewportEngine.ScrollTop(_top, delta, contentRows); private void PageDown(int contentRows) => _top = _viewportEngine.PageDownTop(_top, contentRows); @@ -363,6 +395,12 @@ private void ApplySearchQuery(string query) { _searchStatusText = BuildSearchStatus(); } + private void ClearSearch() { + _search.SetQuery(string.Empty); + _searchInputBuffer.Clear(); + _searchStatusText = string.Empty; + } + private void JumpToNextMatch() { if (!_search.HasQuery) { _searchStatusText = "No active search. Press / to search."; @@ -412,9 +450,11 @@ private string BuildSearchStatus() { private Layout BuildRenderable(PagerViewportWindow viewport, int width) { int footerHeight = GetFooterHeight(width); int searchInputHeight = GetSearchInputHeight(); - IRenderable content = viewport.Count <= 0 - ? Text.Empty - : BuildContentRenderable(viewport); + IRenderable content = _isHelpOverlayActive + ? BuildHelpOverlayPanel() + : viewport.Count <= 0 + ? Text.Empty + : BuildContentRenderable(viewport); IRenderable footer = BuildFooter(width, viewport); var root = new Layout("root"); @@ -448,9 +488,40 @@ private Panel BuildSearchInputPanel() { }; } + private static Panel BuildHelpOverlayPanel() { + var helpRows = new Rows( + new Text("Keybindings", new Style(Color.White, decoration: Decoration.Bold)), + Text.Empty, + new Text(" Up/Down Move one item", new Style(Color.Grey)), + new Text(" PgUp/PgDn Page navigation", new Style(Color.Grey)), + new Text(" Home/End Jump to start/end", new Style(Color.Grey)), + new Text(" / or Ctrl+F Search", new Style(Color.Grey)), + new Text(" n / N Next / previous match", new Style(Color.Grey)), + new Text(" c Clear active search", new Style(Color.Grey)), + new Text(" ? Toggle this help", new Style(Color.Grey)), + new Text(" q or Esc Quit pager", new Style(Color.Grey)), + Text.Empty, + new Text("Press ? or Esc to close help.", new Style(Color.Yellow)) + ); + + return new Panel(new Align(helpRows, HorizontalAlignment.Left, VerticalAlignment.Middle)) { + Header = new PanelHeader("Pager Help", Justify.Left), + Border = BoxBorder.Double, + Padding = new Padding(2, 1, 2, 1), + Expand = true + }; + } + private IRenderable BuildContentRenderable(PagerViewportWindow viewport) { - if (_sourceHighlightedText is not null && !_search.HasQuery) { - _sourceHighlightedText.SetView(_renderables, viewport.Top, viewport.Count); + if (_sourceHighlightedText is not null) { + if (_search.HasQuery) { + List highlightedItems = BuildSearchAwareItems(viewport); + _sourceHighlightedText.SetView(highlightedItems, 0, highlightedItems.Count); + } + else { + _sourceHighlightedText.SetView(_renderables, viewport.Top, viewport.Count); + } + _sourceHighlightedText.LineNumberStart = (_originalLineNumberStart ?? 1) + viewport.Top; _sourceHighlightedText.LineNumberWidth = _stableLineNumberWidth; return _sourceHighlightedText; @@ -459,7 +530,7 @@ private IRenderable BuildContentRenderable(PagerViewportWindow viewport) { return _search.HasQuery ? BuildSearchAwareContent(viewport) : new Rows(_renderables.Skip(viewport.Top).Take(viewport.Count)); } - private Rows BuildSearchAwareContent(PagerViewportWindow viewport) { + private List BuildSearchAwareItems(PagerViewportWindow viewport) { var items = new List(viewport.Count); for (int i = 0; i < viewport.Count; i++) { int renderableIndex = viewport.Top + i; @@ -467,9 +538,12 @@ private Rows BuildSearchAwareContent(PagerViewportWindow viewport) { items.Add(highlighted); } - return new Rows(items); + return items; } + private Rows BuildSearchAwareContent(PagerViewportWindow viewport) + => new(BuildSearchAwareItems(viewport)); + private IRenderable ApplySearchHighlight(int renderableIndex, IRenderable renderable) { IReadOnlyList hits = _search.GetHitsForRenderable(renderableIndex); @@ -563,10 +637,13 @@ private Text BuildSimpleFooter(PagerViewportWindow viewport) { int total = _renderables.Count; int start = total == 0 ? 0 : viewport.Top + 1; int end = viewport.EndExclusive; - string baseText = $"↑↓ Scroll PgUp/PgDn Page Home/End Jump / or Ctrl+F Search n/N Match q/Esc Quit {start}-{end}/{total}"; - string inputHelp = _isSearchInputActive ? " Enter Apply Esc Cancel" : string.Empty; + string baseText = $"{start}-{end}/{total}"; + string defaultHelp = !_isSearchInputActive && string.IsNullOrEmpty(_searchStatusText) + ? " Press ? for help" + : string.Empty; + string inputHelp = _isSearchInputActive ? " Search: Enter Apply Esc Cancel" : string.Empty; return string.IsNullOrEmpty(_searchStatusText) - ? new Text(baseText + inputHelp, new Style(Color.Grey)) + ? new Text(baseText + defaultHelp + inputHelp, new Style(Color.Grey)) : new Text($"{baseText}{inputHelp} {_searchStatusText}", new Style(Color.Grey)); } @@ -577,14 +654,16 @@ private Panel BuildRichFooter(int width, PagerViewportWindow viewport) { int safeTotal = Math.Max(1, total); int digits = Math.Max(1, safeTotal.ToString(CultureInfo.InvariantCulture).Length); - string keyText = "↑↓ Scroll PgUp/PgDn Page Home/End Jump / or Ctrl+F Search n/N Match q/Esc Quit"; + string keyText = "Press ? for help"; string statusText = $"{start.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}-{end.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}/{total.ToString(CultureInfo.InvariantCulture).PadLeft(digits)}".PadLeft(_statusColumnWidth); if (_isSearchInputActive) { - keyText = $"{keyText} Enter Apply Esc Cancel"; + keyText = "Search: Enter Apply Esc Cancel"; } if (!string.IsNullOrEmpty(_searchStatusText)) { - keyText = $"{keyText} {_searchStatusText}"; + keyText = string.IsNullOrEmpty(keyText) + ? _searchStatusText + : $"{keyText} {_searchStatusText}"; } int chartWidth = Math.Clamp(width / 4, 14, 40); diff --git a/src/PSTextMate/Pager/PagerViewport.cs b/src/PSTextMate/Pager/PagerViewport.cs index 94ef181..e3a7b38 100644 --- a/src/PSTextMate/Pager/PagerViewport.cs +++ b/src/PSTextMate/Pager/PagerViewport.cs @@ -110,7 +110,7 @@ public int GetMaxTop(int contentRows) { return top; } - public int ScrollTop(int currentTop, int delta, int windowHeight) { + public int ScrollTop(int currentTop, int delta, int contentRows) { if (_renderables.Count == 0) { return currentTop; } @@ -120,7 +120,7 @@ public int ScrollTop(int currentTop, int delta, int windowHeight) { return currentTop; } - int maxTop = GetMaxTop(Math.Max(1, windowHeight - 1)); + int maxTop = GetMaxTop(Math.Max(1, contentRows)); return Math.Clamp(currentTop + direction, 0, maxTop); } diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs index 2ec19ee..b89fe17 100644 --- a/src/PSTextMate/Utilities/SpectreRenderBridge.cs +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -191,12 +191,12 @@ private static IRenderable ConvertAnsiToRenderable(string ansi) { .Replace('\r', '\n') .Split('\n'); if (lines.Length <= 1) { - return Helpers.VTConversion.ToParagraph(lines[0]); + return VTConversion.ToParagraph(lines[0]); } var renderables = new IRenderable[lines.Length]; for (int i = 0; i < lines.Length; i++) { - renderables[i] = Helpers.VTConversion.ToParagraph(lines[i]); + renderables[i] = VTConversion.ToParagraph(lines[i]); } return new Rows(renderables); diff --git a/src/PSTextMate/Utilities/VTConversion.cs b/src/PSTextMate/Utilities/VTConversion.cs index e78ea2f..f251bed 100644 --- a/src/PSTextMate/Utilities/VTConversion.cs +++ b/src/PSTextMate/Utilities/VTConversion.cs @@ -1,4 +1,4 @@ -namespace PSTextMate.Helpers; +namespace PSTextMate.Utilities; /// /// Efficient parser for VT (Virtual Terminal) escape sequences that converts them to Spectre.Console objects. diff --git a/src/PSTextMate/Utilities/VTHelpers.cs b/src/PSTextMate/Utilities/VTHelpers.cs index 01556df..b6b857a 100644 --- a/src/PSTextMate/Utilities/VTHelpers.cs +++ b/src/PSTextMate/Utilities/VTHelpers.cs @@ -24,6 +24,9 @@ public static partial class VTHelpers { public static void ReserveRow(int height) => Console.Write($"\x1b[1;{height}r"); // Reset scroll region to full height (CSI r) public static void ResetScrollRegion() => Console.Write("\x1b[r"); + public static void MoveCursorRowUp(int rows) => Console.Write($"\x1b[{rows}A"); + public static void MoveCursorRowDown(int rows) => Console.Write($"\x1b[{rows}B"); + /// /// Enables xterm alternate-scroll mode (1007) so mouse wheel can map to diff --git a/src/PSTextMate/Utilities/Writer.cs b/src/PSTextMate/Utilities/Writer.cs index d9f0bb7..827ab0c 100644 --- a/src/PSTextMate/Utilities/Writer.cs +++ b/src/PSTextMate/Utilities/Writer.cs @@ -32,20 +32,22 @@ public static string Write(IRenderable renderable) { /// Renders highlighted text to string. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string Write(HighlightedText highlightedText, bool autoPage = false) { + public static string? Write(HighlightedText highlightedText, bool autoPage = false, bool FromFormat = false) { ArgumentNullException.ThrowIfNull(highlightedText); if (highlightedText.Page || (autoPage && ShouldPage(highlightedText))) { var pager = new Pager(highlightedText); pager.Show(); - return string.Empty; + if (FromFormat) VTHelpers.MoveCursorRowUp(2); + return null; } // Sixel payload must be written as raw control sequences. Converting to a string // and flowing through host formatting can strip DCS wrappers and print payload text. if (ContainsImageRenderables(highlightedText.Renderables)) { AnsiConsole.Write(highlightedText); - return string.Empty; + if (FromFormat) VTHelpers.MoveCursorRowUp(2); + return null; } return WriteToString(highlightedText); diff --git a/src/PSTextMate/Utilities/using.cs b/src/PSTextMate/Utilities/using.cs index 3e984bf..ef60b27 100644 --- a/src/PSTextMate/Utilities/using.cs +++ b/src/PSTextMate/Utilities/using.cs @@ -42,7 +42,6 @@ global using TextMateSharp.Registry; global using TextMateSharp.Themes; global using Color = Spectre.Console.Color; -global using PSHostUserInterface = System.Management.Automation.Host.PSHostUserInterface; global using SixColor = SixLabors.ImageSharp.Color; global using SixImage = SixLabors.ImageSharp.Image; global using TableColumnAlign = Markdig.Extensions.Tables.TableColumnAlign; diff --git a/tests/pager-highlight-showcase.md b/tests/pager-highlight-showcase.md index 3f5f4c2..a935d9f 100644 --- a/tests/pager-highlight-showcase.md +++ b/tests/pager-highlight-showcase.md @@ -19,6 +19,14 @@ Search queries to try: `Out-Page`, `Format-Markdown`, `Panel`, `Sixel`. 2. Press `n` repeatedly. 3. Confirm highlight stays on the expected row. +## Nested Lists with Tasks + +1. First item + - [x] Nested completed task + - [ ] Nested incomplete task +2. Second item + - [ ] Another nested task + ## Block Quote > The pager should highlight matches in quoted text. @@ -35,11 +43,21 @@ Search queries to try: `Out-Page`, `Format-Markdown`, `Panel`, `Sixel`. ## Fenced Code ```powershell -Get-Content README.md -Raw | - Format-Markdown -Theme SolarizedLight | +Get-Content -Path '.\README.md' -Raw | + Format-Markdown -Theme 'SolarizedLight' | Out-Page ``` +```csharp +public class TestClass { + public string Name { get; set; } = "Test"; + + public void DoSomething() { + Console.WriteLine($"Hello {Name}!"); + } +} +``` + ## Inline HTML Block
@@ -47,6 +65,10 @@ Get-Content README.md -Raw |

Query target: Out-Page

+### Inline Sixel HTML + +Demo + ## Mixed Emphasis Use **Out-Page** for paging and _Format-Markdown_ for markdown highlighting. From 7c41c1de20c415bdb6377fce3c91452f1d4cd069 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:33:01 +0100 Subject: [PATCH 12/17] =?UTF-8?q?feat(pager):=20=E2=9C=A8=20Enhance=20sear?= =?UTF-8?q?ch=20functionality=20and=20rendering=20efficiency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored `PagerDocumentEntry` to use lazy evaluation for `SearchText` and `LineStarts`, improving performance. * Updated `PagerSearchSession` to maintain a dictionary of hits by renderable, optimizing hit retrieval. * Improved `PagerViewportEngine` to avoid unnecessary height recalculations when layout parameters remain unchanged. * Enhanced error handling in various rendering and image processing methods to ensure robustness. * Removed unused interfaces and classes related to TextMate styling, streamlining the codebase. * Added tests to verify the correctness of search functionality and rendering behavior under various scenarios. --- Module/TextMate.psd1 | 1 + src/PSTextMate/Cmdlets/OutPage.cs | 1 + src/PSTextMate/Cmdlets/TextMateCmdletBase.cs | 9 +- src/PSTextMate/Core/HighlightedText.cs | 91 ----------- src/PSTextMate/Pager/Pager.cs | 149 +++++++++--------- src/PSTextMate/Pager/PagerDocument.cs | 34 +++- src/PSTextMate/Pager/PagerHighlighting.cs | 29 ++-- src/PSTextMate/Pager/PagerSearchSession.cs | 47 ++---- src/PSTextMate/Pager/PagerViewport.cs | 28 +++- src/PSTextMate/Rendering/ImageRenderer.cs | 76 ++++++--- src/PSTextMate/Rendering/MarkdownRenderer.cs | 6 - src/PSTextMate/Utilities/ITextMateStyler.cs | 24 --- src/PSTextMate/Utilities/ImageFile.cs | 63 +++++--- .../Utilities/SpectreTextMateStyler.cs | 63 -------- .../Utilities/StringBuilderExtensions.cs | 86 ---------- src/PSTextMate/Utilities/ThemeExtensions.cs | 48 ------ .../Utilities/TokenStyleProcessor.cs | 66 -------- src/PSTextMate/Utilities/VTConversion.cs | 68 -------- .../PagerCoreTests.cs | 105 ++++++++++-- 19 files changed, 352 insertions(+), 642 deletions(-) delete mode 100644 src/PSTextMate/Utilities/ITextMateStyler.cs delete mode 100644 src/PSTextMate/Utilities/SpectreTextMateStyler.cs delete mode 100644 src/PSTextMate/Utilities/StringBuilderExtensions.cs delete mode 100644 src/PSTextMate/Utilities/ThemeExtensions.cs delete mode 100644 src/PSTextMate/Utilities/TokenStyleProcessor.cs diff --git a/Module/TextMate.psd1 b/Module/TextMate.psd1 index 50e39f3..263baa3 100644 --- a/Module/TextMate.psd1 +++ b/Module/TextMate.psd1 @@ -23,6 +23,7 @@ 'fps' 'ftm' 'Show-TextMate' + 'page' ) FormatsToProcess = 'TextMate.format.ps1xml' RequiredModules = @( diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index 78bdcb1..38833ff 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -4,6 +4,7 @@ namespace PSTextMate.Commands; /// Sends renderables or VT-formatted strings to the interactive pager. ///
[Cmdlet(VerbsData.Out, "Page")] +[Alias("page")] [OutputType(typeof(void))] public sealed class OutPageCmdlet : PSCmdlet { private readonly List _renderables = []; diff --git a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs index 5c0e094..0f4268a 100644 --- a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs +++ b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs @@ -33,11 +33,6 @@ public abstract class TextMateCmdletBase : PSCmdlet { [Parameter] public SwitchParameter LineNumbers { get; set; } - /// - /// When present, wrap output in a Spectre Panel. - /// - [Parameter] - public SwitchParameter Panel { get; set; } /// /// When present, always render through the interactive pager. @@ -164,7 +159,7 @@ protected override void EndProcessing() { Renderables = renderables, ShowLineNumbers = LineNumbers.IsPresent, Language = token, - WrapInPanel = Panel.IsPresent, + // WrapInPanel = Panel.IsPresent, Page = Page.IsPresent }; } @@ -200,7 +195,7 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { Renderables = renderables, ShowLineNumbers = LineNumbers.IsPresent, Language = token, - WrapInPanel = Panel.IsPresent, + // WrapInPanel = Panel.IsPresent, Page = Page.IsPresent }; } diff --git a/src/PSTextMate/Core/HighlightedText.cs b/src/PSTextMate/Core/HighlightedText.cs index 78a4c48..1058dc1 100644 --- a/src/PSTextMate/Core/HighlightedText.cs +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -239,70 +239,6 @@ private static int CountLines(List segments) { return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; } - // Helper used by external callers to measure this instance's renderables by - // height (in rows) for a given width. Returns an array of heights aligned - // with the current `Renderables` array (or the underlying source when a - // view is active). - public int[] MeasureRenderables(int width) { - Capabilities caps = AnsiConsole.Console.Profile.Capabilities; - var size = new Size(width, Math.Max(1, Console.WindowHeight)); - var options = new RenderOptions(caps, size); - - IEnumerable source = _viewSource is null ? Renderables : _viewSource; - var list = new List(source.Count()); - - foreach (IRenderable? r in source) { - if (r is null) { - list.Add(0); - continue; - } - - try { - // Prefer Measure() to avoid rendering side-effects (images, sixels). - Measurement m = r.Measure(options, width); - int maxWidth = Math.Max(1, m.Max); - int estimatedLines = maxWidth <= width ? 1 : (int)Math.Ceiling((double)maxWidth / width); - list.Add(Math.Max(1, estimatedLines)); - } - catch { - list.Add(1); - } - } - - return [.. list]; - } - - /// - /// Measure each renderable and return the full Measurement for each item. - /// This is similar to but preserves the - /// Measurement (min/max) which callers can use to compute both width and - /// estimated height. - /// - public Measurement[] MeasureRenderablesFull(int width) { - Capabilities caps = AnsiConsole.Console.Profile.Capabilities; - var size = new Size(width, Math.Max(1, Console.WindowHeight)); - var options = new RenderOptions(caps, size); - - IEnumerable source = _viewSource is null ? Renderables : _viewSource; - var list = new List(source.Count()); - - foreach (IRenderable? r in source) { - if (r is null) { - list.Add(new Measurement(1, 1)); - continue; - } - - try { - Measurement m = r.Measure(options, width); - list.Add(m); - } - catch { - list.Add(new Measurement(1, 1)); - } - } - - return [.. list]; - } private int ResolveLineNumberWidth(int lineCount) { if (LineNumberWidth.HasValue && LineNumberWidth.Value > 0) { @@ -358,39 +294,12 @@ public Panel ToPanel(string? title = null, BoxBorder? border = null) { /// Padding size for all sides /// Padder containing the highlighted text public Padder WithPadding(int size) => new(this, new Padding(size)); - /// - /// Create a page-scoped HighlightedText that reuses this instance's settings - /// but contains only a slice of the underlying renderables. - /// - /// Zero-based start index into . - /// Number of renderables to include. - /// Optional stable gutter width to apply to the slice. - /// A new representing the requested slice. - public HighlightedText Slice(int start, int count, int? overrideLineNumberWidth = null) { - return new HighlightedText { - Renderables = [.. Renderables.Skip(start).Take(count)], - ShowLineNumbers = ShowLineNumbers, - LineNumberStart = LineNumberStart + start, - LineNumberWidth = overrideLineNumberWidth ?? LineNumberWidth, - GutterSeparator = GutterSeparator, - Language = Language, - WrapInPanel = WrapInPanel, - Page = Page - }; - } public void ShowPager() { if (LineCount <= 0) return; var pager = new Pager(this); pager.Show(); } - public IRenderable? AutoPage() { - if (LineCount > Console.WindowHeight - 2) { - ShowPager(); - return null; - } - return this; - } /// /// Renders this highlighted text to a string. diff --git a/src/PSTextMate/Pager/Pager.cs b/src/PSTextMate/Pager/Pager.cs index dadff19..08b99bc 100644 --- a/src/PSTextMate/Pager/Pager.cs +++ b/src/PSTextMate/Pager/Pager.cs @@ -30,7 +30,6 @@ public sealed class Pager { private int _top; private int WindowHeight; private int WindowWidth; - private readonly object _lock = new(); private int _lastRenderedRows; private bool _lastPageHadImages; private string _searchStatusText = string.Empty; @@ -41,6 +40,7 @@ public sealed class Pager { private static readonly Style SearchMatchTextStyle = new(Color.Black, Color.Orange1); private const string SearchRowStyle = "white on grey"; private const string SearchMatchStyle = "black on orange1"; + private const int KeyPollingIntervalMs = 50; private bool TryReadKey(out ConsoleKeyInfo key) { if (_tryReadKeyOverride is not null) { @@ -231,93 +231,91 @@ private void Navigate(LiveDisplayContext ctx) { // Wait for input, checking for resize while idle. if (!TryReadKey(out ConsoleKeyInfo key)) { - Thread.Sleep(50); + Thread.Sleep(KeyPollingIntervalMs); continue; } - lock (_lock) { - if (_isSearchInputActive) { - HandleSearchInputKey(key, ref forceRedraw); + if (_isSearchInputActive) { + HandleSearchInputKey(key, ref forceRedraw); + continue; + } + + if (_isHelpOverlayActive) { + if (key.Key == ConsoleKey.Q) { + running = false; continue; } - if (_isHelpOverlayActive) { - if (key.Key == ConsoleKey.Q) { - running = false; - continue; - } - - _isHelpOverlayActive = false; - forceRedraw = true; - - if (key.Key == ConsoleKey.Escape || key.KeyChar == '?') { - continue; - } + _isHelpOverlayActive = false; + forceRedraw = true; + if (key.Key == ConsoleKey.Escape || key.KeyChar == '?') { continue; } - bool isCtrlF = key.Key == ConsoleKey.F && (key.Modifiers & ConsoleModifiers.Control) != 0; - if (key.KeyChar == '/' || isCtrlF) { - BeginSearchInput(); - forceRedraw = true; - continue; - } + continue; + } - if (key.KeyChar == '?') { - _isHelpOverlayActive = true; - forceRedraw = true; - continue; - } + bool isCtrlF = key.Key == ConsoleKey.F && (key.Modifiers & ConsoleModifiers.Control) != 0; + if (key.KeyChar == '/' || isCtrlF) { + BeginSearchInput(); + forceRedraw = true; + continue; + } - switch (key.Key) { - case ConsoleKey.DownArrow: - ScrollRenderable(1, contentRows); - forceRedraw = true; - break; - case ConsoleKey.UpArrow: - ScrollRenderable(-1, contentRows); - forceRedraw = true; - break; - case ConsoleKey.Spacebar: - case ConsoleKey.PageDown: - PageDown(contentRows); - forceRedraw = true; - break; - case ConsoleKey.PageUp: - PageUp(contentRows); - forceRedraw = true; - break; - case ConsoleKey.Home: - GoToTop(); - forceRedraw = true; - break; - case ConsoleKey.End: - GoToEnd(contentRows); - forceRedraw = true; - break; - case ConsoleKey.N: - if ((key.Modifiers & ConsoleModifiers.Shift) != 0) { - JumpToPreviousMatch(); - } - else { - JumpToNextMatch(); - } + if (key.KeyChar == '?') { + _isHelpOverlayActive = true; + forceRedraw = true; + continue; + } + + switch (key.Key) { + case ConsoleKey.DownArrow: + ScrollRenderable(1, contentRows); + forceRedraw = true; + break; + case ConsoleKey.UpArrow: + ScrollRenderable(-1, contentRows); + forceRedraw = true; + break; + case ConsoleKey.Spacebar: + case ConsoleKey.PageDown: + PageDown(contentRows); + forceRedraw = true; + break; + case ConsoleKey.PageUp: + PageUp(contentRows); + forceRedraw = true; + break; + case ConsoleKey.Home: + GoToTop(); + forceRedraw = true; + break; + case ConsoleKey.End: + GoToEnd(contentRows); + forceRedraw = true; + break; + case ConsoleKey.N: + if ((key.Modifiers & ConsoleModifiers.Shift) != 0) { + JumpToPreviousMatch(); + } + else { + JumpToNextMatch(); + } + forceRedraw = true; + break; + case ConsoleKey.C: + if (_search.HasQuery) { + ClearSearch(); forceRedraw = true; - break; - case ConsoleKey.C: - if (_search.HasQuery) { - ClearSearch(); - forceRedraw = true; - } + } - break; - case ConsoleKey.Q: - case ConsoleKey.Escape: - running = false; - break; - } + break; + case ConsoleKey.Q: + case ConsoleKey.Escape: + running = false; + break; } } } @@ -623,7 +621,10 @@ private string ExtractPlainTextForSearchHighlight(IRenderable renderable) { ? normalized : PagerHighlighting.NormalizeText(renderable.ToString()); } - catch { + catch (InvalidOperationException) { + return PagerHighlighting.NormalizeText(renderable.ToString()); + } + catch (IOException) { return PagerHighlighting.NormalizeText(renderable.ToString()); } } diff --git a/src/PSTextMate/Pager/PagerDocument.cs b/src/PSTextMate/Pager/PagerDocument.cs index dfeb376..53e62ad 100644 --- a/src/PSTextMate/Pager/PagerDocument.cs +++ b/src/PSTextMate/Pager/PagerDocument.cs @@ -3,10 +3,14 @@ internal sealed record PagerDocumentEntry( int RenderableIndex, IRenderable Renderable, - string SearchText, - int[] LineStarts, + Func GetSearchText, + Func GetLineStarts, bool IsImage -); +) { + public string SearchText => GetSearchText(); + + public int[] LineStarts => GetLineStarts(); +} internal sealed class PagerDocument { private readonly List _entries = []; @@ -22,9 +26,22 @@ public PagerDocument(IEnumerable renderables) { int index = 0; foreach (IRenderable renderable in renderables) { bool isImage = IsImageRenderable(renderable); - string searchText = isImage ? string.Empty : ExtractSearchText(renderable); - int[] lineStarts = BuildLineStarts(searchText); - _entries.Add(new PagerDocumentEntry(index, renderable, searchText, lineStarts, isImage)); + Lazy lazySearchText = new( + () => isImage ? string.Empty : ExtractSearchText(renderable), + isThreadSafe: false + ); + Lazy lazyLineStarts = new( + () => BuildLineStarts(lazySearchText.Value), + isThreadSafe: false + ); + + _entries.Add(new PagerDocumentEntry( + index, + renderable, + () => lazySearchText.Value, + () => lazyLineStarts.Value, + isImage + )); renderableList.Add(renderable); index++; } @@ -55,7 +72,10 @@ private static string ExtractSearchText(IRenderable renderable) { ? normalized : Normalize(renderable.ToString()); } - catch { + catch (InvalidOperationException) { + return Normalize(renderable.ToString()); + } + catch (IOException) { return Normalize(renderable.ToString()); } } diff --git a/src/PSTextMate/Pager/PagerHighlighting.cs b/src/PSTextMate/Pager/PagerHighlighting.cs index d4b0cd2..388e28b 100644 --- a/src/PSTextMate/Pager/PagerHighlighting.cs +++ b/src/PSTextMate/Pager/PagerHighlighting.cs @@ -99,9 +99,11 @@ private static Table CloneTableWithHighlight( string matchStyle, IReadOnlyList indexedHits ) { - HashSet matchedRowsFromHits = ResolveTableRowsFromHitLines(source, indexedHits); bool[] rowCellMatches = [.. source.Rows.Select(row => row.Any(cell => RenderableContainsQuery(cell, query)))]; bool hasAnyRowCellMatch = rowCellMatches.Any(static match => match); + HashSet matchedRowsFromHits = hasAnyRowCellMatch + ? [] + : ResolveTableRowsFromHitLines(source, indexedHits); var clone = new Table { Border = source.Border, @@ -134,7 +136,6 @@ IReadOnlyList indexedHits int rowIndex = 0; foreach (TableRow sourceRow in source.Rows) { - string[] cellTexts = [.. sourceRow.Select(ExtractRenderableText).Select(NormalizeText)]; bool rowHasCellMatch = rowCellMatches[rowIndex]; bool rowHasIndexedContextMatch = !hasAnyRowCellMatch && matchedRowsFromHits.Contains(rowIndex); bool rowHasMatch = rowHasCellMatch || rowHasIndexedContextMatch; @@ -167,9 +168,11 @@ private static Grid CloneGridWithHighlight( string matchStyle, IReadOnlyList indexedHits ) { - HashSet matchedRowsFromHits = ResolveGridRowsFromHitLines(source, indexedHits); bool[] rowCellMatches = [.. source.Rows.Select(row => row.Any(cell => RenderableContainsQuery(cell, query)))]; bool hasAnyRowCellMatch = rowCellMatches.Any(static match => match); + HashSet matchedRowsFromHits = hasAnyRowCellMatch + ? [] + : ResolveGridRowsFromHitLines(source, indexedHits); var clone = new Grid { Expand = source.Expand, @@ -187,7 +190,6 @@ IReadOnlyList indexedHits int rowIndex = 0; foreach (GridRow sourceRow in source.Rows) { - string[] cellTexts = [.. sourceRow.Select(ExtractRenderableText).Select(NormalizeText)]; bool rowHasCellMatch = rowCellMatches[rowIndex]; bool rowHasIndexedContextMatch = !hasAnyRowCellMatch && matchedRowsFromHits.Contains(rowIndex); bool rowHasMatch = rowHasCellMatch || rowHasIndexedContextMatch; @@ -538,7 +540,10 @@ private static int CountRenderableLines(IRenderable renderable) { return lines; } - catch { + catch (InvalidOperationException) { + return 0; + } + catch (IOException) { return 0; } } @@ -563,9 +568,12 @@ string matchStyle return renderable; } + var rowTextStyle = Style.Parse(rowStyle); + var matchTextStyle = Style.Parse(matchStyle); + if (string.IsNullOrEmpty(query)) { return applyRowStyle - ? BuildHighlightedTextRenderable(plainText, [], Style.Parse(rowStyle), Style.Parse(matchStyle)) + ? BuildHighlightedTextRenderable(plainText, [], rowTextStyle, matchTextStyle) : renderable; } @@ -583,7 +591,7 @@ string matchStyle return hits.Count == 0 && !applyRowStyle ? renderable - : BuildHighlightedTextRenderable(plainText, hits, Style.Parse(rowStyle), Style.Parse(matchStyle)); + : BuildHighlightedTextRenderable(plainText, hits, rowTextStyle, matchTextStyle); } private static string ExtractRenderableText(IRenderable renderable) { @@ -610,7 +618,7 @@ private static string ExtractRenderableText(IRenderable renderable) { return extracted; } } - catch { + catch (InvalidOperationException) { } try { @@ -619,7 +627,10 @@ private static string ExtractRenderableText(IRenderable renderable) { ? string.Empty : rendered; } - catch { + catch (InvalidOperationException) { + return string.Empty; + } + catch (IOException) { return string.Empty; } } diff --git a/src/PSTextMate/Pager/PagerSearchSession.cs b/src/PSTextMate/Pager/PagerSearchSession.cs index ff27e5b..43b7a04 100644 --- a/src/PSTextMate/Pager/PagerSearchSession.cs +++ b/src/PSTextMate/Pager/PagerSearchSession.cs @@ -11,6 +11,7 @@ int Column internal sealed class PagerSearchSession { private readonly PagerDocument _document; private readonly List _hits = []; + private readonly Dictionary> _hitsByRenderable = []; private static readonly IReadOnlyList s_noHits = []; public string Query { get; private set; } = string.Empty; public int CurrentHitIndex { get; private set; } = -1; @@ -63,31 +64,14 @@ public void SetQuery(string query) { } public IReadOnlyList GetHitsForRenderable(int renderableIndex) { - if (_hits.Count == 0) { - return s_noHits; - } - - List? matches = null; - foreach (PagerSearchHit hit in _hits) { - if (hit.RenderableIndex != renderableIndex) { - continue; - } - - matches ??= []; - matches.Add(hit); - } - - return matches ?? s_noHits; + return _hitsByRenderable.TryGetValue(renderableIndex, out List? matches) + ? matches + : s_noHits; } - public bool IsCurrentHit(PagerSearchHit hit) - => CurrentHit is PagerSearchHit current - && current.RenderableIndex == hit.RenderableIndex - && current.Offset == hit.Offset - && current.Length == hit.Length; - private void RebuildHits() { _hits.Clear(); + _hitsByRenderable.Clear(); CurrentHitIndex = -1; if (!HasQuery) { @@ -107,11 +91,20 @@ private void RebuildHits() { } (int line, int column) = ResolveLineColumn(entry.LineStarts, hitOffset); - _hits.Add(new PagerSearchHit(entry.RenderableIndex, hitOffset, Query.Length, line, column)); + var hit = new PagerSearchHit(entry.RenderableIndex, hitOffset, Query.Length, line, column); + _hits.Add(hit); + + if (!_hitsByRenderable.TryGetValue(entry.RenderableIndex, out List? existing)) { + _hitsByRenderable[entry.RenderableIndex] = [hit]; + } + else { + existing.Add(hit); + } searchStart = hitOffset + Math.Max(1, Query.Length); } } + } private static (int line, int column) ResolveLineColumn(int[] lineStarts, int offset) { @@ -119,14 +112,8 @@ private static (int line, int column) ResolveLineColumn(int[] lineStarts, int of return (0, offset); } - int line = 0; - for (int i = 1; i < lineStarts.Length; i++) { - if (lineStarts[i] > offset) { - break; - } - - line = i; - } + int line = Array.BinarySearch(lineStarts, offset); + line = line >= 0 ? line : Math.Max(0, (~line) - 1); int column = offset - lineStarts[line]; return (line, Math.Max(0, column)); diff --git a/src/PSTextMate/Pager/PagerViewport.cs b/src/PSTextMate/Pager/PagerViewport.cs index e3a7b38..ae8acb2 100644 --- a/src/PSTextMate/Pager/PagerViewport.cs +++ b/src/PSTextMate/Pager/PagerViewport.cs @@ -6,6 +6,10 @@ internal sealed class PagerViewportEngine { private readonly IReadOnlyList _renderables; private readonly HighlightedText? _sourceHighlightedText; private List _renderableHeights = []; + private int _lastWidth = -1; + private int _lastContentRows = -1; + private int _lastWindowHeight = -1; + private int _lastRenderableCount = -1; public PagerViewportEngine(IReadOnlyList renderables, HighlightedText? sourceHighlightedText) { _renderables = renderables ?? throw new ArgumentNullException(nameof(renderables)); @@ -15,6 +19,14 @@ public PagerViewportEngine(IReadOnlyList renderables, HighlightedTe public void RecalculateHeights(int width, int contentRows, int windowHeight, IAnsiConsole console) { ArgumentNullException.ThrowIfNull(console); + if (_renderableHeights.Count == _renderables.Count + && _lastWidth == width + && _lastContentRows == contentRows + && _lastWindowHeight == windowHeight + && _lastRenderableCount == _renderables.Count) { + return; + } + _renderableHeights = new List(_renderables.Count); Capabilities capabilities = console.Profile.Capabilities; int measurementHeight = windowHeight > 0 ? windowHeight : Math.Max(1, contentRows + 3); @@ -47,11 +59,20 @@ public void RecalculateHeights(int width, int contentRows, int windowHeight, IAn int lines = CountLinesSegments(segments); _renderableHeights.Add(Math.Max(1, lines)); } - catch { + catch (InvalidOperationException) { + // Fallback: assume single-line if measurement fails. + _renderableHeights.Add(1); + } + catch (IOException) { // Fallback: assume single-line if measurement fails. _renderableHeights.Add(1); } } + + _lastWidth = width; + _lastContentRows = contentRows; + _lastWindowHeight = windowHeight; + _lastRenderableCount = _renderables.Count; } public PagerViewportWindow BuildViewport(int proposedTop, int contentRows) { @@ -202,7 +223,10 @@ private static int EstimateImageHeight(IRenderable renderable, int width, int co try { measure = renderable.Measure(options, width); } - catch { + catch (InvalidOperationException) { + return Math.Clamp(contentRows, 1, contentRows); + } + catch (IOException) { return Math.Clamp(contentRows, 1, contentRows); } diff --git a/src/PSTextMate/Rendering/ImageRenderer.cs b/src/PSTextMate/Rendering/ImageRenderer.cs index 7ac0c9f..edd1984 100644 --- a/src/PSTextMate/Rendering/ImageRenderer.cs +++ b/src/PSTextMate/Rendering/ImageRenderer.cs @@ -35,22 +35,15 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW } // Use a timeout for image processing - string? localImagePath = null; - Task imageTask = Task.Run(async () => { - string? result = await ImageFile.NormalizeImageSourceAsync(imageUrl, CurrentMarkdownDirectory); - // Track what paths we're trying to resolve for error reporting - if (result is null && CurrentMarkdownDirectory is not null) { + if (!TryNormalizeImagePath(imageUrl, out string? localImagePath, out bool timedOut)) { + if (timedOut) { + _lastImageError = $"Image download timeout after {ImageTimeout.TotalSeconds} seconds: {imageUrl}"; + } + else if (CurrentMarkdownDirectory is not null) { _lastImageError = $"Failed to resolve '{imageUrl}' with base directory '{CurrentMarkdownDirectory}'"; } - return result; - }); - if (imageTask.Wait(ImageTimeout)) { - localImagePath = imageTask.Result; - } - else { // Timeout occurred - _lastImageError = $"Image download timeout after {ImageTimeout.TotalSeconds} seconds: {imageUrl}"; return CreateImageFallback(altText, imageUrl); } @@ -109,13 +102,7 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int } // Use a timeout for image processing - string? localImagePath = null; - Task? imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl, CurrentMarkdownDirectory)); - - if (imageTask.Wait(ImageTimeout)) { - localImagePath = imageTask.Result; - } - else { + if (!TryNormalizeImagePath(imageUrl, out string? localImagePath, out bool timedOut) || timedOut) { // Timeout occurred return CreateImageFallbackInline(altText, imageUrl); } @@ -136,7 +123,19 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int return CreateImageFallbackInline(altText, imageUrl); } } - catch { + catch (InvalidOperationException) { + // If anything goes wrong, fall back to the link representation + return CreateImageFallbackInline(altText, imageUrl); + } + catch (HttpRequestException) { + // If anything goes wrong, fall back to the link representation + return CreateImageFallbackInline(altText, imageUrl); + } + catch (IOException) { + // If anything goes wrong, fall back to the link representation + return CreateImageFallbackInline(altText, imageUrl); + } + catch (TaskCanceledException) { // If anything goes wrong, fall back to the link representation return CreateImageFallbackInline(altText, imageUrl); } @@ -148,6 +147,33 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int private static bool TryCreateSixelRenderable(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) => TryCreatePixelImage(imagePath, maxWidth, maxHeight, out result); + private static bool TryNormalizeImagePath(string imageUrl, out string? localImagePath, out bool timedOut) { + localImagePath = null; + timedOut = false; + + try { + Task task = ImageFile.NormalizeImageSourceAsync(imageUrl, CurrentMarkdownDirectory); + localImagePath = task.WaitAsync(ImageTimeout).GetAwaiter().GetResult(); + return true; + } + catch (TimeoutException) { + timedOut = true; + return false; + } + catch (InvalidOperationException) { + return false; + } + catch (HttpRequestException) { + return false; + } + catch (IOException) { + return false; + } + catch (TaskCanceledException) { + return false; + } + } + /// /// Attempts to create a local PixelImage backed by the new sixel implementation. /// @@ -221,7 +247,10 @@ private static IRenderable CreateEnhancedImageFallback(string altText, string im .Border(BoxBorder.Rounded) .BorderColor(Color.Grey); } - catch { + catch (InvalidOperationException) { + return CreateImageFallback(altText, imageUrl); + } + catch (IOException) { return CreateImageFallback(altText, imageUrl); } } @@ -258,7 +287,10 @@ public static bool IsSixelImageAvailable() { try { return Compatibility.TerminalSupportsSixel(); } - catch { + catch (InvalidOperationException) { + return false; + } + catch (IOException) { return false; } } diff --git a/src/PSTextMate/Rendering/MarkdownRenderer.cs b/src/PSTextMate/Rendering/MarkdownRenderer.cs index 69e2ad6..252fb27 100644 --- a/src/PSTextMate/Rendering/MarkdownRenderer.cs +++ b/src/PSTextMate/Rendering/MarkdownRenderer.cs @@ -79,12 +79,6 @@ private static int GetBlockEndLine(Block block, string markdown) { return block.Line + newlineCount; } - /// - /// Returns true for blocks that render with visual borders and need padding. - /// - private static bool IsBorderedBlock(Block block) => - block is QuoteBlock or FencedCodeBlock or HtmlBlock or Markdig.Extensions.Tables.Table; - /// /// Creates the Markdig pipeline with all necessary extensions and trivia tracking enabled. /// Pipeline follows Markdig's roundtrip parser design pattern - see: diff --git a/src/PSTextMate/Utilities/ITextMateStyler.cs b/src/PSTextMate/Utilities/ITextMateStyler.cs deleted file mode 100644 index 21930b7..0000000 --- a/src/PSTextMate/Utilities/ITextMateStyler.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace PSTextMate.Core; - -/// -/// Abstraction for applying TextMate token styles to text. -/// Enables reuse of TextMate highlighting in different contexts -/// (code blocks, inline code, etc). -/// -public interface ITextMateStyler { - /// - /// Gets the Spectre Style for a token's scope hierarchy. - /// - /// Token scope hierarchy - /// Theme for color lookup - /// Spectre Style or null if no style found - Style? GetStyleForScopes(IEnumerable scopes, Theme theme); - - /// - /// Applies a style to text. - /// - /// Text to style - /// Style to apply (can be null) - /// Rendered text with style applied - Text ApplyStyle(string text, Style? style); -} diff --git a/src/PSTextMate/Utilities/ImageFile.cs b/src/PSTextMate/Utilities/ImageFile.cs index 9dcd9ae..9a80353 100644 --- a/src/PSTextMate/Utilities/ImageFile.cs +++ b/src/PSTextMate/Utilities/ImageFile.cs @@ -9,6 +9,7 @@ namespace PSTextMate.Utilities; internal static partial class ImageFile { private static readonly HttpClient HttpClient = new(); private static readonly string[] SupportedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]; + private static readonly TimeSpan TempFileCleanupDelay = TimeSpan.FromHours(1); [GeneratedRegex(@"^data:image\/(?[a-zA-Z]+);base64,(?[A-Za-z0-9+/=]+)$", RegexOptions.Compiled)] private static partial Regex Base64Regex(); @@ -88,21 +89,17 @@ private static bool TryResolveFilePath(string inputPath, out string? resolvedPat await File.WriteAllBytesAsync(tempFileName, imageBytes); - // Schedule cleanup after a reasonable time (1 hour) - _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => { - try { - if (File.Exists(tempFileName)) { - File.Delete(tempFileName); - } - } - catch { - // Ignore cleanup errors - } - }); + ScheduleTempFileCleanup(tempFileName); return tempFileName; } - catch { + catch (FormatException) { + return null; + } + catch (IOException) { + return null; + } + catch (UnauthorizedAccessException) { return null; } } @@ -129,25 +126,43 @@ private static bool TryResolveFilePath(string inputPath, out string? resolvedPat using FileStream fileStream = File.Create(tempFileName); await response.Content.CopyToAsync(fileStream); - // Schedule cleanup after a reasonable time (1 hour) - _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => { - try { - if (File.Exists(tempFileName)) { - File.Delete(tempFileName); - } - } - catch { - // Ignore cleanup errors - } - }); + ScheduleTempFileCleanup(tempFileName); return tempFileName; } - catch { + catch (HttpRequestException) { + return null; + } + catch (TaskCanceledException) { + return null; + } + catch (IOException) { + return null; + } + catch (UnauthorizedAccessException) { return null; } } + private static void ScheduleTempFileCleanup(string tempFileName) { + _ = DeleteTempFileLaterAsync(tempFileName); + } + + private static async Task DeleteTempFileLaterAsync(string tempFileName) { + try { + await Task.Delay(TempFileCleanupDelay).ConfigureAwait(false); + if (File.Exists(tempFileName)) { + File.Delete(tempFileName); + } + } + catch (IOException) { + // Best effort cleanup only. + } + catch (UnauthorizedAccessException) { + // Best effort cleanup only. + } + } + /// /// Gets the file extension based on the content type. /// diff --git a/src/PSTextMate/Utilities/SpectreTextMateStyler.cs b/src/PSTextMate/Utilities/SpectreTextMateStyler.cs deleted file mode 100644 index 24d184a..0000000 --- a/src/PSTextMate/Utilities/SpectreTextMateStyler.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace PSTextMate.Core; - -/// -/// Spectre.Console implementation of ITextMateStyler. -/// Caches Style objects to avoid repeated creation. -/// -internal class SpectreTextMateStyler : ITextMateStyler { - /// - /// Cache: (scopesKey, themeHash) → Style - /// - private readonly ConcurrentDictionary<(string scopesKey, int themeHash), Style?> - _styleCache = new(); - - public Style? GetStyleForScopes(IEnumerable scopes, Theme theme) { - if (scopes == null) - return null; - - // Create cache key from scopes and theme instance - string scopesKey = string.Join(",", scopes); - int themeHash = RuntimeHelpers.GetHashCode(theme); - (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); - - // Return cached style or compute new one - return _styleCache.GetOrAdd(cacheKey, _ => ComputeStyle(scopes, theme)); - } - - public Text ApplyStyle(string text, Style? style) - => string.IsNullOrEmpty(text) ? Text.Empty : new Text(text, style ?? Style.Plain); - - /// - /// Computes the Spectre Style for a scope hierarchy by looking up theme rules. - /// Follows same pattern as TokenProcessor.GetStyleForScopes for consistency. - /// - private static Style? ComputeStyle(IEnumerable scopes, Theme theme) { - // Convert to list if not already (theme.Match expects IList) - IList scopesList = scopes as IList ?? [.. scopes]; - - int foreground = -1; - int background = -1; - FontStyle fontStyle = FontStyle.NotSet; - - // Match all applicable theme rules for this scope hierarchy - foreach (ThemeTrieElementRule? rule in theme.Match(scopesList)) { - if (foreground == -1 && rule.foreground > 0) - foreground = rule.foreground; - if (background == -1 && rule.background > 0) - background = rule.background; - if (fontStyle == FontStyle.NotSet && rule.fontStyle > 0) - fontStyle = rule.fontStyle; - } - - // No matching rules found - if (foreground == -1 && background == -1 && fontStyle == FontStyle.NotSet) - return null; - - // Use StyleHelper for consistent color and decoration conversion - Color? foregroundColor = StyleHelper.GetColor(foreground, theme); - Color? backgroundColor = StyleHelper.GetColor(background, theme); - Decoration decoration = StyleHelper.GetDecoration(fontStyle); - - return new Style(foregroundColor, backgroundColor, decoration); - } -} diff --git a/src/PSTextMate/Utilities/StringBuilderExtensions.cs b/src/PSTextMate/Utilities/StringBuilderExtensions.cs deleted file mode 100644 index 7f25ce6..0000000 --- a/src/PSTextMate/Utilities/StringBuilderExtensions.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace PSTextMate.Utilities; - -/// -/// Provides optimized StringBuilder extension methods for text rendering operations. -/// Reduces string allocations during the markup generation process. -/// -public static class StringBuilderExtensions { - /// - /// Appends a Spectre.Console link markup: [link=url]text[/] - /// - /// StringBuilder to append to - /// The URL for the link - /// The link text - /// The same StringBuilder for method chaining - public static StringBuilder AppendLink(this StringBuilder builder, string url, string text) { - builder.Append("[link=") - .Append(url.EscapeMarkup()) - .Append(']') - .Append(text.EscapeMarkup()) - .Append("[/]"); - return builder; - } - /// - /// Appends an integer value with optional style using invariant culture formatting. - /// - /// StringBuilder to append to - /// Optional style to apply - /// Nullable integer to append - /// The same StringBuilder for method chaining - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, int? value) - => AppendWithStyle(builder, style, value?.ToString(CultureInfo.InvariantCulture)); - - /// - /// Appends a string value with optional style markup, escaping special characters. - /// - /// StringBuilder to append to - /// Optional style to apply - /// String text to append - /// The same StringBuilder for method chaining - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, string? value) { - value ??= string.Empty; - return style is not null - ? builder.Append('[') - .Append(SpectreStyleCompat.ToMarkup(style)) - .Append(']') - .Append(value.EscapeMarkup()) - .Append("[/]") - : builder.Append(value); - } - - /// - /// Appends a string value with optional style markup and space separator, escaping special characters. - /// - /// StringBuilder to append to - /// Optional style to apply - /// String text to append - /// The same StringBuilder for method chaining - public static StringBuilder AppendWithStyleN(this StringBuilder builder, Style? style, string? value) { - value ??= string.Empty; - return style is not null - ? builder.Append('[') - .Append(SpectreStyleCompat.ToMarkup(style)) - .Append(']') - .Append(value) - .Append("[/] ") - : builder.Append(value); - } - - /// - /// Efficiently appends text with optional style markup using spans to reduce allocations. - /// This method is optimized for the common pattern of conditional style application. - /// - /// StringBuilder to append to - /// Optional style to apply - /// Text content to append - /// The same StringBuilder for method chaining - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, ReadOnlySpan value) { - return style is not null - ? builder.Append('[') - .Append(SpectreStyleCompat.ToMarkup(style)) - .Append(']') - .Append(value) - .Append("[/]") - : builder.Append(value); - } -} diff --git a/src/PSTextMate/Utilities/ThemeExtensions.cs b/src/PSTextMate/Utilities/ThemeExtensions.cs deleted file mode 100644 index 61f4862..0000000 --- a/src/PSTextMate/Utilities/ThemeExtensions.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace PSTextMate.Utilities; - -/// -/// Extension methods for converting TextMate themes and colors to Spectre.Console styling. -/// -public static class ThemeExtensions { - /// - /// Converts a TextMate theme to a Spectre.Console style. - /// This is a placeholder - actual theming should be done via scope-based lookups. - /// - /// The TextMate theme to convert. - /// A Spectre.Console style representing the TextMate theme. - public static Style ToSpectreStyle(this Theme theme) => new(foreground: Color.Default, background: Color.Default); - /// - /// Converts a TextMate color to a Spectre.Console color. - /// - /// The TextMate color to convert. - /// A Spectre.Console color representing the TextMate color. - // Try to use a more general color type, e.g. System.Drawing.Color or a custom struct/class - // If theme.Foreground and theme.Background are strings (hex), parse them accordingly - public static Color ToSpectreColor(this object color) { - if (color is string hex && !string.IsNullOrWhiteSpace(hex)) { - try { - return StyleHelper.HexToColor(hex); - } - catch { - return Color.Default; - } - } - return Color.Default; - } - /// - /// Converts a TextMate font style to a Spectre.Console font style. - /// - /// The TextMate font style to convert. - /// A Spectre.Console font style representing the TextMate font style. - - public static FontStyle ToSpectreFontStyle(this FontStyle fontStyle) { - FontStyle result = FontStyle.None; - if ((fontStyle & FontStyle.Italic) != 0) - result |= FontStyle.Italic; - if ((fontStyle & FontStyle.Bold) != 0) - result |= FontStyle.Bold; - if ((fontStyle & FontStyle.Underline) != 0) - result |= FontStyle.Underline; - return result; - } -} diff --git a/src/PSTextMate/Utilities/TokenStyleProcessor.cs b/src/PSTextMate/Utilities/TokenStyleProcessor.cs deleted file mode 100644 index b2180d2..0000000 --- a/src/PSTextMate/Utilities/TokenStyleProcessor.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace PSTextMate.Core; - -/// -/// Processes tokens and applies TextMate styling to produce Spectre renderables. -/// Decoupled from specific rendering context (can be used in code blocks, inline code, etc). -/// -internal static class TokenStyleProcessor { - /// - /// Processes tokens from a single line and produces styled Text objects. - /// - /// Tokens from grammar tokenization - /// Source line text - /// Theme for color lookup - /// Styler instance (inject for testability) - /// Array of styled Text renderables - public static IRenderable[] ProcessTokens( - IToken[] tokens, - string line, - Theme theme, - ITextMateStyler styler) { - var result = new List(); - - foreach (IToken token in tokens) { - int startIndex = Math.Min(token.StartIndex, line.Length); - int endIndex = Math.Min(token.EndIndex, line.Length); - - // Skip empty tokens - if (startIndex >= endIndex) - continue; - - // Extract text - string tokenText = line[startIndex..endIndex]; - - // Get style for this token's scopes - Style? style = styler.GetStyleForScopes(token.Scopes, theme); - - // Apply style and add to result - result.Add(styler.ApplyStyle(tokenText, style)); - } - - return [.. result]; - } - - /// - /// Process multiple lines of tokens and return combined renderables. - /// - public static IRenderable[] ProcessLines( - (IToken[] tokens, string line)[] tokenizedLines, - Theme theme, - ITextMateStyler styler) { - var result = new List(); - - foreach ((IToken[] tokens, string line) in tokenizedLines) { - // Process each line - IRenderable[] lineRenderables = ProcessTokens(tokens, line, theme, styler); - - // Wrap line's tokens in a Row - if (lineRenderables.Length > 0) - result.Add(new Rows(lineRenderables)); - else - result.Add(Text.Empty); - } - - return [.. result]; - } -} diff --git a/src/PSTextMate/Utilities/VTConversion.cs b/src/PSTextMate/Utilities/VTConversion.cs index f251bed..e2af84b 100644 --- a/src/PSTextMate/Utilities/VTConversion.cs +++ b/src/PSTextMate/Utilities/VTConversion.cs @@ -9,11 +9,6 @@ public static class VTConversion { private const char CSI_START = '['; private const char OSC_START = ']'; private const char SGR_END = 'm'; - private enum ForeignImageType { - Sixel, - IIP, - Kitty - } /// /// Parses a string containing VT escape sequences and returns a Paragraph object. @@ -515,68 +510,5 @@ public void Reset() { public readonly Style ToSpectreStyle() => SpectreStyleCompat.CreateWithLink(Foreground, Background, Decoration, Link); - public readonly string ToMarkup() { - // Use StringBuilder to avoid List allocation - // Typical markup is <64 chars, so inline capacity avoids resizing - var sb = new StringBuilder(64); - - if (Foreground.HasValue) { - sb.Append(Foreground.Value.ToMarkup()); - } - else { - sb.Append("Default "); - } - - if (Background.HasValue) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("on ").Append(Background.Value.ToMarkup()); - } - - if (Decoration != Decoration.None) { - if ((Decoration & Decoration.Bold) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("bold"); - } - if ((Decoration & Decoration.Dim) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("dim"); - } - if ((Decoration & Decoration.Italic) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("italic"); - } - if ((Decoration & Decoration.Underline) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("underline"); - } - if ((Decoration & Decoration.Strikethrough) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("strikethrough"); - } - if ((Decoration & Decoration.SlowBlink) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("slowblink"); - } - if ((Decoration & Decoration.RapidBlink) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("rapidblink"); - } - if ((Decoration & Decoration.Invert) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("invert"); - } - if ((Decoration & Decoration.Conceal) != 0) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("conceal"); - } - } - - if (!string.IsNullOrEmpty(Link)) { - if (sb.Length > 0) sb.Append(' '); - sb.Append("link=").Append(Link); - } - - return sb.ToString(); - } } } diff --git a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs index a114efe..eb0a666 100644 --- a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs +++ b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs @@ -47,6 +47,34 @@ public void SetQuery_EmptyValue_ClearsHitsAndCurrentSelection() { Assert.Null(session.CurrentHit); } + [Fact] + public void SetQuery_RepeatedAndChangedQuery_RebuildsRenderableHitIndexWithoutStaleEntries() { + PagerDocument document = new([ + new Markup("alpha beta"), + new Markup("beta beta"), + new Markup("gamma") + ]); + + PagerSearchSession session = new(document); + + session.SetQuery("beta"); + Assert.Equal(3, session.HitCount); + Assert.Single(session.GetHitsForRenderable(0)); + Assert.Equal(2, session.GetHitsForRenderable(1).Count); + Assert.Empty(session.GetHitsForRenderable(2)); + + session.SetQuery("beta"); + Assert.Equal(3, session.HitCount); + Assert.Single(session.GetHitsForRenderable(0)); + Assert.Equal(2, session.GetHitsForRenderable(1).Count); + + session.SetQuery("gamma"); + Assert.Equal(1, session.HitCount); + Assert.Empty(session.GetHitsForRenderable(0)); + Assert.Empty(session.GetHitsForRenderable(1)); + Assert.Single(session.GetHitsForRenderable(2)); + } + [Fact] public void SetQuery_CustomRenderableWithToStringFallback_FindsMatch() { PagerDocument document = new([ @@ -71,7 +99,7 @@ public void SetQuery_FromHighlightedTextWithCustomRenderable_FindsMatch() { Renderables = [new ThrowingRenderable("search target")] }; - PagerDocument document = PagerDocument.FromHighlightedText(highlighted); + var document = PagerDocument.FromHighlightedText(highlighted); PagerSearchSession session = new(document); session.SetQuery("target"); @@ -92,6 +120,43 @@ public void SetQuery_RenderableWithEmptyWriterOutput_UsesToStringFallback() { Assert.NotNull(session.MoveNext(topIndex: 0)); } + [Fact] + public void PagerDocument_SearchText_IsBuiltLazily() { + var renderable = new CountingRenderable("lazy search target"); + + PagerDocument document = new([renderable]); + + Assert.Equal(0, renderable.RenderCallCount); + + PagerSearchSession session = new(document); + session.SetQuery("target"); + + Assert.True(renderable.RenderCallCount > 0); + Assert.Equal(1, session.HitCount); + } + + [Fact] + public void RecalculateHeights_SameLayout_DoesNotRecomputeRenderHeights() { + var first = new CountingRenderable("alpha"); + var second = new CountingRenderable("beta"); + IReadOnlyList renderables = [first, second]; + + PagerViewportEngine engine = new(renderables, sourceHighlightedText: null); + + engine.RecalculateHeights(width: 80, contentRows: 20, windowHeight: 40, AnsiConsole.Console); + int firstPassRenders = first.RenderCallCount + second.RenderCallCount; + + engine.RecalculateHeights(width: 80, contentRows: 20, windowHeight: 40, AnsiConsole.Console); + int secondPassRenders = first.RenderCallCount + second.RenderCallCount; + + Assert.Equal(firstPassRenders, secondPassRenders); + + engine.RecalculateHeights(width: 81, contentRows: 20, windowHeight: 40, AnsiConsole.Console); + int thirdPassRenders = first.RenderCallCount + second.RenderCallCount; + + Assert.True(thirdPassRenders > secondPassRenders); + } + private sealed class ThrowingRenderable : IRenderable { private readonly string _text; @@ -99,17 +164,11 @@ public ThrowingRenderable(string text) { _text = text; } - public Measurement Measure(RenderOptions options, int maxWidth) { - throw new InvalidOperationException("test render failure"); - } + public Measurement Measure(RenderOptions options, int maxWidth) => throw new InvalidOperationException("test render failure"); - public IEnumerable Render(RenderOptions options, int maxWidth) { - throw new InvalidOperationException("test render failure"); - } + public IEnumerable Render(RenderOptions options, int maxWidth) => throw new InvalidOperationException("test render failure"); - public override string ToString() { - return _text; - } + public override string ToString() => _text; } private sealed class EmptyRenderable : IRenderable { @@ -119,16 +178,32 @@ public EmptyRenderable(string text) { _text = text; } + public Measurement Measure(RenderOptions options, int maxWidth) => new(1, 1); + + public IEnumerable Render(RenderOptions options, int maxWidth) => []; + + public override string ToString() => _text; + } + + private sealed class CountingRenderable : IRenderable { + private readonly string _text; + + public int RenderCallCount { get; private set; } + + public CountingRenderable(string text) { + _text = text; + } + public Measurement Measure(RenderOptions options, int maxWidth) { - return new Measurement(1, 1); + int width = Math.Max(1, Math.Min(maxWidth, _text.Length)); + return new Measurement(width, width); } public IEnumerable Render(RenderOptions options, int maxWidth) { - return []; + RenderCallCount++; + return [new Segment(_text)]; } - public override string ToString() { - return _text; - } + public override string ToString() => _text; } } From 339f78db83396212cdedebad265effc40b4aaa6e Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:29:11 +0100 Subject: [PATCH 13/17] =?UTF-8?q?feat(rendering):=20=E2=9C=A8=20Enhance=20?= =?UTF-8?q?markdown=20rendering=20and=20styling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated `HeadingRenderer` to use neutral fallback colors for headings. * Improved `HorizontalRuleRenderer` to add padding for better visual separation. * Enhanced `HtmlBlockRenderer` to handle exceptions more gracefully. * Refactored `ImageRenderer` to use `AsyncLocal` for error tracking and improved exception handling. * Modified `ListRenderer` to support nested lists and improved inline content extraction. * Updated `MarkdownRenderer` to streamline pipeline creation and improve compatibility with GitHub Flavored Markdown. * Enhanced `ParagraphRenderer` for better handling of inline elements and trailing line breaks. * Changed `QuoteRenderer` to use rounded borders for quotes. * Refactored `TableRenderer` to improve cell text extraction and removed unused header style method. * Updated `ImageCanvas` and `PixelImage` to implement `IRenderable` interface for better rendering consistency. * Improved utility methods in `StringBuilderPool` and `InlineTextExtractor` for optimized string handling. * Added tests for task lists, nested lists, and link rendering to ensure proper functionality. --- src/PSTextMate/Cmdlets/GetTextMateGrammar.cs | 3 +- src/PSTextMate/Cmdlets/OutPage.cs | 4 +- src/PSTextMate/Cmdlets/TextMateCmdletBase.cs | 28 +- src/PSTextMate/Core/HighlightedText.cs | 99 ++-- src/PSTextMate/Pager/Pager.cs | 59 +-- src/PSTextMate/Pager/PagerDocument.cs | 72 ++- src/PSTextMate/Pager/PagerHighlighting.cs | 490 ++---------------- src/PSTextMate/Rendering/CodeBlockRenderer.cs | 24 +- src/PSTextMate/Rendering/HeadingRenderer.cs | 18 +- .../Rendering/HorizontalRuleRenderer.cs | 9 +- src/PSTextMate/Rendering/HtmlBlockRenderer.cs | 7 +- src/PSTextMate/Rendering/ImageRenderer.cs | 73 ++- src/PSTextMate/Rendering/ListRenderer.cs | 94 +--- src/PSTextMate/Rendering/MarkdownRenderer.cs | 15 +- src/PSTextMate/Rendering/ParagraphRenderer.cs | 43 +- src/PSTextMate/Rendering/QuoteRenderer.cs | 2 +- src/PSTextMate/Rendering/TableRenderer.cs | 36 +- src/PSTextMate/Sixel/ImageCanvas.cs | 6 +- src/PSTextMate/Sixel/PixelImage.cs | 8 +- src/PSTextMate/Utilities/ImageFile.cs | 5 +- .../Utilities/InlineTextExtractor.cs | 10 +- src/PSTextMate/Utilities/MarkdownPatterns.cs | 43 +- src/PSTextMate/Utilities/StringBuilderPool.cs | 3 +- src/PSTextMate/Utilities/StringExtensions.cs | 161 ------ tests/Format-Markdown.tests.ps1 | 32 ++ .../PagerCoreTests.cs | 71 ++- 26 files changed, 495 insertions(+), 920 deletions(-) diff --git a/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs b/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs index 1dd4a30..b98e458 100644 --- a/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs +++ b/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs @@ -11,5 +11,6 @@ public sealed class GetTextMateGrammarCmdlet : PSCmdlet { /// /// Finalizes processing and outputs all supported languages. /// - protected override void EndProcessing() => WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); + protected override void EndProcessing() + => WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); } diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index 38833ff..2bc6389 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -152,9 +152,7 @@ private static int GetConsoleWidth() { } } - // Keep one-column slack so width-bound lines from Out-String do not - // wrap in the live pager viewport and skew row-height calculations. - private static int GetOutStringWidth() => Math.Max(20, GetConsoleWidth() - 1); + private static int GetOutStringWidth() => Math.Max(20, GetConsoleWidth() - 5); private static bool TryConvertForeignSpectreRenderable( object value, diff --git a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs index 0f4268a..86a0845 100644 --- a/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs +++ b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs @@ -155,13 +155,13 @@ protected override void EndProcessing() { return renderables is null ? null - : new HighlightedText { - Renderables = renderables, - ShowLineNumbers = LineNumbers.IsPresent, - Language = token, - // WrapInPanel = Panel.IsPresent, - Page = Page.IsPresent - }; + : new HighlightedText( + renderables, + showLineNumbers: LineNumbers.IsPresent, + language: token, + page: Page.IsPresent, + sourceLines: Page.IsPresent ? lines : null + ); } private void EmitHighlightedResult(HighlightedText result) { @@ -191,13 +191,13 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: UseAlternate); if (renderables is not null) { - yield return new HighlightedText { - Renderables = renderables, - ShowLineNumbers = LineNumbers.IsPresent, - Language = token, - // WrapInPanel = Panel.IsPresent, - Page = Page.IsPresent - }; + yield return new HighlightedText( + renderables, + showLineNumbers: LineNumbers.IsPresent, + language: token, + page: Page.IsPresent, + sourceLines: Page.IsPresent ? lines : null + ); } } diff --git a/src/PSTextMate/Core/HighlightedText.cs b/src/PSTextMate/Core/HighlightedText.cs index 1058dc1..a9574ee 100644 --- a/src/PSTextMate/Core/HighlightedText.cs +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -5,16 +5,41 @@ namespace PSTextMate.Core; /// Provides a clean, consistent output type. /// Implements IRenderable so it can be used directly with Spectre.Console. /// -public sealed class HighlightedText : Renderable { +public sealed class HighlightedText : IRenderable { + private static readonly IRenderable[] s_emptyRenderables = []; + + private IRenderable[] _renderables = s_emptyRenderables; + + public HighlightedText() { + } + + public HighlightedText( + IRenderable[] renderables, + bool showLineNumbers = false, + string language = "", + bool page = false, + IReadOnlyList? sourceLines = null + ) { + Renderables = renderables; + ShowLineNumbers = showLineNumbers; + Language = language ?? string.Empty; + Page = page; + SourceLines = sourceLines; + } + /// /// The highlighted renderables ready for display. /// - public IRenderable[] Renderables { get; set; } = []; + public IRenderable[] Renderables { + get => _renderables; + set => _renderables = value ?? s_emptyRenderables; + } // Optional view into an external renderable sequence to avoid allocating // new arrays when rendering paged slices. When _viewSource is non-null, // rendering methods use the view (Skip/Take) rather than the `Renderables` array. private IEnumerable? _viewSource; + private IReadOnlyList? _viewSourceList; private int _viewStart; private int _viewCount; // When a view is active, keep the total document line count when available @@ -22,6 +47,10 @@ public sealed class HighlightedText : Renderable { // (prevents gutter from changing across pages). private int _documentLineCount = -1; + // Optional source text retained for pager search fast path. + // This is intentionally opt-in to avoid unnecessary memory usage when paging is not used. + internal IReadOnlyList? SourceLines { get; private set; } + /// /// When true, prepend line numbers with a gutter separator. /// @@ -54,10 +83,13 @@ public sealed class HighlightedText : Renderable { /// public void SetView(IEnumerable source, int start, int count) { _viewSource = source ?? throw new ArgumentNullException(nameof(source)); + _viewSourceList = source as IReadOnlyList; _viewStart = Math.Max(0, start); _viewCount = Math.Max(0, count); // Try to capture the full source count when possible (ICollection/IReadOnlyCollection/IList) - _documentLineCount = source is ICollection coll + _documentLineCount = _viewSourceList is not null + ? _viewSourceList.Count + : source is ICollection coll ? coll.Count : source is IReadOnlyCollection rocoll ? rocoll.Count @@ -69,20 +101,29 @@ public void SetView(IEnumerable source, int start, int count) { /// public void ClearView() { _viewSource = null; + _viewSourceList = null; _viewStart = 0; _viewCount = 0; _documentLineCount = -1; } - private IEnumerable GetRenderablesEnumerable() => - _viewSource is null ? Renderables : _viewSource.Skip(_viewStart).Take(_viewCount); - public string Language { get; set; } = string.Empty; + private IEnumerable GetRenderablesEnumerable() { + return _viewSourceList is not null + ? EnumerateViewList(_viewSourceList, _viewStart, _viewCount) + : _viewSource is null ? Renderables : _viewSource.Skip(_viewStart).Take(_viewCount); + } - /// - /// When true, consumers should render this highlighted text inside a Panel. - /// This is preserved across slices and allows the pager to respect panel state. - /// - public bool WrapInPanel { get; set; } + private static IEnumerable EnumerateViewList(IReadOnlyList source, int start, int count) { + int begin = Math.Clamp(start, 0, source.Count); + int end = Math.Clamp(begin + Math.Max(0, count), begin, source.Count); + for (int i = begin; i < end; i++) { + yield return source[i]; + } + } + + internal void SetSourceLines(IReadOnlyList? sourceLines) + => SourceLines = sourceLines; + public string Language { get; set; } = string.Empty; /// /// When true, writing this renderable should use the interactive pager. @@ -92,23 +133,7 @@ private IEnumerable GetRenderablesEnumerable() => /// /// Renders the highlighted text by combining all renderables into a single output. /// - protected override IEnumerable Render(RenderOptions options, int maxWidth) { - // If a panel wrapper is requested, render the inner content via a dedicated IRenderable - // and let Spectre.Console's Panel handle borders/padding. - if (WrapInPanel) { - // Fast path: if we don't need line numbers, wrap the raw rows directly - // to avoid creating the InnerContentRenderable wrapper. - if (!ShowLineNumbers) { - var rowsInner = new Rows(GetRenderablesEnumerable()); - var panelInner = new Panel(rowsInner) { Padding = new Padding(0, 0), Expand = true }; - return ((IRenderable)panelInner).Render(options, maxWidth); - } - - var inner = new InnerContentRenderable(this); - var panel = new Panel(inner) { Padding = new Padding(0, 0), Expand = true }; - return ((IRenderable)panel).Render(options, maxWidth); - } - + public IEnumerable Render(RenderOptions options, int maxWidth) { // Delegate to Rows which efficiently renders all renderables var rows = new Rows(GetRenderablesEnumerable()); return !ShowLineNumbers ? ((IRenderable)rows).Render(options, maxWidth) : RenderWithLineNumbers(rows, options, maxWidth); @@ -117,19 +142,7 @@ protected override IEnumerable Render(RenderOptions options, int maxWid /// /// Measures the dimensions of the highlighted text. /// - protected override Measurement Measure(RenderOptions options, int maxWidth) { - if (WrapInPanel) { - if (!ShowLineNumbers) { - var rowsInner = new Rows(GetRenderablesEnumerable()); - var panelInner = new Panel(rowsInner) { Padding = new Padding(0, 0), Expand = true }; - return ((IRenderable)panelInner).Measure(options, maxWidth); - } - - var inner = new InnerContentRenderable(this); - var panel = new Panel(inner) { Padding = new Padding(0, 0), Expand = true }; - return ((IRenderable)panel).Measure(options, maxWidth); - } - + public Measurement Measure(RenderOptions options, int maxWidth) { // Delegate to Rows for measurement var rows = new Rows(GetRenderablesEnumerable()); return !ShowLineNumbers ? ((IRenderable)rows).Measure(options, maxWidth) : MeasureWithLineNumbers(rows, options, maxWidth); @@ -261,7 +274,9 @@ private int ResolveLineNumberWidth(int lineCount) { public Panel ToPanel(string? title = null, BoxBorder? border = null) { // Build the panel around the actual inner content instead of `this` to avoid // creating nested panels when consumers already wrap the object. - IRenderable content = !ShowLineNumbers ? new Rows(Renderables) : new InnerContentRenderable(this); + IRenderable content = !ShowLineNumbers + ? new Rows(GetRenderablesEnumerable()) + : new InnerContentRenderable(this); var panel = new Panel(content); panel.Padding(0, 0); diff --git a/src/PSTextMate/Pager/Pager.cs b/src/PSTextMate/Pager/Pager.cs index 08b99bc..c35de06 100644 --- a/src/PSTextMate/Pager/Pager.cs +++ b/src/PSTextMate/Pager/Pager.cs @@ -3,8 +3,8 @@ namespace PSTextMate.Terminal; /// /// Simple pager implemented with Spectre.Console Live display. /// Interaction keys: -/// - Up/Down: move one renderable item -/// - PageUp/PageDown: move by one viewport of items +/// - Up/Down or j/k: move one renderable item +/// - PageUp/PageDown/Space or h/l: move by one viewport of items /// - Home/End: go to start/end /// - / or Ctrl+F: prompt for search query /// - n / N: next / previous match @@ -24,7 +24,6 @@ public sealed class Pager { private readonly HighlightedText? _sourceHighlightedText; private readonly int? _originalLineNumberStart; private readonly int? _originalLineNumberWidth; - private readonly bool? _originalWrapInPanel; private readonly int? _stableLineNumberWidth; private readonly int _statusColumnWidth; private int _top; @@ -38,10 +37,8 @@ public sealed class Pager { private readonly StringBuilder _searchInputBuffer = new(64); private static readonly Style SearchRowTextStyle = new(Color.White, Color.Grey); private static readonly Style SearchMatchTextStyle = new(Color.Black, Color.Orange1); - private const string SearchRowStyle = "white on grey"; - private const string SearchMatchStyle = "black on orange1"; private const int KeyPollingIntervalMs = 50; - + private const int MaxSearchQueryLength = 256; private bool TryReadKey(out ConsoleKeyInfo key) { if (_tryReadKeyOverride is not null) { ConsoleKeyInfo? injected = _tryReadKeyOverride(); @@ -126,7 +123,6 @@ public Pager(HighlightedText highlightedText) { _tryReadKeyOverride = null; _suppressTerminalControlSequences = false; _sourceHighlightedText = highlightedText; - _originalWrapInPanel = highlightedText.WrapInPanel; int totalLines = highlightedText.LineCount; int lastLineNumber = highlightedText.LineNumberStart + Math.Max(0, totalLines - 1); @@ -134,9 +130,6 @@ public Pager(HighlightedText highlightedText) { _originalLineNumberStart = highlightedText.LineNumberStart; _originalLineNumberWidth = highlightedText.LineNumberWidth; - // Panel rendering in pager mode causes unstable layout; disable it for the paging session. - highlightedText.WrapInPanel = false; - _document = PagerDocument.FromHighlightedText(highlightedText); _renderables = _document.Renderables; _search = new PagerSearchSession(_document); @@ -271,19 +264,23 @@ private void Navigate(LiveDisplayContext ctx) { switch (key.Key) { case ConsoleKey.DownArrow: + case ConsoleKey.J: ScrollRenderable(1, contentRows); forceRedraw = true; break; case ConsoleKey.UpArrow: + case ConsoleKey.K: ScrollRenderable(-1, contentRows); forceRedraw = true; break; case ConsoleKey.Spacebar: case ConsoleKey.PageDown: + case ConsoleKey.L: PageDown(contentRows); forceRedraw = true; break; case ConsoleKey.PageUp: + case ConsoleKey.H: PageUp(contentRows); forceRedraw = true; break; @@ -371,12 +368,18 @@ private void HandleSearchInputKey(ConsoleKeyInfo key, ref bool forceRedraw) { } if (!char.IsControl(key.KeyChar)) { - _searchInputBuffer.Append(key.KeyChar); - forceRedraw = true; + if (_searchInputBuffer.Length < MaxSearchQueryLength) { + _searchInputBuffer.Append(key.KeyChar); + forceRedraw = true; + } } } private void ApplySearchQuery(string query) { + if (query.Length > MaxSearchQueryLength) { + query = query[..MaxSearchQueryLength]; + } + _search.SetQuery(query); if (!_search.HasQuery) { _searchStatusText = string.Empty; @@ -490,8 +493,8 @@ private static Panel BuildHelpOverlayPanel() { var helpRows = new Rows( new Text("Keybindings", new Style(Color.White, decoration: Decoration.Bold)), Text.Empty, - new Text(" Up/Down Move one item", new Style(Color.Grey)), - new Text(" PgUp/PgDn Page navigation", new Style(Color.Grey)), + new Text(" Up/Down or j/k Move one item", new Style(Color.Grey)), + new Text(" PgUp/PgDn/h/l Page navigation", new Style(Color.Grey)), new Text(" Home/End Jump to start/end", new Style(Color.Grey)), new Text(" / or Ctrl+F Search", new Style(Color.Grey)), new Text(" n / N Next / previous match", new Style(Color.Grey)), @@ -504,7 +507,7 @@ private static Panel BuildHelpOverlayPanel() { return new Panel(new Align(helpRows, HorizontalAlignment.Left, VerticalAlignment.Middle)) { Header = new PanelHeader("Pager Help", Justify.Left), - Border = BoxBorder.Double, + Border = BoxBorder.Rounded, Padding = new Padding(2, 1, 2, 1), Expand = true }; @@ -545,22 +548,6 @@ private Rows BuildSearchAwareContent(PagerViewportWindow viewport) private IRenderable ApplySearchHighlight(int renderableIndex, IRenderable renderable) { IReadOnlyList hits = _search.GetHitsForRenderable(renderableIndex); - bool isStructuredRenderable = PagerHighlighting.IsStructuredRowHighlightCandidate(renderable); - - // Keep table-specific highlighting in the structured path so row styling - // is scoped to matching rows rather than the whole rendered table block. - if ((isStructuredRenderable || hits.Count == 0) - && PagerHighlighting.TryBuildStructuredHighlightRenderable( - renderable, - _search.Query, - SearchRowStyle, - SearchMatchStyle, - hits, - out IRenderable structuredHighlight - )) { - return structuredHighlight; - } - string plainText = GetSearchTextForHighlight(renderableIndex, renderable); if (plainText.Length == 0 || !_search.HasQuery) { return renderable; @@ -573,7 +560,14 @@ out IRenderable structuredHighlight } } - return PagerHighlighting.BuildSegmentHighlightRenderable(renderable, _search.Query, SearchRowTextStyle, SearchMatchTextStyle); + bool highlightLinkedLabelsOnNoDirectMatch = hits.Count > 0; + return PagerHighlighting.BuildSegmentHighlightRenderable( + renderable, + _search.Query, + SearchRowTextStyle, + SearchMatchTextStyle, + highlightLinkedLabelsOnNoDirectMatch + ); } private static List BuildQueryHits(string plainText, string query, int renderableIndex) { @@ -774,7 +768,6 @@ private void ShowCore() { _sourceHighlightedText.ClearView(); _sourceHighlightedText.LineNumberStart = _originalLineNumberStart ?? 1; _sourceHighlightedText.LineNumberWidth = _originalLineNumberWidth; - _sourceHighlightedText.WrapInPanel = _originalWrapInPanel ?? false; } if (!_suppressTerminalControlSequences) { diff --git a/src/PSTextMate/Pager/PagerDocument.cs b/src/PSTextMate/Pager/PagerDocument.cs index 53e62ad..383ce57 100644 --- a/src/PSTextMate/Pager/PagerDocument.cs +++ b/src/PSTextMate/Pager/PagerDocument.cs @@ -12,22 +12,36 @@ bool IsImage public int[] LineStarts => GetLineStarts(); } -internal sealed class PagerDocument { +internal sealed partial class PagerDocument { private readonly List _entries = []; + private static readonly Regex s_hyperlinkTargetRegex = HyperlinkTargetRegex(); public IReadOnlyList Entries => _entries; - public IReadOnlyList Renderables { get; } + public IReadOnlyList Renderables { get; private set; } = []; public PagerDocument(IEnumerable renderables) { + Initialize(renderables, sourceLines: null); + } + + private PagerDocument(IEnumerable renderables, IReadOnlyList? sourceLines) { + Initialize(renderables, sourceLines); + } + + private void Initialize(IEnumerable renderables, IReadOnlyList? sourceLines) { ArgumentNullException.ThrowIfNull(renderables); var renderableList = new List(); int index = 0; foreach (IRenderable renderable in renderables) { + int entryIndex = index; bool isImage = IsImageRenderable(renderable); Lazy lazySearchText = new( - () => isImage ? string.Empty : ExtractSearchText(renderable), + () => isImage + ? string.Empty + : sourceLines is not null + ? Normalize(sourceLines[entryIndex]) + : ExtractSearchText(renderable), isThreadSafe: false ); Lazy lazyLineStarts = new( @@ -51,7 +65,11 @@ public PagerDocument(IEnumerable renderables) { public static PagerDocument FromHighlightedText(HighlightedText highlightedText) { ArgumentNullException.ThrowIfNull(highlightedText); - return new PagerDocument(highlightedText.Renderables); + + IReadOnlyList? sourceLines = highlightedText.SourceLines; + return sourceLines is not null && sourceLines.Count == highlightedText.Renderables.Length + ? new PagerDocument(highlightedText.Renderables, sourceLines) + : new PagerDocument(highlightedText.Renderables); } public PagerDocumentEntry? GetEntry(int renderableIndex) { @@ -61,15 +79,17 @@ public static PagerDocument FromHighlightedText(HighlightedText highlightedText) } private static string ExtractSearchText(IRenderable renderable) { - if (renderable is Text text) { - return Normalize(text.ToString()); - } - try { string rendered = Writer.WriteToString(renderable, width: 200); - string normalized = Normalize(VTHelpers.StripAnsi(rendered)); - return !string.IsNullOrEmpty(normalized) - ? normalized + string visibleText = Normalize(VTHelpers.StripAnsi(rendered)); + string hyperlinkTargets = ExtractHyperlinkTargets(rendered); + + return !string.IsNullOrEmpty(hyperlinkTargets) + ? string.IsNullOrEmpty(visibleText) + ? hyperlinkTargets + : $"{visibleText}\n{hyperlinkTargets}" + : !string.IsNullOrEmpty(visibleText) + ? visibleText : Normalize(renderable.ToString()); } catch (InvalidOperationException) { @@ -78,8 +98,38 @@ private static string ExtractSearchText(IRenderable renderable) { catch (IOException) { return Normalize(renderable.ToString()); } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { + return Normalize(renderable.ToString()); + } } + private static string ExtractHyperlinkTargets(string rendered) { + if (string.IsNullOrEmpty(rendered)) { + return string.Empty; + } + + var urls = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Match match in s_hyperlinkTargetRegex.Matches(rendered)) { + string url = match.Groups["url"].Value; + if (string.IsNullOrWhiteSpace(url)) { + continue; + } + + if (seen.Add(url)) { + urls.Add(url); + } + } + + return urls.Count == 0 + ? string.Empty + : Normalize(string.Join('\n', urls)); + } + + [GeneratedRegex("\\x1b\\]8;;(?.*?)(?:\\x1b\\\\|\\x07)", RegexOptions.NonBacktracking, 250)] + private static partial Regex HyperlinkTargetRegex(); + private static string Normalize(string? value) { return string.IsNullOrEmpty(value) ? string.Empty diff --git a/src/PSTextMate/Pager/PagerHighlighting.cs b/src/PSTextMate/Pager/PagerHighlighting.cs index 388e28b..499d8e4 100644 --- a/src/PSTextMate/Pager/PagerHighlighting.cs +++ b/src/PSTextMate/Pager/PagerHighlighting.cs @@ -1,88 +1,13 @@ namespace PSTextMate.Terminal; internal static class PagerHighlighting { - private static readonly FieldInfo? s_panelChildField = typeof(Panel).GetField("_child", BindingFlags.Instance | BindingFlags.NonPublic); - - internal static Paragraph BuildHighlightedTextRenderable( - string plainText, - IReadOnlyList hits, - Style rowStyle, - Style matchStyle - ) { - var result = new Paragraph(); - if (plainText.Length == 0) { - return result; - } - - if (hits.Count == 0) { - result.Append(plainText, rowStyle); - return result; - } - - int position = 0; - foreach (PagerSearchHit hit in hits.OrderBy(static h => h.Offset)) { - int start = Math.Clamp(hit.Offset, 0, plainText.Length); - int length = Math.Clamp(hit.Length, 0, plainText.Length - start); - if (length <= 0 || start < position) { - continue; - } - - if (start > position) { - result.Append(plainText[position..start], rowStyle); - } - - result.Append(plainText.Substring(start, length), matchStyle); - position = start + length; - } - - if (position < plainText.Length) { - result.Append(plainText[position..], rowStyle); - } - - return result; - } - - internal static bool TryBuildStructuredHighlightRenderable( - IRenderable renderable, - string query, - string rowStyle, - string matchStyle, - IReadOnlyList indexedHits, - out IRenderable highlighted - ) { - highlighted = renderable; - if (string.IsNullOrEmpty(query)) { - return false; - } - - if (renderable is Table table) { - highlighted = CloneTableWithHighlight(table, query, rowStyle, matchStyle, indexedHits); - return true; - } - - if (renderable is Grid grid) { - highlighted = CloneGridWithHighlight(grid, query, rowStyle, matchStyle, indexedHits); - return true; - } - - if (renderable is Panel panel) { - highlighted = ClonePanelWithHighlight(panel, query, rowStyle, matchStyle); - return true; - } - - return false; - } - - internal static bool IsStructuredRowHighlightCandidate(IRenderable renderable) - => renderable is Table or Grid or Panel; - internal static IRenderable BuildSegmentHighlightRenderable( IRenderable renderable, string query, Style rowStyle, - Style matchStyle - ) => new SegmentHighlightRenderable(renderable, query, rowStyle, matchStyle); - + Style matchStyle, + bool highlightLinkedLabelsOnNoDirectMatch = false + ) => new SegmentHighlightRenderable(renderable, query, rowStyle, matchStyle, highlightLinkedLabelsOnNoDirectMatch); internal static string NormalizeText(string? text) { return string.IsNullOrEmpty(text) @@ -92,252 +17,6 @@ internal static string NormalizeText(string? text) { .TrimEnd('\n'); } - private static Table CloneTableWithHighlight( - Table source, - string query, - string rowStyle, - string matchStyle, - IReadOnlyList indexedHits - ) { - bool[] rowCellMatches = [.. source.Rows.Select(row => row.Any(cell => RenderableContainsQuery(cell, query)))]; - bool hasAnyRowCellMatch = rowCellMatches.Any(static match => match); - HashSet matchedRowsFromHits = hasAnyRowCellMatch - ? [] - : ResolveTableRowsFromHitLines(source, indexedHits); - - var clone = new Table { - Border = source.Border, - BorderStyle = source.BorderStyle, - UseSafeBorder = source.UseSafeBorder, - ShowHeaders = source.ShowHeaders, - ShowRowSeparators = source.ShowRowSeparators, - ShowFooters = source.ShowFooters, - Expand = source.Expand, - Width = source.Width, - Title = source.Title is null ? null : new TableTitle(source.Title.Text, source.Title.Style), - Caption = source.Caption is null ? null : new TableTitle(source.Caption.Text, source.Caption.Style) - }; - - foreach (TableColumn sourceColumn in source.Columns) { - IRenderable header = HighlightRenderableNode(sourceColumn.Header, query, applyRowStyle: false, rowStyle, matchStyle); - IRenderable? footer = sourceColumn.Footer is null - ? null - : HighlightRenderableNode(sourceColumn.Footer, query, applyRowStyle: false, rowStyle, matchStyle); - var column = new TableColumn(header) { - Width = sourceColumn.Width, - Padding = sourceColumn.Padding, - NoWrap = sourceColumn.NoWrap, - Alignment = sourceColumn.Alignment, - Footer = footer - }; - - clone.AddColumn(column); - } - - int rowIndex = 0; - foreach (TableRow sourceRow in source.Rows) { - bool rowHasCellMatch = rowCellMatches[rowIndex]; - bool rowHasIndexedContextMatch = !hasAnyRowCellMatch && matchedRowsFromHits.Contains(rowIndex); - bool rowHasMatch = rowHasCellMatch || rowHasIndexedContextMatch; - - var rowItems = new List(); - foreach (IRenderable sourceCell in sourceRow) { - rowItems.Add(HighlightRenderableNode(sourceCell, query, rowHasMatch, rowStyle, matchStyle)); - } - - clone.AddRow(rowItems); - rowIndex++; - } - - return clone; - } - - private static bool RenderableContainsQuery(IRenderable renderable, string query) { - if (string.IsNullOrEmpty(query)) { - return false; - } - - string plainText = NormalizeText(ExtractRenderableText(renderable)); - return plainText.Contains(query, StringComparison.OrdinalIgnoreCase); - } - - private static Grid CloneGridWithHighlight( - Grid source, - string query, - string rowStyle, - string matchStyle, - IReadOnlyList indexedHits - ) { - bool[] rowCellMatches = [.. source.Rows.Select(row => row.Any(cell => RenderableContainsQuery(cell, query)))]; - bool hasAnyRowCellMatch = rowCellMatches.Any(static match => match); - HashSet matchedRowsFromHits = hasAnyRowCellMatch - ? [] - : ResolveGridRowsFromHitLines(source, indexedHits); - - var clone = new Grid { - Expand = source.Expand, - Width = source.Width - }; - - foreach (GridColumn sourceColumn in source.Columns) { - clone.AddColumn(new GridColumn { - Width = sourceColumn.Width, - NoWrap = sourceColumn.NoWrap, - Padding = sourceColumn.Padding, - Alignment = sourceColumn.Alignment - }); - } - - int rowIndex = 0; - foreach (GridRow sourceRow in source.Rows) { - bool rowHasCellMatch = rowCellMatches[rowIndex]; - bool rowHasIndexedContextMatch = !hasAnyRowCellMatch && matchedRowsFromHits.Contains(rowIndex); - bool rowHasMatch = rowHasCellMatch || rowHasIndexedContextMatch; - - IRenderable[] rowItems = [.. sourceRow.Select(cell => HighlightRenderableNode(cell, query, rowHasMatch, rowStyle, matchStyle))]; - clone.AddRow(rowItems); - rowIndex++; - } - - return clone; - } - - private static HashSet ResolveTableRowsFromHitLines(Table source, IReadOnlyList indexedHits) { - var matchedRows = new HashSet(); - if (indexedHits.Count == 0 || source.Rows.Count == 0) { - return matchedRows; - } - - Table probe = BuildTableSkeleton(source); - int previousLineCount = CountRenderableLines(probe); - - int rowIndex = 0; - foreach (TableRow sourceRow in source.Rows) { - probe.AddRow([.. sourceRow]); - int currentLineCount = CountRenderableLines(probe); - int rowStartLine = previousLineCount; - int rowEndLine = Math.Max(previousLineCount, currentLineCount - 1); - - bool hitInRow = indexedHits.Any(hit => hit.Line >= rowStartLine && hit.Line <= rowEndLine); - if (hitInRow) { - matchedRows.Add(rowIndex); - } - - previousLineCount = currentLineCount; - rowIndex++; - } - - return matchedRows; - } - - private static HashSet ResolveGridRowsFromHitLines(Grid source, IReadOnlyList indexedHits) { - var matchedRows = new HashSet(); - if (indexedHits.Count == 0 || source.Rows.Count == 0) { - return matchedRows; - } - - var probe = new Grid { - Expand = source.Expand, - Width = source.Width - }; - - foreach (GridColumn sourceColumn in source.Columns) { - probe.AddColumn(new GridColumn { - Width = sourceColumn.Width, - NoWrap = sourceColumn.NoWrap, - Padding = sourceColumn.Padding, - Alignment = sourceColumn.Alignment - }); - } - - int previousLineCount = CountRenderableLines(probe); - int rowIndex = 0; - foreach (GridRow sourceRow in source.Rows) { - probe.AddRow([.. sourceRow]); - int currentLineCount = CountRenderableLines(probe); - int rowStartLine = previousLineCount; - int rowEndLine = Math.Max(previousLineCount, currentLineCount - 1); - - bool hitInRow = indexedHits.Any(hit => hit.Line >= rowStartLine && hit.Line <= rowEndLine); - if (hitInRow) { - matchedRows.Add(rowIndex); - } - - previousLineCount = currentLineCount; - rowIndex++; - } - - return matchedRows; - } - - private static Table BuildTableSkeleton(Table source) { - var probe = new Table { - Border = source.Border, - BorderStyle = source.BorderStyle, - UseSafeBorder = source.UseSafeBorder, - ShowHeaders = source.ShowHeaders, - ShowRowSeparators = source.ShowRowSeparators, - ShowFooters = source.ShowFooters, - Expand = source.Expand, - Width = source.Width, - Title = source.Title is null ? null : new TableTitle(source.Title.Text, source.Title.Style), - Caption = source.Caption is null ? null : new TableTitle(source.Caption.Text, source.Caption.Style) - }; - - foreach (TableColumn sourceColumn in source.Columns) { - probe.AddColumn(new TableColumn(sourceColumn.Header) { - Width = sourceColumn.Width, - Padding = sourceColumn.Padding, - NoWrap = sourceColumn.NoWrap, - Alignment = sourceColumn.Alignment, - Footer = sourceColumn.Footer - }); - } - - return probe; - } - - private static Panel ClonePanelWithHighlight( - Panel source, - string query, - string rowStyle, - string matchStyle - ) { - if (!TryGetPanelChild(source, out IRenderable child)) { - return source; - } - - var parsedRowStyle = Style.Parse(rowStyle); - var parsedMatchStyle = Style.Parse(matchStyle); - IRenderable highlightedChild = IsStructuredRowHighlightCandidate(child) - && TryBuildStructuredHighlightRenderable(child, query, rowStyle, matchStyle, [], out IRenderable structuredChild) - ? structuredChild - : new SegmentHighlightRenderable(child, query, parsedRowStyle, parsedMatchStyle); - - // Preserve structured semantics for nested tables/grids inside panels. - - return new Panel(highlightedChild) { - Border = source.Border, - UseSafeBorder = source.UseSafeBorder, - BorderStyle = source.BorderStyle, - Expand = source.Expand, - Padding = source.Padding, - Header = source.Header, - Width = source.Width, - Height = source.Height - }; - } - - private static bool TryGetPanelChild(Panel panel, out IRenderable child) { - if (s_panelChildField?.GetValue(panel) is IRenderable renderable) { - child = renderable; - return true; - } - - child = Text.Empty; - return false; - } - private static List BuildQueryHits(string plainText, string query) { if (string.IsNullOrEmpty(plainText) || string.IsNullOrEmpty(query)) { return []; @@ -358,23 +37,31 @@ private static List BuildQueryHits(string plainText, string quer return hits; } - private sealed class SegmentHighlightRenderable : Renderable { + private sealed class SegmentHighlightRenderable : IRenderable { private readonly IRenderable _inner; private readonly string _query; private readonly Style _rowStyle; private readonly Style _matchStyle; - - public SegmentHighlightRenderable(IRenderable inner, string query, Style rowStyle, Style matchStyle) { + private readonly bool _highlightLinkedLabelsOnNoDirectMatch; + + public SegmentHighlightRenderable( + IRenderable inner, + string query, + Style rowStyle, + Style matchStyle, + bool highlightLinkedLabelsOnNoDirectMatch + ) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _query = query ?? string.Empty; _rowStyle = rowStyle; _matchStyle = matchStyle; + _highlightLinkedLabelsOnNoDirectMatch = highlightLinkedLabelsOnNoDirectMatch; } - protected override Measurement Measure(RenderOptions options, int maxWidth) + public Measurement Measure(RenderOptions options, int maxWidth) => _inner.Measure(options, maxWidth); - protected override IEnumerable Render(RenderOptions options, int maxWidth) { + public IEnumerable Render(RenderOptions options, int maxWidth) { List source = [.. _inner.Render(options, maxWidth)]; if (source.Count == 0 || string.IsNullOrEmpty(_query)) { return source; @@ -386,7 +73,12 @@ protected override IEnumerable Render(RenderOptions options, int maxWid } List hits = BuildQueryHits(plainText, _query); - if (hits.Count == 0) { + bool hasDirectHits = hits.Count > 0; + bool highlightLinkedLabels = _highlightLinkedLabelsOnNoDirectMatch + && !hasDirectHits + && source.Any(segment => SegmentLinkMatchesQuery(segment, _query)); + + if (!hasDirectHits && !highlightLinkedLabels) { return source; } @@ -400,7 +92,17 @@ protected override IEnumerable Render(RenderOptions options, int maxWid } bool[] lineHasMatch = BuildLineMatchMask(plainText, hits); - return RebuildSegmentsWithHighlights(source, matchMask, lineHasMatch); + return RebuildSegmentsWithHighlights(source, matchMask, lineHasMatch, highlightLinkedLabels); + } + + private static bool SegmentLinkMatchesQuery(Segment segment, string query) { + if (string.IsNullOrWhiteSpace(query) || segment.IsControlCode || segment.IsLineBreak) { + return false; + } + + string? link = segment.Style.Link; + return !string.IsNullOrWhiteSpace(link) + && link.Contains(query, StringComparison.OrdinalIgnoreCase); } private static string BuildPlainText(IEnumerable segments) { @@ -455,7 +157,12 @@ private static int ResolveLine(List lineStarts, int offset) { return line; } - private List RebuildSegmentsWithHighlights(List source, bool[] matchMask, bool[] lineHasMatch) { + private List RebuildSegmentsWithHighlights( + List source, + bool[] matchMask, + bool[] lineHasMatch, + bool highlightLinkedLabels + ) { var output = new List(source.Count * 2); int absolute = 0; int line = 0; @@ -480,6 +187,8 @@ private List RebuildSegmentsWithHighlights(List source, bool[] continue; } + bool segmentLinkMatchesQuery = highlightLinkedLabels && SegmentLinkMatchesQuery(segment, _query); + var chunk = new StringBuilder(); Style? chunkStyle = null; @@ -497,7 +206,11 @@ private List RebuildSegmentsWithHighlights(List source, bool[] bool inMatch = absolute >= 0 && absolute < matchMask.Length && matchMask[absolute]; bool inMatchedLine = line >= 0 && line < lineHasMatch.Length && lineHasMatch[line]; - Style style = inMatch ? _matchStyle : inMatchedLine ? _rowStyle : segment.Style; + Style style = inMatch || segmentLinkMatchesQuery + ? _matchStyle + : inMatchedLine + ? _rowStyle + : segment.Style; if (chunkStyle is null || !chunkStyle.Equals(style)) { FlushChunk(output, chunk, chunkStyle); @@ -524,115 +237,4 @@ private static void FlushChunk(List output, StringBuilder chunk, Style? } } - private static int CountRenderableLines(IRenderable renderable) { - try { - string rendered = Writer.WriteToString(renderable, width: 200); - if (string.IsNullOrEmpty(rendered)) { - return 0; - } - - int lines = 1; - foreach (char ch in rendered) { - if (ch == '\n') { - lines++; - } - } - - return lines; - } - catch (InvalidOperationException) { - return 0; - } - catch (IOException) { - return 0; - } - } - - private static IRenderable HighlightRenderableNode( - IRenderable renderable, - string query, - bool applyRowStyle, - string rowStyle, - string matchStyle - ) => HighlightLeafRenderable(renderable, query, applyRowStyle, rowStyle, matchStyle); - - private static IRenderable HighlightLeafRenderable( - IRenderable renderable, - string query, - bool applyRowStyle, - string rowStyle, - string matchStyle - ) { - string plainText = NormalizeText(ExtractRenderableText(renderable)); - if (plainText.Length == 0) { - return renderable; - } - - var rowTextStyle = Style.Parse(rowStyle); - var matchTextStyle = Style.Parse(matchStyle); - - if (string.IsNullOrEmpty(query)) { - return applyRowStyle - ? BuildHighlightedTextRenderable(plainText, [], rowTextStyle, matchTextStyle) - : renderable; - } - - var hits = new List(); - int searchStart = 0; - while (searchStart <= plainText.Length - query.Length) { - int hitOffset = plainText.IndexOf(query, searchStart, StringComparison.OrdinalIgnoreCase); - if (hitOffset < 0) { - break; - } - - hits.Add(new PagerSearchHit(0, hitOffset, query.Length, 0, hitOffset)); - searchStart = hitOffset + Math.Max(1, query.Length); - } - - return hits.Count == 0 && !applyRowStyle - ? renderable - : BuildHighlightedTextRenderable(plainText, hits, rowTextStyle, matchTextStyle); - } - - private static string ExtractRenderableText(IRenderable renderable) { - try { - var options = RenderOptions.Create(AnsiConsole.Console); - IEnumerable segments = renderable.Render(options, maxWidth: 200); - var builder = new StringBuilder(); - - foreach (Segment segment in segments) { - if (segment.IsControlCode) { - continue; - } - - if (segment.IsLineBreak) { - builder.Append('\n'); - continue; - } - - builder.Append(segment.Text); - } - - string extracted = builder.ToString(); - if (!string.IsNullOrEmpty(extracted)) { - return extracted; - } - } - catch (InvalidOperationException) { - } - - try { - string rendered = Writer.WriteToString(renderable, width: 200); - return string.IsNullOrEmpty(rendered) - ? string.Empty - : rendered; - } - catch (InvalidOperationException) { - return string.Empty; - } - catch (IOException) { - return string.Empty; - } - } - } diff --git a/src/PSTextMate/Rendering/CodeBlockRenderer.cs b/src/PSTextMate/Rendering/CodeBlockRenderer.cs index 0f2b97b..2e40e12 100644 --- a/src/PSTextMate/Rendering/CodeBlockRenderer.cs +++ b/src/PSTextMate/Rendering/CodeBlockRenderer.cs @@ -28,19 +28,21 @@ public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Them .Header(language, Justify.Left); } } - catch { - // Fallback to plain rendering + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { + // Highlighter failures should not fail markdown rendering. } } // Fallback: create Text object directly instead of markup strings return CreateCodePanel(codeLines, language, theme); - } /// - /// Renders an indented code block with proper whitespace handling. - /// - /// The code block to render - /// Theme for styling - /// Rendered code block in a panel + } + + /// + /// Renders an indented code block with proper whitespace handling. + /// + /// The code block to render + /// Theme for styling + /// Rendered code block in a panel public static IRenderable RenderCodeBlock(CodeBlock code, Theme theme) { string[] codeLines = ExtractCodeLinesFromStringLineGroup(code.Lines); return CreateCodePanel(codeLines, "code", theme); @@ -65,7 +67,11 @@ private static string[] ExtractCodeLinesWithWhitespaceHandling(StringLineGroup l codeLines.Add(lineText); } - catch { + catch (InvalidOperationException) { + // If any error occurs, just use empty line + codeLines.Add(string.Empty); + } + catch (IOException) { // If any error occurs, just use empty line codeLines.Add(string.Empty); } diff --git a/src/PSTextMate/Rendering/HeadingRenderer.cs b/src/PSTextMate/Rendering/HeadingRenderer.cs index 72f00ea..f4982f9 100644 --- a/src/PSTextMate/Rendering/HeadingRenderer.cs +++ b/src/PSTextMate/Rendering/HeadingRenderer.cs @@ -52,8 +52,8 @@ private static Style CreateHeadingStyle(int foreground, int background, FontStyl // Apply font style decorations Decoration decoration = StyleHelper.GetDecoration(fontStyle); - // Apply level-specific styling as fallbacks - foregroundColor ??= GetDefaultHeadingColor(level); + // Keep fallback neutral to better match GitHub-style heading rendering. + foregroundColor ??= Color.Default; // Ensure headings are bold by default if (decoration == Decoration.None) { @@ -63,18 +63,4 @@ private static Style CreateHeadingStyle(int foreground, int background, FontStyl return new Style(foregroundColor ?? Color.Default, backgroundColor ?? Color.Default, decoration); } - /// - /// Gets default colors for heading levels when theme doesn't provide them. - /// - private static Color GetDefaultHeadingColor(int level) { - return level switch { - 1 => Color.Red, - 2 => Color.Orange1, - 3 => Color.Yellow, - 4 => Color.Green, - 5 => Color.Blue, - 6 => Color.Purple, - _ => Color.White - }; - } } diff --git a/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs b/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs index d9933ef..d9b7d72 100644 --- a/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs +++ b/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs @@ -4,10 +4,17 @@ namespace PSTextMate.Rendering; /// Renders markdown horizontal rules (thematic breaks). /// internal static class HorizontalRuleRenderer { + private const int HorizontalInset = 5; + /// /// Renders a horizontal rule as a styled line. /// /// Rendered horizontal rule public static IRenderable Render() - => new Rule().RuleStyle(Style.Parse("grey")); + // Keep some side room so thematic breaks do not look edge-to-edge, + // especially when line numbers or other gutters are enabled. + => new Padder( + new Rule().RuleStyle(new Style(Color.Gray)), + new Padding(0, 0, HorizontalInset, 0) + ); } diff --git a/src/PSTextMate/Rendering/HtmlBlockRenderer.cs b/src/PSTextMate/Rendering/HtmlBlockRenderer.cs index 77a7f80..abf73d8 100644 --- a/src/PSTextMate/Rendering/HtmlBlockRenderer.cs +++ b/src/PSTextMate/Rendering/HtmlBlockRenderer.cs @@ -28,8 +28,8 @@ public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName the .Header("html", Justify.Left); } } - catch { - // Fallback to plain rendering + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { + // Highlighter failures should not fail markdown rendering. } // Fallback: plain HTML panel @@ -80,8 +80,7 @@ private static bool TryExtractImageTag(List htmlLines, out HtmlImageTag? } private static string? ExtractAttribute(string attributes, string attributeName) { - MatchCollection matches = HtmlAttributeRegex().Matches(attributes); - foreach (Match match in matches) { + foreach (Match match in HtmlAttributeRegex().Matches(attributes)) { string name = match.Groups["name"].Value; if (string.Equals(name, attributeName, StringComparison.OrdinalIgnoreCase)) { return match.Groups["value"].Value; diff --git a/src/PSTextMate/Rendering/ImageRenderer.cs b/src/PSTextMate/Rendering/ImageRenderer.cs index edd1984..a88f48e 100644 --- a/src/PSTextMate/Rendering/ImageRenderer.cs +++ b/src/PSTextMate/Rendering/ImageRenderer.cs @@ -4,15 +4,20 @@ namespace PSTextMate.Rendering; /// Handles rendering of images in markdown using Sixel format when possible. /// public static class ImageRenderer { - private static string? _lastSixelError; - private static string? _lastImageError; + private static readonly AsyncLocal s_lastSixelError = new(); + private static readonly AsyncLocal s_lastImageError = new(); + private static readonly AsyncLocal s_currentMarkdownDirectory = new(); + private static readonly TimeSpan ImageTimeout = TimeSpan.FromSeconds(5); // Increased to 5 seconds /// /// The base directory for resolving relative image paths in markdown. /// Set this before rendering markdown content to enable relative path resolution. /// - public static string? CurrentMarkdownDirectory { get; set; } + public static string? CurrentMarkdownDirectory { + get => s_currentMarkdownDirectory.Value; + set => s_currentMarkdownDirectory.Value = value; + } /// /// Renders an image using Sixel format if possible, otherwise falls back to a link. @@ -25,22 +30,22 @@ public static class ImageRenderer { public static IRenderable RenderImage(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { try { // Clear previous errors - _lastImageError = null; - _lastSixelError = null; + s_lastImageError.Value = null; + s_lastSixelError.Value = null; // Check if the image format is likely supported if (!ImageFile.IsLikelySupportedImageFormat(imageUrl, CurrentMarkdownDirectory)) { - _lastImageError = $"Unsupported image format: {imageUrl}"; + SetLastImageError($"Unsupported image format: {imageUrl}"); return CreateImageFallback(altText, imageUrl); } // Use a timeout for image processing if (!TryNormalizeImagePath(imageUrl, out string? localImagePath, out bool timedOut)) { if (timedOut) { - _lastImageError = $"Image download timeout after {ImageTimeout.TotalSeconds} seconds: {imageUrl}"; + SetLastImageError($"Image download timeout after {ImageTimeout.TotalSeconds} seconds: {imageUrl}"); } else if (CurrentMarkdownDirectory is not null) { - _lastImageError = $"Failed to resolve '{imageUrl}' with base directory '{CurrentMarkdownDirectory}'"; + SetLastImageError($"Failed to resolve '{imageUrl}' with base directory '{CurrentMarkdownDirectory}'"); } // Timeout occurred @@ -48,19 +53,19 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW } if (localImagePath is null) { - _lastImageError = $"Failed to normalize image source: {imageUrl}"; + SetLastImageError($"Failed to normalize image source: {imageUrl}"); return CreateImageFallback(altText, imageUrl); } // Verify the downloaded file exists and has content if (!File.Exists(localImagePath)) { - _lastImageError = $"Downloaded image file does not exist: {localImagePath}"; + SetLastImageError($"Downloaded image file does not exist: {localImagePath}"); return CreateImageFallback(altText, imageUrl); } var fileInfo = new FileInfo(localImagePath); if (fileInfo.Length == 0) { - _lastImageError = $"Downloaded image file is empty: {localImagePath} (0 bytes)"; + SetLastImageError($"Downloaded image file is empty: {localImagePath} (0 bytes)"); return CreateImageFallback(altText, imageUrl); } @@ -75,13 +80,26 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW } else { // Fallback to enhanced link representation with file info - _lastImageError = $"SixelImage creation failed. File: {localImagePath} ({fileInfo.Length} bytes). Sixel error: {_lastSixelError}"; + SetLastImageError($"SixelImage creation failed. File: {localImagePath} ({fileInfo.Length} bytes). Sixel error: {s_lastSixelError.Value}"); return CreateEnhancedImageFallback(altText, imageUrl, localImagePath); } } - catch (Exception ex) { + catch (InvalidOperationException ex) { + // If anything goes wrong, fall back to the basic link representation + SetLastImageError($"Exception in RenderImage: {ex.Message}"); + return CreateImageFallback(altText, imageUrl); + } + catch (IOException ex) { // If anything goes wrong, fall back to the basic link representation - _lastImageError = $"Exception in RenderImage: {ex.Message}"; + SetLastImageError($"Exception in RenderImage: {ex.Message}"); + return CreateImageFallback(altText, imageUrl); + } + catch (HttpRequestException ex) { + SetLastImageError($"Exception in RenderImage: {ex.Message}"); + return CreateImageFallback(altText, imageUrl); + } + catch (TaskCanceledException ex) { + SetLastImageError($"Exception in RenderImage: {ex.Message}"); return CreateImageFallback(altText, imageUrl); } } @@ -118,25 +136,20 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int if (TryCreateSixelRenderable(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) { return sixelImage; } - else { - // Fallback to inline link representation - return CreateImageFallbackInline(altText, imageUrl); - } + // Fallback to inline link representation + return CreateImageFallbackInline(altText, imageUrl); } + // If anything goes wrong, fall back to the link representation catch (InvalidOperationException) { - // If anything goes wrong, fall back to the link representation return CreateImageFallbackInline(altText, imageUrl); } catch (HttpRequestException) { - // If anything goes wrong, fall back to the link representation return CreateImageFallbackInline(altText, imageUrl); } catch (IOException) { - // If anything goes wrong, fall back to the link representation return CreateImageFallbackInline(altText, imageUrl); } catch (TaskCanceledException) { - // If anything goes wrong, fall back to the link representation return CreateImageFallbackInline(altText, imageUrl); } } @@ -182,12 +195,12 @@ private static bool TryCreatePixelImage(string imagePath, int? maxWidth, int? ma try { if (!Compatibility.TerminalSupportsSixel()) { - _lastSixelError = "Terminal does not report Sixel support."; + SetLastSixelError("Terminal does not report Sixel support."); return false; } if (!File.Exists(imagePath)) { - _lastSixelError = $"Image file not found: {imagePath}"; + SetLastSixelError($"Image file not found: {imagePath}"); return false; } @@ -200,7 +213,7 @@ private static bool TryCreatePixelImage(string imagePath, int? maxWidth, int? ma // MaxHeight is handled internally by PixelImage through terminal-height based clipping // when MaxWidth is not explicitly user-limited. if (maxHeight.HasValue && maxHeight.Value <= 0) { - _lastSixelError = "MaxHeight must be greater than zero when specified."; + SetLastSixelError("MaxHeight must be greater than zero when specified."); return false; } @@ -208,7 +221,7 @@ private static bool TryCreatePixelImage(string imagePath, int? maxWidth, int? ma return true; } catch (Exception ex) { - _lastSixelError = ex.Message; + SetLastSixelError(ex.Message); } return false; @@ -271,13 +284,17 @@ private static Text CreateImageFallbackInline(string altText, string imageUrl) { /// Gets debug information about the last image processing error. /// /// The last error message, if any - public static string? GetLastImageError() => _lastImageError; + public static string? GetLastImageError() => s_lastImageError.Value; /// /// Gets debug information about the last Sixel error. /// /// The last error message, if any - public static string? GetLastSixelError() => _lastSixelError; + public static string? GetLastSixelError() => s_lastSixelError.Value; + + private static void SetLastImageError(string? message) => s_lastImageError.Value = message; + + private static void SetLastSixelError(string? message) => s_lastSixelError.Value = message; /// /// Checks if SixelImage type is available in the current environment. diff --git a/src/PSTextMate/Rendering/ListRenderer.cs b/src/PSTextMate/Rendering/ListRenderer.cs index a8809a3..202766e 100644 --- a/src/PSTextMate/Rendering/ListRenderer.cs +++ b/src/PSTextMate/Rendering/ListRenderer.cs @@ -28,7 +28,7 @@ public static IEnumerable Render(ListBlock list, Theme theme) { string prefixText = CreateListPrefixText(list.IsOrdered, isTaskList, isChecked, ref number); itemParagraph.Append(prefixText, Style.Plain); - List nestedRenderables = AppendListItemContent(itemParagraph, item, theme); + List nestedRenderables = AppendListItemContent(itemParagraph, item, theme, indentLevel: 0); renderables.Add(itemParagraph); if (nestedRenderables.Count > 0) { @@ -65,7 +65,7 @@ private static string CreateListPrefixText(bool isOrdered, bool isTaskList, bool /// Appends list item content directly to the paragraph using styled Text objects. /// This eliminates the need for markup parsing and VT escaping. /// - private static List AppendListItemContent(Paragraph paragraph, ListItemBlock item, Theme theme) { + private static List AppendListItemContent(Paragraph paragraph, ListItemBlock item, Theme theme, int indentLevel) { var nestedRenderables = new List(); foreach (Block subBlock in item) { @@ -80,12 +80,7 @@ private static List AppendListItemContent(Paragraph paragraph, List break; case ListBlock nestedList: - string nestedContent = RenderNestedListAsText(nestedList, theme, 1); - if (!string.IsNullOrEmpty(nestedContent)) { - var nestedParagraph = new Paragraph(); - nestedParagraph.Append(nestedContent, Style.Plain); - nestedRenderables.Add(nestedParagraph); - } + nestedRenderables.AddRange(RenderNestedList(nestedList, theme, indentLevel + 1)); break; default: break; @@ -101,10 +96,10 @@ private static List AppendListItemContent(Paragraph paragraph, List private static void AppendInlineContent(Paragraph paragraph, ContainerInline? inlines, Theme theme) { if (inlines is null) return; - foreach (Inline inline in new List(inlines)) { + foreach (Inline inline in inlines) { switch (inline) { case LiteralInline literal: - string literalText = literal.Content.ToString(); + string literalText = ExtractLiteralText(literal.Content); if (!string.IsNullOrEmpty(literalText)) { paragraph.Append(literalText, Style.Plain); } @@ -134,6 +129,9 @@ private static void AppendInlineContent(Paragraph paragraph, ContainerInline? in } } + private static string ExtractLiteralText(StringSlice slice) + => slice.Text is null || slice.Length <= 0 ? string.Empty : new string(slice.Text.AsSpan(slice.Start, slice.Length)); + /// /// Extracts plain text from inline elements without markup. /// @@ -151,72 +149,28 @@ private static string ExtractInlineText(Inline inline) { /// - /// Renders nested lists as indented text content. + /// Renders nested lists as indented renderables while preserving link styling. /// - private static string RenderNestedListAsText(ListBlock list, Theme theme, int indentLevel) { - StringBuilder builder = StringBuilderPool.Rent(); - try { - string indent = new(' ', indentLevel * 4); - int number = 1; - bool isFirstItem = true; - - foreach (ListItemBlock item in list.Cast()) { - if (!isFirstItem) { - builder.Append('\n'); - } - - builder.Append(indent); - - (bool isTaskList, bool isChecked) = DetectTaskListItem(item); + private static List RenderNestedList(ListBlock list, Theme theme, int indentLevel) { + var renderables = new List(); + int number = 1; + string indent = new(' ', indentLevel * 4); - if (isTaskList) { - builder.Append(isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji); - } - else if (list.IsOrdered) { - builder.Append(CultureInfo.InvariantCulture, $"{number++}. "); - } - else { - builder.Append(UnorderedBullet); - } + foreach (ListItemBlock item in list.Cast()) { + var itemParagraph = new Paragraph(); + itemParagraph.Append(indent, Style.Plain); - // Extract item text without complex inline processing for nested items - string itemText = ExtractListItemTextSimple(item); - builder.Append(itemText.Trim()); + (bool isTaskList, bool isChecked) = DetectTaskListItem(item); + string prefixText = CreateListPrefixText(list.IsOrdered, isTaskList, isChecked, ref number); + itemParagraph.Append(prefixText, Style.Plain); - isFirstItem = false; + List deeperRenderables = AppendListItemContent(itemParagraph, item, theme, indentLevel); + renderables.Add(itemParagraph); + if (deeperRenderables.Count > 0) { + renderables.AddRange(deeperRenderables); } - - return builder.ToString(); } - finally { - StringBuilderPool.Return(builder); - } - } - - /// - /// Simple text extraction for nested list items. - /// - private static string ExtractListItemTextSimple(ListItemBlock item) { - StringBuilder builder = StringBuilderPool.Rent(); - try { - foreach (Block subBlock in item) { - if (subBlock is ParagraphBlock subPara && subPara.Inline is not null) { - foreach (Inline inline in subPara.Inline) { - if (inline is not TaskList) { - // Skip TaskList markers - builder.Append(ExtractInlineText(inline)); - } - } - } - else if (subBlock is CodeBlock subCode) { - builder.Append(subCode.Lines.ToString()); - } - } - return builder.ToString(); - } - finally { - StringBuilderPool.Return(builder); - } + return renderables; } } diff --git a/src/PSTextMate/Rendering/MarkdownRenderer.cs b/src/PSTextMate/Rendering/MarkdownRenderer.cs index 252fb27..69e596a 100644 --- a/src/PSTextMate/Rendering/MarkdownRenderer.cs +++ b/src/PSTextMate/Rendering/MarkdownRenderer.cs @@ -6,7 +6,7 @@ namespace PSTextMate.Rendering; /// internal static class MarkdownRenderer { /// - /// Cached Markdig pipeline with trivia tracking enabled. + /// Cached Markdig pipeline configured for markdown features used by this renderer. /// Pipelines are expensive to create, so we cache it as a static field for reuse. /// Thread-safe: Markdig pipelines are immutable once built. /// @@ -37,13 +37,12 @@ public static IRenderable[] Render(string markdown, Theme theme, ThemeName theme } } - // Calculate blank lines from source line numbers - // This is more reliable than trivia since extensions break trivia tracking + // Calculate blank lines from source line numbers to preserve visible spacing. if (previousBlock is not null) { int previousEndLine = GetBlockEndLine(previousBlock, markdown); int gap = block.Line - previousEndLine - 1; for (int j = 0; j < gap; j++) { - rows.Add(new Rows(Text.Empty)); + rows.Add(Text.Empty); } } @@ -80,18 +79,18 @@ private static int GetBlockEndLine(Block block, string markdown) { } /// - /// Creates the Markdig pipeline with all necessary extensions and trivia tracking enabled. + /// Creates the Markdig pipeline with extensions used by the renderer. /// Pipeline follows Markdig's roundtrip parser design pattern - see: /// https://github.com/xoofx/markdig/blob/master/src/Markdig/Roundtrip.md /// - /// Configured MarkdownPipeline with trivia tracking enabled + /// Configured MarkdownPipeline private static MarkdownPipeline CreateMarkdownPipeline() { return new MarkdownPipelineBuilder() - .UseAdvancedExtensions() + // Keep parser behavior close to GitHub Flavored Markdown expectations. + .UseEmphasisExtras() .UseTaskLists() .UsePipeTables() .UseAutoLinks() - .EnableTrackTrivia() .Build(); } } diff --git a/src/PSTextMate/Rendering/ParagraphRenderer.cs b/src/PSTextMate/Rendering/ParagraphRenderer.cs index ba4a276..594aff8 100644 --- a/src/PSTextMate/Rendering/ParagraphRenderer.cs +++ b/src/PSTextMate/Rendering/ParagraphRenderer.cs @@ -36,25 +36,12 @@ private static void BuildTextSegments(List segments, ContainerInlin var paragraph = new Paragraph(); bool addedAny = false; - List inlineList = [.. inlines]; - - for (int i = 0; i < inlineList.Count; i++) { - Inline inline = inlineList[i]; - - bool isTrailingLineBreak = false; - if (inline is LineBreakInline && i < inlineList.Count) { - isTrailingLineBreak = true; - for (int j = i + 1; j < inlineList.Count; j++) { - if (inlineList[j] is not LineBreakInline) { - isTrailingLineBreak = false; - break; - } - } - } + for (Inline? inline = inlines.FirstChild; inline is not null; inline = inline.NextSibling) { + bool isTrailingLineBreak = inline is LineBreakInline && IsTrailingLineBreak(inline); switch (inline) { case LiteralInline literal: { - string literalText = literal.Content.ToString(); + string literalText = ExtractLiteralText(literal.Content); if (!string.IsNullOrEmpty(literalText)) { if (TryParseUsernameLinks(literalText, out TextSegment[]? usernameSegments)) { foreach (TextSegment segment in usernameSegments) { @@ -85,7 +72,7 @@ private static void BuildTextSegments(List segments, ContainerInlin foreach (Inline emphInline in emphasis) { switch (emphInline) { case LiteralInline lit: - paragraph.Append(lit.Content.ToString(), emphasisStyle); + paragraph.Append(ExtractLiteralText(lit.Content), emphasisStyle); addedAny = true; break; case CodeInline codeInline: @@ -161,6 +148,19 @@ private static void BuildTextSegments(List segments, ContainerInlin } } + private static bool IsTrailingLineBreak(Inline inline) { + for (Inline? next = inline.NextSibling; next is not null; next = next.NextSibling) { + if (next is not LineBreakInline) { + return false; + } + } + + return true; + } + + private static string ExtractLiteralText(StringSlice slice) + => slice.Text is null || slice.Length <= 0 ? string.Empty : new string(slice.Text.AsSpan(slice.Start, slice.Length)); + /// /// Process link as Text with Style including link parameter for clickability. /// @@ -231,16 +231,11 @@ private static string ExtractInlineText(Inline inline) { } /// - /// Determine decoration to use for emphasis based on delimiter count and environment fallback. - /// If environment variable `PSTEXTMATE_EMPHASIS_FALLBACK` == "underline" then use underline - /// for single-asterisk emphasis so italics are visible on terminals that do not support italic. + /// Determines the decoration to use for emphasis based on delimiter count. /// private static Decoration GetEmphasisDecoration(int delimiterCount) { - // Read once per call; environment lookups are cheap here since rendering isn't hot inner loop - string? fallback = Environment.GetEnvironmentVariable("PSTEXTMATE_EMPHASIS_FALLBACK"); - return delimiterCount switch { - 1 => string.Equals(fallback, "underline", StringComparison.OrdinalIgnoreCase) ? Decoration.Underline : Decoration.Italic, + 1 => Decoration.Italic, 2 => Decoration.Bold, 3 => Decoration.Bold | Decoration.Italic, _ => Decoration.None, diff --git a/src/PSTextMate/Rendering/QuoteRenderer.cs b/src/PSTextMate/Rendering/QuoteRenderer.cs index d196b55..3d5403d 100644 --- a/src/PSTextMate/Rendering/QuoteRenderer.cs +++ b/src/PSTextMate/Rendering/QuoteRenderer.cs @@ -36,7 +36,7 @@ public static IRenderable Render(QuoteBlock quote, Theme theme) { }; return new Panel(content) - .Border(BoxBorder.Heavy) + .Border(BoxBorder.Rounded) .Header("quote", Justify.Left); } } diff --git a/src/PSTextMate/Rendering/TableRenderer.cs b/src/PSTextMate/Rendering/TableRenderer.cs index 4e50c0d..3ceb335 100644 --- a/src/PSTextMate/Rendering/TableRenderer.cs +++ b/src/PSTextMate/Rendering/TableRenderer.cs @@ -119,19 +119,21 @@ internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment /// private static string ExtractCellText(TCell cell, Theme theme) { StringBuilder textBuilder = StringBuilderPool.Rent(); - - foreach (Block block in cell) { - if (block is ParagraphBlock paragraph && paragraph.Inline is not null) { - ExtractInlineText(paragraph.Inline, textBuilder); - } - else if (block is CodeBlock code) { - textBuilder.Append(code.Lines.ToString()); + try { + foreach (Block block in cell) { + if (block is ParagraphBlock paragraph && paragraph.Inline is not null) { + ExtractInlineText(paragraph.Inline, textBuilder); + } + else if (block is CodeBlock code) { + textBuilder.Append(code.Lines.ToString()); + } } - } - string result = textBuilder.ToString().Trim(); - StringBuilderPool.Return(textBuilder); - return result; + return textBuilder.ToString().Trim(); + } + finally { + StringBuilderPool.Return(textBuilder); + } } /// @@ -179,18 +181,6 @@ private static Style GetTableBorderStyle(Theme theme) { return style ?? new Style(foreground: Color.Grey); } - /// - /// Gets the header style for table headers. - /// - private static Style GetHeaderStyle(Theme theme) { - string[] headerScopes = ["markup.heading.table"]; - Style? baseStyle = TokenProcessor.GetStyleForScopes(headerScopes, theme); - Color fgColor = baseStyle?.Foreground ?? Color.Yellow; - Color? bgColor = baseStyle?.Background; - Decoration decoration = (baseStyle?.Decoration ?? Decoration.None) | Decoration.Bold; - return new Style(fgColor, bgColor, decoration); - } - /// /// Gets the cell style for table data cells. /// diff --git a/src/PSTextMate/Sixel/ImageCanvas.cs b/src/PSTextMate/Sixel/ImageCanvas.cs index 828f4a4..f991771 100644 --- a/src/PSTextMate/Sixel/ImageCanvas.cs +++ b/src/PSTextMate/Sixel/ImageCanvas.cs @@ -3,7 +3,7 @@ namespace PSTextMate.Sixel; /// /// Represents a renderable canvas. /// -internal sealed class ImageCanvas : Renderable { +internal sealed class ImageCanvas : IRenderable { private struct Cell { public char Glyph; public Color? Foreground; @@ -95,7 +95,7 @@ public ImageCanvas SetCell(int x, int y, char glyph, Color? foreground, Color? b } /// - protected override Measurement Measure(RenderOptions options, int maxWidth) { + public Measurement Measure(RenderOptions options, int maxWidth) { if (PixelWidth < 0) { throw new InvalidOperationException("Pixel width must be greater than zero."); } @@ -106,7 +106,7 @@ protected override Measurement Measure(RenderOptions options, int maxWidth) { } /// - protected override IEnumerable Render(RenderOptions options, int maxWidth) { + public IEnumerable Render(RenderOptions options, int maxWidth) { if (PixelWidth < 0) { throw new InvalidOperationException("Pixel width must be greater than zero."); } diff --git a/src/PSTextMate/Sixel/PixelImage.cs b/src/PSTextMate/Sixel/PixelImage.cs index a0fe139..60c4344 100644 --- a/src/PSTextMate/Sixel/PixelImage.cs +++ b/src/PSTextMate/Sixel/PixelImage.cs @@ -6,7 +6,7 @@ namespace PSTextMate.Sixel; /// /// Initializes a new instance of the class. /// -internal sealed class PixelImage : Renderable { +internal sealed class PixelImage : IRenderable { private const char ESC = '\u001b'; /// /// Gets the image width in pixels. @@ -61,7 +61,7 @@ public PixelImage(string filename, bool animationDisabled = false) { } /// - protected override Measurement Measure(RenderOptions options, int maxWidth) { + public Measurement Measure(RenderOptions options, int maxWidth) { if (PixelWidth < 0) { throw new InvalidOperationException("Pixel width must be greater than zero."); } @@ -71,7 +71,7 @@ protected override Measurement Measure(RenderOptions options, int maxWidth) { } /// - protected override IEnumerable Render(RenderOptions options, int maxWidth) { + public IEnumerable Render(RenderOptions options, int maxWidth) { // Got a max width smaller than the render max width? // When MaxWidth is explicitly set by the user, use it and don't constrain height. // When MaxWidth is not set, constrain the image to the terminal height so tall images @@ -104,7 +104,7 @@ protected override IEnumerable Render(RenderOptions options, int maxWid // The segment list is a transparent canvas followed by a couple of zero-width control segments for sixel data output. // Rendering the sixel data after the canvas allows the canvas to be truncated in a layout without destroying the layout. - var segments = ((IRenderable)canvas).Render(options, maxWidth).ToList(); + var segments = canvas.Render(options, maxWidth).ToList(); // Remove the final line break from the canvas so the sixel data can be rendered relative to the top left of the canvas. // Leaving the line break in means when this is rendered with IAlignable the cursor position after the canvas is in the wrong location. diff --git a/src/PSTextMate/Utilities/ImageFile.cs b/src/PSTextMate/Utilities/ImageFile.cs index 9a80353..7020ff5 100644 --- a/src/PSTextMate/Utilities/ImageFile.cs +++ b/src/PSTextMate/Utilities/ImageFile.cs @@ -144,9 +144,8 @@ private static bool TryResolveFilePath(string inputPath, out string? resolvedPat } } - private static void ScheduleTempFileCleanup(string tempFileName) { - _ = DeleteTempFileLaterAsync(tempFileName); - } + private static void ScheduleTempFileCleanup(string tempFileName) + => _ = DeleteTempFileLaterAsync(tempFileName); private static async Task DeleteTempFileLaterAsync(string tempFileName) { try { diff --git a/src/PSTextMate/Utilities/InlineTextExtractor.cs b/src/PSTextMate/Utilities/InlineTextExtractor.cs index 1671cd5..4be0a92 100644 --- a/src/PSTextMate/Utilities/InlineTextExtractor.cs +++ b/src/PSTextMate/Utilities/InlineTextExtractor.cs @@ -13,7 +13,7 @@ internal static class InlineTextExtractor { public static void ExtractText(Inline inline, StringBuilder builder) { switch (inline) { case LiteralInline literal: - builder.Append(literal.Content.ToString()); + AppendStringSlice(literal.Content, builder); break; case ContainerInline container: @@ -31,6 +31,14 @@ public static void ExtractText(Inline inline, StringBuilder builder) { } } + private static void AppendStringSlice(StringSlice slice, StringBuilder builder) { + if (slice.Text is null || slice.Length <= 0) { + return; + } + + builder.Append(slice.Text.AsSpan(slice.Start, slice.Length)); + } + /// /// Extracts all text from an inline container into a single string. /// diff --git a/src/PSTextMate/Utilities/MarkdownPatterns.cs b/src/PSTextMate/Utilities/MarkdownPatterns.cs index 45c7036..07eb9a1 100644 --- a/src/PSTextMate/Utilities/MarkdownPatterns.cs +++ b/src/PSTextMate/Utilities/MarkdownPatterns.cs @@ -16,23 +16,40 @@ public static bool IsStandaloneImage(ParagraphBlock paragraph) { return false; } - // Check if the paragraph contains only one LinkInline with IsImage = true - var inlines = paragraph.Inline.ToList(); + int significantCount = 0; + LinkInline? candidate = null; - // Single image case - if (inlines.Count == 1 && inlines[0] is LinkInline link && link.IsImage) { + foreach (Inline inline in paragraph.Inline) { + if (inline is LineBreakInline) { + continue; + } + + if (inline is LiteralInline literal && IsWhitespaceLiteral(literal.Content)) { + continue; + } + + significantCount++; + if (significantCount > 1) { + return false; + } + + candidate = inline as LinkInline; + } + + return significantCount == 1 && candidate is { IsImage: true }; + } + + private static bool IsWhitespaceLiteral(StringSlice slice) { + if (slice.Text is null || slice.Length <= 0) { return true; } - // Sometimes there might be whitespace inlines around the image - // Filter out empty/whitespace literals - var nonWhitespace = inlines - .Where(i => i is not LineBreakInline && - !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) - .ToList(); + foreach (char c in slice.Text.AsSpan(slice.Start, slice.Length)) { + if (!char.IsWhiteSpace(c)) { + return false; + } + } - return nonWhitespace.Count == 1 - && nonWhitespace[0] is LinkInline imageLink - && imageLink.IsImage; + return true; } } diff --git a/src/PSTextMate/Utilities/StringBuilderPool.cs b/src/PSTextMate/Utilities/StringBuilderPool.cs index 3ede0b1..8864f08 100644 --- a/src/PSTextMate/Utilities/StringBuilderPool.cs +++ b/src/PSTextMate/Utilities/StringBuilderPool.cs @@ -3,7 +3,8 @@ namespace PSTextMate.Utilities; internal static class StringBuilderPool { private static readonly ConcurrentBag _bag = []; - public static StringBuilder Rent() => _bag.TryTake(out StringBuilder? sb) ? sb : new StringBuilder(); + public static StringBuilder Rent() + => _bag.TryTake(out StringBuilder? sb) ? sb : new StringBuilder(); public static void Return(StringBuilder sb) { if (sb is null) return; diff --git a/src/PSTextMate/Utilities/StringExtensions.cs b/src/PSTextMate/Utilities/StringExtensions.cs index 9ce5fec..103ff18 100644 --- a/src/PSTextMate/Utilities/StringExtensions.cs +++ b/src/PSTextMate/Utilities/StringExtensions.cs @@ -1,36 +1,6 @@ namespace PSTextMate.Utilities; -/// -/// Provides optimized string manipulation methods using modern .NET performance patterns. -/// Uses Span and ReadOnlySpan to minimize memory allocations during text processing. -/// public static class StringExtensions { - /// - /// Efficiently extracts substring using Span to avoid string allocations. - /// This is significantly faster than traditional substring operations for large text processing. - /// - /// Source string to extract from - /// Starting index for substring - /// Ending index for substring - /// ReadOnlySpan representing the substring - public static ReadOnlySpan SpanSubstring(this string source, int startIndex, int endIndex) { - return startIndex < 0 || endIndex > source.Length || startIndex > endIndex - ? [] - : source.AsSpan(startIndex, endIndex - startIndex); - } - - /// - /// Optimized substring method that works with spans internally but returns a string. - /// Provides better performance than traditional substring while maintaining string return type. - /// - /// Source string to extract from - /// Starting index for substring - /// Ending index for substring - /// Substring as string, or empty string if invalid indexes - public static string SubstringAtIndexes(this string source, int startIndex, int endIndex) { - ReadOnlySpan span = source.SpanSubstring(startIndex, endIndex); - return span.IsEmpty ? string.Empty : span.ToString(); - } /// /// Checks if all strings in the array are null or empty. @@ -41,135 +11,4 @@ public static string SubstringAtIndexes(this string source, int startIndex, int public static bool AllIsNullOrEmpty(this string[] strings) => strings.All(string.IsNullOrEmpty); - /// - /// Joins string arrays using span operations for better performance. - /// Avoids multiple string allocations during concatenation. - /// - /// Array of strings to join - /// Separator character - /// Joined string - public static string SpanJoin(this string[] values, char separator) { - if (values.Length == 0) return string.Empty; - if (values.Length == 1) return values[0] ?? string.Empty; - - // Calculate total capacity to avoid StringBuilder reallocations - int totalLength = values.Length - 1; // separators - foreach (string value in values) - totalLength += value?.Length ?? 0; - - var builder = new StringBuilder(totalLength); - - for (int i = 0; i < values.Length; i++) { - if (i > 0) builder.Append(separator); - if (values[i] is not null) - builder.Append(values[i].AsSpan()); - } - - return builder.ToString(); - } - - /// - /// Splits strings using span operations with pre-allocated results array. - /// Provides better performance for known maximum split counts. - /// - /// Source string to split - /// Array of separator characters - /// String split options - /// Maximum expected number of splits for optimization - /// Array of split strings - public static string[] SpanSplit(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) { - if (string.IsNullOrEmpty(source)) - return []; - - // Use span-based operations for better performance - ReadOnlySpan sourceSpan = source.AsSpan(); - var results = new List(Math.Min(maxSplits, 64)); // Cap initial capacity - - int start = 0; - for (int i = 0; i <= sourceSpan.Length; i++) { - bool isSeparator = i < sourceSpan.Length && separators.Contains(sourceSpan[i]); - bool isEnd = i == sourceSpan.Length; - - if (isSeparator || isEnd) { - ReadOnlySpan segment = sourceSpan[start..i]; - - if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) { - start = i + 1; - continue; - } - - if (options.HasFlag(StringSplitOptions.TrimEntries)) - segment = segment.Trim(); - - results.Add(segment.ToString()); - start = i + 1; - } - } - - return [.. results]; - } - - /// - /// Trims whitespace using span operations and returns the result as a string. - /// More efficient than traditional Trim() for subsequent string operations. - /// - /// Source string to trim - /// Trimmed string - public static string SpanTrim(this string source) { - if (string.IsNullOrEmpty(source)) - return source ?? string.Empty; - - ReadOnlySpan trimmed = source.AsSpan().Trim(); - return trimmed.Length == source.Length ? source : trimmed.ToString(); - } - - /// - /// Efficiently checks if a string contains any of the specified characters using spans. - /// - /// Source string to search - /// Characters to search for - /// True if any character is found - public static bool SpanContainsAny(this string source, ReadOnlySpan chars) - => !string.IsNullOrEmpty(source) && !chars.IsEmpty && source.AsSpan().IndexOfAny(chars) >= 0; - - /// - /// Replaces characters in a string using span operations for better performance. - /// - /// Source string - /// Character to replace - /// Replacement character - /// String with replacements - public static string SpanReplace(this string source, char oldChar, char newChar) { - if (string.IsNullOrEmpty(source)) - return source ?? string.Empty; - - ReadOnlySpan sourceSpan = source.AsSpan(); - int firstIndex = sourceSpan.IndexOf(oldChar); - - if (firstIndex < 0) - return source; // No replacement needed - - // Use span-based building for efficiency - var result = new StringBuilder(source.Length); - int lastIndex = 0; - - do { - result.Append(sourceSpan[lastIndex..firstIndex]); - result.Append(newChar); - lastIndex = firstIndex + 1; - - if (lastIndex >= sourceSpan.Length) - break; - - firstIndex = sourceSpan[lastIndex..].IndexOf(oldChar); - if (firstIndex >= 0) - firstIndex += lastIndex; - - } while (firstIndex >= 0); - - if (lastIndex < sourceSpan.Length) - result.Append(sourceSpan[lastIndex..]); - - return result.ToString(); - } } diff --git a/tests/Format-Markdown.tests.ps1 b/tests/Format-Markdown.tests.ps1 index b74a536..af7cebf 100644 --- a/tests/Format-Markdown.tests.ps1 +++ b/tests/Format-Markdown.tests.ps1 @@ -2,6 +2,8 @@ BeforeAll { if (-Not (Get-Module 'TextMate')) { Import-Module (Join-Path $PSScriptRoot '..' 'output' 'TextMate.psd1') -ErrorAction Stop } + + Import-Module (Join-Path $PSScriptRoot 'testhelper.psm1') -Force } @@ -49,6 +51,36 @@ Describe 'Format-Markdown' { $rendered | Should -Match '🖼️\s+Image:\s+logo width' $rendered | Should -Not -Match ' quoted`n`n```powershell`nWrite-Host 'hi'`n``` +"@ + $out = $md | Format-Markdown + $rendered = _GetSpectreRenderable -RenderableObject $out + + $rendered | Should -Match 'quote' + $rendered | Should -Match 'powershell' + } + + It 'Renders nested list links with hyperlink target' { + $md = "- parent`n - [nested](https://example.com/nested)" + $out = $md | Format-Markdown + $rendered = _GetSpectreRenderable -RenderableObject $out + + $rendered | Should -Match 'nested' + $rendered | Should -Match 'https://example.com/nested' + } + It 'Should have Help and examples' { $help = Get-Help Format-Markdown -Full $help.Synopsis | Should -Not -BeNullOrEmpty diff --git a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs index eb0a666..71c4bb9 100644 --- a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs +++ b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs @@ -1,5 +1,5 @@ -using PSTextMate.Terminal; -using PSTextMate.Core; +using PSTextMate.Core; +using PSTextMate.Terminal; using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; @@ -157,6 +157,73 @@ public void RecalculateHeights_SameLayout_DoesNotRecomputeRenderHeights() { Assert.True(thirdPassRenders > secondPassRenders); } + [Fact] + public void SetQuery_LinkRenderable_MatchesLabelAndUrl() { + PagerDocument document = new([ + new Text("Guide"), + new Osc8Renderable("Guide", "https://example.com/docs") + ]); + PagerSearchSession session = new(document); + + session.SetQuery("Guide"); + Assert.True(session.HitCount >= 1); + Assert.NotNull(session.MoveNext(topIndex: 0)); + + session.SetQuery("example.com/docs"); + Assert.Equal(1, session.HitCount); + + PagerSearchHit? urlHit = session.MoveNext(topIndex: 0); + Assert.NotNull(urlHit); + Assert.Equal(1, urlHit.RenderableIndex); + } + + [Fact] + public void SegmentHighlighter_UrlMatch_HighlightsLinkLabel() { + var paragraph = new Paragraph(); + SpectreStyleCompat.Append(paragraph, "Guide", Style.Plain, "https://example.com/docs"); + + IRenderable highlighted = PagerHighlighting.BuildSegmentHighlightRenderable( + paragraph, + "http", + new Style(Color.White, Color.Grey), + new Style(Color.Black, Color.Orange1), + highlightLinkedLabelsOnNoDirectMatch: true + ); + + var options = RenderOptions.Create(AnsiConsole.Console); + List segments = [.. highlighted.Render(options, 120)]; + + bool hasHighlightedLabel = segments.Any(segment => + !segment.IsControlCode + && !segment.IsLineBreak + && segment.Text.Contains("Guide", StringComparison.Ordinal) + && segment.Style.Foreground == Color.Black + && segment.Style.Background == Color.Orange1); + + Assert.True(hasHighlightedLabel); + } + + private sealed class Osc8Renderable : IRenderable { + private readonly string _label; + private readonly string _url; + + public Osc8Renderable(string label, string url) { + _label = label; + _url = url; + } + + public Measurement Measure(RenderOptions options, int maxWidth) { + int width = Math.Max(1, Math.Min(maxWidth, _label.Length)); + return new Measurement(width, width); + } + + public IEnumerable Render(RenderOptions options, int maxWidth) { + string esc = "\x1b"; + string osc8 = $"{esc}]8;;{_url}{esc}\\{_label}{esc}]8;;{esc}\\"; + return [new Segment(osc8, Style.Plain)]; + } + } + private sealed class ThrowingRenderable : IRenderable { private readonly string _text; From 84a6826da8117326f24f251a0edbaec3a5e3557c Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:57:00 +0100 Subject: [PATCH 14/17] =?UTF-8?q?feat(pager):=20=E2=9C=A8=20Refactor=20pag?= =?UTF-8?q?er=20rendering=20and=20enhance=20line=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Simplified `AddLines` method to utilize `TextMateHelper.AddSplitLines`. * Improved `PagerDocument` to handle hyperlink targets and segment extraction more effectively. * Enhanced `PagerHighlighting` to maintain original styles for borders while applying highlights. * Updated tests to reflect changes in renderable matching logic and added new tests for segment highlighting. --- src/PSTextMate/Cmdlets/OutPage.cs | 21 +-- src/PSTextMate/Pager/PagerDocument.cs | 56 ++++++-- src/PSTextMate/Pager/PagerHighlighting.cs | 128 ++++++++++-------- src/PSTextMate/Rendering/BlockRenderer.cs | 26 ++-- src/PSTextMate/Utilities/Helpers.cs | 60 +++++++- .../PagerCoreTests.cs | 65 +++++++-- 6 files changed, 247 insertions(+), 109 deletions(-) diff --git a/src/PSTextMate/Cmdlets/OutPage.cs b/src/PSTextMate/Cmdlets/OutPage.cs index 2bc6389..dbd848b 100644 --- a/src/PSTextMate/Cmdlets/OutPage.cs +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -124,24 +124,11 @@ private static List ConvertWithOutStringLines(List values) { } } - private static void AddLines(List lines, string text) { - string normalized = text.Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace('\r', '\n'); - - string[] split = normalized.Split('\n'); - int count = split.Length; - - // Out-String -Stream commonly returns one chunk per logical line with a - // trailing newline terminator. Ignore only that terminator-induced empty - // entry so pager row counts match what is actually rendered. - if (count > 0 && split[^1].Length == 0 && normalized.EndsWith('\n')) { - count--; - } + // Out-String -Stream commonly returns one chunk per logical line with a + // trailing newline terminator. Trim only that final synthetic empty line. + private static void AddLines(List lines, string text) => - for (int i = 0; i < count; i++) { - lines.Add(split[i]); - } - } + TextMateHelper.AddSplitLines(lines, text, trimTrailingTerminatorEmptyLine: true); private static int GetConsoleWidth() { try { diff --git a/src/PSTextMate/Pager/PagerDocument.cs b/src/PSTextMate/Pager/PagerDocument.cs index 383ce57..0804c69 100644 --- a/src/PSTextMate/Pager/PagerDocument.cs +++ b/src/PSTextMate/Pager/PagerDocument.cs @@ -84,22 +84,60 @@ private static string ExtractSearchText(IRenderable renderable) { string visibleText = Normalize(VTHelpers.StripAnsi(rendered)); string hyperlinkTargets = ExtractHyperlinkTargets(rendered); - return !string.IsNullOrEmpty(hyperlinkTargets) - ? string.IsNullOrEmpty(visibleText) + if (!string.IsNullOrEmpty(hyperlinkTargets)) { + return string.IsNullOrEmpty(visibleText) ? hyperlinkTargets - : $"{visibleText}\n{hyperlinkTargets}" - : !string.IsNullOrEmpty(visibleText) - ? visibleText - : Normalize(renderable.ToString()); + : $"{visibleText}\n{hyperlinkTargets}"; + } + + if (!string.IsNullOrEmpty(visibleText)) { + return visibleText; + } + + string segmentText = ExtractSegmentText(renderable); + return !string.IsNullOrEmpty(segmentText) ? segmentText : string.Empty; } catch (InvalidOperationException) { - return Normalize(renderable.ToString()); + return ExtractSegmentText(renderable); } catch (IOException) { - return Normalize(renderable.ToString()); + return ExtractSegmentText(renderable); } catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { - return Normalize(renderable.ToString()); + return ExtractSegmentText(renderable); + } + } + + private static string ExtractSegmentText(IRenderable renderable) { + try { + var options = RenderOptions.Create(AnsiConsole.Console); + IEnumerable segments = renderable.Render(options, maxWidth: 200); + StringBuilder builder = StringBuilderPool.Rent(); + try { + foreach (Segment segment in segments) { + if (segment.IsControlCode) { + continue; + } + + if (segment.IsLineBreak) { + builder.Append('\n'); + continue; + } + + builder.Append(segment.Text); + } + + return Normalize(builder.ToString()); + } + finally { + StringBuilderPool.Return(builder); + } + } + catch (InvalidOperationException) { + return string.Empty; + } + catch (IOException) { + return string.Empty; } } diff --git a/src/PSTextMate/Pager/PagerHighlighting.cs b/src/PSTextMate/Pager/PagerHighlighting.cs index 499d8e4..bdee363 100644 --- a/src/PSTextMate/Pager/PagerHighlighting.cs +++ b/src/PSTextMate/Pager/PagerHighlighting.cs @@ -1,4 +1,4 @@ -namespace PSTextMate.Terminal; +namespace PSTextMate.Terminal; internal static class PagerHighlighting { internal static IRenderable BuildSegmentHighlightRenderable( @@ -106,21 +106,26 @@ private static bool SegmentLinkMatchesQuery(Segment segment, string query) { } private static string BuildPlainText(IEnumerable segments) { - var builder = new StringBuilder(); - foreach (Segment segment in segments) { - if (segment.IsControlCode) { - continue; - } + StringBuilder builder = StringBuilderPool.Rent(); + try { + foreach (Segment segment in segments) { + if (segment.IsControlCode) { + continue; + } - if (segment.IsLineBreak) { - builder.Append('\n'); - continue; + if (segment.IsLineBreak) { + builder.Append('\n'); + continue; + } + + builder.Append(segment.Text); } - builder.Append(segment.Text); + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); } - - return builder.ToString(); } private static bool[] BuildLineMatchMask(string plainText, IReadOnlyList hits) { @@ -166,36 +171,17 @@ bool highlightLinkedLabels var output = new List(source.Count * 2); int absolute = 0; int line = 0; + StringBuilder chunk = StringBuilderPool.Rent(); - foreach (Segment segment in source) { - if (segment.IsControlCode) { - output.Add(segment); - continue; - } - - if (segment.IsLineBreak) { - output.Add(segment); - if (absolute < matchMask.Length) { - absolute++; + try { + foreach (Segment segment in source) { + if (segment.IsControlCode) { + output.Add(segment); + continue; } - line = Math.Min(line + 1, lineHasMatch.Length - 1); - continue; - } - - if (segment.Text.Length == 0) { - continue; - } - - bool segmentLinkMatchesQuery = highlightLinkedLabels && SegmentLinkMatchesQuery(segment, _query); - - var chunk = new StringBuilder(); - Style? chunkStyle = null; - - foreach (char ch in segment.Text) { - if (ch == '\n') { - FlushChunk(output, chunk, chunkStyle); - output.Add(Segment.LineBreak); + if (segment.IsLineBreak) { + output.Add(segment); if (absolute < matchMask.Length) { absolute++; } @@ -204,24 +190,58 @@ bool highlightLinkedLabels continue; } - bool inMatch = absolute >= 0 && absolute < matchMask.Length && matchMask[absolute]; - bool inMatchedLine = line >= 0 && line < lineHasMatch.Length && lineHasMatch[line]; - Style style = inMatch || segmentLinkMatchesQuery - ? _matchStyle - : inMatchedLine - ? _rowStyle - : segment.Style; - - if (chunkStyle is null || !chunkStyle.Equals(style)) { - FlushChunk(output, chunk, chunkStyle); - chunkStyle = style; + if (segment.Text.Length == 0) { + continue; } - chunk.Append(ch); - absolute++; - } + bool segmentLinkMatchesQuery = highlightLinkedLabels && SegmentLinkMatchesQuery(segment, _query); + + chunk.Clear(); + Style? chunkStyle = null; + + foreach (char ch in segment.Text) { + if (ch == '\n') { + FlushChunk(output, chunk, chunkStyle); + output.Add(Segment.LineBreak); + if (absolute < matchMask.Length) { + absolute++; + } + + line = Math.Min(line + 1, lineHasMatch.Length - 1); + continue; + } + + bool inMatch = absolute >= 0 && absolute < matchMask.Length && matchMask[absolute]; + bool inMatchedLine = line >= 0 && line < lineHasMatch.Length && lineHasMatch[line]; + Style style; + if (ch == '│') { + // leave borders as is. + style = segment.Style; + } + else if (inMatch || segmentLinkMatchesQuery) { + style = _matchStyle; + } + else if (inMatchedLine) { + style = _rowStyle; + } + else { + style = segment.Style; + } - FlushChunk(output, chunk, chunkStyle); + if (chunkStyle is null || !chunkStyle.Equals(style)) { + FlushChunk(output, chunk, chunkStyle); + chunkStyle = style; + } + + chunk.Append(ch); + absolute++; + } + + FlushChunk(output, chunk, chunkStyle); + } + } + finally { + StringBuilderPool.Return(chunk); } return output; diff --git a/src/PSTextMate/Rendering/BlockRenderer.cs b/src/PSTextMate/Rendering/BlockRenderer.cs index 326558d..5d353c7 100644 --- a/src/PSTextMate/Rendering/BlockRenderer.cs +++ b/src/PSTextMate/Rendering/BlockRenderer.cs @@ -89,18 +89,22 @@ HtmlBlock html /// Extracts alt text from an image link inline. /// private static string ExtractImageAltText(LinkInline imageLink) { - var textBuilder = new StringBuilder(); - - foreach (Inline inline in imageLink) { - if (inline is LiteralInline literal) { - textBuilder.Append(literal.Content.ToString()); - } - else if (inline is CodeInline code) { - textBuilder.Append(code.Content); + StringBuilder textBuilder = StringBuilderPool.Rent(); + try { + foreach (Inline inline in imageLink) { + if (inline is LiteralInline literal) { + textBuilder.Append(literal.Content.ToString()); + } + else if (inline is CodeInline code) { + textBuilder.Append(code.Content); + } } - } - string result = textBuilder.ToString(); - return string.IsNullOrEmpty(result) ? "Image" : result; + string result = textBuilder.ToString(); + return string.IsNullOrEmpty(result) ? "Image" : result; + } + finally { + StringBuilderPool.Return(textBuilder); + } } } diff --git a/src/PSTextMate/Utilities/Helpers.cs b/src/PSTextMate/Utilities/Helpers.cs index 364ff98..8963188 100644 --- a/src/PSTextMate/Utilities/Helpers.cs +++ b/src/PSTextMate/Utilities/Helpers.cs @@ -1,9 +1,11 @@ -namespace PSTextMate; +namespace PSTextMate.Utilities; /// /// Provides utility methods for accessing available TextMate languages and file extensions. /// public static class TextMateHelper { + private static readonly SearchValues NewLineChars = SearchValues.Create(['\r', '\n']); + /// /// Array of supported file extensions (e.g., ".ps1", ".md", ".cs"). /// @@ -34,10 +36,15 @@ static TextMateHelper() { throw new TypeInitializationException(nameof(TextMateHelper), ex); } } + internal static string[] SplitToLines(string input) { - return input.Length == 0 - ? [string.Empty] - : input.Contains('\n') || input.Contains('\r') ? input.Split(["\r\n", "\n", "\r"], StringSplitOptions.None) : [input]; + if (input.Length == 0) { + return [string.Empty]; + } + + var lines = new List(Math.Min(16, (input.Length / 8) + 1)); + AddSplitLines(lines, input, trimTrailingTerminatorEmptyLine: false); + return [.. lines]; } internal static string[] NormalizeToLines(List buffer) { @@ -47,9 +54,52 @@ internal static string[] NormalizeToLines(List buffer) { var lines = new List(buffer.Count * 2); foreach (string item in buffer) { - lines.AddRange(SplitToLines(item)); + AddSplitLines(lines, item, trimTrailingTerminatorEmptyLine: false); } return [.. lines]; } + + internal static void AddSplitLines(List destination, string input, bool trimTrailingTerminatorEmptyLine) { + ArgumentNullException.ThrowIfNull(destination); + ArgumentNullException.ThrowIfNull(input); + + if (input.Length == 0) { + destination.Add(string.Empty); + return; + } + + ReadOnlySpan span = input.AsSpan(); + int lineStart = 0; + + while (lineStart <= span.Length) { + int relativeBreak = span[lineStart..].IndexOfAny(NewLineChars); + if (relativeBreak < 0) { + destination.Add(new string(span[lineStart..])); + break; + } + + int breakIndex = lineStart + relativeBreak; + destination.Add(new string(span[lineStart..breakIndex])); + + if (span[breakIndex] == '\r' && breakIndex + 1 < span.Length && span[breakIndex + 1] == '\n') { + lineStart = breakIndex + 2; + } + else { + lineStart = breakIndex + 1; + } + + if (lineStart == span.Length) { + destination.Add(string.Empty); + break; + } + } + + if (trimTrailingTerminatorEmptyLine + && destination.Count > 0 + && destination[^1].Length == 0 + && (span[^1] == '\n' || span[^1] == '\r')) { + destination.RemoveAt(destination.Count - 1); + } + } } diff --git a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs index 71c4bb9..214790f 100644 --- a/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs +++ b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs @@ -76,7 +76,7 @@ public void SetQuery_RepeatedAndChangedQuery_RebuildsRenderableHitIndexWithoutSt } [Fact] - public void SetQuery_CustomRenderableWithToStringFallback_FindsMatch() { + public void SetQuery_CustomRenderableWithoutRenderableText_DoesNotMatch() { PagerDocument document = new([ new ThrowingRenderable("alpha beta"), new ThrowingRenderable("gamma") @@ -86,18 +86,16 @@ public void SetQuery_CustomRenderableWithToStringFallback_FindsMatch() { session.SetQuery("beta"); Assert.True(session.HasQuery); - Assert.Equal(1, session.HitCount); - - PagerSearchHit? hit = session.MoveNext(topIndex: 0); - Assert.NotNull(hit); - Assert.Equal(0, hit.RenderableIndex); + Assert.Equal(0, session.HitCount); + Assert.Null(session.MoveNext(topIndex: 0)); } [Fact] - public void SetQuery_FromHighlightedTextWithCustomRenderable_FindsMatch() { - HighlightedText highlighted = new() { - Renderables = [new ThrowingRenderable("search target")] - }; + public void SetQuery_FromHighlightedTextWithSourceLines_FindsMatch() { + HighlightedText highlighted = new( + [new ThrowingRenderable("ignored render text")], + sourceLines: ["search target"] + ); var document = PagerDocument.FromHighlightedText(highlighted); PagerSearchSession session = new(document); @@ -108,7 +106,7 @@ public void SetQuery_FromHighlightedTextWithCustomRenderable_FindsMatch() { } [Fact] - public void SetQuery_RenderableWithEmptyWriterOutput_UsesToStringFallback() { + public void SetQuery_RenderableWithEmptyWriterOutput_DoesNotMatch() { PagerDocument document = new([ new EmptyRenderable("delta epsilon") ]); @@ -116,8 +114,8 @@ public void SetQuery_RenderableWithEmptyWriterOutput_UsesToStringFallback() { PagerSearchSession session = new(document); session.SetQuery("epsilon"); - Assert.Equal(1, session.HitCount); - Assert.NotNull(session.MoveNext(topIndex: 0)); + Assert.Equal(0, session.HitCount); + Assert.Null(session.MoveNext(topIndex: 0)); } [Fact] @@ -203,6 +201,47 @@ public void SegmentHighlighter_UrlMatch_HighlightsLinkLabel() { Assert.True(hasHighlightedLabel); } + [Fact] + public void SegmentHighlighter_DirectMatch_StylesRowTextButNotBorders() { + Style baseStyle = new(Color.Blue, Color.Black); + var text = new Text("Guide │ details", baseStyle); + + IRenderable highlighted = PagerHighlighting.BuildSegmentHighlightRenderable( + text, + "Guide", + new Style(Color.White, Color.Grey), + new Style(Color.Black, Color.Orange1), + highlightLinkedLabelsOnNoDirectMatch: false + ); + + var options = RenderOptions.Create(AnsiConsole.Console); + List segments = [.. highlighted.Render(options, 120)]; + + bool hasMatchSegment = segments.Any(segment => + !segment.IsControlCode + && !segment.IsLineBreak + && segment.Text.Contains("Guide", StringComparison.Ordinal) + && segment.Style.Foreground == Color.Black + && segment.Style.Background == Color.Orange1); + + bool hasRowBackgroundOnNonMatchText = segments.Any(segment => + !segment.IsControlCode + && !segment.IsLineBreak + && !segment.Text.Contains('│') + && !segment.Text.Contains("Guide", StringComparison.Ordinal) + && segment.Style.Background == Color.Grey); + + bool borderKeptOriginalStyle = segments.Any(segment => + !segment.IsControlCode + && !segment.IsLineBreak + && segment.Text.Contains('│') + && segment.Style.Equals(baseStyle)); + + Assert.True(hasMatchSegment); + Assert.True(hasRowBackgroundOnNonMatchText); + Assert.True(borderKeptOriginalStyle); + } + private sealed class Osc8Renderable : IRenderable { private readonly string _label; private readonly string _url; From 6546b6f64fc58534c1a83328262945005f5f7c08 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:06:44 +0100 Subject: [PATCH 15/17] =?UTF-8?q?feat(docs):=20=E2=9C=A8=20Update=20README?= =?UTF-8?q?=20and=20Out-Page=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Clarified the description of the builtin pager in README.md. * Improved the explanation of Sixel images in markdown. * Added detailed navigation instructions for the Out-Page cmdlet. --- README.md | 8 ++++++-- docs/en-us/Out-Page.md | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cd06137..4d7539c 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ TextMate delivers syntax-aware highlighting for PowerShell on top of TextMate gr What it does - Highlights source text using TextMate grammars such as PowerShell, C#, Markdown, and Python. +- Builtin pager, either through `-Page` or piping to `Out-Page` - Returns `HighlightedText` renderables that implement Spectre.Console's contract, so they can be written directly or through other Spectre hosts. - Provides discovery and testing helpers for installed grammars, extensions, or language IDs. -- Does inline Sixel images in markdown +- Sixel images in markdown. ![Demo](./assets/demo.png) @@ -24,7 +25,7 @@ What it does | [Out-Page](docs/en-us/Out-Page.md) | Builtin terminal pager | ```note -Format-CSharp/Markdown/Powershell is just sugar for Format-TextMate -Language CSharp/PowerShell/Markdown +Format-CSharp/Markdown/Powershell is just syntactic sugar for Format-TextMate -Language CSharp/PowerShell/Markdown ``` ## Examples @@ -36,6 +37,9 @@ Format-CSharp/Markdown/Powershell is just sugar for Format-TextMate -Language CS # render a Markdown file with a theme Get-Content README.md -Raw | Format-Markdown -Theme SolarizedLight +# FileInfo Object +Get-Item .\script.ps1 | Format-TextMate + # list supported grammars Get-SupportedTextMate ``` diff --git a/docs/en-us/Out-Page.md b/docs/en-us/Out-Page.md index cdc97a1..381f2f5 100644 --- a/docs/en-us/Out-Page.md +++ b/docs/en-us/Out-Page.md @@ -32,6 +32,18 @@ and displayed line-by-line. The pager supports keyboard navigation for scrolling and paging through large output. +use `?` for interactive help. + +Navigation: +Arrows Up/Down +PageUp/PageDown/space +Home/End +h/j/k/l +`/` or ctrl+f for search +N for next search match +C for clearing search +q/ESC for exiting pager + ## EXAMPLES ### Example 1 From 310f744a7bb271f0ee2a69870e87b5f6178017f0 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:12:29 +0100 Subject: [PATCH 16/17] =?UTF-8?q?fix(pager):=20=F0=9F=90=9B=20Update=20Mar?= =?UTF-8?q?kdig.Signed=20package=20version=20to=201.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ensures compatibility with the latest features and fixes. --- src/PSTextMate/PSTextMate.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PSTextMate/PSTextMate.csproj b/src/PSTextMate/PSTextMate.csproj index 0c5ebd5..c4aba27 100644 --- a/src/PSTextMate/PSTextMate.csproj +++ b/src/PSTextMate/PSTextMate.csproj @@ -14,7 +14,7 @@ - + From fccc6824d492df2de7cf34d96934b7ecefaf65cf Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:20:47 +0100 Subject: [PATCH 17/17] =?UTF-8?q?feat(compatibility):=20=E2=9C=A8=20Enhanc?= =?UTF-8?q?e=20input=20handling=20and=20add=20HasPendingInput=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improved input handling by checking for pending input before draining. * Introduced `HasPendingInput` method to encapsulate input availability logic. * Refactored `RenderToString` method in `SpectreRenderBridge` for better error handling and clarity. * Updated `Writer` class to use consistent parameter naming conventions. * Fixed window rectangle calculations in test helper for accurate buffer retrieval. --- src/PSTextMate/Sixel/Compatibility.cs | 18 ++++++++-- .../Utilities/SpectreRenderBridge.cs | 33 +++++++++++-------- src/PSTextMate/Utilities/Writer.cs | 6 ++-- tests/testhelper.psm1 | 4 +-- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/PSTextMate/Sixel/Compatibility.cs b/src/PSTextMate/Sixel/Compatibility.cs index acb1735..d706f47 100644 --- a/src/PSTextMate/Sixel/Compatibility.cs +++ b/src/PSTextMate/Sixel/Compatibility.cs @@ -34,8 +34,9 @@ internal static string GetControlSequenceResponse(string controlSequence) { const int maxRetries = 2; lock (s_controlSequenceLock) { - // Drain any stale bytes that may have leaked from prior VT interactions. - DrainPendingInput(); + if (HasPendingInput()) { + return string.Empty; + } for (int retry = 0; retry < maxRetries; retry++) { try { @@ -89,6 +90,19 @@ internal static string GetControlSequenceResponse(string controlSequence) { return string.Empty; } + private static bool HasPendingInput() { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return false; + } + + try { + return Console.KeyAvailable; + } + catch { + return false; + } + } + /// /// Attempts to read a key if one is available. /// diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs index b89fe17..10cff8a 100644 --- a/src/PSTextMate/Utilities/SpectreRenderBridge.cs +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -18,16 +18,18 @@ public static class SpectreRenderBridge { public static string RenderToString(object renderableObject, bool escapeAnsi = false, int? width = null) { ArgumentNullException.ThrowIfNull(renderableObject); - string rendered = renderableObject is IRenderable localRenderable - ? RenderLocal(localRenderable, width) - : RenderForeign(renderableObject, width); - - return rendered.Length != 0 - ? escapeAnsi ? VTHelpers.StripAnsi(rendered) : rendered - : throw new ArgumentException( + string rendered; + if (renderableObject is IRenderable localRenderable) { + rendered = RenderLocal(localRenderable, width); + } + else if (!TryRenderForeign(renderableObject, width, out rendered)) { + throw new ArgumentException( $"Object of type '{renderableObject.GetType().FullName}' does not implement a supported Spectre IRenderable shape.", nameof(renderableObject) ); + } + + return escapeAnsi ? VTHelpers.StripAnsi(rendered) : rendered; } /// @@ -87,7 +89,8 @@ private static string RenderLocal(IRenderable renderable, int? width) { return writer.ToString(); } - private static string RenderForeign(object renderableObject, int? width) { + private static bool TryRenderForeign(object renderableObject, int? width, out string rendered) { + rendered = string.Empty; Type valueType = renderableObject.GetType(); Assembly assembly = valueType.Assembly; @@ -101,7 +104,7 @@ private static string RenderForeign(object renderableObject, int? width) { || ansiConsoleSettingsType is null || ansiConsoleOutputType is null || foreignRenderableType?.IsInstanceOfType(renderableObject) != true) { - return string.Empty; + return false; } using StringWriter writer = new(new StringBuilder(1024), CultureInfo.InvariantCulture); @@ -109,7 +112,7 @@ private static string RenderForeign(object renderableObject, int? width) { object? settings = Activator.CreateInstance(ansiConsoleSettingsType); PropertyInfo? outProperty = ansiConsoleSettingsType.GetProperty("Out"); if (output is null || settings is null || outProperty is null || !outProperty.CanWrite) { - return string.Empty; + return false; } outProperty.SetValue(settings, output); @@ -121,7 +124,7 @@ private static string RenderForeign(object renderableObject, int? width) { && parameters[0].ParameterType == ansiConsoleSettingsType); object? console = createMethod?.Invoke(null, [settings]); if (console is null) { - return string.Empty; + return false; } if (width is int targetWidth && targetWidth > 0) { @@ -136,7 +139,8 @@ private static string RenderForeign(object renderableObject, int? width) { MethodInfo? writeMethod = console.GetType().GetMethod("Write", [foreignRenderableType]); if (writeMethod is not null) { _ = writeMethod.Invoke(console, [renderableObject]); - return writer.ToString(); + rendered = writer.ToString(); + return true; } Type? extType = assembly.GetType("Spectre.Console.AnsiConsoleExtensions"); @@ -146,11 +150,12 @@ private static string RenderForeign(object renderableObject, int? width) { && method.GetParameters() is { Length: 2 } parameters && parameters[1].ParameterType == foreignRenderableType); if (extWriteMethod is null) { - return string.Empty; + return false; } _ = extWriteMethod.Invoke(null, [console, renderableObject]); - return writer.ToString(); + rendered = writer.ToString(); + return true; } private static CallSite> CreateConvertToRenderableCallSite() { diff --git a/src/PSTextMate/Utilities/Writer.cs b/src/PSTextMate/Utilities/Writer.cs index 827ab0c..a5b823f 100644 --- a/src/PSTextMate/Utilities/Writer.cs +++ b/src/PSTextMate/Utilities/Writer.cs @@ -32,13 +32,13 @@ public static string Write(IRenderable renderable) { /// Renders highlighted text to string. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string? Write(HighlightedText highlightedText, bool autoPage = false, bool FromFormat = false) { + public static string? Write(HighlightedText highlightedText, bool autoPage = false, bool fromFormat = false) { ArgumentNullException.ThrowIfNull(highlightedText); if (highlightedText.Page || (autoPage && ShouldPage(highlightedText))) { var pager = new Pager(highlightedText); pager.Show(); - if (FromFormat) VTHelpers.MoveCursorRowUp(2); + if (fromFormat) VTHelpers.MoveCursorRowUp(2); return null; } @@ -46,7 +46,7 @@ public static string Write(IRenderable renderable) { // and flowing through host formatting can strip DCS wrappers and print payload text. if (ContainsImageRenderables(highlightedText.Renderables)) { AnsiConsole.Write(highlightedText); - if (FromFormat) VTHelpers.MoveCursorRowUp(2); + if (fromFormat) VTHelpers.MoveCursorRowUp(2); return null; } diff --git a/tests/testhelper.psm1 b/tests/testhelper.psm1 index d6e5372..82a4a76 100644 --- a/tests/testhelper.psm1 +++ b/tests/testhelper.psm1 @@ -29,8 +29,8 @@ function Get-HostBuffer { $windowRect = [System.Management.Automation.Host.Rectangle]::new( $windowPosition.X, $windowPosition.Y, - ($windowPosition.X + $windowWidth), - ($windowPosition.Y + $windowHeight)) + ($windowPosition.X + $windowWidth - 1), + ($windowPosition.Y + $windowHeight - 1)) $windowBuffer = $host.UI.RawUI.GetBufferContents($windowRect) foreach ($x in 0..($windowHeight - 1)) { $row = foreach ($y in 0..($windowWidth - 1)) {