diff --git a/.editorconfig b/.editorconfig index 048fa3f..e009c0a 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? @@ -156,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..53a4125 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,12 @@ src/.vs/* Module/lib debug.md [Oo]utput/ -*/obj/* -*/bin/* +**/obj/** +**/bin/** .github/chatmodes/* .github/instructions/* .github/prompts/* ref/** Copilot-Processing.md tools/** +src/Utilities/TMConsole.cs diff --git a/Module/TextMate.format.ps1xml b/Module/TextMate.format.ps1xml index bc839ef..c19315e 100644 --- a/Module/TextMate.format.ps1xml +++ b/Module/TextMate.format.ps1xml @@ -41,5 +41,24 @@ + + HighlightedText + + PSTextMate.Core.HighlightedText + + + + + + + + [PSTextMate.Utilities.Writer]::Write($_, $false, $true) + + + + + + + diff --git a/Module/TextMate.psd1 b/Module/TextMate.psd1 index 7dd35b3..263baa3 100644 --- a/Module/TextMate.psd1 +++ b/Module/TextMate.psd1 @@ -1,11 +1,11 @@ @{ - RootModule = 'lib/PSTextMate.dll' - ModuleVersion = '0.1.0' + RootModule = 'TextMate.psm1' + ModuleVersion = '0.2.0' GUID = 'fe78d2cb-2418-4308-9309-a0850e504cd6' 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' ) @@ -22,12 +23,13 @@ 'fps' 'ftm' 'Show-TextMate' + 'page' ) FormatsToProcess = 'TextMate.format.ps1xml' RequiredModules = @( @{ ModuleName = 'PwshSpectreConsole' - ModuleVersion = '2.3.0' + ModuleVersion = '2.6.3' MaximumVersion = '2.99.99' } ) 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/README.md b/README.md index 178190f..4d7539c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # 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. +- 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) @@ -21,9 +22,10 @@ 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 +Format-CSharp/Markdown/Powershell is just syntactic sugar for Format-TextMate -Language CSharp/PowerShell/Markdown ``` ## Examples @@ -35,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 ``` @@ -77,8 +82,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/TextMate.build.ps1 b/TextMate.build.ps1 index 401fb60..e8aa524 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,14 +36,32 @@ 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) { $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) { @@ -77,12 +96,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 +123,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 @@ -127,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 @@ -134,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 ef6d148..2040db3 100644 --- a/TextMate.slnx +++ b/TextMate.slnx @@ -1,3 +1,5 @@ - + + + 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/docs/en-us/Out-Page.md b/docs/en-us/Out-Page.md new file mode 100644 index 0000000..381f2f5 --- /dev/null +++ b/docs/en-us/Out-Page.md @@ -0,0 +1,139 @@ +--- +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. + +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 + +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/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/Core/HighlightedText.cs b/src/Core/HighlightedText.cs deleted file mode 100644 index 0dc5c28..0000000 --- a/src/Core/HighlightedText.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Globalization; -using System.Linq; -using Spectre.Console; -using Spectre.Console.Rendering; - -namespace PSTextMate.Core; - -/// -/// Represents syntax-highlighted text ready for rendering. -/// Provides a clean, consistent output type. -/// Implements IRenderable so it can be used directly with Spectre.Console. -/// -public sealed class HighlightedText : Renderable { - /// - /// The highlighted renderables ready for display. - /// - public required IRenderable[] Renderables { get; init; } - - /// - /// When true, prepend line numbers with a gutter separator. - /// - public bool ShowLineNumbers { get; init; } - - /// - /// Starting line number for the gutter. - /// - public int LineNumberStart { get; init; } = 1; - - /// - /// Optional fixed width for the line number column. - /// - public int? LineNumberWidth { get; init; } - - /// - /// Separator inserted between the line number and content. - /// - public string GutterSeparator { get; init; } = " │ "; - - /// - /// Number of lines contained in this highlighted text. - /// - public int LineCount => Renderables.Length; - - /// - /// 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); - - return !ShowLineNumbers ? ((IRenderable)rows).Render(options, maxWidth) : RenderWithLineNumbers(rows, options, maxWidth); - } - - /// - /// 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); - - return !ShowLineNumbers ? ((IRenderable)rows).Measure(options, maxWidth) : 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); - } - - private Measurement MeasureWithLineNumbers(Rows rows, RenderOptions options, int maxWidth) { - (List segments, int width, int contentWidth) = RenderInnerSegments(rows, options, maxWidth); - Measurement measurement = ((IRenderable)rows).Measure(options, contentWidth); - int gutterWidth = width + GutterSeparator.Length; - return new Measurement(measurement.Min + gutterWidth, measurement.Max + gutterWidth); - } - - private (List segments, int width, int contentWidth) RenderInnerSegments(Rows rows, RenderOptions options, int maxWidth) { - int width = ResolveLineNumberWidth(LineCount); - int contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); - var segments = ((IRenderable)rows).Render(options, contentWidth).ToList(); - - int actualLineCount = CountLines(segments); - int actualWidth = ResolveLineNumberWidth(actualLineCount); - if (actualWidth != width) { - width = actualWidth; - contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); - segments = [.. ((IRenderable)rows).Render(options, contentWidth)]; - } - - return (segments, width, contentWidth); - } - - private IEnumerable PrefixLineNumbers(List segments, RenderOptions options, int width, int contentWidth) { - int lineNumber = LineNumberStart; - - 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)) { - yield return segment; - } - - foreach (Segment segment in line) { - yield return segment; - } - - yield return Segment.LineBreak; - lineNumber++; - } - } - - private static IEnumerable> SplitLines(IEnumerable segments) { - List current = []; - bool sawLineBreak = false; - - foreach (Segment segment in segments) { - if (segment.IsLineBreak) { - yield return current; - current = []; - sawLineBreak = true; - continue; - } - - current.Add(segment); - } - - if (current.Count > 0 || !sawLineBreak) { - if (current.Count > 0) { - yield return current; - } - } - } - - private static int CountLines(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 int ResolveLineNumberWidth(int lineCount) { - if (LineNumberWidth.HasValue && LineNumberWidth.Value > 0) { - return LineNumberWidth.Value; - } - - int lastLineNumber = LineNumberStart + Math.Max(0, lineCount - 1); - return lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; - } - - /// - /// Wraps the highlighted text in a Spectre.Console Panel. - /// - /// Optional panel title - /// Border style to use (default: Rounded) - /// Panel containing the highlighted text - public Panel ToPanel(string? title = null, BoxBorder? border = null) { - Panel panel = new(this); - - if (!string.IsNullOrEmpty(title)) { - panel.Header(title); - } - - if (border != null) { - panel.Border(border); - } - else { - panel.Border(BoxBorder.Rounded); - } - - return panel; - } - - // public override string ToString() => ToPanel(); - - /// - /// Wraps the highlighted text with padding. - /// - /// Padding to apply - /// Padder containing the highlighted text - public Padder WithPadding(Padding padding) => new(this, padding); - - /// - /// Wraps the highlighted text with uniform padding on all sides. - /// - /// Padding size for all sides - /// Padder containing the highlighted text - public Padder WithPadding(int size) => new(this, new Padding(size)); -} 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 81% rename from src/Cmdlets/FormatCSharp.cs rename to src/PSTextMate/Cmdlets/FormatCSharp.cs index a950cc7..86c090b 100644 --- a/src/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/Cmdlets/FormatMarkdown.cs b/src/PSTextMate/Cmdlets/FormatMarkdown.cs similarity index 88% rename from src/Cmdlets/FormatMarkdown.cs rename to src/PSTextMate/Cmdlets/FormatMarkdown.cs index 38fa96e..a891819 100644 --- a/src/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/Cmdlets/FormatPowershell.cs b/src/PSTextMate/Cmdlets/FormatPowershell.cs similarity index 82% rename from src/Cmdlets/FormatPowershell.cs rename to src/PSTextMate/Cmdlets/FormatPowershell.cs index bcaa7c1..21ebd3e 100644 --- a/src/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/Cmdlets/FormatTextMateCmdlet.cs b/src/PSTextMate/Cmdlets/FormatTextMateCmdlet.cs similarity index 91% rename from src/Cmdlets/FormatTextMateCmdlet.cs rename to src/PSTextMate/Cmdlets/FormatTextMateCmdlet.cs index fdb5df4..5d72916 100644 --- a/src/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/Cmdlets/GetTextMateGrammar.cs b/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs similarity index 69% rename from src/Cmdlets/GetTextMateGrammar.cs rename to src/PSTextMate/Cmdlets/GetTextMateGrammar.cs index 0547b6a..b98e458 100644 --- a/src/Cmdlets/GetTextMateGrammar.cs +++ b/src/PSTextMate/Cmdlets/GetTextMateGrammar.cs @@ -1,5 +1,3 @@ -using System.Management.Automation; -using TextMateSharp.Grammars; namespace PSTextMate.Commands; @@ -13,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 new file mode 100644 index 0000000..dbd848b --- /dev/null +++ b/src/PSTextMate/Cmdlets/OutPage.cs @@ -0,0 +1,161 @@ +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 = []; + private readonly List _outStringInputs = []; + private HighlightedText? _singleHighlightedText; + private bool _sawNonHighlightedInput; + + [Parameter(Mandatory = true, ValueFromPipeline = true, Position = 0)] + [System.Management.Automation.AllowNull] + public PSObject? InputObject { get; set; } + + protected override void ProcessRecord() { + if (InputObject?.BaseObject is null) { + return; + } + + 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); + return; + } + + if (value is string text) { + _outStringInputs.Add(text); + return; + } + + if (TryConvertForeignSpectreRenderable(value, out IRenderable? convertedRenderable)) { + _renderables.Add(convertedRenderable); + return; + } + + _outStringInputs.Add(InputObject); + } + + protected override void EndProcessing() { + if (_singleHighlightedText is not null && !_sawNonHighlightedInput && _renderables.Count == 0 && _outStringInputs.Count == 0) { + var highlightedPager = new Pager(_singleHighlightedText); + highlightedPager.Show(); + return; + } + + if (_outStringInputs.Count > 0) { + List formattedLines = ConvertWithOutStringLines(_outStringInputs); + if (formattedLines.Count > 0) { + foreach (string line in formattedLines) { + _renderables.Add(line.Length == 0 ? Text.Empty : VTConversion.ToParagraph(line)); + } + + } + else { + foreach (object value in _outStringInputs) { + _renderables.Add(new Text(LanguagePrimitives.ConvertTo(value))); + } + + } + } + + if (_renderables.Count == 0) { + return; + } + + var pager = new Pager(_renderables, AnsiConsole.Console, null, suppressTerminalControlSequences: false); + pager.Show(); + } + + private static List ConvertWithOutStringLines(List values) { + if (values.Count == 0) { + return []; + } + + OutputRendering previousOutputRendering = PSStyle.Instance.OutputRendering; + try { + PSStyle.Instance.OutputRendering = OutputRendering.Ansi; + + using var ps = PowerShell.Create(RunspaceMode.CurrentRunspace); + ps.AddCommand("Out-String") + .AddParameter("Stream") + .AddParameter("Width", GetOutStringWidth()); + + Collection results = ps.Invoke(values); + if (ps.HadErrors || results.Count == 0) { + return []; + } + + var lines = new List(results.Count); + foreach (PSObject? result in results) { + if (result?.BaseObject is string text) { + AddLines(lines, text); + } + else { + AddLines(lines, result?.ToString() ?? string.Empty); + } + } + + return lines; + } + catch { + return []; + } + finally { + PSStyle.Instance.OutputRendering = previousOutputRendering; + } + } + + // 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) => + + TextMateHelper.AddSplitLines(lines, text, trimTrailingTerminatorEmptyLine: true); + + private static int GetConsoleWidth() { + try { + return Console.WindowWidth > 0 ? Console.WindowWidth : 120; + } + catch { + return 120; + } + } + + private static int GetOutStringWidth() => Math.Max(20, GetConsoleWidth() - 5); + + private static bool TryConvertForeignSpectreRenderable( + object value, + [NotNullWhen(true)] out IRenderable? renderable + ) { + renderable = null; + + Type valueType = value.GetType(); + string? fullName = valueType.FullName; + return IsSpectreObject(fullName) + && value is not IRenderable + && SpectreRenderBridge.TryConvertToLocalRenderable(value, out renderable); + } + 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/Cmdlets/TestTextMate.cs b/src/PSTextMate/Cmdlets/TestTextMate.cs similarity index 86% rename from src/Cmdlets/TestTextMate.cs rename to src/PSTextMate/Cmdlets/TestTextMate.cs index f90f1b9..669f827 100644 --- a/src/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; @@ -16,6 +13,7 @@ public sealed class TestTextMateCmdlet : PSCmdlet { /// [Parameter( ParameterSetName = "ExtensionSet", + ValueFromPipelineByPropertyName = true, Mandatory = true )] [ValidateNotNullOrEmpty] @@ -26,6 +24,7 @@ public sealed class TestTextMateCmdlet : PSCmdlet { /// [Parameter( ParameterSetName = "LanguageSet", + ValueFromPipelineByPropertyName = true, Mandatory = true )] [ValidateNotNullOrEmpty] @@ -36,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/Cmdlets/TextMateCmdletBase.cs b/src/PSTextMate/Cmdlets/TextMateCmdletBase.cs similarity index 85% rename from src/Cmdlets/TextMateCmdletBase.cs rename to src/PSTextMate/Cmdlets/TextMateCmdletBase.cs index e051457..86a0845 100644 --- a/src/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; @@ -23,7 +17,7 @@ public abstract class TextMateCmdletBase : PSCmdlet { Position = 0 )] [AllowEmptyString] - [AllowNull] + [System.Management.Automation.AllowNull] [Alias("FullName", "Path")] public PSObject? InputObject { get; set; } @@ -39,6 +33,13 @@ public abstract class TextMateCmdletBase : PSCmdlet { [Parameter] public SwitchParameter LineNumbers { get; set; } + + /// + /// When present, always render through the interactive pager. + /// + [Parameter] + public SwitchParameter Page { get; set; } + /// /// Fixed language or extension token used for rendering. /// @@ -89,7 +90,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) { @@ -112,7 +113,7 @@ protected override void ProcessRecord() { try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result); + EmitHighlightedResult(result); } } catch (Exception ex) { @@ -133,7 +134,7 @@ protected override void EndProcessing() { HighlightedText? result = ProcessStringInput(); if (result is not null) { - WriteObject(result); + EmitHighlightedResult(result); } } catch (Exception ex) { @@ -154,10 +155,22 @@ protected override void EndProcessing() { return renderables is null ? null - : new HighlightedText { - Renderables = renderables, - ShowLineNumbers = LineNumbers.IsPresent - }; + : new HighlightedText( + renderables, + showLineNumbers: LineNumbers.IsPresent, + language: token, + page: Page.IsPresent, + sourceLines: Page.IsPresent ? lines : null + ); + } + + private void EmitHighlightedResult(HighlightedText result) { + if (Page.IsPresent) { + result.ShowPager(); + return; + } + + WriteObject(result); } private IEnumerable ProcessPathInput(FileInfo filePath) { @@ -178,10 +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 - }; + yield return new HighlightedText( + renderables, + showLineNumbers: LineNumbers.IsPresent, + language: token, + page: Page.IsPresent, + sourceLines: Page.IsPresent ? lines : null + ); } } diff --git a/src/Core/CacheManager.cs b/src/PSTextMate/Core/CacheManager.cs similarity index 92% rename from src/Core/CacheManager.cs rename to src/PSTextMate/Core/CacheManager.cs index e5587af..44a8b66 100644 --- a/src/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 new file mode 100644 index 0000000..a9574ee --- /dev/null +++ b/src/PSTextMate/Core/HighlightedText.cs @@ -0,0 +1,327 @@ +namespace PSTextMate.Core; + +/// +/// Represents syntax-highlighted text ready for rendering. +/// Provides a clean, consistent output type. +/// Implements IRenderable so it can be used directly with Spectre.Console. +/// +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 => _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 + // so line-number gutter width can be computed against the full document + // (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. + /// + public bool ShowLineNumbers { get; set; } + + /// + /// Starting line number for the gutter. + /// + public int LineNumberStart { get; set; } = 1; + + /// + /// Optional fixed width for the line number column. + /// + public int? LineNumberWidth { get; set; } + + /// + /// Separator inserted between the line number and content. + /// + public string GutterSeparator { get; set; } = " │ "; + + /// + /// Number of lines contained in this highlighted text. + /// + 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)); + _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 = _viewSourceList is not null + ? _viewSourceList.Count + : source is ICollection coll + ? coll.Count + : source is IReadOnlyCollection rocoll + ? rocoll.Count + : source is ICollection nonGeneric ? nonGeneric.Count : -1; + } + + /// + /// Clears any active view so the instance renders its own array. + /// + public void ClearView() { + _viewSource = null; + _viewSourceList = null; + _viewStart = 0; + _viewCount = 0; + _documentLineCount = -1; + } + + private IEnumerable GetRenderablesEnumerable() { + return _viewSourceList is not null + ? EnumerateViewList(_viewSourceList, _viewStart, _viewCount) + : _viewSource is null ? Renderables : _viewSource.Skip(_viewStart).Take(_viewCount); + } + + 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. + /// + public bool Page { get; set; } + + /// + /// Renders the highlighted text by combining all renderables into a single output. + /// + 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); + } + + /// + /// Measures the dimensions of the highlighted text. + /// + 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); + } + + // 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); + } + + private Measurement MeasureWithLineNumbers(Rows rows, RenderOptions options, int maxWidth) { + (List segments, int width, int contentWidth) = RenderInnerSegments(rows, options, maxWidth); + Measurement measurement = ((IRenderable)rows).Measure(options, contentWidth); + int gutterWidth = width + GutterSeparator.Length; + return new Measurement(measurement.Min + gutterWidth, measurement.Max + gutterWidth); + } + + private (List segments, int width, int contentWidth) RenderInnerSegments(Rows rows, RenderOptions options, int maxWidth) { + int width = ResolveLineNumberWidth(LineCount); + int contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); + var segments = ((IRenderable)rows).Render(options, contentWidth).ToList(); + + int actualLineCount = CountLines(segments); + int actualWidth = ResolveLineNumberWidth(actualLineCount); + // 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)]; + } + + return (segments, width, contentWidth); + } + + private IEnumerable PrefixLineNumbers(List segments, RenderOptions options, int width, int contentWidth) { + int lineNumber = LineNumberStart; + + foreach (List line in SplitLines(segments)) { + string label = lineNumber.ToString(CultureInfo.InvariantCulture).PadLeft(width) + GutterSeparator; + int gutterWidth = width + GutterSeparator.Length; + foreach (Segment segment in ((IRenderable)new Text(label)).Render(options, gutterWidth)) { + yield return segment; + } + + foreach (Segment segment in line) { + yield return segment; + } + + yield return Segment.LineBreak; + lineNumber++; + } + } + + private static IEnumerable> SplitLines(IEnumerable segments) { + List current = []; + bool sawLineBreak = false; + + foreach (Segment segment in segments) { + if (segment.IsLineBreak) { + yield return current; + current = []; + sawLineBreak = true; + continue; + } + + current.Add(segment); + } + + if (current.Count > 0 || !sawLineBreak) { + if (current.Count > 0) { + yield return current; + } + } + } + + private static int CountLines(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 int ResolveLineNumberWidth(int lineCount) { + if (LineNumberWidth.HasValue && LineNumberWidth.Value > 0) { + return LineNumberWidth.Value; + } + + // 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; + } + + /// + /// Wraps the highlighted text in a Spectre.Console Panel. + /// + /// Optional panel title + /// Border style to use (default: Rounded) + /// Panel containing the highlighted text + 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(GetRenderablesEnumerable()) + : new InnerContentRenderable(this); + + var panel = new Panel(content); + panel.Padding(0, 0); + panel.Expand(); + + if (!string.IsNullOrEmpty(title)) { + panel.Header(title); + } + + if (border != null) { + panel.Border(border); + } + else { + panel.Border(BoxBorder.Rounded); + } + + return panel; + } + + /// + /// Wraps the highlighted text with padding. + /// + /// Padding to apply + /// Padder containing the highlighted text + public Padder WithPadding(Padding padding) => new(this, padding); + + /// + /// Wraps the highlighted text with uniform padding on all sides. + /// + /// Padding size for all sides + /// Padder containing the highlighted text + public Padder WithPadding(int size) => new(this, new Padding(size)); + public void ShowPager() { + if (LineCount <= 0) return; + + var pager = new Pager(this); + pager.Show(); + } + + /// + /// Renders this highlighted text to a string. + /// + public string? Write() + => Writer.Write(this, Page); + + public override string ToString() + => Writer.WriteToString(this); +} 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 89% rename from src/Core/MarkdownRenderer.cs rename to src/PSTextMate/Core/MarkdownRenderer.cs index f7f6e6f..b4d4b9d 100644 --- a/src/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/Core/MarkdownToken.cs b/src/PSTextMate/Core/MarkdownToken.cs similarity index 90% rename from src/Core/MarkdownToken.cs rename to src/PSTextMate/Core/MarkdownToken.cs index d5a5eca..9009780 100644 --- a/src/Core/MarkdownToken.cs +++ b/src/PSTextMate/Core/MarkdownToken.cs @@ -1,5 +1,3 @@ -using TextMateSharp.Grammars; - namespace PSTextMate.Core; /// diff --git a/src/Core/StandardRenderer.cs b/src/PSTextMate/Core/StandardRenderer.cs similarity index 90% rename from src/Core/StandardRenderer.cs rename to src/PSTextMate/Core/StandardRenderer.cs index c24e5f4..3c43b3f 100644 --- a/src/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/Core/StyleHelper.cs b/src/PSTextMate/Core/StyleHelper.cs similarity index 94% rename from src/Core/StyleHelper.cs rename to src/PSTextMate/Core/StyleHelper.cs index 2ff5602..a270c1d 100644 --- a/src/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/Core/TextMateProcessor.cs b/src/PSTextMate/Core/TextMateProcessor.cs similarity index 91% rename from src/Core/TextMateProcessor.cs rename to src/PSTextMate/Core/TextMateProcessor.cs index f5a8fd9..e448d6b 100644 --- a/src/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/Core/TokenProcessor.cs b/src/PSTextMate/Core/TokenProcessor.cs similarity index 92% rename from src/Core/TokenProcessor.cs rename to src/PSTextMate/Core/TokenProcessor.cs index 8427a00..5281e2d 100644 --- a/src/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; /// @@ -66,7 +58,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 +80,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 +106,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 +123,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 82% rename from src/TextMate.csproj rename to src/PSTextMate/PSTextMate.csproj index 72b0e53..c4aba27 100644 --- a/src/TextMate.csproj +++ b/src/PSTextMate/PSTextMate.csproj @@ -13,12 +13,14 @@ latest-Recommended - - + + + + @@ -29,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..c35de06 --- /dev/null +++ b/src/PSTextMate/Pager/Pager.cs @@ -0,0 +1,780 @@ +namespace PSTextMate.Terminal; + +/// +/// Simple pager implemented with Spectre.Console Live display. +/// Interaction keys: +/// - 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 +/// - c: clear active search +/// - ?: show/hide keybindings +/// - 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 int? _stableLineNumberWidth; + private readonly int _statusColumnWidth; + private int _top; + private int WindowHeight; + private int WindowWidth; + private int _lastRenderedRows; + 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); + private const int KeyPollingIntervalMs = 50; + private const int MaxSearchQueryLength = 256; + 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; + + 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; + + _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 = null, + 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(KeyPollingIntervalMs); + continue; + } + + if (_isSearchInputActive) { + HandleSearchInputKey(key, ref forceRedraw); + 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(); + forceRedraw = true; + continue; + } + + if (key.KeyChar == '?') { + _isHelpOverlayActive = true; + forceRedraw = true; + continue; + } + + 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; + 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.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, int contentRows) => _top = _viewportEngine.ScrollTop(_top, delta, contentRows); + + 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)) { + 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; + return; + } + + PagerSearchHit? hit = _search.MoveNext(_top); + if (hit is null) { + _searchStatusText = $"/{_search.Query} (no matches)"; + return; + } + + _top = hit.RenderableIndex; + _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."; + 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 = _isHelpOverlayActive + ? BuildHelpOverlayPanel() + : 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 static Panel BuildHelpOverlayPanel() { + var helpRows = new Rows( + new Text("Keybindings", new Style(Color.White, decoration: Decoration.Bold)), + Text.Empty, + 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)), + 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.Rounded, + Padding = new Padding(2, 1, 2, 1), + Expand = true + }; + } + + private IRenderable BuildContentRenderable(PagerViewportWindow viewport) { + 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; + } + + return _search.HasQuery ? BuildSearchAwareContent(viewport) : new Rows(_renderables.Skip(viewport.Top).Take(viewport.Count)); + } + + private List BuildSearchAwareItems(PagerViewportWindow viewport) { + var items = new List(viewport.Count); + for (int i = 0; i < viewport.Count; i++) { + int renderableIndex = viewport.Top + i; + IRenderable highlighted = ApplySearchHighlight(renderableIndex, _renderables[renderableIndex]); + items.Add(highlighted); + } + + return items; + } + + private Rows BuildSearchAwareContent(PagerViewportWindow viewport) + => new(BuildSearchAwareItems(viewport)); + + private IRenderable ApplySearchHighlight(int renderableIndex, IRenderable renderable) { + IReadOnlyList hits = _search.GetHitsForRenderable(renderableIndex); + + string plainText = GetSearchTextForHighlight(renderableIndex, renderable); + if (plainText.Length == 0 || !_search.HasQuery) { + return renderable; + } + + if (hits.Count == 0) { + hits = BuildQueryHits(plainText, _search.Query, renderableIndex); + if (hits.Count == 0) { + return renderable; + } + } + + bool highlightLinkedLabelsOnNoDirectMatch = hits.Count > 0; + return PagerHighlighting.BuildSegmentHighlightRenderable( + renderable, + _search.Query, + SearchRowTextStyle, + SearchMatchTextStyle, + highlightLinkedLabelsOnNoDirectMatch + ); + } + + 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) { + 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); + string normalized = PagerHighlighting.NormalizeText(VTHelpers.StripAnsi(rendered)); + return normalized.Length > 0 + ? normalized + : PagerHighlighting.NormalizeText(renderable.ToString()); + } + catch (InvalidOperationException) { + return PagerHighlighting.NormalizeText(renderable.ToString()); + } + catch (IOException) { + return PagerHighlighting.NormalizeText(renderable.ToString()); + } + } + + 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 = $"{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 + defaultHelp + 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 = "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 = "Search: Enter Apply Esc Cancel"; + } + + if (!string.IsNullOrEmpty(_searchStatusText)) { + keyText = string.IsNullOrEmpty(keyText) + ? _searchStatusText + : $"{keyText} {_searchStatusText}"; + } + + 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() + .Width(chartWidth) + .WithMaxValue(chartWidth) + .HideValues() + .AddItem(" ", chartValue, Color.Lime); + + 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(footerBody) { + 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; + } + + 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..0804c69 --- /dev/null +++ b/src/PSTextMate/Pager/PagerDocument.cs @@ -0,0 +1,199 @@ +namespace PSTextMate.Terminal; + +internal sealed record PagerDocumentEntry( + int RenderableIndex, + IRenderable Renderable, + Func GetSearchText, + Func GetLineStarts, + bool IsImage +) { + public string SearchText => GetSearchText(); + + public int[] LineStarts => GetLineStarts(); +} + +internal sealed partial class PagerDocument { + private readonly List _entries = []; + private static readonly Regex s_hyperlinkTargetRegex = HyperlinkTargetRegex(); + + public IReadOnlyList Entries => _entries; + + 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 + : sourceLines is not null + ? Normalize(sourceLines[entryIndex]) + : 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++; + } + + Renderables = renderableList; + } + + public static PagerDocument FromHighlightedText(HighlightedText highlightedText) { + ArgumentNullException.ThrowIfNull(highlightedText); + + 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) { + return renderableIndex < 0 || renderableIndex >= _entries.Count + ? null + : _entries[renderableIndex]; + } + + private static string ExtractSearchText(IRenderable renderable) { + try { + string rendered = Writer.WriteToString(renderable, width: 200); + string visibleText = Normalize(VTHelpers.StripAnsi(rendered)); + string hyperlinkTargets = ExtractHyperlinkTargets(rendered); + + if (!string.IsNullOrEmpty(hyperlinkTargets)) { + return string.IsNullOrEmpty(visibleText) + ? hyperlinkTargets + : $"{visibleText}\n{hyperlinkTargets}"; + } + + if (!string.IsNullOrEmpty(visibleText)) { + return visibleText; + } + + string segmentText = ExtractSegmentText(renderable); + return !string.IsNullOrEmpty(segmentText) ? segmentText : string.Empty; + } + catch (InvalidOperationException) { + return ExtractSegmentText(renderable); + } + catch (IOException) { + return ExtractSegmentText(renderable); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { + 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; + } + } + + 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 + : 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..bdee363 --- /dev/null +++ b/src/PSTextMate/Pager/PagerHighlighting.cs @@ -0,0 +1,260 @@ +namespace PSTextMate.Terminal; + +internal static class PagerHighlighting { + internal static IRenderable BuildSegmentHighlightRenderable( + IRenderable renderable, + string query, + Style rowStyle, + Style matchStyle, + bool highlightLinkedLabelsOnNoDirectMatch = false + ) => new SegmentHighlightRenderable(renderable, query, rowStyle, matchStyle, highlightLinkedLabelsOnNoDirectMatch); + + 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 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 : IRenderable { + private readonly IRenderable _inner; + private readonly string _query; + private readonly Style _rowStyle; + private readonly 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; + } + + public Measurement Measure(RenderOptions options, int maxWidth) + => _inner.Measure(options, maxWidth); + + public 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); + bool hasDirectHits = hits.Count > 0; + bool highlightLinkedLabels = _highlightLinkedLabelsOnNoDirectMatch + && !hasDirectHits + && source.Any(segment => SegmentLinkMatchesQuery(segment, _query)); + + if (!hasDirectHits && !highlightLinkedLabels) { + 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, 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) { + 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 builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } + } + + 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, + bool highlightLinkedLabels + ) { + var output = new List(source.Count * 2); + int absolute = 0; + int line = 0; + StringBuilder chunk = StringBuilderPool.Rent(); + + try { + 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; + } + + 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; + } + + 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; + } + + 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(); + } + } + +} diff --git a/src/PSTextMate/Pager/PagerSearchSession.cs b/src/PSTextMate/Pager/PagerSearchSession.cs new file mode 100644 index 0000000..43b7a04 --- /dev/null +++ b/src/PSTextMate/Pager/PagerSearchSession.cs @@ -0,0 +1,121 @@ +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 readonly Dictionary> _hitsByRenderable = []; + 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) { + return _hitsByRenderable.TryGetValue(renderableIndex, out List? matches) + ? matches + : s_noHits; + } + + private void RebuildHits() { + _hits.Clear(); + _hitsByRenderable.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); + 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) { + if (lineStarts.Length == 0) { + return (0, offset); + } + + 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 new file mode 100644 index 0000000..ae8acb2 --- /dev/null +++ b/src/PSTextMate/Pager/PagerViewport.cs @@ -0,0 +1,245 @@ +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 = []; + 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)); + _sourceHighlightedText = sourceHighlightedText; + } + + 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); + 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 (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) { + 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 contentRows) { + if (_renderables.Count == 0) { + return currentTop; + } + + int direction = Math.Sign(delta); + if (direction == 0) { + return currentTop; + } + + int maxTop = GetMaxTop(Math.Max(1, contentRows)); + 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 (InvalidOperationException) { + return Math.Clamp(contentRows, 1, contentRows); + } + catch (IOException) { + 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/Rendering/BlockRenderer.cs b/src/PSTextMate/Rendering/BlockRenderer.cs similarity index 84% rename from src/Rendering/BlockRenderer.cs rename to src/PSTextMate/Rendering/BlockRenderer.cs index f47090f..5d353c7 100644 --- a/src/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,18 +89,22 @@ HtmlBlock html /// Extracts alt text from an image link inline. /// private static string ExtractImageAltText(LinkInline imageLink) { - var textBuilder = new System.Text.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/Rendering/CodeBlockRenderer.cs b/src/PSTextMate/Rendering/CodeBlockRenderer.cs similarity index 89% rename from src/Rendering/CodeBlockRenderer.cs rename to src/PSTextMate/Rendering/CodeBlockRenderer.cs index 44a2df9..2e40e12 100644 --- a/src/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; /// @@ -39,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); @@ -76,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/Rendering/HeadingRenderer.cs b/src/PSTextMate/Rendering/HeadingRenderer.cs similarity index 75% rename from src/Rendering/HeadingRenderer.cs rename to src/PSTextMate/Rendering/HeadingRenderer.cs index 56e2f69..f4982f9 100644 --- a/src/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; /// @@ -61,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) { @@ -72,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 new file mode 100644 index 0000000..d9b7d72 --- /dev/null +++ b/src/PSTextMate/Rendering/HorizontalRuleRenderer.cs @@ -0,0 +1,20 @@ +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() + // 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/Rendering/HtmlBlockRenderer.cs b/src/PSTextMate/Rendering/HtmlBlockRenderer.cs similarity index 85% rename from src/Rendering/HtmlBlockRenderer.cs rename to src/PSTextMate/Rendering/HtmlBlockRenderer.cs index b1104e4..abf73d8 100644 --- a/src/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; /// @@ -37,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 @@ -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()); } @@ -89,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; @@ -106,11 +96,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/Rendering/ImageBlockRenderer.cs b/src/PSTextMate/Rendering/ImageBlockRenderer.cs similarity index 96% rename from src/Rendering/ImageBlockRenderer.cs rename to src/PSTextMate/Rendering/ImageBlockRenderer.cs index e82b86e..698f0c0 100644 --- a/src/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 new file mode 100644 index 0000000..a88f48e --- /dev/null +++ b/src/PSTextMate/Rendering/ImageRenderer.cs @@ -0,0 +1,315 @@ +namespace PSTextMate.Rendering; + +/// +/// Handles rendering of images in markdown using Sixel format when possible. +/// +public static class ImageRenderer { + 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 => s_currentMarkdownDirectory.Value; + set => s_currentMarkdownDirectory.Value = value; + } + + /// + /// Renders an image using Sixel format if possible, otherwise falls back to a link. + /// + /// Alternative text for the image + /// URL or path to the image + /// Maximum width for the image (optional) + /// Maximum height for the image (optional) + /// A renderable representing the image or fallback + public static IRenderable RenderImage(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { + try { + // Clear previous errors + s_lastImageError.Value = null; + s_lastSixelError.Value = null; + + // Check if the image format is likely supported + if (!ImageFile.IsLikelySupportedImageFormat(imageUrl, CurrentMarkdownDirectory)) { + 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) { + SetLastImageError($"Image download timeout after {ImageTimeout.TotalSeconds} seconds: {imageUrl}"); + } + else if (CurrentMarkdownDirectory is not null) { + SetLastImageError($"Failed to resolve '{imageUrl}' with base directory '{CurrentMarkdownDirectory}'"); + } + + // Timeout occurred + return CreateImageFallback(altText, imageUrl); + } + + if (localImagePath is null) { + SetLastImageError($"Failed to normalize image source: {imageUrl}"); + return CreateImageFallback(altText, imageUrl); + } + + // Verify the downloaded file exists and has content + if (!File.Exists(localImagePath)) { + SetLastImageError($"Downloaded image file does not exist: {localImagePath}"); + return CreateImageFallback(altText, imageUrl); + } + + var fileInfo = new FileInfo(localImagePath); + if (fileInfo.Length == 0) { + SetLastImageError($"Downloaded image file is empty: {localImagePath} (0 bytes)"); + return CreateImageFallback(altText, imageUrl); + } + + // Set reasonable defaults for markdown display + int defaultMaxWidth = maxWidth ?? 80; // Default to ~80 characters wide for terminal display + int defaultMaxHeight = maxHeight ?? 30; // Default to ~30 lines high + + if (TryCreateSixelRenderable(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) { + // Return the sixel image directly. The caller may append an explicit Text.NewLine + // so it renders as a separate row (avoids embedding the blank row inside the same widget). + return sixelImage; + } + else { + // Fallback to enhanced link representation with file info + SetLastImageError($"SixelImage creation failed. File: {localImagePath} ({fileInfo.Length} bytes). Sixel error: {s_lastSixelError.Value}"); + return CreateEnhancedImageFallback(altText, imageUrl, localImagePath); + } + } + 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 + 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); + } + } + + /// + /// Renders an image inline (without panel) using Sixel format if possible. + /// + /// Alternative text for the image + /// URL or path to the image + /// Maximum width for the image (optional) + /// Maximum height for the image (optional) + /// A renderable representing the image or fallback + public static IRenderable RenderImageInline(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { + try { + // Check if the image format is likely supported + if (!ImageFile.IsLikelySupportedImageFormat(imageUrl, CurrentMarkdownDirectory)) { + return CreateImageFallbackInline(altText, imageUrl); + } + + // Use a timeout for image processing + if (!TryNormalizeImagePath(imageUrl, out string? localImagePath, out bool timedOut) || timedOut) { + // Timeout occurred + return CreateImageFallbackInline(altText, imageUrl); + } + + if (localImagePath is null) { + return CreateImageFallbackInline(altText, imageUrl); + } + + // Smaller defaults for inline images + int width = maxWidth ?? 60; // Default max width for inline images + int height = maxHeight ?? 20; // Default max height for inline images + + if (TryCreateSixelRenderable(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) { + return sixelImage; + } + // Fallback to inline link representation + return CreateImageFallbackInline(altText, imageUrl); + } + // If anything goes wrong, fall back to the link representation + catch (InvalidOperationException) { + return CreateImageFallbackInline(altText, imageUrl); + } + catch (HttpRequestException) { + return CreateImageFallbackInline(altText, imageUrl); + } + catch (IOException) { + return CreateImageFallbackInline(altText, imageUrl); + } + catch (TaskCanceledException) { + return CreateImageFallbackInline(altText, imageUrl); + } + } + + /// + /// 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, 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. + /// + private static bool TryCreatePixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { + result = null; + + try { + if (!Compatibility.TerminalSupportsSixel()) { + SetLastSixelError("Terminal does not report Sixel support."); + return false; + } + + if (!File.Exists(imagePath)) { + SetLastSixelError($"Image file not found: {imagePath}"); + return false; + } + + var pixelImage = new PixelImage(imagePath, animationDisabled: false); + + if (maxWidth.HasValue) { + pixelImage.MaxWidth = maxWidth.Value; + } + + // MaxHeight is handled internally by PixelImage through terminal-height based clipping + // when MaxWidth is not explicitly user-limited. + if (maxHeight.HasValue && maxHeight.Value <= 0) { + SetLastSixelError("MaxHeight must be greater than zero when specified."); + return false; + } + + result = pixelImage; + return true; + } + catch (Exception ex) { + SetLastSixelError(ex.Message); + } + + return false; + } + + /// + /// Creates a fallback representation of an image as a clickable link with an icon. + /// + /// Alternative text for the image + /// URL or path to the image + /// A markup string representing the image as a link + private static Text CreateImageFallback(string altText, string imageUrl) { + string linkText = $"🖼️ Image: {altText}"; + Style style = SpectreStyleCompat.CreateWithLink(Color.Blue, null, Decoration.Underline, imageUrl); + return new Text(linkText, style); + } + + /// + /// Creates an enhanced fallback representation with file information. + /// + /// Alternative text for the image + /// Original URL or path to the image + /// Local path to the image file + /// A panel with enhanced image information + private static IRenderable CreateEnhancedImageFallback(string altText, string imageUrl, string localPath) { + try { + var fileInfo = new FileInfo(localPath); + string? sizeText = fileInfo.Exists ? $" ({fileInfo.Length / 1024:N0} KB)" : ""; + + // Build a text-based content with clickable link style + string display = $"🖼️ {altText}{sizeText}"; + 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)") + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey); + } + catch (InvalidOperationException) { + return CreateImageFallback(altText, imageUrl); + } + catch (IOException) { + return CreateImageFallback(altText, imageUrl); + } + } + + /// + /// Creates an inline fallback representation of an image as a clickable link with an icon. + /// + /// Alternative text for the image + /// URL or path to the image + /// A markup string representing the image as a link + private static Text CreateImageFallbackInline(string altText, string imageUrl) { + string display = $"🖼️ {altText}"; + Style style = SpectreStyleCompat.CreateWithLink(Color.Blue, null, Decoration.Underline, imageUrl); + return new Text(display, style); + } + + /// + /// Gets debug information about the last image processing error. + /// + /// The last error message, if any + 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() => 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. + /// + /// True if SixelImage can be found + public static bool IsSixelImageAvailable() { + try { + return Compatibility.TerminalSupportsSixel(); + } + catch (InvalidOperationException) { + return false; + } + catch (IOException) { + return false; + } + } + +} diff --git a/src/Rendering/ListRenderer.cs b/src/PSTextMate/Rendering/ListRenderer.cs similarity index 59% rename from src/Rendering/ListRenderer.cs rename to src/PSTextMate/Rendering/ListRenderer.cs index 98a6d9c..202766e 100644 --- a/src/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; /// @@ -37,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) { @@ -74,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) { @@ -89,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; @@ -110,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); } @@ -128,8 +114,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: @@ -143,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. /// @@ -160,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(System.Globalization.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/Rendering/MarkdownRenderer.cs b/src/PSTextMate/Rendering/MarkdownRenderer.cs similarity index 77% rename from src/Rendering/MarkdownRenderer.cs rename to src/PSTextMate/Rendering/MarkdownRenderer.cs index 5279221..69e596a 100644 --- a/src/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; /// @@ -16,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. /// @@ -47,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); } } @@ -90,24 +79,18 @@ private static int GetBlockEndLine(Block block, string markdown) { } /// - /// 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. + /// 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/Rendering/ParagraphRenderer.cs b/src/PSTextMate/Rendering/ParagraphRenderer.cs similarity index 77% rename from src/Rendering/ParagraphRenderer.cs rename to src/PSTextMate/Rendering/ParagraphRenderer.cs index 2892d70..594aff8 100644 --- a/src/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; /// @@ -49,31 +36,19 @@ 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) { 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 { @@ -97,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: @@ -109,8 +84,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: @@ -173,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. /// @@ -181,8 +169,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 +179,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 +191,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); } /// @@ -243,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/Rendering/QuoteRenderer.cs b/src/PSTextMate/Rendering/QuoteRenderer.cs similarity index 86% rename from src/Rendering/QuoteRenderer.cs rename to src/PSTextMate/Rendering/QuoteRenderer.cs index 1ee56ba..3d5403d 100644 --- a/src/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; /// @@ -41,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/Rendering/TableRenderer.cs b/src/PSTextMate/Rendering/TableRenderer.cs similarity index 80% rename from src/Rendering/TableRenderer.cs rename to src/PSTextMate/Rendering/TableRenderer.cs index 6f39bca..3ceb335 100644 --- a/src/Rendering/TableRenderer.cs +++ b/src/PSTextMate/Rendering/TableRenderer.cs @@ -1,13 +1,4 @@ -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; @@ -24,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 @@ -110,7 +101,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,21 +117,23 @@ 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) { - 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); + } } /// @@ -185,19 +178,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); - } - - /// - /// 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 is not null ? baseStyle.Decoration : Decoration.None) | Decoration.Bold; - return new Style(fgColor, bgColor, decoration); + return style ?? new Style(foreground: Color.Grey); } /// diff --git a/src/PSTextMate/Sixel/CellSize.cs b/src/PSTextMate/Sixel/CellSize.cs new file mode 100644 index 0000000..1435147 --- /dev/null +++ b/src/PSTextMate/Sixel/CellSize.cs @@ -0,0 +1,10 @@ +namespace PSTextMate.Sixel; + +/// +/// Represents terminal cell dimensions in pixels. +/// +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 new file mode 100644 index 0000000..d706f47 --- /dev/null +++ b/src/PSTextMate/Sixel/Compatibility.cs @@ -0,0 +1,418 @@ +namespace PSTextMate.Sixel; + +/// +/// Provides methods and cached properties for detecting terminal compatibility, supported protocols, and cell/window sizes. +/// +public static partial class Compatibility { + private static readonly object s_controlSequenceLock = new(); + /// + /// Memory-caches the result of the terminal supporting sixel graphics. + /// + internal static bool? _terminalSupportsSixel; + /// + /// 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 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. + /// + internal static string GetControlSequenceResponse(string controlSequence) { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return string.Empty; + } + + const int timeoutMs = 500; + const int maxRetries = 2; + + lock (s_controlSequenceLock) { + if (HasPendingInput()) { + return string.Empty; + } + + 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; + } + } + } + + DrainPendingInput(); + } + + 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. + /// + /// The key read from stdin. + /// True when a key was read, otherwise false. + private static bool TryReadAvailableKey(out char key) { + key = default; + + try { + if (!Console.KeyAvailable) { + return false; + } + + key = Console.ReadKey(true).KeyChar; + return true; + } + catch { + return false; + } + } + + /// + /// Drains any pending stdin bytes to prevent VT probe responses from leaking into user input. + /// + private static void DrainPendingInput() { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return; + } + + try { + const int quietPeriodMs = 20; + const int maxDrainMs = 250; + + var stopwatch = Stopwatch.StartNew(); + long lastReadAt = stopwatch.ElapsedMilliseconds; + + while (stopwatch.ElapsedMilliseconds < maxDrainMs) { + if (!Console.KeyAvailable) { + if (stopwatch.ElapsedMilliseconds - lastReadAt >= quietPeriodMs) { + break; + } + + Thread.Sleep(1); + continue; + } + + _ = Console.ReadKey(true); + lastReadAt = stopwatch.ElapsedMilliseconds; + } + } + catch { + // Best effort only. + } + } + + + /// + /// Check for complete terminal responses + /// + private static bool IsCompleteResponse(StringBuilder response) { + int length = response.Length; + if (length < 2) return false; + + + // Most VT terminal responses end with specific letters + 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] == '['; + // String Terminator (ESC\) + case '\\': + return length >= 2 && response[length - 2] == '\x1b'; + // BEL character + case (char)7: + return true; + + default: + // Check for Kitty graphics protocol: ends with ";OK" followed by ST and then another response + // Minimum for ";OK" + ESC\ + ESC[...c + if (length >= 7) { + // 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; + } + } + + /// + /// 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. + /// + 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 + + 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. + internal static int GetTerminalHeight() { + try { + if (!Console.IsOutputRedirected) { + return Console.WindowHeight; + } + } + catch { + // Terminal height is unavailable (e.g. no console attached). + } + 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 new file mode 100644 index 0000000..f991771 --- /dev/null +++ b/src/PSTextMate/Sixel/ImageCanvas.cs @@ -0,0 +1,197 @@ +namespace PSTextMate.Sixel; + +/// +/// Represents a renderable canvas. +/// +internal sealed class ImageCanvas : IRenderable { + 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; + } + + /// + public 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); + } + + /// + public 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..00c2e5a --- /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. +/// +internal 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..60c4344 --- /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. +/// +internal sealed class PixelImage : IRenderable { + 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); + } + + /// + public 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); + } + + /// + 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 + // 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 = 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..b7f07f5 --- /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. +internal 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..69ce3a2 --- /dev/null +++ b/src/PSTextMate/Sixel/SixelRender.cs @@ -0,0 +1,284 @@ +using SixLabors.ImageSharp; +using Size = SixLabors.ImageSharp.Size; + +namespace PSTextMate.Sixel; + +/// +/// Contains methods for converting an image to a Sixel format. +/// +internal static class SixelRender { + /// + /// The character to use when entering a terminal escape code sequence. + /// + 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. + /// + 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. + /// + 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. + /// + 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. + /// + 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. + /// + internal const char SixelDECGCR = '$'; + /// + /// The start of a sixel sequence. + /// https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1. + /// + 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. + /// + internal const string SixelRaster = "\"1;1;"; + /// + /// The end of a sixel sequence. + /// + 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. + /// + internal const string SixelTransparentColor = "#0;2;0;0;0"; + + /// + /// explicit space char for clarity + /// + internal const char Space = ' '; + internal const char Divider = ';'; + + /// + /// 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)('?' + (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(); + } + + 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(SixelColorStart) + .Append(colorIndex) + .Append(SixelColorParam) + .Append(r) + .Append(Divider) + .Append(g) + .Append(Divider) + .Append(b); + } + 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 = SixelTransparent; + } + if (repeatCounter <= 1) { + // single entry + _ = sixelBuilder + .Append(SixelColorStart) + .Append(colorIndex) + .Append(sixel); + } + else { + // add repeats + _ = sixelBuilder + .Append(SixelColorStart) + .Append(colorIndex) + .Append(SixelRepeat) + .Append(repeatCounter) + .Append(sixel); + } + } + private static void AppendCarriageReturn(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(SixelDECGCR); + } + + private static void AppendNextLine(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(SixelDECGNL); + } + + private static void AppendExitSixel(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(ST); + } + + private static void StartSixel(this StringBuilder sixelBuilder, int width, int height) { + _ = sixelBuilder + .Append(SixelStart) + .Append(SixelRaster) + .Append(width) + .Append(Divider) + .Append(height) + .Append(SixelTransparentColor); + } +} diff --git a/src/Utilities/AssemblyInfo.cs b/src/PSTextMate/Utilities/AssemblyInfo.cs similarity index 70% rename from src/Utilities/AssemblyInfo.cs rename to src/PSTextMate/Utilities/AssemblyInfo.cs index 1febb66..e3b4415 100644 --- a/src/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/Utilities/Completers.cs b/src/PSTextMate/Utilities/Completers.cs similarity index 94% rename from src/Utilities/Completers.cs rename to src/PSTextMate/Utilities/Completers.cs index d70283e..5eeaa10 100644 --- a/src/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 new file mode 100644 index 0000000..8963188 --- /dev/null +++ b/src/PSTextMate/Utilities/Helpers.cs @@ -0,0 +1,105 @@ +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"). + /// + public static readonly string[] Extensions; + /// + /// Array of supported TextMate language identifiers (e.g., "powershell", "markdown", "csharp"). + /// + public static readonly string[] Languages; + /// + /// List of all available language definitions with metadata. + /// + public static readonly List AvailableLanguages; + static TextMateHelper() { + try { + RegistryOptions _registryOptions = new(ThemeName.DarkPlus); + AvailableLanguages = _registryOptions.GetAvailableLanguages(); + + // Get all the extensions and languages from the available languages + Extensions = [.. AvailableLanguages + .Where(x => x.Extensions is not null) + .SelectMany(x => x.Extensions)]; + + Languages = [.. AvailableLanguages + .Where(x => x.Id is not null) + .Select(x => x.Id)]; + } + catch (Exception ex) { + throw new TypeInitializationException(nameof(TextMateHelper), ex); + } + } + + internal static string[] SplitToLines(string 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) { + if (buffer.Count == 0) { + return []; + } + + var lines = new List(buffer.Count * 2); + foreach (string item in buffer) { + 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/src/Utilities/ImageFile.cs b/src/PSTextMate/Utilities/ImageFile.cs similarity index 84% rename from src/Utilities/ImageFile.cs rename to src/PSTextMate/Utilities/ImageFile.cs index 8b58502..7020ff5 100644 --- a/src/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; /// @@ -16,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(); @@ -95,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; } } @@ -136,23 +126,40 @@ 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. + } } /// diff --git a/src/Utilities/InlineTextExtractor.cs b/src/PSTextMate/Utilities/InlineTextExtractor.cs similarity index 82% rename from src/Utilities/InlineTextExtractor.cs rename to src/PSTextMate/Utilities/InlineTextExtractor.cs index e4edabb..4be0a92 100644 --- a/src/Utilities/InlineTextExtractor.cs +++ b/src/PSTextMate/Utilities/InlineTextExtractor.cs @@ -1,6 +1,3 @@ -using System.Text; -using Markdig.Syntax.Inlines; - namespace PSTextMate.Utilities; /// @@ -16,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: @@ -34,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 new file mode 100644 index 0000000..07eb9a1 --- /dev/null +++ b/src/PSTextMate/Utilities/MarkdownPatterns.cs @@ -0,0 +1,55 @@ +namespace PSTextMate.Utilities; + +/// +/// Utility for detecting common markdown patterns like standalone images. +/// Consolidates pattern detection logic used across multiple renderers. +/// +internal static class MarkdownPatterns { + /// + /// Checks if a paragraph block contains only a single image (no other text). + /// Used to apply special rendering or spacing for standalone images. + /// + /// The paragraph block to check + /// True if paragraph contains only an image, false otherwise + public static bool IsStandaloneImage(ParagraphBlock paragraph) { + if (paragraph.Inline is null) { + return false; + } + + int significantCount = 0; + LinkInline? candidate = null; + + 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; + } + + foreach (char c in slice.Text.AsSpan(slice.Start, slice.Length)) { + if (!char.IsWhiteSpace(c)) { + return false; + } + } + + return true; + } +} diff --git a/src/PSTextMate/Utilities/SpectreRenderBridge.cs b/src/PSTextMate/Utilities/SpectreRenderBridge.cs new file mode 100644 index 0000000..10cff8a --- /dev/null +++ b/src/PSTextMate/Utilities/SpectreRenderBridge.cs @@ -0,0 +1,238 @@ +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. + /// + /// 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, int? width = null) { + ArgumentNullException.ThrowIfNull(renderableObject); + + 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; + } + + /// + /// 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; + } + + 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, 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 bool TryRenderForeign(object renderableObject, int? width, out string rendered) { + rendered = string.Empty; + 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?.IsInstanceOfType(renderableObject) != true) { + return false; + } + + 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 false; + } + + 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 false; + } + + 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?.CanWrite == true) { + widthProperty.SetValue(profile, targetWidth); + } + } + + MethodInfo? writeMethod = console.GetType().GetMethod("Write", [foreignRenderableType]); + if (writeMethod is not null) { + _ = writeMethod.Invoke(console, [renderableObject]); + rendered = writer.ToString(); + return true; + } + + 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 false; + } + + _ = extWriteMethod.Invoke(null, [console, renderableObject]); + rendered = writer.ToString(); + return true; + } + + private static CallSite> CreateConvertToRenderableCallSite() { + return CallSite>.Create( + Microsoft.CSharp.RuntimeBinder.Binder.Convert( + CSharpBinderFlags.ConvertExplicit, + typeof(IRenderable), + typeof(SpectreRenderBridge) + ) + ); + } + + 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?.IsInstanceOfType(value) != true) { + 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 VTConversion.ToParagraph(lines[0]); + } + + var renderables = new IRenderable[lines.Length]; + for (int i = 0; i < lines.Length; i++) { + renderables[i] = 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/SpectreStyleCompat.cs b/src/PSTextMate/Utilities/SpectreStyleCompat.cs new file mode 100644 index 0000000..e509c12 --- /dev/null +++ b/src/PSTextMate/Utilities/SpectreStyleCompat.cs @@ -0,0 +1,36 @@ +namespace PSTextMate.Utilities; + +internal static class SpectreStyleCompat { + 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) + : new Style(foreground, background, decoration, link); + } + + 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; + } + + Style baseStyle = Resolve(style); + Style linked = CreateWithLink(baseStyle.Foreground, baseStyle.Background, baseStyle.Decoration, link); + paragraph.Append(text, linked); + } +} 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/src/Utilities/StringBuilderPool.cs b/src/PSTextMate/Utilities/StringBuilderPool.cs similarity index 60% rename from src/Utilities/StringBuilderPool.cs rename to src/PSTextMate/Utilities/StringBuilderPool.cs index 2317a2e..8864f08 100644 --- a/src/Utilities/StringBuilderPool.cs +++ b/src/PSTextMate/Utilities/StringBuilderPool.cs @@ -1,12 +1,10 @@ -using System.Collections.Concurrent; -using System.Text; - 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 new file mode 100644 index 0000000..103ff18 --- /dev/null +++ b/src/PSTextMate/Utilities/StringExtensions.cs @@ -0,0 +1,14 @@ +namespace PSTextMate.Utilities; + +public static class StringExtensions { + + /// + /// Checks if all strings in the array are null or empty. + /// Uses modern pattern matching for cleaner, more efficient code. + /// + /// Array of strings to check + /// True if all strings are null or empty, false otherwise + public static bool AllIsNullOrEmpty(this string[] strings) + => strings.All(string.IsNullOrEmpty); + +} diff --git a/src/Utilities/TextMateResolver.cs b/src/PSTextMate/Utilities/TextMateResolver.cs similarity index 95% rename from src/Utilities/TextMateResolver.cs rename to src/PSTextMate/Utilities/TextMateResolver.cs index 5bdb41c..06fe6ac 100644 --- a/src/Utilities/TextMateResolver.cs +++ b/src/PSTextMate/Utilities/TextMateResolver.cs @@ -1,5 +1,3 @@ -using System; - namespace PSTextMate; /// diff --git a/src/Utilities/VTConversion.cs b/src/PSTextMate/Utilities/VTConversion.cs similarity index 82% rename from src/Utilities/VTConversion.cs rename to src/PSTextMate/Utilities/VTConversion.cs index 587a4df..e2af84b 100644 --- a/src/Utilities/VTConversion.cs +++ b/src/PSTextMate/Utilities/VTConversion.cs @@ -1,14 +1,10 @@ -using System.Runtime.CompilerServices; -using System.Text; -using Spectre.Console; - -namespace PSTextMate.Helpers; +namespace PSTextMate.Utilities; /// /// 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 = ']'; @@ -228,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 && @@ -244,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 { @@ -264,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); } /// @@ -381,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; @@ -438,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 }; /// @@ -518,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; @@ -530,71 +507,8 @@ public void Reset() { Link = null; } - public readonly Style ToSpectreStyle() => - new(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); + public readonly Style ToSpectreStyle() + => SpectreStyleCompat.CreateWithLink(Foreground, Background, Decoration, Link); - 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/src/PSTextMate/Utilities/VTHelpers.cs b/src/PSTextMate/Utilities/VTHelpers.cs new file mode 100644 index 0000000..b6b857a --- /dev/null +++ b/src/PSTextMate/Utilities/VTHelpers.cs @@ -0,0 +1,143 @@ +namespace PSTextMate.Utilities; + +public static partial class VTHelpers { + 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"; + 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"); + 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"); + // 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"); + 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 + /// 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. + /// + 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; + } + } + + + [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 new file mode 100644 index 0000000..a5b823f --- /dev/null +++ b/src/PSTextMate/Utilities/Writer.cs @@ -0,0 +1,147 @@ +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 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. + /// + [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 = 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); + 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); + if (fromFormat) VTHelpers.MoveCursorRowUp(2); + return null; + } + + return WriteToString(highlightedText); + } + + /// + /// 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. + /// Uses a stable in-memory rendering path so the output can be streamed + /// as plain text, redirected, or post-processed by custom formatters. + /// + internal static string WriteToString(IRenderable renderable, int? width = null) { + ArgumentNullException.ThrowIfNull(renderable); + + RenderContext context = _threadContext ??= new RenderContext(); + context.Console.Profile.Width = ResolveWidth(width); + + context.Console.Write(renderable); + return GetTrimmedOutputAndReset(context.Buffer); + } + + /// + /// Compatibility wrapper for previous API shape. + /// No host-direct output is performed; this returns the rendered string only. + /// + internal 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) + }; + + return AnsiConsole.Create(settings); + } + + private static int ResolveWidth(int? widthOverride) { + int width = widthOverride ?? GetConsoleWidth(); + return Math.Max(1, width); + } + + private static int GetConsoleWidth() { + try { + return Console.WindowWidth > 0 ? Console.WindowWidth : 80; + } + catch { + return 80; + } + } + + 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) + || 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..ef60b27 --- /dev/null +++ b/src/PSTextMate/Utilities/using.cs @@ -0,0 +1,47 @@ +global using System; +global using System.Buffers; +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.Diagnostics.CodeAnalysis; +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.Runtime.InteropServices; +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 Microsoft.CSharp.RuntimeBinder; +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; +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 SixColor = SixLabors.ImageSharp.Color; +global using SixImage = SixLabors.ImageSharp.Image; +global using TableColumnAlign = Markdig.Extensions.Tables.TableColumnAlign; diff --git a/src/Rendering/HorizontalRuleRenderer.cs b/src/Rendering/HorizontalRuleRenderer.cs deleted file mode 100644 index 253479e..0000000 --- a/src/Rendering/HorizontalRuleRenderer.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Spectre.Console; -using Spectre.Console.Rendering; - -namespace PSTextMate.Rendering; - -/// -/// Renders markdown horizontal rules (thematic breaks). -/// -internal static class HorizontalRuleRenderer { - /// - /// Renders a horizontal rule as a styled line. - /// - /// Rendered horizontal rule - public static IRenderable Render() - => new Rule().RuleStyle(Style.Parse("grey")); -} diff --git a/src/Rendering/ImageRenderer.cs b/src/Rendering/ImageRenderer.cs deleted file mode 100644 index c73d65f..0000000 --- a/src/Rendering/ImageRenderer.cs +++ /dev/null @@ -1,413 +0,0 @@ -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; - -/// -/// 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 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; } - - /// - /// Renders an image using Sixel format if possible, otherwise falls back to a link. - /// - /// Alternative text for the image - /// URL or path to the image - /// Maximum width for the image (optional) - /// Maximum height for the image (optional) - /// A renderable representing the image or fallback - public static IRenderable RenderImage(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { - try { - // Clear previous errors - _lastImageError = null; - _lastSixelError = null; - - // Check if the image format is likely supported - if (!ImageFile.IsLikelySupportedImageFormat(imageUrl, CurrentMarkdownDirectory)) { - _lastImageError = $"Unsupported image format: {imageUrl}"; - return CreateImageFallback(altText, imageUrl); - } - - // 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) { - _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); - } - - if (localImagePath is null) { - _lastImageError = $"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}"; - return CreateImageFallback(altText, imageUrl); - } - - var fileInfo = new FileInfo(localImagePath); - if (fileInfo.Length == 0) { - _lastImageError = $"Downloaded image file is empty: {localImagePath} (0 bytes)"; - return CreateImageFallback(altText, imageUrl); - } - - // Set reasonable defaults for markdown display - int defaultMaxWidth = maxWidth ?? 80; // Default to ~80 characters wide for terminal display - int defaultMaxHeight = maxHeight ?? 30; // Default to ~30 lines high - - if (TryCreateSixelRenderable(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) { - // Return the sixel image directly. The caller may append an explicit Text.NewLine - // so it renders as a separate row (avoids embedding the blank row inside the same widget). - return sixelImage; - } - else { - // Fallback to enhanced link representation with file info - _lastImageError = $"SixelImage creation failed. File: {localImagePath} ({fileInfo.Length} bytes). Sixel error: {_lastSixelError}"; - return CreateEnhancedImageFallback(altText, imageUrl, localImagePath); - } - } - catch (Exception ex) { - // If anything goes wrong, fall back to the basic link representation - _lastImageError = $"Exception in RenderImage: {ex.Message}"; - return CreateImageFallback(altText, imageUrl); - } - } - - /// - /// Renders an image inline (without panel) using Sixel format if possible. - /// - /// Alternative text for the image - /// URL or path to the image - /// Maximum width for the image (optional) - /// Maximum height for the image (optional) - /// A renderable representing the image or fallback - public static IRenderable RenderImageInline(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { - try { - // Check if the image format is likely supported - if (!ImageFile.IsLikelySupportedImageFormat(imageUrl, CurrentMarkdownDirectory)) { - return CreateImageFallbackInline(altText, imageUrl); - } - - // 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 { - // Timeout occurred - return CreateImageFallbackInline(altText, imageUrl); - } - - if (localImagePath is null) { - return CreateImageFallbackInline(altText, imageUrl); - } - - // Smaller defaults for inline images - int width = maxWidth ?? 60; // Default max width for inline images - int height = maxHeight ?? 20; // Default max height for inline images - - if (TryCreateSixelRenderable(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) { - return sixelImage; - } - else { - // Fallback to inline link representation - return CreateImageFallbackInline(altText, imageUrl); - } - } - catch { - // If anything goes wrong, fall back to the link representation - return CreateImageFallbackInline(altText, imageUrl); - } - } - - /// - /// 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); - - /// - /// Attempts to create a PixelImage from PwshSpectreConsole using reflection. - /// - private static bool TryCreatePixelImage(string imagePath, int? maxWidth, out IRenderable? result) { - result = null; - - try { - var pixelImageType = Type.GetType("PwshSpectreConsole.PixelImage, PwshSpectreConsole"); - if (pixelImageType is null) { - return false; - } - - ConstructorInfo? constructor = pixelImageType.GetConstructor([typeof(string), typeof(bool)]); - if (constructor is null) { - _lastSixelError = "Constructor not found for PixelImage with (string, bool) parameters"; - 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; - } - - 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; - } - - 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"; - 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; - } - } - catch (Exception ex) { - // Capture the error for debugging - _lastSixelError = ex.Message; - } - - return false; - } - - /// - /// Creates a fallback representation of an image as a clickable link with an icon. - /// - /// Alternative text for the image - /// URL or path to the image - /// 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); - return new Text(linkText, style); - } - - /// - /// Creates an enhanced fallback representation with file information. - /// - /// Alternative text for the image - /// Original URL or path to the image - /// Local path to the image file - /// A panel with enhanced image information - private static IRenderable CreateEnhancedImageFallback(string altText, string imageUrl, string localPath) { - try { - var fileInfo = new FileInfo(localPath); - string? sizeText = fileInfo.Exists ? $" ({fileInfo.Length / 1024:N0} KB)" : ""; - - // Build a text-based content with clickable link style - string display = $"🖼️ {altText}{sizeText}"; - var linkStyle = new Style(Color.Blue, null, Decoration.Underline, imageUrl); - var text = new Text(display, linkStyle); - return new Panel(text) - .Header("Image (Sixel not available)") - .Border(BoxBorder.Rounded) - .BorderColor(Color.Grey); - } - catch { - return CreateImageFallback(altText, imageUrl); - } - } - - /// - /// Creates an inline fallback representation of an image as a clickable link with an icon. - /// - /// Alternative text for the image - /// URL or path to the image - /// 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); - return new Text(display, style); - } - - /// - /// Gets debug information about the last image processing error. - /// - /// The last error message, if any - public static string? GetLastImageError() => _lastImageError; - - /// - /// Gets debug information about the last Sixel error. - /// - /// The last error message, if any - public static string? GetLastSixelError() => _lastSixelError; - - /// - /// Checks if SixelImage type is available in the current environment. - /// - /// 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; - } - catch { - return false; - } - } - -} diff --git a/src/Utilities/Helpers.cs b/src/Utilities/Helpers.cs deleted file mode 100644 index 7bf78a8..0000000 --- a/src/Utilities/Helpers.cs +++ /dev/null @@ -1,63 +0,0 @@ -using TextMateSharp.Grammars; - -namespace PSTextMate; - -/// -/// Provides utility methods for accessing available TextMate languages and file extensions. -/// -public static class TextMateHelper { - /// - /// Array of supported file extensions (e.g., ".ps1", ".md", ".cs"). - /// - public static readonly string[] Extensions; - /// - /// Array of supported TextMate language identifiers (e.g., "powershell", "markdown", "csharp"). - /// - public static readonly string[] Languages; - /// - /// List of all available language definitions with metadata. - /// - public static readonly List AvailableLanguages; - static TextMateHelper() { - try { - RegistryOptions _registryOptions = new(ThemeName.DarkPlus); - AvailableLanguages = _registryOptions.GetAvailableLanguages(); - - // Get all the extensions and languages from the available languages - Extensions = [.. AvailableLanguages - .Where(x => x.Extensions is not null) - .SelectMany(x => x.Extensions)]; - - Languages = [.. AvailableLanguages - .Where(x => x.Id is not null) - .Select(x => x.Id)]; - } - catch (Exception ex) { - throw new TypeInitializationException(nameof(TextMateHelper), ex); - } - } - internal static string[] SplitToLines(string input) { - if (input.Length == 0) { - return [string.Empty]; - } - - if (input.Contains('\n') || input.Contains('\r')) { - return input.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); - } - - return [input]; - } - - internal static string[] NormalizeToLines(List buffer) { - if (buffer.Count == 0) { - return []; - } - - var lines = new List(buffer.Count * 2); - foreach (string item in buffer) { - lines.AddRange(SplitToLines(item)); - } - - return [.. lines]; - } -} diff --git a/src/Utilities/ITextMateStyler.cs b/src/Utilities/ITextMateStyler.cs deleted file mode 100644 index 47c9cca..0000000 --- a/src/Utilities/ITextMateStyler.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Spectre.Console; -using TextMateSharp.Themes; - -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/Utilities/MarkdownPatterns.cs b/src/Utilities/MarkdownPatterns.cs deleted file mode 100644 index 8dc175e..0000000 --- a/src/Utilities/MarkdownPatterns.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Markdig.Syntax; -using Markdig.Syntax.Inlines; - -namespace PSTextMate.Utilities; - -/// -/// Utility for detecting common markdown patterns like standalone images. -/// Consolidates pattern detection logic used across multiple renderers. -/// -internal static class MarkdownPatterns { - /// - /// Checks if a paragraph block contains only a single image (no other text). - /// Used to apply special rendering or spacing for standalone images. - /// - /// The paragraph block to check - /// True if paragraph contains only an image, false otherwise - public static bool IsStandaloneImage(ParagraphBlock paragraph) { - if (paragraph.Inline is null) { - return false; - } - - // Check if the paragraph contains only one LinkInline with IsImage = true - var inlines = paragraph.Inline.ToList(); - - // Single image case - if (inlines.Count == 1 && inlines[0] is LinkInline link && link.IsImage) { - 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(); - - return nonWhitespace.Count == 1 - && nonWhitespace[0] is LinkInline imageLink - && imageLink.IsImage; - } -} diff --git a/src/Utilities/SpectreTextMateStyler.cs b/src/Utilities/SpectreTextMateStyler.cs deleted file mode 100644 index f091585..0000000 --- a/src/Utilities/SpectreTextMateStyler.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using Spectre.Console; -using TextMateSharp.Themes; - -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/Utilities/StringBuilderExtensions.cs b/src/Utilities/StringBuilderExtensions.cs deleted file mode 100644 index 0027f27..0000000 --- a/src/Utilities/StringBuilderExtensions.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Globalization; -using System.Text; -using Spectre.Console; - -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(style.ToMarkup()) - .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(style.ToMarkup()) - .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(style.ToMarkup()) - .Append(']') - .Append(value) - .Append("[/]") - : builder.Append(value); - } -} diff --git a/src/Utilities/StringExtensions.cs b/src/Utilities/StringExtensions.cs deleted file mode 100644 index 89c200d..0000000 --- a/src/Utilities/StringExtensions.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System.Text; - -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. - /// Uses modern pattern matching for cleaner, more efficient code. - /// - /// Array of strings to check - /// True if all strings are null or empty, false otherwise - 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/src/Utilities/ThemeExtensions.cs b/src/Utilities/ThemeExtensions.cs deleted file mode 100644 index f908a47..0000000 --- a/src/Utilities/ThemeExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using PSTextMate.Core; -using Spectre.Console; -using TextMateSharp.Themes; - -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/Utilities/TokenStyleProcessor.cs b/src/Utilities/TokenStyleProcessor.cs deleted file mode 100644 index 1010fe0..0000000 --- a/src/Utilities/TokenStyleProcessor.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Collections.Generic; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -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/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/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..214790f --- /dev/null +++ b/tests/PSTextMate.InteractiveTests/PagerCoreTests.cs @@ -0,0 +1,315 @@ +using PSTextMate.Core; +using PSTextMate.Terminal; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Rendering; +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); + } + + [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_CustomRenderableWithoutRenderableText_DoesNotMatch() { + PagerDocument document = new([ + new ThrowingRenderable("alpha beta"), + new ThrowingRenderable("gamma") + ]); + + PagerSearchSession session = new(document); + session.SetQuery("beta"); + + Assert.True(session.HasQuery); + Assert.Equal(0, session.HitCount); + Assert.Null(session.MoveNext(topIndex: 0)); + } + + [Fact] + 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); + session.SetQuery("target"); + + Assert.Equal(1, session.HitCount); + Assert.NotNull(session.MoveNext(topIndex: 0)); + } + + [Fact] + public void SetQuery_RenderableWithEmptyWriterOutput_DoesNotMatch() { + PagerDocument document = new([ + new EmptyRenderable("delta epsilon") + ]); + + PagerSearchSession session = new(document); + session.SetQuery("epsilon"); + + Assert.Equal(0, session.HitCount); + Assert.Null(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); + } + + [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); + } + + [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; + + 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; + + 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() => _text; + } + + private sealed class EmptyRenderable : IRenderable { + private readonly string _text; + + 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) { + int width = Math.Max(1, Math.Min(maxWidth, _text.Length)); + return new Measurement(width, width); + } + + public IEnumerable Render(RenderOptions options, int maxWidth) { + RenderCallCount++; + return [new Segment(_text)]; + } + + public override string ToString() => _text; + } +} 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/pager-highlight-showcase.md b/tests/pager-highlight-showcase.md new file mode 100644 index 0000000..a935d9f --- /dev/null +++ b/tests/pager-highlight-showcase.md @@ -0,0 +1,78 @@ +# 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. + +## 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. +> 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 -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 + +
+

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

+

Query target: Out-Page

+
+ +### Inline Sixel HTML + +Demo + +## 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. diff --git a/tests/testhelper.psm1 b/tests/testhelper.psm1 index 8eed7d7..82a4a76 100644 --- a/tests/testhelper.psm1 +++ b/tests/testhelper.psm1 @@ -1,31 +1,43 @@ 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) + [PSTextMate.Utilities.SpectreRenderBridge]::RenderToString( + $RenderableObject, + $EscapeAnsi.IsPresent + ) +} + + + +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 - 1), + ($windowPosition.Y + $windowHeight - 1)) + $windowBuffer = $host.UI.RawUI.GetBufferContents($windowRect) + foreach ($x in 0..($windowHeight - 1)) { + $row = foreach ($y in 0..($windowWidth - 1)) { + $windowBuffer[$x, $y].Character } - $writer.ToString() + -join $row } - finally { - ${writer}?.Dispose() - } -} -filter _EscapeAnsi { - [Host.PSHostUserInterface]::GetOutputString($_, $false) } -Export-ModuleMember -Function _GetSpectreRenderable, _EscapeAnsi +Export-ModuleMember -Function _GetSpectreRenderable, Get-HostBuffer