diff --git a/.claude/rules/event-patterns.md b/.claude/rules/event-patterns.md index 6d66dc17dd..bff0fb6d42 100644 --- a/.claude/rules/event-patterns.md +++ b/.claude/rules/event-patterns.md @@ -1,5 +1,26 @@ # Event Patterns +## When to Use `-ing` vs `-ed` Events + +Terminal.Gui exposes paired events — `Accepting`/`Accepted`, `Activating`/`Activated`, `ValueChanging`/`ValueChanged`, etc. + +**Rule:** Use `-ed` (past-tense) for side-effects. Use `-ing` (present-progressive) only when you need to inspect or cancel the in-flight operation. + +```csharp +// ✅ Correct — fire-and-forget side-effect +button.Accepted += (_, _) => DoTheThing (); + +// ✅ Correct — actually cancels +button.Accepting += (_, e) => { if (!CanProceed ()) e.Handled = true; }; + +// ❌ Wrong — handler ignores EventArgs; use Accepted instead +button.Accepting += (_, _) => DoTheThing (); +``` + +If the handler body doesn't reference `e` at all (or ignores `e.Handled`, `e.Cancel`, and the candidate value), it belongs on the `-ed` event. + +The `-ing` event runs synchronously in the middle of the dispatch path; subscribing when you don't need to cancel adds unnecessary overhead and misleads readers. + ## Lambda Parameters **Replace unused parameters with discards `_`:** diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml new file mode 100644 index 0000000000..85fd662dce --- /dev/null +++ b/.github/workflows/perf-gate.yml @@ -0,0 +1,212 @@ +name: Performance Gate + +on: + push: + branches: [ main, develop ] + paths-ignore: + - '**.md' + pull_request: + branches: [ main, develop ] + paths-ignore: + - '**.md' + +# Only run on Linux to keep results comparable across runs. +# Windows/macOS times vary too much to use as a performance baseline. +permissions: + contents: read + +jobs: + perf-smoke-tests: + name: Performance Smoke Tests (Linux) + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DisableRealDriverIO: "1" + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 # GitVersion needs full history + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + dotnet-quality: 'ga' + + - name: Restore dependencies + run: dotnet restore + + - name: Build (Release) + run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612 + + - name: Build Tests (Debug — smoke tests run in Debug to match CI unit tests) + run: dotnet build Tests/PerformanceTests --no-restore -property:NoWarn=0618%3B0612 + + - name: Run performance smoke tests (Layer 1 gate) + id: smoke_tests + run: | + dotnet test \ + --project Tests/PerformanceTests \ + --no-build \ + --verbosity normal + + - name: Upload smoke test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: perf-smoke-test-logs + path: | + TestResults/ + if-no-files-found: ignore + retention-days: 7 + + perf-benchmarks: + name: Benchmarks (Linux, ShortRun) + runs-on: ubuntu-latest + # Only run on pushes to develop/main, not on every PR (slow and not blocking). + if: github.event_name == 'push' + timeout-minutes: 30 + env: + DisableRealDriverIO: "1" + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + dotnet-quality: 'ga' + + - name: Restore dependencies + run: dotnet restore + + - name: Build Release + run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612 + + - name: Run benchmarks (ShortRun ≈ 30–60 s) + id: run_benchmarks + run: | + dotnet run \ + --project Tests/Benchmarks \ + --configuration Release \ + --no-build \ + -- \ + --filter '*Scroll*' '*Config*' '*Scheme*' '*Theme*' \ + --job short \ + --exporters json \ + --artifacts ./BenchmarkResults + continue-on-error: true # Don't block the workflow; comparison step decides outcome + + - name: Compare results to baseline + id: compare + run: | + python3 - << 'PYEOF' + import json, os, sys, glob + + REGRESSION_FACTOR = 3.0 # Fail if any benchmark is >3× baseline + IMPROVEMENT_FACTOR = 0.8 # Celebrate 🎉 if any benchmark drops below 0.8× baseline + + baseline_path = "Tests/Benchmarks/baseline.json" + results_dir = "BenchmarkResults" + + # --- Load baseline --- + try: + with open(baseline_path) as f: + baseline_data = json.load(f) + baseline = { + f"{b['type']}/{b['method']}/{b['params']}": b["meanNs"] + for b in baseline_data["benchmarks"] + } + except FileNotFoundError: + print("::warning::baseline.json not found — skipping comparison") + sys.exit(0) + + # --- Find BenchmarkDotNet JSON results --- + result_files = glob.glob(f"{results_dir}/**/*.json", recursive=True) + result_files = [f for f in result_files if "results" in f.lower() or "report" in f.lower()] + if not result_files: + print("::warning::No BenchmarkDotNet result files found — skipping comparison") + sys.exit(0) + + # --- Parse results --- + results = {} + for fpath in result_files: + try: + with open(fpath) as f: + data = json.load(f) + for bm in data.get("Benchmarks", []): + key = f"{bm['Type']}/{bm['Method']}/{bm.get('Parameters', '')}" + results[key] = bm.get("Statistics", {}).get("Mean", None) + except Exception as e: + print(f"::warning::Could not parse {fpath}: {e}") + + # --- Build comparison table --- + rows = [] + regressions = [] + improvements = [] + + for key, base_ns in baseline.items(): + if base_ns <= 0: + continue + cur_ns = results.get(key) + if cur_ns is None: + rows.append(f"| {key} | {base_ns/1000:.1f} µs | — (not measured) | — |") + continue + + ratio = cur_ns / base_ns + emoji = "✅" + if ratio >= REGRESSION_FACTOR: + emoji = "❌" + regressions.append((key, base_ns, cur_ns, ratio)) + elif ratio <= IMPROVEMENT_FACTOR: + emoji = "🎉" + improvements.append((key, base_ns, cur_ns, ratio)) + rows.append( + f"| {key} | {base_ns/1000:.1f} µs | {cur_ns/1000:.1f} µs | {ratio:.2f}× {emoji} |" + ) + + # --- Write step summary --- + summary = "## 📊 Benchmark Comparison\n\n" + summary += "| Benchmark | Baseline | Current | Ratio |\n" + summary += "|-----------|----------|---------|-------|\n" + summary += "\n".join(rows) + "\n\n" + + if improvements: + summary += "### 🎉 Performance Improvements\n" + for k, b, c, r in improvements: + summary += f"- **{k}**: {b/1000:.1f} µs → {c/1000:.1f} µs ({r:.2f}×)\n" + summary += "\n" + + if regressions: + summary += "### ❌ Regressions Detected\n" + for k, b, c, r in regressions: + summary += f"- **{k}**: {b/1000:.1f} µs → {c/1000:.1f} µs ({r:.2f}×) — exceeds {REGRESSION_FACTOR}× threshold\n" + summary += "\n" + + with open(os.environ.get("GITHUB_STEP_SUMMARY", "/dev/null"), "a") as f: + f.write(summary) + + print(summary) + + if regressions: + print(f"::error::Performance regressions detected: {len(regressions)} benchmark(s) exceeded {REGRESSION_FACTOR}× baseline") + sys.exit(1) + + if improvements: + print(f"Performance improvements detected: {len(improvements)} benchmark(s) improved!") + PYEOF + + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmark-results-${{ github.sha }} + path: BenchmarkResults/ + if-no-files-found: ignore + retention-days: 30 diff --git a/CLAUDE.md b/CLAUDE.md index 50dcbfde13..4e94524017 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,7 @@ > **Guidance for AI agents working with Terminal.Gui.** > For humans, see [CONTRIBUTING.md](./CONTRIBUTING.md). +> For Terminal.Gui's mission, tenets, and engineering philosophy, see [specs/constitution.md](./specs/constitution.md). > See also: [llms.txt](./llms.txt) for machine-readable context. ## CRITICAL: Discard v1 Training Data diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c9a265bd8..5626a08e60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,8 @@ # Contributing to Terminal.Gui > **📘 This document is the single source of truth for all contributors (humans and AI agents) to Terminal.Gui.** +> +> For Terminal.Gui's product mission, design tenets, and engineering philosophy, see **[specs/constitution.md](./specs/constitution.md)**. Welcome! This guide provides everything you need to know to contribute effectively to Terminal.Gui, including project structure, build instructions, coding conventions, testing requirements, and CI/CD workflows. diff --git a/Examples/Themes/code-dark.config.json b/Examples/Themes/code-dark.config.json new file mode 100644 index 0000000000..48e2d40579 --- /dev/null +++ b/Examples/Themes/code-dark.config.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json", + "Themes": [ + { + "Dark": { + "Schemes": [ + { + "Base": { + "CodeKeyword": { + "Foreground": "#ff79c6", + "Background": "None", + "Style": "Bold" + }, + "CodeString": { + "Foreground": "#f1fa8c", + "Background": "None", + "Style": "None" + } + } + } + ] + } + } + ] +} diff --git a/Examples/UICatalog/Resources/config.json b/Examples/UICatalog/Resources/config.json index afeb11edc6..272464b673 100644 --- a/Examples/UICatalog/Resources/config.json +++ b/Examples/UICatalog/Resources/config.json @@ -50,7 +50,20 @@ "Disabled": { "Foreground": "BrightGreen", "Background": "Gray" - } + }, + "Code": { "Foreground": "Black", "Background": "#FFFF00", "Style": "None" }, + "CodeComment": { "Foreground": "#008000", "Background": "None", "Style": "None" }, + "CodeKeyword": { "Foreground": "#FF0000", "Background": "None", "Style": "None" }, + "CodeString": { "Foreground": "#0000CC", "Background": "None", "Style": "None" }, + "CodeNumber": { "Foreground": "#800080", "Background": "None", "Style": "None" }, + "CodeOperator": { "Foreground": "Black", "Background": "None", "Style": "None" }, + "CodeType": { "Foreground": "#C00000", "Background": "None", "Style": "None" }, + "CodePreprocessor": { "Foreground": "#008000", "Background": "None", "Style": "None" }, + "CodeIdentifier": { "Foreground": "Black", "Background": "None", "Style": "None" }, + "CodeConstant": { "Foreground": "#800080", "Background": "None", "Style": "None" }, + "CodePunctuation": { "Foreground": "Black", "Background": "None", "Style": "None" }, + "CodeFunctionName": { "Foreground": "#0000CC", "Background": "None", "Style": "None" }, + "CodeAttribute": { "Foreground": "#008000", "Background": "None", "Style": "None" } } }, { @@ -213,7 +226,20 @@ "Disabled": { "Foreground": "BrightGreen", "Background": "Gray" - } + }, + "Code": { "Foreground": "White", "Background": "Green", "Style": "None" }, + "CodeComment": { "Foreground": "LightGray", "Background": "None", "Style": "None" }, + "CodeKeyword": { "Foreground": "Yellow", "Background": "None", "Style": "None" }, + "CodeString": { "Foreground": "LightCyan", "Background": "None", "Style": "None" }, + "CodeNumber": { "Foreground": "LightYellow", "Background": "None", "Style": "None" }, + "CodeOperator": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeType": { "Foreground": "Cyan", "Background": "None", "Style": "None" }, + "CodePreprocessor": { "Foreground": "BrightRed", "Background": "None", "Style": "None" }, + "CodeIdentifier": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeConstant": { "Foreground": "Yellow", "Background": "None", "Style": "None" }, + "CodePunctuation": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeFunctionName": { "Foreground": "LightCyan", "Background": "None", "Style": "None" }, + "CodeAttribute": { "Foreground": "LightGray", "Background": "None", "Style": "None" } } }, { @@ -292,4 +318,4 @@ } } ] -} \ No newline at end of file +} diff --git a/Examples/UICatalog/Scenarios/CodeViewDemo.cs b/Examples/UICatalog/Scenarios/CodeViewDemo.cs new file mode 100644 index 0000000000..dc33af5f8c --- /dev/null +++ b/Examples/UICatalog/Scenarios/CodeViewDemo.cs @@ -0,0 +1,159 @@ +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("Code View Demo", "Demonstrates the Code view with theme-aware syntax roles.")] +[ScenarioCategory ("Controls")] +[ScenarioCategory ("Text and Formatting")] +[ScenarioCategory ("Colors")] +public sealed class CodeViewDemo : Scenario +{ + private readonly Dictionary _snippets = new (StringComparer.OrdinalIgnoreCase) + { + ["cs"] = """ + #nullable enable + using System; + + // Code CodeComment CodeKeyword CodeString + // CodeNumber CodeOperator CodeType CodePreprocessor + // CodeIdentifier CodeConstant CodePunctuation + // CodeFunctionName CodeAttribute + [Obsolete ("CodeAttribute")] + public sealed class Person + { + // CodeComment + private const int CodeNumber = 37; + public string CodeString { get; init; } = "Ada"; + public bool CodeConstant => true; + public int CodeFunctionName => Math.Max (CodeNumber, 21 + 16); + } + + """, + ["json"] = """ + { + "roles1": "Code CodeComment CodeKeyword CodeString", + "roles2": "CodeNumber CodeOperator CodeType CodePreprocessor", + "roles3": "CodeIdentifier CodeConstant CodePunctuation", + "roles4": "CodeFunctionName CodeAttribute", + "code": "Code", + "comment": "CodeComment", + "keyword": "CodeKeyword", + "string": "CodeString", + "number": 37, + "operator": "CodeOperator", + "type": "CodeType", + "preprocessor": "CodePreprocessor", + "identifier": "CodeIdentifier", + "constant": true, + "punctuation": "CodePunctuation", + "function": "CodeFunctionName", + "attribute": "CodeAttribute" + } + """, + ["xml"] = """ + + + + + + + 37 + + + """, + ["md"] = """ + # Code view + + `VisualRole.CodeKeyword` follows the active theme. + + Code CodeComment CodeKeyword CodeString + CodeNumber CodeOperator CodeType CodePreprocessor + CodeIdentifier CodeConstant CodePunctuation + CodeFunctionName CodeAttribute + + ```cs + #nullable enable + [Obsolete ("CodeAttribute")] + public string CodeString => "Ada"; // CodeComment + public int CodeFunctionName => Math.Max (37, 21 + 16); + ``` + + """ + }; + + /// + public override void Main () + { + ConfigurationManager.Enable (ConfigLocations.All); + + using IApplication app = Application.Create (); + app.Init (); + + using Runnable window = new (); + window.Title = GetQuitKeyAndName (); + window.BorderStyle = LineStyle.None; + + string [] languages = ["cs", "json", "xml", "md"]; + + OptionSelector languageSelector = new () + { + Title = "_Language", + Labels = languages, + BorderStyle = LineStyle.Rounded, + Width = 16, + Height = Dim.Auto (), + Value = 0 + }; + + string [] themes = ThemeManager.GetThemeNames ().Select (theme => "_" + theme).ToArray (); + + OptionSelector themeSelector = new () + { + Title = "_Theme", + Labels = themes, + BorderStyle = LineStyle.Rounded, + X = Pos.Right (languageSelector) + 1, + Width = 24, + Height = Dim.Auto (), + Value = ThemeManager.GetThemeNames ().IndexOf (ThemeManager.Theme) + }; + + Code code = new () + { + X = 0, + Y = Pos.Bottom (themeSelector) + 1, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Rounded, + Title = "Code", + Language = languages [0], + SyntaxHighlighter = new CodeRoleLegendHighlighter (), + Text = _snippets [languages [0]] + }; + + languageSelector.ValueChanged += (_, args) => + { + if (args.NewValue is null) + { + return; + } + + string language = languages [(int)args.NewValue]; + code.Language = language; + code.Text = _snippets [language]; + }; + + themeSelector.ValueChanged += (_, args) => + { + if (args.NewValue is null) + { + return; + } + + string theme = themes [(int)args.NewValue] [1..]; + ThemeManager.Theme = theme; + ConfigurationManager.Apply (); + }; + + window.Add (languageSelector, themeSelector, code); + app.Run (window); + } +} diff --git a/Examples/UICatalog/Scenarios/Images.cs b/Examples/UICatalog/Scenarios/Images.cs index 5bf55bacb3..0a118c84c2 100644 --- a/Examples/UICatalog/Scenarios/Images.cs +++ b/Examples/UICatalog/Scenarios/Images.cs @@ -1,5 +1,4 @@ -using System.Collections.Concurrent; -using System.Text; +using System.Text; using ColorHelper; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; @@ -13,8 +12,7 @@ namespace UICatalog.Scenarios; public class Images : Scenario { private ImageView _imageView; - private Point _screenLocationForSixel; - private string _encodedSixelData; + private Image _fullResImage; private Window _win; /// @@ -42,7 +40,7 @@ public class Images : Scenario /// /// The view into which the currently opened sixel image is bounded /// - private View _sixelView; + private ImageView _sixelView; private DoomFire _fire; private SixelEncoder _fireEncoder; @@ -52,9 +50,8 @@ public class Images : Scenario private OptionSelector _osPaletteBuilder; private OptionSelector _osDistanceAlgorithm; private NumericUpDown _popularityThreshold; - private SixelToRender _sixelImage; - // Start by assuming no support + // Start by assuming no support — updated from driver-level detection private SixelSupportResult _sixelSupportResult = new (); private CheckBox _cbSupportsSixel; private IApplication _app; @@ -153,11 +150,15 @@ public override void Main () _win.Add (tabBasic); _win.Add (_tabSixel); - // Start trying to detect sixel support - SixelSupportDetector sixelSupportDetector = new (app.Driver); - sixelSupportDetector.Detect (UpdateSixelSupportState); - _win.SubViewsLaidOut += Win_SubViewsLaidOut; + _win.Initialized += (_, _) => + { + app.Driver?.SixelSupportChanged += (_, args) => UpdateSixelSupportState (args.NewValue); + if (app.Driver?.SixelSupport is { } support) + { + UpdateSixelSupportState (support); + } + }; app.Run (_win); _win.Dispose (); } @@ -175,25 +176,7 @@ private void Win_SubViewsLaidOut (object sender, LayoutEventArgs e) if (_fireSixel is { }) { - SixelToRender sixelToRender = null; - _app.Driver?.GetOutput ().GetSixels ().TryDequeue (out sixelToRender); - - if (sixelToRender is { Id: "sixelImage" }) - { - _app.Driver?.GetOutput ().GetSixels ().Enqueue (_sixelImage); - - if (_app.Driver?.GetOutput ().GetSixels ().Count > 1) - { - _app.Driver?.GetOutput ().GetSixels ().TryDequeue (out _); - } - } - GenerateSixelFire (false); - - if (!string.IsNullOrEmpty (_fireSixel.SixelData)) - { - _app.Driver?.GetOutput ().GetSixels ().Enqueue (_fireSixel); - } } } @@ -204,6 +187,7 @@ private void UpdateSixelSupportState (SixelSupportResult newResult) _cbSupportsSixel.Value = newResult.IsSupported ? CheckState.Checked : CheckState.UnChecked; _pxX.Value = _sixelSupportResult.Resolution.Width; _pxY.Value = _sixelSupportResult.Resolution.Height; + SetupSixelSupported (newResult.IsSupported); } private void SetupSixelSupported (bool isSupported) @@ -273,7 +257,7 @@ private bool AdvanceFireTimerCallback () if (_fireSixel == null) { - _fireSixel = new SixelToRender { SixelData = sixelFireData, ScreenPosition = new Point (0, 0), Id = "fireSixel" }; + _fireSixel = new SixelToRender { SixelData = sixelFireData, ScreenPosition = new Point (0, 0), Id = "fireSixel", AlwaysRender = true }; _app.Driver?.GetOutput ().GetSixels ().Enqueue (_fireSixel); } @@ -293,6 +277,7 @@ protected override void Dispose (bool disposing) { base.Dispose (disposing); _imageView.Dispose (); + _fullResImage?.Dispose (); _sixelNotSupported.Dispose (); _sixelSupported.Dispose (); _isDisposed = true; @@ -339,7 +324,9 @@ private void OpenImage (object sender, CommandEventArgs e) return; } - _imageView.SetImage (img); + _fullResImage?.Dispose (); + _fullResImage = img; + _imageView.Image = ConvertToColorArray (img); ApplyShowTabViewHack (); _app?.LayoutAndDraw (); } @@ -355,7 +342,8 @@ private void BuildBasicTab (View tabBasic) { Width = Dim.Fill (), Height = Dim.Fill (), - CanFocus = true + CanFocus = true, + UseSixel = false // Basic tab uses cell-based rendering }; tabBasic.Add (_imageView); @@ -387,11 +375,12 @@ private void BuildSixelTab () VerticalTextAlignment = Alignment.Center }); - _sixelView = new View + _sixelView = new ImageView { Width = Dim.Percent (50), Height = Dim.Fill (), - BorderStyle = LineStyle.Dotted + BorderStyle = LineStyle.Dotted, + UseSixel = true }; _sixelView.SubViewsLaidOut += SixelView_SubViewsLaidOut; _sixelSupported.Add (_sixelView); @@ -522,28 +511,7 @@ private void SixelView_SubViewsLaidOut (object sender, LayoutEventArgs e) _sixelImageSize = e.OldContentSize; - if (_sixelImage is { }) - { - SixelToRender sixelToRender = null; - _app.Driver?.GetOutput ().GetSixels ().TryDequeue (out sixelToRender); - - if (sixelToRender is { Id: "fireSixel" }) - { - _app.Driver?.GetOutput ().GetSixels ().Enqueue (_fireSixel); - - if (_app.Driver?.GetOutput ().GetSixels ().Count > 1) - { - _app.Driver?.GetOutput ().GetSixels ().TryDequeue (out _); - } - } - - GenerateSixelImage (false); - if (!string.IsNullOrEmpty (_sixelImage.SixelData)) - { - _app.Driver?.GetOutput ().GetSixels ().Enqueue (_sixelImage); - } - } } private IPaletteBuilder GetPaletteBuilder () @@ -568,7 +536,7 @@ private IColorDistance GetDistanceAlgorithm () private void OutputSixelButtonClick (object sender, CommandEventArgs e) { - if (_imageView.FullResImage == null) + if (_fullResImage == null) { MessageBox.Query (_app!, "No Image Loaded", "You must first open an image. Use the 'Open Image' button above.", "Ok"); @@ -576,76 +544,23 @@ private void OutputSixelButtonClick (object sender, CommandEventArgs e) } _sixelImageSize = _sixelView.Viewport.Size; + _sixelView.Visible = false; GenerateSixelImage (true); + _sixelView.Visible = true; } private void GenerateSixelImage (bool openDialog) - { - _screenLocationForSixel = _sixelView.ViewportToScreen ().Location; - - _encodedSixelData = GenerateSixelData (_imageView.FullResImage, - _sixelView.Viewport.Size, - _pxX.Value, - _pxY.Value, - openDialog); - - if (_sixelImage == null) - { - _sixelImage = new SixelToRender { SixelData = _encodedSixelData, ScreenPosition = _screenLocationForSixel, Id = "sixelImage"}; - - _app.Driver?.GetOutput ().GetSixels ().Enqueue (_sixelImage); - } - else - { - _sixelImage.ScreenPosition = _screenLocationForSixel; - _sixelImage.SixelData = _encodedSixelData; - } - - _sixelView.SetNeedsDraw (); - } - - //private void SixelViewOnDrawingContent (object sender, DrawEventArgs e) - //{ - // if (!string.IsNullOrWhiteSpace (_encodedSixelData)) - // { - // // Does not work (see https://github.com/gui-cs/Terminal.Gui/issues/3763) - // _app.Driver?.Move (_screenLocationForSixel.X, _screenLocationForSixel.Y); - // _app.Driver?.AddStr (_encodedSixelData); - - // // Works in DotNetDriver but results in screen flicker when moving mouse but vanish instantly - // Console.SetCursorPosition (_screenLocationForSixel.X, _screenLocationForSixel.Y); - // Console.Write (_encodedSixelData); - // } - //} - - public string GenerateSixelData (Image fullResImage, Size maxSize, int pixelsPerCellX, int pixelsPerCellY, bool openDialog) { SixelEncoder encoder = new (); encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder (); encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm (); + _sixelView.SixelEncoder = encoder; - // Calculate the target size in pixels based on console units - int targetWidthInPixels = maxSize.Width * pixelsPerCellX; - int targetHeightInPixels = encoder.GetHeightInPixels (maxSize.Height, pixelsPerCellY); - - // Get the original image dimensions - int originalWidth = fullResImage.Width; - int originalHeight = fullResImage.Height; - - // Use the helper function to get the resized dimensions while maintaining the aspect ratio - Size newSize = CalculateAspectRatioFit (originalWidth, originalHeight, targetWidthInPixels, targetHeightInPixels); - - if (newSize == Size.Empty) - { - return string.Empty; - } - - // Resize the image to match the console size - Image resizedImage = fullResImage.Clone (x => x.Resize (newSize.Width, newSize.Height)); - - string encoded = encoder.EncodeSixel (ConvertToColorArray (resizedImage)); + Size targetSize = _sixelView.FitImageInViewportInPixels (new Size (_fullResImage.Width, _fullResImage.Height)); + using Image resized = _fullResImage.Clone (i => i.Resize (targetSize.Width, targetSize.Height)); + _sixelView.Image = ConvertToColorArray (resized); if (openDialog) { @@ -658,25 +573,6 @@ public string GenerateSixelData (Image fullResImage, Size maxSize, int p dlg.Dispose (); } - - return encoded; - } - - private Size CalculateAspectRatioFit (int originalWidth, int originalHeight, int targetWidth, int targetHeight) - { - // Calculate the scaling factor for width and height - double widthScale = (double)targetWidth / originalWidth; - double heightScale = (double)targetHeight / originalHeight; - - // Use the smaller scaling factor to maintain the aspect ratio - double scale = Math.Min (widthScale, heightScale); - - // Calculate the new width and height while keeping the aspect ratio - int newWidth = (int)(originalWidth * scale); - int newHeight = (int)(originalHeight * scale); - - // Return the new size as a Size object - return new Size (newWidth, newHeight); } public static Color [,] ConvertToColorArray (Image image) @@ -698,55 +594,6 @@ private Size CalculateAspectRatioFit (int originalWidth, int originalHeight, int return colors; } - private class ImageView : View - { - private readonly ConcurrentDictionary _cache = new (); - public Image FullResImage; - private Image _matchSize; - - protected override bool OnDrawingContent (DrawContext context) - { - if (FullResImage == null) - { - return true; - } - - // if we have not got a cached resized image of this size - if (_matchSize == null || Viewport.Width != _matchSize.Width || Viewport.Height != _matchSize.Height) - { - // generate one - _matchSize = FullResImage.Clone (x => x.Resize (Viewport.Width, Viewport.Height)); - } - - for (int y = 0; y < Viewport.Height; y++) - { - for (int x = 0; x < Viewport.Width; x++) - { - Rgba32 rgb = _matchSize [x, y]; - - Attribute attr = _cache.GetOrAdd ( - rgb, - rgba32 => new Attribute ( - new Color (), - new Color (rgba32.R, rgba32.G, rgba32.B) - ) - ); - - SetAttribute (attr); - AddRune (x, y, (Rune)' '); - } - } - - return true; - } - - internal void SetImage (Image image) - { - FullResImage = image; - SetNeedsDraw (); - } - } - public class PaletteView : View { private readonly List _palette; @@ -1117,4 +964,4 @@ public void AdvanceFrame () } public Color [,] GetFirePixels () { return _firePixels; } -} +} \ No newline at end of file diff --git a/Examples/UICatalog/Scenarios/Themes.cs b/Examples/UICatalog/Scenarios/Themes.cs index 561d0ea19d..8da5582bda 100644 --- a/Examples/UICatalog/Scenarios/Themes.cs +++ b/Examples/UICatalog/Scenarios/Themes.cs @@ -1,6 +1,7 @@ #nullable enable using System.Collections.ObjectModel; + // ReSharper disable AccessToDisposedClosure namespace UICatalog.Scenarios; @@ -52,6 +53,29 @@ public override void Main () }; defaultAttributeView.Border.Thickness = new Thickness (0, 1, 0, 0); + FrameView codeRolesPanel = CreateCodeRolesPanel (); + codeRolesPanel.Y = Pos.Bottom (defaultAttributeView); + codeRolesPanel.Width = Dim.Width (themeOptionSelector); + + Markdown codeRolesMarkdown = new () + { + Title = "Code Fence", + BorderStyle = LineStyle.Rounded, + Y = Pos.Bottom (codeRolesPanel), + Width = Dim.Width (themeOptionSelector), + Height = 8, + SyntaxHighlighter = new TextMateSyntaxHighlighter (), + Text = """ + ```cs + public sealed class Demo + { + string Text => "theme"; + } + ``` + """ + }; + codeRolesMarkdown.Border.Thickness = new Thickness (0, 1, 0, 0); + themeOptionSelector.ValueChanged += (sender, args) => { if (sender is not OptionSelector optionSelector) @@ -129,7 +153,15 @@ public override void Main () viewPropertiesEditor.ViewToEdit = _view; }; - appWindow.Add (themeOptionSelector, defaultAttributeView, themeViewer, allViewsCheckBox, viewListView, viewPropertiesEditor, viewFrame); + appWindow.Add (themeOptionSelector, + defaultAttributeView, + codeRolesPanel, + codeRolesMarkdown, + themeViewer, + allViewsCheckBox, + viewListView, + viewPropertiesEditor, + viewFrame); viewListView.SelectedItem = 0; @@ -203,6 +235,29 @@ private static List GetAllViewClassesCollection () return types; } + private static FrameView CreateCodeRolesPanel () + { + FrameView panel = new () { Title = "Code roles", BorderStyle = LineStyle.Rounded, Height = Dim.Auto () }; + panel.Border.Thickness = new Thickness (0, 1, 0, 0); + + VisualRoleViewer? previous = null; + + foreach (VisualRole role in Enum.GetValues ().Where (role => role >= VisualRole.Code)) + { + VisualRoleViewer roleViewer = new () { Role = role, SchemeName = "Base" }; + + if (previous is { }) + { + roleViewer.Y = Pos.Bottom (previous); + } + + panel.Add (roleViewer); + previous = roleViewer; + } + + return panel; + } + private View? CreateView (Type type) { // If we are to create a generic Type diff --git a/GitVersion.yml b/GitVersion.yml index 18f1a8b6f5..18d79ce02e 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -34,7 +34,7 @@ branches: # Matches the main branch regex: ^main$ # Uses an empty label for stable releases - label: '' + label: rc # Increments patch version (x.y.z+1) on commits increment: Patch # Specifies develop as the source branch diff --git a/Scripts/delist-nuget.ps1 b/Scripts/delist-nuget.ps1 index dc63242e85..904a1f9c95 100644 --- a/Scripts/delist-nuget.ps1 +++ b/Scripts/delist-nuget.ps1 @@ -1,101 +1,134 @@ -# PowerShell script to unlist NuGet packages using dotnet CLI -# This script delists old develop and alpha packages while keeping the most recent ones +<# +.SYNOPSIS + Bulk de-list NuGet packages matching a glob pattern. + +.DESCRIPTION + Queries nuget.org for all versions of packages matching -Glob, + then unlists each version using the NuGet API. + +.PARAMETER Key + Your NuGet API key. + +.PARAMETER Glob + Package ID glob pattern (e.g. "Terminal.Gui.Text*"). + Supports * and ? wildcards. + +.PARAMETER VersionFilter + Optional wildcard pattern to filter versions (e.g. "*-develop.*"). + Only versions matching this pattern will be delisted. If omitted, all listed versions are delisted. + +.PARAMETER WhatIf + Show what would be delisted without actually doing it. + +.EXAMPLE + .\delist-nuget.ps1 -Key "my-api-key" -Glob "Terminal.Gui.Text*" + .\delist-nuget.ps1 -Key "my-api-key" -Glob "Terminal.Gui.Text*" -VersionFilter "*-develop.*" -WhatIf +#> +[CmdletBinding(SupportsShouldProcess)] param( - [Parameter(Mandatory=$true)] - [string]$ApiKey, - - [Parameter(Mandatory=$false)] - [string]$JustPublishedVersion = "" + [Parameter(Mandatory)] + [string]$Key, + + [Parameter(Mandatory)] + [string]$Glob, + + [Parameter()] + [string]$VersionFilter ) -$packageId = "terminal.gui" # Ensure this is the correct package name (case-sensitive) -$nugetSource = "https://api.nuget.org/v3/index.json" +$ErrorActionPreference = 'Stop' -# Fetch ONLY listed package versions from NuGet Autocomplete API -$nugetApiUrl = "https://api-v2v3search-0.nuget.org/autocomplete?id=$packageId&prerelease=true&semVerLevel=2.0.0" -Write-Host "Fetching listed package versions for '$packageId'..." +# Convert glob to regex +$regexPattern = '^' + [regex]::Escape($Glob).Replace('\*', '.*').Replace('\?', '.') + '$' -try { - $versionsResponse = Invoke-RestMethod -Uri $nugetApiUrl - $allVersions = $versionsResponse.data -} catch { - Write-Host "Error fetching package versions: $_" - exit 0 +Write-Host "Searching nuget.org for packages matching: $Glob" -ForegroundColor Cyan +Write-Host " (regex: $regexPattern)" -ForegroundColor DarkGray +if ($VersionFilter) { + Write-Host " Version filter: $VersionFilter" -ForegroundColor DarkGray } -Write-Host "Found $($allVersions.Count) listed versions." +# Search NuGet for matching package IDs (paginated, up to 1000) +$skip = 0 +$take = 100 +$packageIds = @() -# Function to parse version and extract numeric parts for comparison -function Get-VersionSortKey { - param([string]$version) - - # Extract the numeric part after the last dot for prerelease versions - # E.g., "2.0.0-develop.123" -> 123, "2.0.0-alpha.5" -> 5 - if ($version -match '[-.](\d+)$') { - return [int]$matches[1] +do { + $searchUrl = "https://azuresearch-usnc.nuget.org/query?q=$([uri]::EscapeDataString($Glob))&skip=$skip&take=$take&prerelease=true&semVerLevel=2.0.0" + $response = Invoke-RestMethod -Uri $searchUrl -Method Get + foreach ($pkg in $response.data) { + if ($pkg.id -match $regexPattern) { + $packageIds += $pkg.id + } } - return 0 + $skip += $take +} while ($response.data.Count -eq $take) + +if ($packageIds.Count -eq 0) { + Write-Host "No packages found matching '$Glob'." -ForegroundColor Yellow + exit 0 } -# Function to process package versions with a specific pattern -function Process-PackageVersions { - param( - [string]$Pattern, - [string]$PackageType, - [array]$AllVersions, - [string]$JustPublished = "" - ) - - $matchingVersions = $AllVersions | Where-Object { $_ -match $Pattern } - - if ($matchingVersions.Count -eq 0) { - Write-Host "No $PackageType versions found." - return +Write-Host "`nFound $($packageIds.Count) package(s): $($packageIds -join ', ')" -ForegroundColor Green + +$totalDelisted = 0 + +foreach ($id in $packageIds) { + # Get all versions from the registration API + $lowerPkgId = $id.ToLowerInvariant() + $regUrl = "https://api.nuget.org/v3/registration5-gz-semver2/$lowerPkgId/index.json" + + try { + $reg = Invoke-RestMethod -Uri $regUrl -Method Get } - - # Determine which version to keep - $toKeep = $null - $toUnlist = @() - - if ($JustPublished -ne "" -and $JustPublished -match $Pattern) { - # Keep the just-published version - $toKeep = $JustPublished - $toUnlist = $matchingVersions | Where-Object { $_ -ne $JustPublished } - - if ($toUnlist.Count -gt 0) { - Write-Host "Found $($matchingVersions.Count) $PackageType versions. Keeping just-published: $toKeep" - } else { - Write-Host "Found $($matchingVersions.Count) $PackageType versions. Just-published version is the only one." - return + catch { + Write-Warning "Could not fetch versions for $id — skipping. $_" + continue + } + + $versions = @() + foreach ($page in $reg.items) { + # Pages may be inlined or require a separate fetch + $items = $page.items + if (-not $items -and $page.'@id') { + $items = (Invoke-RestMethod -Uri $page.'@id' -Method Get).items } - } else { - # Keep the most recent version - if ($matchingVersions.Count -eq 1) { - Write-Host "Found 1 $PackageType version. Nothing to unlist." - return + foreach ($entry in $items) { + $ver = $entry.catalogEntry.version + $listed = $entry.catalogEntry.listed + if ($listed -ne $false) { + if ($VersionFilter -and $ver -notlike $VersionFilter) { + continue + } + $versions += $ver + } } - - $sortedVersions = $matchingVersions | Sort-Object { Get-VersionSortKey $_ } -Descending - $toKeep = $sortedVersions[0] - $toUnlist = $sortedVersions | Select-Object -Skip 1 - - Write-Host "Found $($matchingVersions.Count) $PackageType versions. Keeping most recent: $toKeep" - } - - # Delist versions - foreach ($version in $toUnlist) { - Write-Host "Unlisting $PackageType package: $packageId - $version" - dotnet nuget delete $packageId $version --source $nugetSource --api-key $ApiKey --non-interactive } -} -# Process develop packages - keep only the most recent -Process-PackageVersions -Pattern "^2\.0\.0-develop\..*$" -PackageType "develop" -AllVersions $allVersions + if ($versions.Count -eq 0) { + Write-Host " $id — no listed versions found." -ForegroundColor DarkGray + continue + } -# Process alpha packages - keep only the just-published one or most recent -Process-PackageVersions -Pattern "^2\.0\.0-alpha\..*$" -PackageType "alpha" -AllVersions $allVersions -JustPublished $JustPublishedVersion + Write-Host "`n $id — $($versions.Count) listed version(s):" -ForegroundColor Cyan -# Process beta packages - keep only the just-published one or most recent (for future use) -Process-PackageVersions -Pattern "^2\.0\.0-beta\..*$" -PackageType "beta" -AllVersions $allVersions -JustPublished $JustPublishedVersion + foreach ($ver in $versions) { + if ($PSCmdlet.ShouldProcess("$id $ver", "Delist from NuGet")) { + Write-Host " Delisting $id $ver ... " -NoNewline + try { + $deleteUrl = "https://www.nuget.org/api/v2/package/$id/$ver" + Invoke-RestMethod -Uri $deleteUrl -Method Delete -Headers @{ 'X-NuGet-ApiKey' = $Key } + Write-Host "done" -ForegroundColor Green + $totalDelisted++ + } + catch { + Write-Host "FAILED: $_" -ForegroundColor Red + } + } + else { + Write-Host " Would delist $id $ver" -ForegroundColor Yellow + $totalDelisted++ + } + } +} -Write-Host "Operation complete." +Write-Host "`nTotal: $totalDelisted version(s) delisted." -ForegroundColor Cyan diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index 386cf7a418..2c0a0a2cc4 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using Terminal.Gui.Tracing; namespace Terminal.Gui.App; @@ -221,33 +221,31 @@ private void BuildDriverIfPossible (IApplication? app) kittyKeyboardDetector.Detect (result => { - if (!result.IsSupported) - { - if (_inputProcessor is AnsiInputProcessor unsupportedAnsiInputProcessor) - { - unsupportedAnsiInputProcessor.SetKittyKeyboardEnabled (false); - } + if (!result.IsSupported) + { + if (_inputProcessor is AnsiInputProcessor unsupportedAnsiInputProcessor) + { + unsupportedAnsiInputProcessor.SetKittyKeyboardEnabled (false); + } - Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); + Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); - return; - } + return; + } - // Kitty is supported. Store the capabilities and set the flags we care about. - _driver?.SetKittyKeyboardCapabilities (result); + // Kitty is supported. Store the capabilities and set the flags we care about. + _driver?.SetKittyKeyboardCapabilities (result); - if (_inputProcessor is AnsiInputProcessor supportedAnsiInputProcessor) - { - supportedAnsiInputProcessor.SetKittyKeyboardEnabled (true); - } + if (_inputProcessor is AnsiInputProcessor supportedAnsiInputProcessor) + { + supportedAnsiInputProcessor.SetKittyKeyboardEnabled (true); + } - kittyKeyboardDetector.Enable (EscSeqUtils.KittyKeyboardRequestedFlags); + kittyKeyboardDetector.Enable (EscSeqUtils.KittyKeyboardRequestedFlags); Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", - $"Requested kitty keyboard flags { - EscSeqUtils.KittyKeyboardRequestedFlags - }; awaiting confirmation"); + $"Requested kitty keyboard flags {EscSeqUtils.KittyKeyboardRequestedFlags}; awaiting confirmation"); }); } catch (Exception ex) @@ -260,6 +258,27 @@ private void BuildDriverIfPossible (IApplication? app) QueueDeviceAttributesProbe (startupGate); } + // Detect sixel support via DAR query. + // Follows the same async callback pattern as TerminalColorDetector above. + if (!_driver.IsLegacyConsole) + { + try + { + SixelSupportDetector sixelDetector = new (_driver); + + sixelDetector.Detect (result => + { + _driver.SetSixelSupport (result); + Logging.Trace ($"app: Sixel support: {result.IsSupported}, Resolution: {result.Resolution}"); + }); + } + catch (Exception ex) + { + Logging.Warning ($"Sixel support detection failed: {ex.Message}"); + } + } + + _startupSemaphore.Release (); Trace.Lifecycle (app?.MainThreadId.ToString (), "Driver", $"_input: {_input}, _output: {_output}"); } diff --git a/Terminal.Gui/Configuration/ConfigProperty.cs b/Terminal.Gui/Configuration/ConfigProperty.cs index eab7feb217..1f30986755 100644 --- a/Terminal.Gui/Configuration/ConfigProperty.cs +++ b/Terminal.Gui/Configuration/ConfigProperty.cs @@ -185,7 +185,16 @@ internal static ConfigProperty CreateImmutableWithAttributeInfo (PropertyInfo pr /// True if the PropertyInfo has a ConfigurationPropertyAttribute; otherwise, false internal static bool HasConfigurationPropertyAttribute (PropertyInfo propertyInfo) { - return propertyInfo.GetCustomAttribute (typeof (ConfigurationPropertyAttribute)) != null; + try + { + return propertyInfo.GetCustomAttribute (typeof (ConfigurationPropertyAttribute)) != null; + } + catch (TypeLoadException) + { + // Foreign assemblies loaded into the process can carry broken custom-attribute metadata. + // Those properties are not Terminal.Gui configuration hosts and should not terminate the scan. + return false; + } } /// @@ -510,17 +519,26 @@ internal static void Initialize () // Supplement the built-in list by discovering host types defined outside Terminal.Gui // (test suites, plugins, embedding apps). This remains important under NativeAOT for // consumer-rooted host types such as app-defined AppSettingsScope entries. - ScanLoadedAssembliesForConfigPropertyHosts (dict); + ScanLoadedAssembliesForConfigPropertyHosts (dict, AppDomain.CurrentDomain.GetAssemblies ()); _classesWithConfigProps = dict.ToImmutableSortedDictionary (); } [RequiresDynamicCode ("Uses reflection to scan assemblies for configuration properties.")] [RequiresUnreferencedCode ("Scanning for types via reflection is not trim-safe.")] - private static void ScanLoadedAssembliesForConfigPropertyHosts (Dictionary dict) + internal static ImmutableSortedDictionary ScanAssembliesForConfigPropertyHosts (IEnumerable assemblies) { - Assembly [] assemblies = AppDomain.CurrentDomain.GetAssemblies (); + Dictionary dict = new (StringComparer.InvariantCultureIgnoreCase); + ScanLoadedAssembliesForConfigPropertyHosts (dict, assemblies); + + return dict.ToImmutableSortedDictionary (); + } + + [RequiresDynamicCode ("Uses reflection to scan assemblies for configuration properties.")] + [RequiresUnreferencedCode ("Scanning for types via reflection is not trim-safe.")] + private static void ScanLoadedAssembliesForConfigPropertyHosts (Dictionary dict, IEnumerable assemblies) + { foreach (Assembly assembly in assemblies) { try diff --git a/Terminal.Gui/Configuration/SchemeJsonConverter.cs b/Terminal.Gui/Configuration/SchemeJsonConverter.cs index 7039f62572..c84b475347 100644 --- a/Terminal.Gui/Configuration/SchemeJsonConverter.cs +++ b/Terminal.Gui/Configuration/SchemeJsonConverter.cs @@ -52,11 +52,23 @@ public override Scheme Read (ref Utf8JsonReader reader, Type typeToConvert, Json "hotactive" => scheme with { HotActive = attribute }, "highlight" => scheme with { Highlight = attribute }, "editable" => scheme with { Editable = attribute }, - "readonly" => scheme with { ReadOnly = attribute }, - "disabled" => scheme with { Disabled = attribute }, - "code" => scheme with { Code = attribute }, - _ => throw new JsonException ($"{propertyName}: Unrecognized Scheme Attribute name.") - }; + "readonly" => scheme with { ReadOnly = attribute }, + "disabled" => scheme with { Disabled = attribute }, + "code" => scheme with { Code = attribute }, + "codecomment" => scheme with { CodeComment = attribute }, + "codekeyword" => scheme with { CodeKeyword = attribute }, + "codestring" => scheme with { CodeString = attribute }, + "codenumber" => scheme with { CodeNumber = attribute }, + "codeoperator" => scheme with { CodeOperator = attribute }, + "codetype" => scheme with { CodeType = attribute }, + "codepreprocessor" => scheme with { CodePreprocessor = attribute }, + "codeidentifier" => scheme with { CodeIdentifier = attribute }, + "codeconstant" => scheme with { CodeConstant = attribute }, + "codepunctuation" => scheme with { CodePunctuation = attribute }, + "codefunctionname" => scheme with { CodeFunctionName = attribute }, + "codeattribute" => scheme with { CodeAttribute = attribute }, + _ => throw new JsonException ($"{propertyName}: Unrecognized Scheme Attribute name.") + }; } else { diff --git a/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs b/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs index cd69b6f828..1286ca2ce4 100644 --- a/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs +++ b/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs @@ -28,6 +28,13 @@ internal static class MarkdownAttributeHelper /// A fully resolved ready for drawing. public static Attribute GetAttributeForSegment (View view, StyledSegment segment, ISyntaxHighlighter? highlighter = null, Color? themeBackground = null) { + if (segment.Role is { } role) + { + Attribute roleAttr = view.GetAttributeForRole (role); + + return ResolveRoleAttribute (view, role, roleAttr, themeBackground); + } + if (segment.Attribute is { } explicitAttr) { // When a caller-provided background override is present, apply it even to @@ -70,6 +77,35 @@ public static Attribute GetAttributeForSegment (View view, StyledSegment segment }; } + private static Attribute ResolveRoleAttribute (View view, VisualRole role, Attribute roleAttr, Color? themeBackground) + { + if (themeBackground is { } roleBg) + { + return roleAttr with { Background = roleBg }; + } + + if (!IsCodeTokenRole (role) || roleAttr.Background != Color.None) + { + return roleAttr; + } + + return roleAttr with { Background = view.GetAttributeForRole (VisualRole.Code).Background }; + } + + private static bool IsCodeTokenRole (VisualRole role) => + role is VisualRole.CodeComment + or VisualRole.CodeKeyword + or VisualRole.CodeString + or VisualRole.CodeNumber + or VisualRole.CodeOperator + or VisualRole.CodeType + or VisualRole.CodePreprocessor + or VisualRole.CodeIdentifier + or VisualRole.CodeConstant + or VisualRole.CodePunctuation + or VisualRole.CodeFunctionName + or VisualRole.CodeAttribute; + /// /// Converts a list of (from parsing) into /// instances suitable for rendering. @@ -80,7 +116,7 @@ public static List ToStyledSegments (IReadOnlyList run foreach (InlineRun run in runs) { - segments.Add (new StyledSegment (run.Text, run.StyleRole, run.Url, run.ImageSource, run.Attribute)); + segments.Add (new StyledSegment (run.Text, run.StyleRole, run.Url, run.ImageSource, run.Attribute, run.Role)); } return segments; diff --git a/Terminal.Gui/Drawing/Markdown/StyledSegment.cs b/Terminal.Gui/Drawing/Markdown/StyledSegment.cs index ad780004f0..f24b40fb59 100644 --- a/Terminal.Gui/Drawing/Markdown/StyledSegment.cs +++ b/Terminal.Gui/Drawing/Markdown/StyledSegment.cs @@ -17,13 +17,21 @@ public sealed class StyledSegment /// for rendering, bypassing the -based resolution in /// . /// - public StyledSegment (string text, MarkdownStyleRole styleRole, string? url = null, string? imageSource = null, Attribute? attribute = null) + /// Optional token-level used for source-code rendering. + public StyledSegment ( + string text, + MarkdownStyleRole styleRole, + string? url = null, + string? imageSource = null, + Attribute? attribute = null, + VisualRole? role = null) { Text = text; StyleRole = styleRole; Url = url; ImageSource = imageSource; Attribute = attribute; + Role = role; } /// Gets the display text of this segment. @@ -47,4 +55,10 @@ public StyledSegment (string text, MarkdownStyleRole styleRole, string? url = nu /// bypassing the normal -based resolution. /// public Attribute? Attribute { get; } + + /// + /// Gets the token-level for this segment, or + /// if the role should be resolved from . + /// + public VisualRole? Role { get; } } diff --git a/Terminal.Gui/Drawing/Markdown/TextMateSyntaxHighlighter.cs b/Terminal.Gui/Drawing/Markdown/TextMateSyntaxHighlighter.cs index ae4fe69893..e2a2a65226 100644 --- a/Terminal.Gui/Drawing/Markdown/TextMateSyntaxHighlighter.cs +++ b/Terminal.Gui/Drawing/Markdown/TextMateSyntaxHighlighter.cs @@ -56,6 +56,28 @@ public class TextMateSyntaxHighlighter : ISyntaxHighlighter [MarkdownStyleRole.ThematicBreak] = [["meta.separator.markdown"], ["comment"]] }; + internal static readonly (string ScopePrefix, VisualRole Role) [] TmScopeRoleMap = + [ + ("keyword.operator", VisualRole.CodeOperator), + ("comment", VisualRole.CodeComment), + ("keyword", VisualRole.CodeKeyword), + ("string", VisualRole.CodeString), + ("constant.numeric", VisualRole.CodeNumber), + ("entity.name.type", VisualRole.CodeType), + ("support.type", VisualRole.CodeType), + ("storage.type", VisualRole.CodeType), + ("meta.preprocessor", VisualRole.CodePreprocessor), + ("entity.name.variable", VisualRole.CodeIdentifier), + ("variable", VisualRole.CodeIdentifier), + ("constant.language", VisualRole.CodeConstant), + ("constant.character", VisualRole.CodeConstant), + ("punctuation", VisualRole.CodePunctuation), + ("entity.name.function", VisualRole.CodeFunctionName), + ("support.function", VisualRole.CodeFunctionName), + ("entity.other.attribute-name", VisualRole.CodeAttribute), + ("meta.tag", VisualRole.CodeAttribute) + ]; + /// Initializes a new with the specified theme. /// /// The VS Code theme to use for colorization. Defaults to . @@ -73,14 +95,14 @@ public IReadOnlyList Highlight (string code, string? language) { if (_nativeLibUnavailable) { - return [new StyledSegment (code, MarkdownStyleRole.CodeBlock)]; + return [new StyledSegment (code, MarkdownStyleRole.CodeBlock, role: VisualRole.Code)]; } IGrammar? grammar = ResolveGrammar (language); if (grammar is null) { - return [new StyledSegment (code, MarkdownStyleRole.CodeBlock)]; + return [new StyledSegment (code, MarkdownStyleRole.CodeBlock, role: VisualRole.Code)]; } ITokenizeLineResult result; @@ -95,12 +117,11 @@ public IReadOnlyList Highlight (string code, string? language) // Degrade gracefully to unstyled code blocks for the rest of this session. _nativeLibUnavailable = true; - return [new StyledSegment (code, MarkdownStyleRole.CodeBlock)]; + return [new StyledSegment (code, MarkdownStyleRole.CodeBlock, role: VisualRole.Code)]; } _ruleStack = result.RuleStack; - Theme theme = _registry.GetTheme (); List segments = []; foreach (IToken token in result.Tokens) @@ -114,13 +135,13 @@ public IReadOnlyList Highlight (string code, string? language) } string text = code [startIndex..endIndex]; - Attribute attr = ResolveAttribute (theme, token.Scopes); - segments.Add (new StyledSegment (text, MarkdownStyleRole.CodeBlock, attribute: attr)); + VisualRole role = ResolveRoleForScopes (token.Scopes); + segments.Add (new StyledSegment (text, MarkdownStyleRole.CodeBlock, role: role)); } if (segments.Count == 0) { - return [new StyledSegment (code, MarkdownStyleRole.CodeBlock)]; + return [new StyledSegment (code, MarkdownStyleRole.CodeBlock, role: VisualRole.Code)]; } return segments; @@ -136,9 +157,11 @@ public IReadOnlyList Highlight (string code, string? language) /// /// The terminal background color to evaluate. /// A theme appropriate for the background luminance. + [Obsolete ("Syntax colors are resolved from ThemeManager.Theme via VisualRole.Code* roles.")] public static ThemeName GetThemeForBackground (Color background) => background.IsDarkColor () ? ThemeName.DarkPlus : ThemeName.LightPlus; /// Gets the that is currently active. + [Obsolete ("Syntax colors are resolved from ThemeManager.Theme via VisualRole.Code* roles.")] public ThemeName ThemeName { get; private set; } /// @@ -189,6 +212,7 @@ public IReadOnlyList Highlight (string code, string? language) /// since theme changes may affect tokenization colors. /// /// The new VS Code theme to use. + [Obsolete ("Syntax colors are resolved from ThemeManager.Theme via VisualRole.Code* roles.")] public void SetTheme (ThemeName theme) { ThemeName = theme; @@ -293,6 +317,19 @@ private Attribute ResolveAttribute (Theme theme, List scopes) return new Attribute (fg, _defaultBackground, style); } + internal static VisualRole ResolveRoleForScopes (IEnumerable scopes) + { + foreach ((string scopePrefix, VisualRole role) in TmScopeRoleMap) + { + if (scopes.Any (scope => scope.StartsWith (scopePrefix, StringComparison.Ordinal))) + { + return role; + } + } + + return VisualRole.Code; + } + private IGrammar? ResolveGrammar (string? language) { if (string.IsNullOrEmpty (language)) diff --git a/Terminal.Gui/Drawing/Scheme.cs b/Terminal.Gui/Drawing/Scheme.cs index 01ac6c9950..81189b5ac2 100644 --- a/Terminal.Gui/Drawing/Scheme.cs +++ b/Terminal.Gui/Drawing/Scheme.cs @@ -188,7 +188,24 @@ internal static ImmutableSortedDictionary GetHardCodedSchemes () CreateAccent ()) ]); - Scheme CreateBase () => new () { Normal = new Attribute (Color.None, Color.None) }; + Scheme CreateBase () => new () + { + Normal = new Attribute (Color.None, Color.None), + CodeComment = CreateCodeAttribute ("#6a9955"), + CodeKeyword = CreateCodeAttribute ("#569cd6"), + CodeString = CreateCodeAttribute ("#ce9178"), + CodeNumber = CreateCodeAttribute ("#b5cea8"), + CodeOperator = CreateCodeAttribute ("#d4d4d4"), + CodeType = CreateCodeAttribute ("#4ec9b0"), + CodePreprocessor = CreateCodeAttribute ("#c586c0"), + CodeIdentifier = CreateCodeAttribute ("#9cdcfe"), + CodeConstant = CreateCodeAttribute ("#569cd6"), + CodePunctuation = CreateCodeAttribute ("#d4d4d4"), + CodeFunctionName = CreateCodeAttribute ("#dcdcaa"), + CodeAttribute = CreateCodeAttribute ("#9cdcfe") + }; + + Attribute CreateCodeAttribute (string foreground) => new (foreground, "None"); Scheme CreateError () => new () { Normal = new Attribute (StandardColor.IndianRed, StandardColor.RaisinBlack) }; @@ -220,7 +237,7 @@ public static Scheme DeriveAccent (Scheme baseScheme, Attribute? defaultTerminal // Force opaque accentBg = new Color (accentBg.R, accentBg.G, accentBg.B, 255); - return new Scheme { Normal = new Attribute (resolvedFg, accentBg) }; + return new Scheme (baseScheme, new Attribute (resolvedFg, accentBg)); } /// Creates a new instance set to the default attributes (see ). @@ -244,6 +261,37 @@ public Scheme (Scheme? scheme) _readOnly = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.ReadOnly, out Attribute? readOnly) ? readOnly : null; _disabled = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.Disabled, out Attribute? disabled) ? disabled : null; _code = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.Code, out Attribute? code) ? code : null; + _codeComment = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeComment, out Attribute? codeComment) ? codeComment : null; + _codeKeyword = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeKeyword, out Attribute? codeKeyword) ? codeKeyword : null; + _codeString = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeString, out Attribute? codeString) ? codeString : null; + _codeNumber = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeNumber, out Attribute? codeNumber) ? codeNumber : null; + _codeOperator = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeOperator, out Attribute? codeOperator) ? codeOperator : null; + _codeType = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeType, out Attribute? codeType) ? codeType : null; + _codePreprocessor = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodePreprocessor, out Attribute? codePreprocessor) ? codePreprocessor : null; + _codeIdentifier = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeIdentifier, out Attribute? codeIdentifier) ? codeIdentifier : null; + _codeConstant = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeConstant, out Attribute? codeConstant) ? codeConstant : null; + _codePunctuation = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodePunctuation, out Attribute? codePunctuation) ? codePunctuation : null; + _codeFunctionName = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeFunctionName, out Attribute? codeFunctionName) ? codeFunctionName : null; + _codeAttribute = scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeAttribute, out Attribute? codeAttribute) ? codeAttribute : null; + } + + private Scheme (Scheme baseScheme, Attribute normal) + { + Normal = normal; + + _code = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.Code, out Attribute? code) ? code : null; + _codeComment = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeComment, out Attribute? codeComment) ? codeComment : null; + _codeKeyword = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeKeyword, out Attribute? codeKeyword) ? codeKeyword : null; + _codeString = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeString, out Attribute? codeString) ? codeString : null; + _codeNumber = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeNumber, out Attribute? codeNumber) ? codeNumber : null; + _codeOperator = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeOperator, out Attribute? codeOperator) ? codeOperator : null; + _codeType = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeType, out Attribute? codeType) ? codeType : null; + _codePreprocessor = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodePreprocessor, out Attribute? codePreprocessor) ? codePreprocessor : null; + _codeIdentifier = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeIdentifier, out Attribute? codeIdentifier) ? codeIdentifier : null; + _codeConstant = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeConstant, out Attribute? codeConstant) ? codeConstant : null; + _codePunctuation = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodePunctuation, out Attribute? codePunctuation) ? codePunctuation : null; + _codeFunctionName = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeFunctionName, out Attribute? codeFunctionName) ? codeFunctionName : null; + _codeAttribute = baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeAttribute, out Attribute? codeAttribute) ? codeAttribute : null; } /// Creates a new instance, initialized with the values from . @@ -294,6 +342,18 @@ public bool TryGetExplicitlySetAttributeForRole (VisualRole role, out Attribute? VisualRole.ReadOnly => _readOnly, VisualRole.Disabled => _disabled, VisualRole.Code => _code, + VisualRole.CodeComment => _codeComment, + VisualRole.CodeKeyword => _codeKeyword, + VisualRole.CodeString => _codeString, + VisualRole.CodeNumber => _codeNumber, + VisualRole.CodeOperator => _codeOperator, + VisualRole.CodeType => _codeType, + VisualRole.CodePreprocessor => _codePreprocessor, + VisualRole.CodeIdentifier => _codeIdentifier, + VisualRole.CodeConstant => _codeConstant, + VisualRole.CodePunctuation => _codePunctuation, + VisualRole.CodeFunctionName => _codeFunctionName, + VisualRole.CodeAttribute => _codeAttribute, _ => null }; @@ -405,6 +465,24 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet break; } + case VisualRole.CodeComment: + case VisualRole.CodeKeyword: + case VisualRole.CodeString: + case VisualRole.CodeNumber: + case VisualRole.CodeOperator: + case VisualRole.CodeType: + case VisualRole.CodePreprocessor: + case VisualRole.CodeIdentifier: + case VisualRole.CodeConstant: + case VisualRole.CodePunctuation: + case VisualRole.CodeFunctionName: + case VisualRole.CodeAttribute: + { + result = GetAttributeForRoleCore (VisualRole.Code, stack, defaultTerminalColors); + + break; + } + case VisualRole.Disabled: { Attribute normal = GetAttributeForRoleCore (VisualRole.Normal, stack, defaultTerminalColors); @@ -614,6 +692,67 @@ public Attribute Disabled /// public Attribute Code { get => GetAttributeForRoleProperty (_code, VisualRole.Code); init => _code = SetAttributeForRoleProperty (value, VisualRole.Code); } + + private readonly Attribute? _codeComment; + + /// The visual role for source-code comments. + public Attribute CodeComment { get => GetAttributeForRoleProperty (_codeComment, VisualRole.CodeComment); init => _codeComment = SetAttributeForRoleProperty (value, VisualRole.CodeComment); } + + private readonly Attribute? _codeKeyword; + + /// The visual role for source-code keywords. + public Attribute CodeKeyword { get => GetAttributeForRoleProperty (_codeKeyword, VisualRole.CodeKeyword); init => _codeKeyword = SetAttributeForRoleProperty (value, VisualRole.CodeKeyword); } + + private readonly Attribute? _codeString; + + /// The visual role for source-code string literals. + public Attribute CodeString { get => GetAttributeForRoleProperty (_codeString, VisualRole.CodeString); init => _codeString = SetAttributeForRoleProperty (value, VisualRole.CodeString); } + + private readonly Attribute? _codeNumber; + + /// The visual role for source-code numeric literals. + public Attribute CodeNumber { get => GetAttributeForRoleProperty (_codeNumber, VisualRole.CodeNumber); init => _codeNumber = SetAttributeForRoleProperty (value, VisualRole.CodeNumber); } + + private readonly Attribute? _codeOperator; + + /// The visual role for source-code operators. + public Attribute CodeOperator { get => GetAttributeForRoleProperty (_codeOperator, VisualRole.CodeOperator); init => _codeOperator = SetAttributeForRoleProperty (value, VisualRole.CodeOperator); } + + private readonly Attribute? _codeType; + + /// The visual role for source-code type names. + public Attribute CodeType { get => GetAttributeForRoleProperty (_codeType, VisualRole.CodeType); init => _codeType = SetAttributeForRoleProperty (value, VisualRole.CodeType); } + + private readonly Attribute? _codePreprocessor; + + /// The visual role for source-code preprocessor directives. + public Attribute CodePreprocessor { get => GetAttributeForRoleProperty (_codePreprocessor, VisualRole.CodePreprocessor); init => _codePreprocessor = SetAttributeForRoleProperty (value, VisualRole.CodePreprocessor); } + + private readonly Attribute? _codeIdentifier; + + /// The visual role for source-code identifiers. + public Attribute CodeIdentifier { get => GetAttributeForRoleProperty (_codeIdentifier, VisualRole.CodeIdentifier); init => _codeIdentifier = SetAttributeForRoleProperty (value, VisualRole.CodeIdentifier); } + + private readonly Attribute? _codeConstant; + + /// The visual role for source-code constants. + public Attribute CodeConstant { get => GetAttributeForRoleProperty (_codeConstant, VisualRole.CodeConstant); init => _codeConstant = SetAttributeForRoleProperty (value, VisualRole.CodeConstant); } + + private readonly Attribute? _codePunctuation; + + /// The visual role for source-code punctuation. + public Attribute CodePunctuation { get => GetAttributeForRoleProperty (_codePunctuation, VisualRole.CodePunctuation); init => _codePunctuation = SetAttributeForRoleProperty (value, VisualRole.CodePunctuation); } + + private readonly Attribute? _codeFunctionName; + + /// The visual role for source-code function names. + public Attribute CodeFunctionName { get => GetAttributeForRoleProperty (_codeFunctionName, VisualRole.CodeFunctionName); init => _codeFunctionName = SetAttributeForRoleProperty (value, VisualRole.CodeFunctionName); } + + private readonly Attribute? _codeAttribute; + + /// The visual role for source-code attributes. + public Attribute CodeAttribute { get => GetAttributeForRoleProperty (_codeAttribute, VisualRole.CodeAttribute); init => _codeAttribute = SetAttributeForRoleProperty (value, VisualRole.CodeAttribute); } + /// public virtual bool Equals (Scheme? other) => other is { } @@ -627,18 +766,35 @@ other is { } && EqualityComparer.Default.Equals (Editable, other.Editable) && EqualityComparer.Default.Equals (ReadOnly, other.ReadOnly) && EqualityComparer.Default.Equals (Disabled, other.Disabled) - && EqualityComparer.Default.Equals (Code, other.Code); + && EqualityComparer.Default.Equals (Code, other.Code) + && EqualityComparer.Default.Equals (CodeComment, other.CodeComment) + && EqualityComparer.Default.Equals (CodeKeyword, other.CodeKeyword) + && EqualityComparer.Default.Equals (CodeString, other.CodeString) + && EqualityComparer.Default.Equals (CodeNumber, other.CodeNumber) + && EqualityComparer.Default.Equals (CodeOperator, other.CodeOperator) + && EqualityComparer.Default.Equals (CodeType, other.CodeType) + && EqualityComparer.Default.Equals (CodePreprocessor, other.CodePreprocessor) + && EqualityComparer.Default.Equals (CodeIdentifier, other.CodeIdentifier) + && EqualityComparer.Default.Equals (CodeConstant, other.CodeConstant) + && EqualityComparer.Default.Equals (CodePunctuation, other.CodePunctuation) + && EqualityComparer.Default.Equals (CodeFunctionName, other.CodeFunctionName) + && EqualityComparer.Default.Equals (CodeAttribute, other.CodeAttribute); /// public override int GetHashCode () => HashCode.Combine (HashCode.Combine (Normal, HotNormal, Focus, HotFocus, Active, HotActive, Highlight, Editable), - HashCode.Combine (ReadOnly, Disabled, Code)); + HashCode.Combine (ReadOnly, Disabled, Code, CodeComment, CodeKeyword, CodeString, CodeNumber, CodeOperator), + HashCode.Combine (CodeType, CodePreprocessor, CodeIdentifier, CodeConstant, CodePunctuation, CodeFunctionName, CodeAttribute)); /// public override string ToString () => $"Normal: {Normal}; HotNormal: {HotNormal}; Focus: {Focus}; HotFocus: {HotFocus}; " + $"Active: {Active}; HotActive: {HotActive}; Highlight: {Highlight}; Editable: {Editable}; " - + $"ReadOnly: {ReadOnly}; Disabled: {Disabled}; Code: {Code}"; + + $"ReadOnly: {ReadOnly}; Disabled: {Disabled}; Code: {Code}; " + + $"CodeComment: {CodeComment}; CodeKeyword: {CodeKeyword}; CodeString: {CodeString}; CodeNumber: {CodeNumber}; " + + $"CodeOperator: {CodeOperator}; CodeType: {CodeType}; CodePreprocessor: {CodePreprocessor}; " + + $"CodeIdentifier: {CodeIdentifier}; CodeConstant: {CodeConstant}; CodePunctuation: {CodePunctuation}; " + + $"CodeFunctionName: {CodeFunctionName}; CodeAttribute: {CodeAttribute}"; /// /// Resolves to a concrete color for use in color math (brighten, dim, invert). diff --git a/Terminal.Gui/Drawing/Sixel/SixelToRender.cs b/Terminal.Gui/Drawing/Sixel/SixelToRender.cs index 2be8780cc2..8417c7711c 100644 --- a/Terminal.Gui/Drawing/Sixel/SixelToRender.cs +++ b/Terminal.Gui/Drawing/Sixel/SixelToRender.cs @@ -1,4 +1,4 @@ -namespace Terminal.Gui.Drawing; +namespace Terminal.Gui.Drawing; /// /// Describes a request to render a given at a given . @@ -21,4 +21,20 @@ public class SixelToRender /// Gets or sets the unique identifier for this sixel render operation. /// public string? Id { get; set; } + + /// + /// Gets or sets whether this sixel needs to be re-rendered to the terminal. + /// When , the output pipeline skips writing this sixel's data. + /// Set to when the owning view's content is invalidated (e.g. via + /// ). + /// + public bool IsDirty { get; set; } = true; + + /// + /// Gets or sets whether this sixel should always be rendered to the terminal. + /// When , the output pipeline always writes this sixel's data. + /// Set to to only render when the owning view's content is + /// invalidated (e.g. via ). + /// + public bool AlwaysRender { get; set; } = false; } diff --git a/Terminal.Gui/Drawing/VisualRole.cs b/Terminal.Gui/Drawing/VisualRole.cs index 0e0a9ea353..c86f173dbf 100644 --- a/Terminal.Gui/Drawing/VisualRole.cs +++ b/Terminal.Gui/Drawing/VisualRole.cs @@ -66,5 +66,41 @@ public enum VisualRole /// The visual role for preformatted or source code content (e.g., , inline code). /// If not explicitly set, derived from with a dimmed background and bold style. /// - Code + Code, + + /// The visual role for source-code comments. + CodeComment, + + /// The visual role for source-code keywords. + CodeKeyword, + + /// The visual role for source-code string literals. + CodeString, + + /// The visual role for source-code numeric literals. + CodeNumber, + + /// The visual role for source-code operators. + CodeOperator, + + /// The visual role for source-code type names. + CodeType, + + /// The visual role for source-code preprocessor directives. + CodePreprocessor, + + /// The visual role for source-code identifiers. + CodeIdentifier, + + /// The visual role for source-code constants. + CodeConstant, + + /// The visual role for source-code punctuation. + CodePunctuation, + + /// The visual role for source-code function names. + CodeFunctionName, + + /// The visual role for source-code attributes. + CodeAttribute } diff --git a/Terminal.Gui/Drivers/AnsiHandling/Osc8UrlLinker.cs b/Terminal.Gui/Drivers/AnsiHandling/Osc8UrlLinker.cs index d4cd43065b..32d8b47c25 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/Osc8UrlLinker.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/Osc8UrlLinker.cs @@ -3,6 +3,8 @@ namespace Terminal.Gui.Drivers; internal static class Osc8UrlLinker { + internal readonly record struct UrlRange (int Start, int Length, string Url); + internal readonly struct Options { internal readonly string [] _allowedSchemes; @@ -30,6 +32,11 @@ internal static StringBuilder WrapOsc8 (StringBuilder input) return WrapOsc8 (input, _defaultOptions); } + internal static List FindUrls (string input) + { + return FindUrls (input, _defaultOptions); + } + internal static StringBuilder WrapOsc8 (StringBuilder input, Options options) { if (input.Length == 0) @@ -103,6 +110,76 @@ internal static StringBuilder WrapOsc8 (StringBuilder input, Options options) return result; } + private static List FindUrls (string input, Options options) + { + List ranges = []; + ReadOnlySpan span = input.AsSpan (); + ReadOnlySpan delimiter = "://".AsSpan (); + int i = 0; + + while (i < span.Length) + { + int rel = span.Slice (i).IndexOf (delimiter, StringComparison.Ordinal); + if (rel < 0) + { + break; + } + + int delimAt = i + rel; + int schemeEnd = delimAt; + int schemeStart = schemeEnd - 1; + + while (schemeStart >= 0 && char.IsLetter (span [schemeStart])) + { + schemeStart--; + } + + schemeStart++; + + if (schemeStart < 0 || schemeStart >= schemeEnd) + { + i = delimAt + delimiter.Length; + continue; + } + + ReadOnlySpan scheme = span.Slice (schemeStart, schemeEnd - schemeStart); + if (!IsAllowedScheme (scheme, options)) + { + i = delimAt + delimiter.Length; + continue; + } + + int urlStart = schemeStart; + int j = delimAt + delimiter.Length; + + while (j < span.Length && !IsUrlTerminator (span [j])) + { + j++; + } + + int urlEnd = TrimTrailingPunctuation (span, urlStart, j); + if (urlEnd <= (delimAt + delimiter.Length)) + { + i = j; + continue; + } + + string candidate = span.Slice (urlStart, urlEnd - urlStart).ToString (); + + Uri? _; + if (options._validateWithUri && !IsValidUrl (candidate, options, out _)) + { + i = j; + continue; + } + + ranges.Add (new (urlStart, urlEnd - urlStart, candidate)); + i = j; + } + + return ranges; + } + private static int ParseEscapeSequence (string text, int start, int len) { int i = start; @@ -341,4 +418,4 @@ private static bool IsValidUrl (string candidate, Options options, out Uri? uri) uri = null; return false; } -} \ No newline at end of file +} diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 6c276de933..997052cdb0 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Runtime.InteropServices; using Terminal.Gui.Tracing; @@ -270,6 +270,22 @@ private void OnSizeMonitorOnSizeChanged (object? _, SizeChangedEventArgs e) => #region Color Support + /// + public SixelSupportResult? SixelSupport { get; private set; } + + /// + public event EventHandler>? SixelSupportChanged; + + /// + /// Sets the terminal's sixel support result (detected during initialization). + /// + internal void SetSixelSupport (SixelSupportResult result) + { + SixelSupportResult? old = SixelSupport; + SixelSupport = result; + SixelSupportChanged?.Invoke (this, new ValueChangedEventArgs (old, result)); + } + /// public bool SupportsTrueColor => !IsLegacyConsole; diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs index c549aae756..efc5e23b75 100644 --- a/Terminal.Gui/Drivers/IDriver.cs +++ b/Terminal.Gui/Drivers/IDriver.cs @@ -128,6 +128,19 @@ public interface IDriver : IDisposable #region Color Support + /// + /// Gets the terminal's sixel support capabilities, detected during driver initialization. + /// + /// + /// if detection has not been performed. + /// + SixelSupportResult? SixelSupport { get; } + + /// + /// Raised when changes (e.g. after terminal color detection completes). + /// + event EventHandler>? SixelSupportChanged; + /// Gets whether the supports TrueColor output. bool SupportsTrueColor { get; } @@ -422,4 +435,4 @@ public interface IDriver : IDisposable public void QueueAnsiRequest (AnsiEscapeSequenceRequest request); #endregion ANSI Escape Sequences -} +} \ No newline at end of file diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index ac2516ab84..b05fff02bc 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -58,13 +58,27 @@ public bool IsLegacyConsole // Last URL used for tracking hyperlink state private string? _lastUrl = null; + // Rows that contained URLs in the last rendered frame; used to emit OSC 8 close + // before re-rendering a row that has since lost all URL cells, so terminals don't + // keep stale hyperlink metadata. + private readonly HashSet _rowsWithUrls = []; + + // Identifies the buffer state we last synced _rowsWithUrls against. When the buffer + // is replaced, resized, or its URL maps are wiped, this stops matching and we drop + // the stale tracking before reading it. + private IOutputBuffer? _lastTrackedBuffer; + private int _lastTrackedRows; + private int _lastTrackedCols; + private int _lastTrackedUrlVersion; + private readonly StringBuilder _lastOutputStringBuilder = new (); private bool _clearLastOutputPending; /// - /// Writes dirty cells from the buffer to the console. Hides cursor, iterates rows/cols, - /// skips clean cells, batches dirty cells into ANSI sequences, wraps URLs with OSC 8, - /// then renders sixel images. Cursor visibility is managed by ApplicationMainLoop.SetCursor(). + /// Writes dirty cells from the buffer to the console. Iterates rows/cols, skips clean cells, + /// batches dirty cells into ANSI sequences, emits OSC 8 hyperlink start/close around URL cells, + /// and finally renders queued sixel images. Cursor visibility is managed by + /// ApplicationMainLoop.SetCursor(). /// public virtual void Write (IOutputBuffer buffer) { @@ -77,6 +91,8 @@ public virtual void Write (IOutputBuffer buffer) Attribute? redrawAttr = null; int lastCol = -1; + InvalidateRowsWithUrlsIfStale (buffer, rows, cols); + // Process each row for (int row = top; row < rows; row++) { @@ -93,9 +109,22 @@ public virtual void Write (IOutputBuffer buffer) return; } + if (!IsLegacyConsole && buffer is OutputBufferImpl outputBuffer) + { + outputBuffer.SyncAutoUrlsForRow (row); + } + + bool rowHadUrlsPreviously = _rowsWithUrls.Contains (row); + bool rowHasUrlsNow = !IsLegacyConsole && RowContainsUrls (buffer, row, cols); + outputStringBuilder.Clear (); _lastUrl = null; // Reset URL state at the start of each row + if (!IsLegacyConsole && rowHadUrlsPreviously && !rowHasUrlsNow) + { + outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ()); + } + // Process columns in row for (int col = left; col < cols; col++) { @@ -170,6 +199,21 @@ public virtual void Write (IOutputBuffer buffer) } } + // Track row's URL status BEFORE the early-exit so _rowsWithUrls stays consistent + // with the buffer state — even for rows whose cells were all flushed via WriteToConsole + // during the inner loop (leaving outputStringBuilder empty at this point). + if (!IsLegacyConsole) + { + if (rowHasUrlsNow) + { + _rowsWithUrls.Add (row); + } + else + { + _rowsWithUrls.Remove (row); + } + } + // Flush buffered output for row. Even when nothing remains buffered, an OSC 8 hyperlink // may still be open in the terminal because it was started in a prior batch flushed by // WriteToConsole and the row ended (or only clean cells followed) before any cell with @@ -197,9 +241,7 @@ public virtual void Write (IOutputBuffer buffer) SetCursorPositionImpl (lastCol, row); - // Wrap URLs with OSC 8 hyperlink sequences - StringBuilder processed = Osc8UrlLinker.WrapOsc8 (outputStringBuilder); - Write (processed); + Write (outputStringBuilder); } if (IsLegacyConsole) @@ -210,13 +252,14 @@ public virtual void Write (IOutputBuffer buffer) // Render queued sixel images foreach (SixelToRender s in GetSixels ()) { - if (string.IsNullOrWhiteSpace (s.SixelData)) + if (string.IsNullOrWhiteSpace (s.SixelData) || (!s.IsDirty && !s.AlwaysRender)) { continue; } SetCursorPositionImpl (s.ScreenPosition.X, s.ScreenPosition.Y); Write (new StringBuilder (s.SixelData)); + s.IsDirty = false; } } @@ -442,6 +485,11 @@ public string ToAnsi (IOutputBuffer buffer) return output.ToString (); } + if (buffer is OutputBufferImpl outputBuffer) + { + outputBuffer.SyncAutoUrlsForAllRows (); + } + StringBuilder ansiOutput = new (); Attribute? lastAttr = null; @@ -451,24 +499,45 @@ public string ToAnsi (IOutputBuffer buffer) } /// - /// Writes buffered output to console, wrapping URLs with OSC 8 hyperlinks (non-legacy only), - /// then clears the buffer and advances by . + /// Writes buffered output to console, then clears the buffer and advances + /// by . /// private void WriteToConsole (StringBuilder output, ref int lastCol, ref int outputWidth) { - if (IsLegacyConsole) - { - Write (output); - } - else - { - // Wrap URLs with OSC 8 hyperlink sequences - StringBuilder processed = Osc8UrlLinker.WrapOsc8 (output); - Write (processed); - } + Write (output); output.Clear (); lastCol += outputWidth; outputWidth = 0; } + + private static bool RowContainsUrls (IOutputBuffer buffer, int row, int cols) + { + for (int col = 0; col < cols; col++) + { + if (!string.IsNullOrEmpty (buffer.GetCellUrl (col, row))) + { + return true; + } + } + + return false; + } + + private void InvalidateRowsWithUrlsIfStale (IOutputBuffer buffer, int rows, int cols) + { + int urlVersion = buffer is OutputBufferImpl outputBuffer ? outputBuffer.UrlStateVersion : 0; + + if (!ReferenceEquals (_lastTrackedBuffer, buffer) + || _lastTrackedRows != rows + || _lastTrackedCols != cols + || _lastTrackedUrlVersion != urlVersion) + { + _rowsWithUrls.Clear (); + _lastTrackedBuffer = buffer; + _lastTrackedRows = rows; + _lastTrackedCols = cols; + _lastTrackedUrlVersion = urlVersion; + } + } } diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index 0ae65a73f1..dd6e814e88 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; namespace Terminal.Gui.Drivers; @@ -21,10 +22,23 @@ public class OutputBufferImpl : IOutputBuffer private int _rows; /// - /// Maps cell positions to URLs for OSC 8 hyperlink support. - /// Only stores entries for cells that actually have URLs, minimizing memory overhead. + /// Maps cell positions to explicitly assigned URLs for OSC 8 hyperlink support. /// - private Dictionary? _urlMap; + private Dictionary? _explicitUrlMap; + + /// + /// Maps cell positions to auto-detected URLs found in plain text content. + /// + private Dictionary? _autoUrlMap; + + private int _urlStateVersion; + + /// + /// Monotonic counter incremented when URL state is wiped (e.g. via + /// or ). Consumers + /// that cache per-frame URL-row tracking can compare to detect resets. + /// + internal int UrlStateVersion => _urlStateVersion; private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement; @@ -58,15 +72,21 @@ public class OutputBufferImpl : IOutputBuffer /// The URL if one exists, otherwise null. public string? GetCellUrl (int col, int row) { - // Fast-path: skip locking when no URLs have been set - if (_urlMap is null) + if (_explicitUrlMap is null && _autoUrlMap is null) { return null; } lock (_contentsLock) { - return _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; + Point point = new (col, row); + + if (_explicitUrlMap?.TryGetValue (point, out string? explicitUrl) == true) + { + return explicitUrl; + } + + return _autoUrlMap?.TryGetValue (point, out string? autoUrl) == true ? autoUrl : null; } } @@ -298,8 +318,14 @@ private void ClearContentsCore (bool initiallyDirty) DirtyLines = new bool [Rows]; - // Clear the URL map - _urlMap?.Clear (); + // Null out (rather than Clear) so GetCellUrl's null fast-path stays effective + // for the lifetime of the buffer until URLs are introduced again. + if (_explicitUrlMap is { } || _autoUrlMap is { }) + { + _explicitUrlMap = null; + _autoUrlMap = null; + _urlStateVersion++; + } for (var row = 0; row < Rows; row++) { @@ -417,7 +443,7 @@ private void SetAttributeAndDirty (int col, int row) } else { - _urlMap?.Remove (new Point (col, row)); + _explicitUrlMap?.Remove (new Point (col, row)); } } @@ -429,8 +455,91 @@ private void SetAttributeAndDirty (int col, int row) /// The URL to associate with this cell. private void SetCellUrl (int col, int row, string url) { - _urlMap ??= []; - _urlMap [new Point (col, row)] = url; + _explicitUrlMap ??= []; + _explicitUrlMap [new Point (col, row)] = url; + } + + internal void SyncAutoUrlsForRow (int row) + { + lock (_contentsLock) + { + SyncAutoUrlsForRowCore (row); + } + } + + internal void SyncAutoUrlsForAllRows () + { + lock (_contentsLock) + { + for (int row = 0; row < Rows; row++) + { + SyncAutoUrlsForRowCore (row); + } + } + } + + private void SyncAutoUrlsForRowCore (int row) + { + if (Contents is null || row < 0 || row >= Rows) + { + return; + } + + if (_autoUrlMap is { Count: > 0 }) + { + for (int col = 0; col < Cols; col++) + { + _autoUrlMap.Remove (new Point (col, row)); + } + } + + // Build the row text and a parallel char-to-column map so grapheme clusters + // wider than a single char (ZWJ emoji, base + combining mark) don't shift the + // detected URL out of alignment with its actual columns. + StringBuilder rowText = new (Cols); + int [] colByChar = new int [Cols * 2]; + int charCount = 0; + + for (int col = 0; col < Cols; col++) + { + string grapheme = Contents [row, col].Grapheme; + string append = string.IsNullOrEmpty (grapheme) ? " " : grapheme; + rowText.Append (append); + + if (charCount + append.Length > colByChar.Length) + { + Array.Resize (ref colByChar, Math.Max (colByChar.Length * 2, charCount + append.Length)); + } + + for (int k = 0; k < append.Length; k++) + { + colByChar [charCount++] = col; + } + } + + foreach (Osc8UrlLinker.UrlRange range in Osc8UrlLinker.FindUrls (rowText.ToString ())) + { + if (range.Start >= charCount) + { + continue; + } + + int startCol = colByChar [range.Start]; + int lastCharIdx = range.Start + range.Length - 1; + int endCol = lastCharIdx < charCount ? colByChar [lastCharIdx] + 1 : Cols; + endCol = Math.Min (endCol, Cols); + + for (int col = startCol; col < endCol; col++) + { + _autoUrlMap ??= []; + _autoUrlMap [new Point (col, row)] = range.Url; + } + } + + if (_autoUrlMap is { Count: 0 }) + { + _autoUrlMap = null; + } } /// diff --git a/Terminal.Gui/Input/Command.cs b/Terminal.Gui/Input/Command.cs index 8fd51dafc9..4f5dba8204 100644 --- a/Terminal.Gui/Input/Command.cs +++ b/Terminal.Gui/Input/Command.cs @@ -214,6 +214,28 @@ public enum Command /// Unix emulation. UnixEmulation, + /// Inserts a tab character or spaces at the cursor or selection. + InsertTab, + + /// Removes one level of indentation from the current line or selection. + Unindent, + + #endregion + + #region Search Commands + + /// Opens or activates a find/search UI. + Find, + + /// Finds the next match. + FindNext, + + /// Finds the previous match. + FindPrevious, + + /// Opens or activates a find-and-replace UI. + Replace, + #endregion #region Tree Commands @@ -336,4 +358,24 @@ public enum Command Edit, #endregion + + #region Multi-Caret Commands + + /// + /// Adds an additional caret one line above the topmost caret (multi-caret editing), + /// preserving the sticky visual column. Mirrors VS Code's + /// editor.action.insertCursorAbove. Views that support multi-caret bind this + /// through or their configurable default key bindings. + /// + InsertCaretAbove, + + /// + /// Adds an additional caret one line below the bottommost caret (multi-caret editing), + /// preserving the sticky visual column. Mirrors VS Code's + /// editor.action.insertCursorBelow. Views that support multi-caret bind this + /// through or their configurable default key bindings. + /// + InsertCaretBelow, + + #endregion } diff --git a/Terminal.Gui/Resources/config.json b/Terminal.Gui/Resources/config.json index 1dec0b1066..ff07593cd2 100644 --- a/Terminal.Gui/Resources/config.json +++ b/Terminal.Gui/Resources/config.json @@ -56,7 +56,20 @@ "Normal": { "Foreground": "Yellow", "Background": "Blue" - } + }, + "Code": { "Foreground": "Yellow", "Background": "Blue", "Style": "None" }, + "CodeComment": { "Foreground": "LightGray", "Background": "None", "Style": "None" }, + "CodeKeyword": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeString": { "Foreground": "LightCyan", "Background": "None", "Style": "None" }, + "CodeNumber": { "Foreground": "LightGreen", "Background": "None", "Style": "None" }, + "CodeOperator": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeType": { "Foreground": "Magenta", "Background": "None", "Style": "None" }, + "CodePreprocessor": { "Foreground": "BrightRed", "Background": "None", "Style": "None" }, + "CodeIdentifier": { "Foreground": "Yellow", "Background": "None", "Style": "None" }, + "CodeConstant": { "Foreground": "LightGreen", "Background": "None", "Style": "None" }, + "CodePunctuation": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeFunctionName": { "Foreground": "LightYellow", "Background": "None", "Style": "None" }, + "CodeAttribute": { "Foreground": "LightCyan", "Background": "None", "Style": "None" } } }, { @@ -129,7 +142,20 @@ "Normal": { "Foreground": "White", "Background": "DarkBlue" - } + }, + "Code": { "Foreground": "White", "Background": "DarkBlue", "Style": "None" }, + "CodeComment": { "Foreground": "#8fbc8f", "Background": "None", "Style": "None" }, + "CodeKeyword": { "Foreground": "#87cefa", "Background": "None", "Style": "None" }, + "CodeString": { "Foreground": "#f0e68c", "Background": "None", "Style": "None" }, + "CodeNumber": { "Foreground": "#98fb98", "Background": "None", "Style": "None" }, + "CodeOperator": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeType": { "Foreground": "#40e0d0", "Background": "None", "Style": "None" }, + "CodePreprocessor": { "Foreground": "#dda0dd", "Background": "None", "Style": "None" }, + "CodeIdentifier": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeConstant": { "Foreground": "#87cefa", "Background": "None", "Style": "None" }, + "CodePunctuation": { "Foreground": "White", "Background": "None", "Style": "None" }, + "CodeFunctionName": { "Foreground": "#fffacd", "Background": "None", "Style": "None" }, + "CodeAttribute": { "Foreground": "#add8e6", "Background": "None", "Style": "None" } } }, { @@ -282,7 +308,20 @@ "Foreground": "Gray", "Background": "None", "Style": "Italic" - } + }, + "Code": { "Foreground": "LightGray", "Background": "Onyx", "Style": "None" }, + "CodeComment": { "Foreground": "#6a9955", "Background": "None", "Style": "None" }, + "CodeKeyword": { "Foreground": "#569cd6", "Background": "None", "Style": "None" }, + "CodeString": { "Foreground": "#ce9178", "Background": "None", "Style": "None" }, + "CodeNumber": { "Foreground": "#b5cea8", "Background": "None", "Style": "None" }, + "CodeOperator": { "Foreground": "#d4d4d4", "Background": "None", "Style": "None" }, + "CodeType": { "Foreground": "#4ec9b0", "Background": "None", "Style": "None" }, + "CodePreprocessor": { "Foreground": "#c586c0", "Background": "None", "Style": "None" }, + "CodeIdentifier": { "Foreground": "#9cdcfe", "Background": "None", "Style": "None" }, + "CodeConstant": { "Foreground": "#569cd6", "Background": "None", "Style": "None" }, + "CodePunctuation": { "Foreground": "#d4d4d4", "Background": "None", "Style": "None" }, + "CodeFunctionName": { "Foreground": "#dcdcaa", "Background": "None", "Style": "None" }, + "CodeAttribute": { "Foreground": "#9cdcfe", "Background": "None", "Style": "None" } } }, { @@ -567,7 +606,20 @@ "Foreground": "Gray", "Background": "None", "Style": "Italic" - } + }, + "Code": { "Foreground": "Black", "Background": "WhiteSmoke", "Style": "None" }, + "CodeComment": { "Foreground": "#008000", "Background": "None", "Style": "None" }, + "CodeKeyword": { "Foreground": "#0000ff", "Background": "None", "Style": "None" }, + "CodeString": { "Foreground": "#a31515", "Background": "None", "Style": "None" }, + "CodeNumber": { "Foreground": "#098658", "Background": "None", "Style": "None" }, + "CodeOperator": { "Foreground": "#000000", "Background": "None", "Style": "None" }, + "CodeType": { "Foreground": "#267f99", "Background": "None", "Style": "None" }, + "CodePreprocessor": { "Foreground": "#0000ff", "Background": "None", "Style": "None" }, + "CodeIdentifier": { "Foreground": "#001080", "Background": "None", "Style": "None" }, + "CodeConstant": { "Foreground": "#0000ff", "Background": "None", "Style": "None" }, + "CodePunctuation": { "Foreground": "#000000", "Background": "None", "Style": "None" }, + "CodeFunctionName": { "Foreground": "#795e26", "Background": "None", "Style": "None" }, + "CodeAttribute": { "Foreground": "#ff0000", "Background": "None", "Style": "None" } } }, { @@ -799,7 +851,20 @@ "Foreground": "GreenPhosphor", "Background": "Charcoal", "Style": "Faint" - } + }, + "Code": { "Foreground": "GreenPhosphor", "Background": "Charcoal", "Style": "None" }, + "CodeComment": { "Foreground": "#7cff7c", "Background": "None", "Style": "None" }, + "CodeKeyword": { "Foreground": "#b6ffb6", "Background": "None", "Style": "None" }, + "CodeString": { "Foreground": "#9cff9c", "Background": "None", "Style": "None" }, + "CodeNumber": { "Foreground": "#ccffcc", "Background": "None", "Style": "None" }, + "CodeOperator": { "Foreground": "GreenPhosphor", "Background": "None", "Style": "None" }, + "CodeType": { "Foreground": "#80ffd4", "Background": "None", "Style": "None" }, + "CodePreprocessor": { "Foreground": "#d0ffd0", "Background": "None", "Style": "None" }, + "CodeIdentifier": { "Foreground": "GreenPhosphor", "Background": "None", "Style": "None" }, + "CodeConstant": { "Foreground": "#b6ffb6", "Background": "None", "Style": "None" }, + "CodePunctuation": { "Foreground": "GreenPhosphor", "Background": "None", "Style": "None" }, + "CodeFunctionName": { "Foreground": "#e0ffe0", "Background": "None", "Style": "None" }, + "CodeAttribute": { "Foreground": "#80ffd4", "Background": "None", "Style": "None" } } }, { @@ -956,7 +1021,20 @@ "Foreground": "AmberPhosphor", "Background": "Charcoal", "Style": "Faint" - } + }, + "Code": { "Foreground": "AmberPhosphor", "Background": "Charcoal", "Style": "None" }, + "CodeComment": { "Foreground": "#d08a00", "Background": "None", "Style": "None" }, + "CodeKeyword": { "Foreground": "#ffd166", "Background": "None", "Style": "None" }, + "CodeString": { "Foreground": "#ffb000", "Background": "None", "Style": "None" }, + "CodeNumber": { "Foreground": "#ffe08a", "Background": "None", "Style": "None" }, + "CodeOperator": { "Foreground": "AmberPhosphor", "Background": "None", "Style": "None" }, + "CodeType": { "Foreground": "#ffc04d", "Background": "None", "Style": "None" }, + "CodePreprocessor": { "Foreground": "#ff9f1c", "Background": "None", "Style": "None" }, + "CodeIdentifier": { "Foreground": "AmberPhosphor", "Background": "None", "Style": "None" }, + "CodeConstant": { "Foreground": "#ffd166", "Background": "None", "Style": "None" }, + "CodePunctuation": { "Foreground": "AmberPhosphor", "Background": "None", "Style": "None" }, + "CodeFunctionName": { "Foreground": "#fff0a6", "Background": "None", "Style": "None" }, + "CodeAttribute": { "Foreground": "#ffc04d", "Background": "None", "Style": "None" } } }, { @@ -1420,4 +1498,4 @@ } } ] -} \ No newline at end of file +} diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index cf510856ff..6f026ccc7c 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -93,9 +93,11 @@ + + diff --git a/Terminal.Gui/ViewBase/View.Command.cs b/Terminal.Gui/ViewBase/View.Command.cs index d1cde141e8..427620807f 100644 --- a/Terminal.Gui/ViewBase/View.Command.cs +++ b/Terminal.Gui/ViewBase/View.Command.cs @@ -296,19 +296,41 @@ private void SetupCommands () } /// - /// Called when a command that has not been bound is invoked. + /// Called when a command that has not been bound (via AddCommand) is invoked on this View. /// Set CommandEventArgs.Handled to and return to indicate the event was /// handled and processing should stop. /// + /// + /// + /// This is part of the command bubbling pipeline. When a entry fires a command + /// that has no AddCommand handler, this method is called. If the command is in an ancestor's + /// list, will forward it to that ancestor. + /// + /// + /// Adding a entry without a corresponding AddCommand handler is + /// intentional and idiomatic — it signals that the command should bubble to an ancestor. + /// + /// /// The event arguments. /// to stop processing. protected virtual bool OnCommandNotBound (CommandEventArgs args) => false; /// - /// Cancelable event raised when a command that has not been bound is invoked. + /// Cancelable event raised when a command that has not been bound (via AddCommand) is invoked. /// Set CommandEventArgs.Handled to to indicate the event was handled and processing should /// stop. /// + /// + /// + /// This event fires as part of the command bubbling mechanism. A common use case is a SubView that binds + /// mouse-wheel events to scroll commands (via ) without adding a local handler. + /// The unhandled command fires this event, and if not cancelled here, forwards + /// the command to the nearest ancestor whose includes it. + /// + /// + /// See also: , . + /// + /// public event EventHandler? CommandNotBound; #region Accept @@ -499,6 +521,16 @@ private void SetupCommands () /// /// See for more information. /// + /// + /// When to use: Subscribe to only when you need to inspect or cancel the + /// in-flight Accept operation (e.g., set e.Handled = true to prevent the accept). For simple side-effects + /// that don't need to cancel, subscribe to instead — it is lighter-weight and communicates + /// intent more clearly. + /// + /// + /// Rule of thumb: If your handler doesn't read or set anything on + /// (no e.Handled, no inspection of context), use . + /// /// public event EventHandler? Accepting; @@ -543,6 +575,19 @@ protected virtual void OnAccepted (ICommandContext? ctx) { } /// /// See for more information. /// + /// + /// When to use: Subscribe to for fire-and-forget side-effects — things that + /// happen after the accept has completed and cannot be cancelled. This is the right choice for the vast + /// majority of button-click–style handlers. + /// + /// + /// Example: + /// + /// button.Accepted += (_, _) => DoTheThing (); // correct — side-effect only + /// button.Accepting += (_, e) => { if (!CanProceed ()) e.Handled = true; }; // correct — cancels + /// button.Accepting += (_, _) => DoTheThing (); // wrong — use Accepted instead + /// + /// /// public event EventHandler? Accepted; @@ -843,6 +888,18 @@ private void BubbleActivatedUp (ICommandContext? ctx, bool compositeOnly = false /// Set CommandEventArgs.Handled to to indicate the event was handled and processing should /// stop. /// + /// + /// + /// When to use: Subscribe to only when you need to inspect or cancel the + /// in-flight Activate operation (e.g., set e.Handled = true to prevent the state change). For simple + /// side-effects that don't need to cancel, subscribe to instead — it is lighter-weight and + /// communicates intent more clearly. + /// + /// + /// Rule of thumb: If your handler doesn't read or set anything on + /// (no e.Handled, no inspection of context), use . + /// + /// public event EventHandler? Activating; /// @@ -913,6 +970,16 @@ protected virtual void OnActivated (ICommandContext? ctx) { } /// Event raised when the user has performed an action (e.g. ) causing the /// View to change state or preparing it for interaction. /// + /// + /// + /// Unlike , this event cannot be cancelled. It is raised after the View has activated. + /// + /// + /// When to use: Subscribe to for fire-and-forget side-effects — things + /// that happen after the activation has completed and cannot be cancelled. This is the right choice for + /// the vast majority of state-change–reaction handlers. + /// + /// public event EventHandler>? Activated; #endregion Activate @@ -1211,17 +1278,32 @@ private static bool IsSourceWithinView (View target, ICommandContext? ctx) /// /// Gets or sets the list of commands that should bubble up to this View from unhandled SubViews - /// or from SubViews within this View's adornments (Padding, Border). + /// or from SubViews within this View's adornments (Padding, Border, Margin). /// When a SubView raises a command that is not handled, and the command is in the SuperView's /// list, the command will be invoked on the SuperView. /// /// /// - /// For SubViews inside an (e.g., a button in Padding or Border), - /// the bubble target is rather than . + /// For SubViews inside an (e.g., a view in Padding or Border), + /// the bubble target is (the owning View) rather than + /// . This means a view added to editor.Padding will bubble + /// commands directly to editor, not to the PaddingView. + /// + /// + /// Mouse-wheel forwarding pattern: A SubView can add a entry + /// (e.g., MouseBindings.Add(MouseFlags.WheeledUp, Command.ScrollUp)) without calling + /// AddCommand for the command. The unhandled command will fire + /// and then bubble via to the nearest + /// ancestor whose contains it. + /// + /// + /// Example — enable scroll command bubbling: + /// + /// editor.CommandsToBubbleUp = [Command.ScrollUp, Command.ScrollDown]; + /// /// /// - /// e.g. to enable bubbling for hierarchical views: + /// Example — enable bubbling for hierarchical views: /// /// menuBar.CommandsToBubbleUp = [Command.Activate]; /// diff --git a/Terminal.Gui/Views/Button.cs b/Terminal.Gui/Views/Button.cs index 3e4d1b61bb..76cddab16a 100644 --- a/Terminal.Gui/Views/Button.cs +++ b/Terminal.Gui/Views/Button.cs @@ -43,6 +43,7 @@ /// public class Button : View, IDesignable, IAcceptTarget { + private readonly TextFormatter _interiorTextFormatter = new (); private readonly Rune _leftBracket; private readonly Rune _leftDefault; private readonly Rune _rightBracket; @@ -71,6 +72,10 @@ public Button () _leftDefault = Glyphs.LeftDefaultIndicator; _rightDefault = Glyphs.RightDefaultIndicator; + _interiorTextFormatter.Alignment = Alignment.Center; + _interiorTextFormatter.VerticalAlignment = Alignment.Center; + _interiorTextFormatter.HotKeySpecifier = HotKeySpecifier; + Height = Dim.Auto (DimAutoStyle.Text); Width = Dim.Auto (DimAutoStyle.Text); @@ -167,13 +172,18 @@ private void Button_TitleChanged (object? sender, EventArgs e) { base.Text = e.Value; TextFormatter.HotKeySpecifier = HotKeySpecifier; + _interiorTextFormatter.HotKeySpecifier = HotKeySpecifier; } /// public override string Text { get => Title; set => base.Text = Title = value; } /// - public override Rune HotKeySpecifier { get => base.HotKeySpecifier; set => TextFormatter.HotKeySpecifier = base.HotKeySpecifier = value; } + public override Rune HotKeySpecifier + { + get => base.HotKeySpecifier; + set => _interiorTextFormatter.HotKeySpecifier = TextFormatter.HotKeySpecifier = base.HotKeySpecifier = value; + } /// public bool IsDefault @@ -208,26 +218,113 @@ public bool IsDefault protected override void UpdateTextFormatterText () { base.UpdateTextFormatterText (); + TextFormatter.Text = GetDecoratedText (); + } - if (NoDecorations) + /// + protected override bool OnDrawingText (DrawContext? context) + { + if (NoDecorations || Driver is null) { - TextFormatter.Text = Text; + return base.OnDrawingText (context); } - else if (IsDefault) + + Rectangle drawRect = new (ContentToScreen (Point.Empty), GetContentSize ()); + + if (drawRect.Width < 2 || drawRect.Height < 1) { - TextFormatter.Text = $"{_leftBracket}{_leftDefault} {Text} {_rightDefault}{_rightBracket}"; + return base.OnDrawingText (context); } - else + + Rectangle interiorRect = new (drawRect.X + 1, drawRect.Y, drawRect.Width - 2, drawRect.Height); + Attribute normalAttr = HasFocus ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal); + Attribute hotAttr = HasFocus ? GetAttributeForRole (VisualRole.HotFocus) : GetAttributeForRole (VisualRole.HotNormal); + string interiorText = GetInteriorText (); + + // Mirror all TextFormatter settings onto _interiorTextFormatter. Guard each assignment + // so NeedsFormat is not set when a value is unchanged. + if (_interiorTextFormatter.Text != interiorText) { - if (NoPadding) - { - TextFormatter.Text = $"{_leftBracket}{Text}{_rightBracket}"; - } - else - { - TextFormatter.Text = $"{_leftBracket} {Text} {_rightBracket}"; - } + _interiorTextFormatter.Text = interiorText; + } + + if (_interiorTextFormatter.Alignment != TextAlignment) + { + _interiorTextFormatter.Alignment = TextAlignment; + } + + if (_interiorTextFormatter.VerticalAlignment != VerticalTextAlignment) + { + _interiorTextFormatter.VerticalAlignment = VerticalTextAlignment; + } + + if (_interiorTextFormatter.Direction != TextDirection) + { + _interiorTextFormatter.Direction = TextDirection; + } + + if (_interiorTextFormatter.PreserveTrailingSpaces != PreserveTrailingSpaces) + { + _interiorTextFormatter.PreserveTrailingSpaces = PreserveTrailingSpaces; + } + + if (_interiorTextFormatter.MultiLine != TextFormatter.MultiLine) + { + _interiorTextFormatter.MultiLine = TextFormatter.MultiLine; + } + + if (_interiorTextFormatter.WordWrap != TextFormatter.WordWrap) + { + _interiorTextFormatter.WordWrap = TextFormatter.WordWrap; + } + + if (_interiorTextFormatter.TabWidth != TextFormatter.TabWidth) + { + _interiorTextFormatter.TabWidth = TextFormatter.TabWidth; + } + + if (_interiorTextFormatter.PreserveTabs != TextFormatter.PreserveTabs) + { + _interiorTextFormatter.PreserveTabs = TextFormatter.PreserveTabs; + } + + if (_interiorTextFormatter.ConstrainToWidth != interiorRect.Width) + { + _interiorTextFormatter.ConstrainToWidth = interiorRect.Width; + } + + if (_interiorTextFormatter.ConstrainToHeight != interiorRect.Height) + { + _interiorTextFormatter.ConstrainToHeight = interiorRect.Height; } + + Region? interiorDrawRegion = (interiorRect.Width > 0 && !string.IsNullOrEmpty (interiorText)) + ? _interiorTextFormatter.GetDrawRegion (interiorRect) + : null; + + context?.AddDrawnRegion (new Region (drawRect)); + + int delimiterRow = GetDelimiterRow (drawRect, interiorDrawRegion); + + // Fill the entire content area with the focus/normal attribute to ensure continuous + // highlight across all rows and across the bracket columns. + Driver.SetAttribute (normalAttr); + Driver.FillRect (drawRect); + + Driver.Move (drawRect.X, delimiterRow); + Driver.AddRune (_leftBracket); + + Driver.Move (drawRect.X + drawRect.Width - 1, delimiterRow); + Driver.AddRune (_rightBracket); + + if (interiorDrawRegion is not null) + { + _interiorTextFormatter.Draw (Driver, interiorRect, normalAttr, hotAttr, Rectangle.Empty); + } + + SetSubViewNeedsDrawDownHierarchy (); + + return true; } /// @@ -237,4 +334,51 @@ public bool EnableForDesign () return true; } + + // GetDecoratedText (called by UpdateTextFormatterText for Dim.Auto sizing) and GetInteriorText + // (called by OnDrawingText for rendering) must remain in sync when modifying button text formatting. + private string GetDecoratedText () + { + if (NoDecorations) + { + return Text; + } + + return $"{_leftBracket}{GetInteriorText ()}{_rightBracket}"; + } + + private string GetInteriorText () + { + if (IsDefault) + { + return $"{_leftDefault} {Text} {_rightDefault}"; + } + + if (NoPadding) + { + return Text; + } + + return $" {Text} "; + } + + private int GetDelimiterRow (Rectangle drawRect, Region? interiorDrawRegion) + { + if (interiorDrawRegion is not null) + { + Rectangle interiorBounds = interiorDrawRegion.GetBounds (); + + if (!interiorBounds.IsEmpty) + { + return interiorBounds.Y; + } + } + + return VerticalTextAlignment switch + { + Alignment.End => drawRect.Y + drawRect.Height - 1, + Alignment.Center => drawRect.Y + (drawRect.Height - 1) / 2, + _ => drawRect.Y + }; + } } diff --git a/Terminal.Gui/Views/Code.cs b/Terminal.Gui/Views/Code.cs new file mode 100644 index 0000000000..e40e6e9cc3 --- /dev/null +++ b/Terminal.Gui/Views/Code.cs @@ -0,0 +1,173 @@ +namespace Terminal.Gui.Views; + +/// A read-only view that renders syntax-highlighted source code. +public class Code : View, IDesignable +{ + /// Initializes a new instance of the class. + public Code () + { + CanFocus = true; + Width = Dim.Auto (DimAutoStyle.Content); + Height = Dim.Auto (DimAutoStyle.Content); + ViewportSettings |= ViewportSettingsFlags.HasHorizontalScrollBar | ViewportSettingsFlags.HasVerticalScrollBar; + } + + /// Gets or sets the source text to render. + public override string Text + { + get => base.Text; + set + { + if (base.Text == value) + { + return; + } + + base.Text = value; + UpdateStyledLines (); + } + } + + /// Gets or sets the language hint used for syntax highlighting. + public string? Language + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + UpdateStyledLines (); + } + } + + /// Gets or sets the syntax highlighter used to tokenize . + public ISyntaxHighlighter? SyntaxHighlighter + { + get; + set + { + if (ReferenceEquals (field, value)) + { + return; + } + + field = value; + UpdateStyledLines (); + } + } = new TextMateSyntaxHighlighter (); + + private IReadOnlyList> _lines = []; + + private void UpdateStyledLines () + { + string [] lines = base.Text.ReplaceLineEndings ("\n").Split ('\n'); + List> styledLines = []; + + if (SyntaxHighlighter is { } highlighter && !string.IsNullOrEmpty (Language)) + { + highlighter.ResetState (); + + styledLines.AddRange (lines.Select (line => highlighter.Highlight (line, Language))); + } + else + { + styledLines.AddRange (lines.Select (line => (IReadOnlyList) + [ + new StyledSegment (line, MarkdownStyleRole.CodeBlock, role: VisualRole.Code) + ])); + } + + _lines = styledLines; + int width = _lines.Count == 0 ? 0 : _lines.Max (line => line.Sum (segment => segment.Text.GetColumns ())); + SetContentSize (new Size (width, _lines.Count)); + SetNeedsLayout (); + SetNeedsDraw (); + } + + /// + protected override bool OnClearingViewport () + { + if (base.OnClearingViewport ()) + { + return true; + } + + SetAttribute (GetAttributeForRole (VisualRole.Code)); + ClearViewport (); + + return true; + } + + /// + protected override bool OnDrawingContent (DrawContext? context) + { + int endLine = Math.Min (Viewport.Y + Viewport.Height, _lines.Count); + + for (int lineIndex = Viewport.Y; lineIndex < endLine; lineIndex++) + { + DrawLine (_lines [lineIndex], lineIndex - Viewport.Y); + } + + return true; + } + + private void DrawLine (IReadOnlyList segments, int y) + { + var x = 0; + + foreach (StyledSegment segment in segments) + { + Attribute attr = MarkdownAttributeHelper.GetAttributeForSegment (this, segment); + SetAttribute (attr); + + foreach (string grapheme in GraphemeHelper.GetGraphemes (segment.Text)) + { + int graphemeWidth = Math.Max (grapheme.GetColumns (), 1); + bool visible = x + graphemeWidth > Viewport.X && x < Viewport.X + Viewport.Width; + + if (!visible) + { + x += graphemeWidth; + + continue; + } + + AddStr (x - Viewport.X, y, grapheme); + x += graphemeWidth; + } + } + } + + /// + bool IDesignable.EnableForDesign () + { + Language = "cs"; + SyntaxHighlighter = new CodeRoleLegendHighlighter (); + + Text = """ + #nullable enable + using System; + + // Code CodeComment CodeKeyword CodeString + // CodeNumber CodeOperator CodeType CodePreprocessor + // CodeIdentifier CodeConstant CodePunctuation + // CodeFunctionName CodeAttribute + [Obsolete ("CodeAttribute")] + public sealed class CodeRoleSample + { + // CodeComment + private const int CodeNumber = 37; + public string CodeString { get; init; } = "Ada"; + public bool CodeConstant => true; + public int CodeFunctionName => Math.Max (CodeNumber, 21 + 16); + } + + """; + + return true; + } +} diff --git a/Terminal.Gui/Views/CodeRoleLegendHighlighter.cs b/Terminal.Gui/Views/CodeRoleLegendHighlighter.cs new file mode 100644 index 0000000000..e463c19a7c --- /dev/null +++ b/Terminal.Gui/Views/CodeRoleLegendHighlighter.cs @@ -0,0 +1,82 @@ +namespace Terminal.Gui.Views; + +internal sealed class CodeRoleLegendHighlighter : ISyntaxHighlighter +{ + private readonly TextMateSyntaxHighlighter _textMate = new (); + + private static readonly (string Text, VisualRole Role) [] _roleMarkers = + [ + (nameof (VisualRole.CodePreprocessor), VisualRole.CodePreprocessor), + (nameof (VisualRole.CodeFunctionName), VisualRole.CodeFunctionName), + (nameof (VisualRole.CodePunctuation), VisualRole.CodePunctuation), + (nameof (VisualRole.CodeIdentifier), VisualRole.CodeIdentifier), + (nameof (VisualRole.CodeAttribute), VisualRole.CodeAttribute), + (nameof (VisualRole.CodeComment), VisualRole.CodeComment), + (nameof (VisualRole.CodeKeyword), VisualRole.CodeKeyword), + (nameof (VisualRole.CodeString), VisualRole.CodeString), + (nameof (VisualRole.CodeNumber), VisualRole.CodeNumber), + (nameof (VisualRole.CodeOperator), VisualRole.CodeOperator), + (nameof (VisualRole.CodeConstant), VisualRole.CodeConstant), + (nameof (VisualRole.CodeType), VisualRole.CodeType), + (nameof (VisualRole.Code), VisualRole.Code) + ]; + + public IReadOnlyList Highlight (string code, string? language) + { + List segments = []; + + foreach (StyledSegment segment in _textMate.Highlight (code, language)) + { + AddRoleSegments (segments, segment); + } + + return segments; + } + + public void ResetState () => _textMate.ResetState (); + + public string ThemeName => ((ISyntaxHighlighter)_textMate).ThemeName; + + public Color? DefaultBackground => _textMate.DefaultBackground; + + public Attribute? GetAttributeForScope (MarkdownStyleRole role) => _textMate.GetAttributeForScope (role); + + private static void AddRoleSegments (List segments, StyledSegment segment) + { + int index = 0; + + while (index < segment.Text.Length) + { + (string text, VisualRole role) = FindRoleMarker (segment.Text, index); + + if (text.Length == 0) + { + AddSegment (segments, segment, segment.Text [index..(index + 1)], segment.Role); + index++; + + continue; + } + + AddSegment (segments, segment, text, role); + index += text.Length; + } + } + + private static (string Text, VisualRole Role) FindRoleMarker (string text, int index) + { + foreach ((string marker, VisualRole role) in _roleMarkers) + { + if (text.AsSpan (index).StartsWith (marker, StringComparison.Ordinal)) + { + return (marker, role); + } + } + + return (string.Empty, VisualRole.Code); + } + + private static void AddSegment (List segments, StyledSegment source, string text, VisualRole? role) + { + segments.Add (new StyledSegment (text, source.StyleRole, source.Url, source.ImageSource, source.Attribute, role)); + } +} diff --git a/Terminal.Gui/Views/Color/ColorBar.cs b/Terminal.Gui/Views/Color/ColorBar.cs index 795eab3dec..7d2389f3d7 100644 --- a/Terminal.Gui/Views/Color/ColorBar.cs +++ b/Terminal.Gui/Views/Color/ColorBar.cs @@ -26,6 +26,29 @@ protected ColorBar () AddCommand (Command.LeftStart, _ => SetZero ()); AddCommand (Command.RightEnd, _ => SetMax ()); + // Override Activate to handle mouse-press and drag. When triggered by a left-button press + // (initial click or drag), extract the position from the MouseBinding context, update + // Value, set focus, and grab the mouse so subsequent drag events are routed to this bar + // exclusively. Grabbing here (rather than in OnMouseEvent) ensures that if a consumer + // cancels the event by setting e.Handled=true in MouseEvent, the press command is never + // reached and no grab occurs. + AddCommand ( + Command.Activate, + ctx => + { + if (ctx?.Binding is MouseBinding { MouseEvent: { } mouse } + && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) + { + UpdateValueFromMousePosition (mouse); + SetFocus (); + App?.Mouse.GrabMouse (this); + + return true; + } + + return DefaultActivateHandler (ctx); + }); + KeyBindings.Add (Key.CursorLeft, Command.Left); KeyBindings.Add (Key.CursorRight, Command.Right); KeyBindings.Add (Key.CursorLeft.WithShift, Command.LeftExtend); @@ -33,6 +56,17 @@ protected ColorBar () KeyBindings.Add (Key.Home, Command.LeftStart); KeyBindings.Add (Key.End, Command.RightEnd); MouseBindings.Remove (MouseFlags.LeftButtonClicked); + + // Remove the base LeftButtonReleased → Activate binding so that releasing the mouse + // does not fire DefaultActivateHandler (Activating/Activated). UngrabMouse is still + // called from OnMouseEvent on release, so the grab lifecycle is unaffected. + MouseBindings.Remove (MouseFlags.LeftButtonReleased); + + // Bind press and drag to Activate so external code can fully suppress mouse interaction + // by removing these two bindings (e.g. TerminalGuiDesigner editor mode). When both are + // absent no value update, focus change, grab, or drag routing occurs. + MouseBindings.Add (MouseFlags.LeftButtonPressed, Command.Activate); + MouseBindings.Add (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, Command.Activate); } /// @@ -123,19 +157,24 @@ protected override bool OnDrawingContent (DrawContext? context) /// protected override bool OnMouseEvent (Mouse mouse) { - if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) + // Release the grab on button-up so drag events stop routing exclusively to this bar. + // The grab itself is established in the Command.Activate handler so that cancelling the + // MouseEvent (e.Handled = true) prevents both the value update and the grab. + if (mouse.IsReleased && App?.Mouse.IsGrabbed (this) == true) { - if (mouse.Position!.Value.X >= _barStartsAt) - { - double v = MaxValue * ((double)mouse.Position!.Value.X - _barStartsAt) / (_barWidth - 1); - Value = Math.Clamp ((int)v, 0, MaxValue); - } - SetFocus (); - - // Do not mark as handled to allow Activating to be raised + App.Mouse.UngrabMouse (); } - return mouse.Handled; + return false; + } + + private void UpdateValueFromMousePosition (Mouse mouse) + { + if (mouse.Position is { } pos && pos.X >= _barStartsAt) + { + double v = MaxValue * ((double)pos.X - _barStartsAt) / (_barWidth - 1); + Value = Math.Clamp ((int)v, 0, MaxValue); + } } /// diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs index 1b8e68d773..9f35136b6b 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs @@ -194,8 +194,21 @@ private IDirectoryInfo StringToDirectoryInfo (string path) private static void SuppressIfBadChar (Key k) { - // don't let user type bad letters - var ch = (char)k; + // Only suppress actual typed printable characters. + // Navigation keys (Home/End/etc.) must pass through to key bindings. + if (k.IsAlt || k.IsCtrl) + { + return; + } + + string grapheme = k.AsGrapheme; + + if (grapheme.Length != 1) + { + return; + } + + char ch = grapheme [0]; if (_badChars.Contains (ch)) { diff --git a/Terminal.Gui/Views/ImageView.cs b/Terminal.Gui/Views/ImageView.cs new file mode 100644 index 0000000000..352a180b6a --- /dev/null +++ b/Terminal.Gui/Views/ImageView.cs @@ -0,0 +1,465 @@ +namespace Terminal.Gui.Views; + +/// +/// Displays an image represented as a 2D array of pixels. +/// Supports two rendering modes: cell-based (one colored space per pixel, works everywhere) +/// and sixel-based (when the terminal supports it). +/// +/// +/// +/// The image data is provided via the property as a Color[,] array +/// where the first dimension is width (x) and the second is height (y). Image loading and +/// decoding from file formats (PNG, JPEG, etc.) is the caller's responsibility — this view +/// has no dependency on any image library. +/// +/// +/// When sixel is available (detected via ) and +/// is , the view will encode the image as +/// sixel escape sequences and render it through the driver's sixel pipeline. Sixel data +/// is only re-encoded and re-sent to the terminal when is true, +/// avoiding redundant rendering of unchanged images. +/// +/// +/// When sixel is not available, the view falls back to cell-based rendering where each +/// terminal cell is colored with the background color of the corresponding pixel. +/// +/// +public class ImageView : View, IDesignable +{ + private Color [,]? _image; + private Color [,]? _scaledImage; + private Size? _scaledImageCellSize; + private SixelToRender? _sixelToRender; + private string? _cachedSixelData; + + // Cell-based rendering cache + private readonly Dictionary _attributeCache = new (); + + /// + /// Gets or sets the pixel data to display. The array is indexed as [x, y] where + /// the first dimension is width and the second is height. + /// + /// + /// Setting this property marks the view as needing redraw. The image will be + /// scaled to fit the current while maintaining + /// aspect ratio using nearest-neighbor interpolation. + /// + public Color [,]? Image + { + get => _image; + set + { + _image = value; + _scaledImage = null; + _cachedSixelData = null; + _scaledImageCellSize = null; + _attributeCache.Clear (); + UpdateSixelData (); + SetNeedsDraw (); + } + } + + /// + /// Gets or sets whether to prefer sixel rendering when the terminal supports it. + /// Default is . + /// + /// + /// When and the terminal supports sixel + /// (per ), the image is rendered using sixel + /// escape sequences for full-resolution display. When , + /// cell-based rendering is always used. + /// + public bool UseSixel { get; set; } = true; + + /// + /// Gets or sets the used to encode images as sixel data. + /// When , a default encoder is created lazily on first use. + /// + /// + /// Set this to provide a custom encoder with specific quantizer settings, palette building + /// algorithms, or color distance algorithms. The encoder's + /// MaxColors will be clamped to the + /// terminal's during rendering. + /// + public SixelEncoder? SixelEncoder { get; set; } + + /// + /// Gets whether the current rendering mode is using sixel. + /// + public bool IsUsingSixel => UseSixel && App?.Driver?.SixelSupport is { IsSupported: true }; + + /// + /// Converts the Viewport to screen coordinates in pixels. + /// + /// + /// + /// This method accounts for the terminal's cell resolution and the viewport's + /// size, returning the exact pixel dimensions and position required for + /// fully cover the viewport. + /// + /// + /// The screen coordinates of the Viewport in pixels. + public Rectangle ViewportToScreenInPixels () + { + SixelSupportResult? support = (App?.Driver?.SixelSupport) ?? throw new InvalidOperationException (@"No sixel support available."); + + int pixelsPerCellX = support.Resolution.Width; + int pixelsPerCellY = support.Resolution.Height; + Rectangle boundsRect = ViewportToScreen (); + + // Calculate target size in pixels based on viewport and cell resolution + int targetWidthInPixels = boundsRect.Width * pixelsPerCellX; + int targetHeightInPixels = SixelEncoder?.GetHeightInPixels (boundsRect.Height, pixelsPerCellY) ?? boundsRect.Height * pixelsPerCellY; + + return new Rectangle (boundsRect.X * pixelsPerCellX, boundsRect.Y * pixelsPerCellY, targetWidthInPixels, targetHeightInPixels); + } + + /// + /// Returns the size in cell terms of the given image resized to fit in the viewport. + /// + /// + /// + /// This method accounts for the terminal's cell resolution and the viewport's + /// size, returning the exact pixel dimensions and position required for + /// fully cover the viewport without changing the images aspect ratio. + /// + /// + /// The size of the image in pixels. + /// The largest possible size of the image in cell terms that fits within the viewport. + public Size FitImageInViewportCells (Size imageSizeInPixels) + { + if (imageSizeInPixels.Width == 0 || imageSizeInPixels.Height == 0) + { + return Size.Empty; + } + + // Account for the terminal cell aspect ratio + double cellAspectRatio = App?.Driver?.SixelSupport is { } support ? (double)support.Resolution.Height / support.Resolution.Width : 2.0; + Size imageSize = new (imageSizeInPixels.Width, (int)(imageSizeInPixels.Height / cellAspectRatio)); + + // Calculate aspect-ratio-preserving size + double widthScale = (double)Viewport.Width / imageSize.Width; + double heightScale = (double)Viewport.Height / imageSize.Height; + double scale = Math.Min (widthScale, heightScale); + + int newWidth = Math.Max (1, (int)(imageSize.Width * scale)); + int newHeight = Math.Max (1, (int)(imageSize.Height * scale)); + + return new Size (newWidth, newHeight); + } + + /// + /// Scales an image to fit within the current Viewport while maintaining aspect ratio. + /// + /// + /// + /// This method calculates the largest possible size for the given image that will fit + /// within the current while maintaining its aspect ratio. + /// + /// + /// The calculation is based on the terminal's cell resolution and the + /// size, returning the exact pixel dimensions and position required for the scaled image. + /// + /// + /// The original size of the image to scale. + /// The scaled size of the image that fits within the . + public Size FitImageInViewportInPixels (Size imageSize) + { + Rectangle viewportInPixels = ViewportToScreenInPixels (); + + if (imageSize.Width == 0 || imageSize.Height == 0) + { + return Size.Empty; + } + + // Calculate aspect-ratio-preserving size + double widthScale = (double)viewportInPixels.Width / imageSize.Width; + double heightScale = (double)viewportInPixels.Height / imageSize.Height; + double scale = Math.Min (widthScale, heightScale); + + int newWidth = Math.Max (1, (int)(imageSize.Width * scale)); + int newHeight = Math.Max (1, (int)(imageSize.Height * scale)); + + return new Size (newWidth, newHeight); + } + + /// + protected override bool OnDrawingContent (DrawContext? context) + { + base.OnDrawingContent (context); + + if (_image is null) + { + return true; + } + + if (IsUsingSixel) + { + DrawSixel (); + + if (_scaledImageCellSize is { } cellSize) + { + Rectangle viewport = ViewportToScreen (); + Rectangle dirtyRect = new (viewport.X, viewport.Y, Math.Min (viewport.Width, cellSize.Width), Math.Min (viewport.Height, cellSize.Height)); + context?.AddDrawnRectangle (dirtyRect); + + // Mark the content buffer for the area we will draw as not dirty. + // This will avoid redrawing the area of the screen that will + // eventually be overwritten by the sixel anyway. + if (ScreenContents is { } contents && Driver is { } driver) + { + for (int y = dirtyRect.Y; y < dirtyRect.Bottom; y++) + { + for (int x = dirtyRect.X; x < dirtyRect.Right; x++) + { + if (x >= 0 && y >= 0 && x < driver.Cols && y < driver.Rows) + { + contents [y, x].IsDirty = false; + } + } + } + } + } + } + else + { + DrawCellBased (); + + if (_scaledImageCellSize is { } cellSize) + { + Rectangle viewport = ViewportToScreen (); + Rectangle dirtyRect = new (viewport.X, viewport.Y, Math.Min (viewport.Width, cellSize.Width), Math.Min (viewport.Height, cellSize.Height)); + context?.AddDrawnRectangle (dirtyRect); + } + } + + return true; + } + + /// + protected override void OnFrameChanged (in Rectangle frame) + { + base.OnFrameChanged (frame); + UpdateSixelData (); + SetNeedsDraw (); + } + + /// + /// Renders the image using cell-based rendering where each terminal cell + /// gets the background color of the corresponding pixel. + /// + private void DrawCellBased () + { + if (_image is null) + { + return; + } + + if (_scaledImage is null) + { + _scaledImage = GetScaledImage (_image, Viewport.Width, Viewport.Height); + + if (_scaledImage is null) + { + return; + } + + _scaledImageCellSize = new Size (_scaledImage.GetLength (0), _scaledImage.GetLength (1)); + } + + int drawWidth = Math.Min (Viewport.Width, _scaledImage.GetLength (0)); + int drawHeight = Math.Min (Viewport.Height, _scaledImage.GetLength (1)); + + for (int y = 0; y < drawHeight; y++) + { + for (int x = 0; x < drawWidth; x++) + { + Color pixel = _scaledImage [x, y]; + + if (!_attributeCache.TryGetValue (pixel, out Attribute attr)) + { + attr = new Attribute (new Color (), pixel); + _attributeCache.Add (pixel, attr); + } + + SetAttribute (attr); + AddRune (x, y, (Rune)' '); + } + } + } + + /// + /// Renders the image using sixel escape sequences. + /// + private void DrawSixel () + { + SixelSupportResult? support = App?.Driver?.SixelSupport; + + if (support is null) + { + return; + } + + if (_cachedSixelData is null) + { + UpdateSixelData (); + } + + // Get screen position for this view's viewport + Point screenPos = ViewportToScreen ().Location; + + if (_sixelToRender is null) + { + _sixelToRender = new SixelToRender + { + SixelData = _cachedSixelData, + ScreenPosition = screenPos, + Id = $"ImageView_{GetHashCode ()}", + IsDirty = true + }; + + App?.Driver?.GetOutput ().GetSixels ().Enqueue (_sixelToRender); + } + else + { + _sixelToRender.SixelData = _cachedSixelData; + _sixelToRender.ScreenPosition = screenPos; + _sixelToRender.IsDirty = true; + } + } + + private void UpdateSixelData () + { + if (!IsUsingSixel || App?.Driver?.SixelSupport is not { } support || _image is null) + { + return; + } + + // Use caller-provided encoder or create a default one + SixelEncoder ??= new SixelEncoder (); + + // Clamp MaxColors regardless of whether the encoder was provided + SixelEncoder.Quantizer.MaxColors = Math.Min (SixelEncoder.Quantizer.MaxColors, support.MaxPaletteColors); + + Rectangle targetRect = ViewportToScreenInPixels (); + + // Scale the image to the target pixel size while maintaining aspect ratio + _scaledImage = GetScaledImage (_image, targetRect.Width, targetRect.Height); + _scaledImageCellSize = FitImageInViewportCells (new Size (_image.GetLength (0), _image.GetLength (1))); + + if (_scaledImage is null) + { + return; + } + + // Encode sixel data + _cachedSixelData = SixelEncoder.EncodeSixel (_scaledImage); + } + + /// + /// Scales the source image to the specified target dimensions using nearest-neighbor + /// interpolation while maintaining aspect ratio. + /// + /// The source image to scale. + /// The target width in the appropriate unit (cells or pixels). + /// The target height in the appropriate unit (cells or pixels). + /// The scaled image, or if the source image is null or the target size is invalid. + private static Color [,]? GetScaledImage (Color [,] image, int targetWidth, int targetHeight) + { + if (image is null || targetWidth <= 0 || targetHeight <= 0) + { + return null; + } + + int srcWidth = image.GetLength (0); + int srcHeight = image.GetLength (1); + + if (srcWidth == 0 || srcHeight == 0) + { + return null; + } + + // Calculate aspect-ratio-preserving size + double widthScale = (double)targetWidth / srcWidth; + double heightScale = (double)targetHeight / srcHeight; + double scale = Math.Min (widthScale, heightScale); + + int newWidth = Math.Max (1, (int)(srcWidth * scale)); + int newHeight = Math.Max (1, (int)(srcHeight * scale)); + + // We can start with the input image, maybe it's the correct size already + Color [,] scaledImage = image; + + // Nearest-neighbor scale + if (scaledImage.GetLength (0) != newWidth || scaledImage.GetLength (1) != newHeight) + { + scaledImage = new Color [newWidth, newHeight]; + ScaleNearestNeighbor (image, scaledImage); + } + + return scaledImage; + } + + /// + /// Scales a Color[,] pixel array into a destination array using nearest-neighbor interpolation. + /// + /// The source pixel array indexed as [x, y]. + /// The destination pixel array indexed as [x, y]. + public static void ScaleNearestNeighbor (Color [,] source, Color [,] destination) + { + int srcWidth = source.GetLength (0); + int srcHeight = source.GetLength (1); + int newWidth = destination.GetLength (0); + int newHeight = destination.GetLength (1); + + for (int y = 0; y < newHeight; y++) + { + int srcY = Math.Min (y * srcHeight / newHeight, srcHeight - 1); + + for (int x = 0; x < newWidth; x++) + { + int srcX = Math.Min (x * srcWidth / newWidth, srcWidth - 1); + destination [x, y] = source [srcX, srcY]; + } + } + } + + /// + protected override void Dispose (bool disposing) + { + if (disposing && _sixelToRender is { }) + { + // Clear the sixel data so it's not rendered anymore. + // The ConcurrentQueue doesn't support removal, but clearing the data + // ensures OutputBase.Write skips it. + _sixelToRender.SixelData = null; + _sixelToRender.IsDirty = false; + } + + base.Dispose (disposing); + } + + /// + bool IDesignable.EnableForDesign () + { + // Create a simple gradient test image for the designer + int width = 20; + int height = 10; + Color [,] testImage = new Color [width, height]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + byte r = (byte)(x * 255 / Math.Max (1, width - 1)); + byte g = (byte)(y * 255 / Math.Max (1, height - 1)); + byte b = (byte)(128); + testImage [x, y] = new Color (r, g, b); + } + } + + Image = testImage; + + return true; + } +} \ No newline at end of file diff --git a/Terminal.Gui/Views/Markdown/InlineRun.cs b/Terminal.Gui/Views/Markdown/InlineRun.cs index ab81437716..ff3dca22ac 100644 --- a/Terminal.Gui/Views/Markdown/InlineRun.cs +++ b/Terminal.Gui/Views/Markdown/InlineRun.cs @@ -1,10 +1,17 @@ namespace Terminal.Gui.Views; -internal sealed class InlineRun (string text, MarkdownStyleRole styleRole, string? url = null, string? imageSource = null, Attribute? attribute = null) +internal sealed class InlineRun ( + string text, + MarkdownStyleRole styleRole, + string? url = null, + string? imageSource = null, + Attribute? attribute = null, + VisualRole? role = null) { public string Text { get; } = text; public MarkdownStyleRole StyleRole { get; } = styleRole; public string? Url { get; } = url; public string? ImageSource { get; } = imageSource; public Attribute? Attribute { get; } = attribute; + public VisualRole? Role { get; } = role; } diff --git a/Terminal.Gui/Views/Markdown/Markdown.cs b/Terminal.Gui/Views/Markdown/Markdown.cs index a2abbbfe17..a0cac24c2e 100644 --- a/Terminal.Gui/Views/Markdown/Markdown.cs +++ b/Terminal.Gui/Views/Markdown/Markdown.cs @@ -36,7 +36,7 @@ namespace Terminal.Gui.Views; /// /// /// Shift+F10 / Right-click -/// Opens a context menu with Select All and Copy items. +/// Opens a context menu with Select All and Copy items. Right-clicking on a hyperlink also adds a Copy Link item that copies the URL to the clipboard. /// /// /// Default mouse bindings: @@ -63,7 +63,8 @@ public partial class Markdown : View, IDesignable private readonly List _blocks = []; private readonly List _renderedLines = []; private readonly List _linkRegions = []; - private readonly HashSet _queuedSixelIds = []; + private readonly Dictionary _sixelRenderMap = []; + private readonly HashSet _visibleSixelIds = []; private readonly Dictionary _headingAnchors = new (StringComparer.OrdinalIgnoreCase); private readonly List _codeBlockViews = []; private readonly List _tableViews = []; @@ -74,6 +75,7 @@ public partial class Markdown : View, IDesignable private int _layoutWidth = -1; private int _maxLineWidth; private int _activeLinkIndex = -1; + private string? _contextMenuLinkUrl; private bool _inLayout; private bool _scrollToTopPending; private int _externalContentWidth; @@ -351,6 +353,9 @@ private void InvalidateParsedAndLayout () _blocks.Clear (); _renderedLines.Clear (); _linkRegions.Clear (); + foreach (var render in _sixelRenderMap.Values) { render.SixelData = null; } + _sixelRenderMap.Clear (); + _visibleSixelIds.Clear (); _activeLinkIndex = -1; _headingAnchors.Clear (); RemoveCodeBlockViews (); @@ -581,4 +586,4 @@ And this text is after. Thematic breaks are rendered as full-width horizontal li That's all folks! 👋 """; -} +} \ No newline at end of file diff --git a/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs b/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs index 77bb700e76..1ee078382f 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs @@ -124,7 +124,7 @@ public IReadOnlyList CodeLines else { List> segments = []; - segments.AddRange (value.Select (line => (IReadOnlyList)[new StyledSegment (line, MarkdownStyleRole.CodeBlock)])); + segments.AddRange (value.Select (line => (IReadOnlyList)[new StyledSegment (line, MarkdownStyleRole.CodeBlock, role: VisualRole.Code)])); _lines = segments; } diff --git a/Terminal.Gui/Views/Markdown/MarkdownTable.cs b/Terminal.Gui/Views/Markdown/MarkdownTable.cs index b92c46f26e..a503326905 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownTable.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownTable.cs @@ -747,7 +747,7 @@ internal static List> WrapSegments (List segm hardChunk = firstGrapheme; } - currentLine.Add (new StyledSegment (hardChunk, segment.StyleRole, segment.Url, segment.ImageSource)); + currentLine.Add (new StyledSegment (hardChunk, segment.StyleRole, segment.Url, segment.ImageSource, segment.Attribute, segment.Role)); lines.Add (currentLine); currentLine = []; currentWidth = 0; @@ -758,7 +758,7 @@ internal static List> WrapSegments (List segm continue; } - currentLine.Add (new StyledSegment (chunk, segment.StyleRole, segment.Url, segment.ImageSource)); + currentLine.Add (new StyledSegment (chunk, segment.StyleRole, segment.Url, segment.ImageSource, segment.Attribute, segment.Role)); currentWidth += chunkWidth; } } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs index b9c4c46f0d..26b25d38ce 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs @@ -1,3 +1,4 @@ +using System.Linq; namespace Terminal.Gui.Views; public partial class Markdown @@ -19,6 +20,8 @@ protected override bool OnDrawingSubViews (DrawContext? context) SetAttribute (fillAttr); FillRect (Viewport with { X = 0, Y = 0 }, (Rune)' '); + _visibleSixelIds.Clear (); + int startRow = Viewport.Y; int endRow = Math.Min (Viewport.Y + Viewport.Height, _renderedLines.Count); @@ -28,6 +31,15 @@ protected override bool OnDrawingSubViews (DrawContext? context) DrawRenderedLine (_renderedLines [contentRow], contentRow, drawRow); } + // Cleanup sixels that are no longer visible + var toRemove = _sixelRenderMap.Keys.Where (id => !_visibleSixelIds.Contains (id)).ToList (); + foreach (var id in toRemove) + { + _sixelRenderMap [id].SixelData = null; + _sixelRenderMap [id].IsDirty = false; + _sixelRenderMap.Remove (id); + } + // Return false so SubViews (copy buttons) still draw on top return false; } @@ -100,7 +112,25 @@ private void DrawSelectionOverlayOnSubViewRows () Rectangle viewportScreen = ViewportToScreen (new Rectangle (Point.Empty, Viewport.Size)); SetClip (new Region (viewportScreen)); - SetAttribute (selAttr); + // Popovers draw before the MarkdownView in the application draw loop, so their menu + // items are already written to the screen buffer when we run. The SetClip call above + // resets the clip to allow drawing over SubView areas, but it also undoes the clip + // exclusion that the popover's DoDrawComplete registered for its drawn cells. Without + // a guard, we would overwrite those cells with stale ScreenContents graphemes, erasing + // the popover. (Paragraph-text selection is drawn in DrawRenderedLine / OnDrawingSubViews + // before the clip reset, so it naturally inherits the popover's exclusion and is safe.) + // Compute the popover's content rect (screen-relative) and skip any cells inside it. + Rectangle? popoverScreenRect = null; + + if (App?.Popovers?.GetActivePopover () is View { Visible: true } popoverView) + { + View? popoverContent = popoverView.SubViews.FirstOrDefault (v => v.Visible); + + if (popoverContent is { }) + { + popoverScreenRect = popoverContent.Frame; + } + } for (int lineIdx = startRow; lineIdx <= Math.Min (endRow, _renderedLines.Count - 1); lineIdx++) { @@ -112,7 +142,7 @@ private void DrawSelectionOverlayOnSubViewRows () } int drawRow = lineIdx - Viewport.Y; - Point screenOrigin = ContentToScreen (new Point (0, drawRow)); + Point screenOrigin = ContentToScreen (new Point (0, lineIdx)); int screenRow = screenOrigin.Y; int screenStartCol = screenOrigin.X; int cols = Viewport.Width; @@ -126,13 +156,22 @@ private void DrawSelectionOverlayOnSubViewRows () continue; } - string grapheme = contents [screenRow, sc].Grapheme; + if (popoverScreenRect is { } psr && psr.Contains (new Point (sc, screenRow))) + { + continue; + } + + int contentX = col + Viewport.X; - if (string.IsNullOrEmpty (grapheme)) + if (!IsInSelection (lineIdx, contentX)) { - grapheme = " "; + continue; } + Cell cell = contents [screenRow, sc]; + string grapheme = string.IsNullOrEmpty (cell.Grapheme) ? " " : cell.Grapheme; + + SetAttribute (selAttr); AddStr (col, drawRow, grapheme); } } @@ -261,7 +300,7 @@ private void DrawGrapheme (StyledSegment segment, string grapheme, int x, int y) private Attribute GetAttributeForSegment (StyledSegment segment) => MarkdownAttributeHelper.GetAttributeForSegment (this, segment, SyntaxHighlighter, UseThemeBackground ? SyntaxHighlighter?.DefaultBackground : null); - private void TryQueueSixel (string imageSource, Point screenPosition) + private void TryQueueSixel (string imageSource, Point viewPosition) { if (!EnableSixelImages || Driver is null) { @@ -273,13 +312,29 @@ private void TryQueueSixel (string imageSource, Point screenPosition) return; } - var queueId = $"{imageSource}:{screenPosition.X}:{screenPosition.Y}"; + var queueId = $"{imageSource}:{viewPosition.X}:{viewPosition.Y}"; + + if (!_visibleSixelIds.Add (queueId)) + { + return; + } - if (!_queuedSixelIds.Add (queueId)) + if (_sixelRenderMap.TryGetValue (queueId, out SixelToRender? render)) { + render.IsDirty = true; + return; } - Driver.GetSixels ().Enqueue (new SixelToRender { Id = queueId, ScreenPosition = ContentToScreen (screenPosition), SixelData = sixelData }); + var newRender = new SixelToRender + { + Id = queueId, + ScreenPosition = ContentToScreen (viewPosition), + SixelData = sixelData, + AlwaysRender = false, + IsDirty = true + }; + _sixelRenderMap [queueId] = newRender; + Driver.GetSixels ().Enqueue (newRender); } -} +} \ No newline at end of file diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs index 9ff33f995d..76f0cb79ce 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs @@ -20,7 +20,7 @@ private void BuildRenderedLines () continue; } - int width = CalculateWidth (block.Runs.Select (r => new StyledSegment (r.Text, r.StyleRole, r.Url, r.ImageSource, r.Attribute)).ToList ()); + int width = CalculateWidth (block.Runs.Select (r => new StyledSegment (r.Text, r.StyleRole, r.Url, r.ImageSource, r.Attribute, r.Role)).ToList ()); if (!string.IsNullOrEmpty (block.Prefix)) { @@ -226,7 +226,7 @@ private static RenderedLine CreateUnwrappedLine (IntermediateBlock block) segments.Add (new StyledSegment (block.Prefix, MarkdownStyleRole.ListMarker)); } - segments.AddRange (block.Runs.Select (run => new StyledSegment (run.Text, run.StyleRole, run.Url, run.ImageSource, run.Attribute))); + segments.AddRange (block.Runs.Select (run => new StyledSegment (run.Text, run.StyleRole, run.Url, run.ImageSource, run.Attribute, run.Role))); int width = CalculateWidth (segments); @@ -328,7 +328,7 @@ private static List WrapBlock (IntermediateBlock block, int viewpo } } - currentSegments.Add (new StyledSegment (grapheme, run.StyleRole, run.Url, run.ImageSource)); + currentSegments.Add (new StyledSegment (grapheme, run.StyleRole, run.Url, run.ImageSource, run.Attribute, run.Role)); currentWidth += graphemeWidth; } } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index 62e68ba58f..0fc9a9fdd4 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -59,7 +59,10 @@ private void SetupBindingsAndCommands () // The base class binds LeftButtonReleased → Activate; remove that so Activate // fires only on LeftButtonClicked (not twice per click which would clear selection). + // Also remove the base class Ctrl+LeftButtonReleased → Context binding so that + // Ctrl+Click can follow links without triggering the context menu popover. MouseBindings.Remove (MouseFlags.LeftButtonReleased); + MouseBindings.Remove (MouseFlags.LeftButtonReleased | MouseFlags.Ctrl); MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); // Right-click is handled directly in OnMouseEvent so that the view can be focused @@ -275,6 +278,28 @@ protected override void OnActivated (ICommandContext? ctx) } } + /// + /// Returns the URL of the link region at content coordinates (, + /// ), or if no link covers that position. + /// + private string? FindLinkUrlAt (int contentX, int contentY) + { + foreach (MarkdownLinkRegion region in _linkRegions) + { + if (region.Line != contentY) + { + continue; + } + + if (contentX >= region.StartX && contentX < region.EndXExclusive) + { + return region.Url; + } + } + + return null; + } + /// /// Builds the deduplicated list of link regions by scanning rendered lines. /// Called at the end of . diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs index 19151b1149..812271589b 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs @@ -535,7 +535,7 @@ private void AddCodeBlockLines (IReadOnlyList codeLines, string? languag { if (codeLines.Count == 0) { - _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.CodeBlock)], false, isCodeBlock: true, language: language)); + _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.CodeBlock, role: VisualRole.Code)], false, isCodeBlock: true, language: language)); return; } @@ -548,7 +548,7 @@ private void AddCodeBlockLines (IReadOnlyList codeLines, string? languag if (SyntaxHighlighter is null) { - runs = [new InlineRun (line, MarkdownStyleRole.CodeBlock)]; + runs = [new InlineRun (line, MarkdownStyleRole.CodeBlock, role: VisualRole.Code)]; } else { @@ -556,10 +556,11 @@ private void AddCodeBlockLines (IReadOnlyList codeLines, string? languag List converted = []; converted.AddRange (highlighted.Select (segment => new InlineRun (segment.Text, - segment.StyleRole, - segment.Url, - segment.ImageSource, - segment.Attribute))); + segment.StyleRole, + segment.Url, + segment.ImageSource, + segment.Attribute, + segment.Role))); runs = converted; } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index 12b89a31f9..d2320447bf 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -131,9 +131,22 @@ private string GetSelectedText () (Point start, Point end) = GetNormalizedSelection (); List outputLines = []; var inCodeBlock = false; - string? currentCodeLanguage = null; + // Fences are injected only when the selection crosses a code-block boundary: + // • Opening fence: emitted when entering a code block after selected non-code + // content, and also when transitioning directly to an adjacent selected code + // block whose opening fence has not yet been written. + // • Closing fence: always when the selection crosses out of the code block into + // non-code content — regardless of whether an opening fence was emitted. + // • No trailing fence: when the selection ends inside a code block, no closing + // fence is added; the selection ends mid-block. + // This produces no fences for a selection entirely within a code block, matching + // the behaviour of the copy-button on MarkdownCodeBlock. codeOpenFenceEmitted + // tracks whether the current selected code block already has its opening fence. + var selectionHasNonCodeContent = false; + var codeOpenFenceEmitted = false; + // Track the last table instance that was output. All placeholder rows for the // same table share the same TableData reference, so we use ReferenceEquals to // emit the reconstructed table markdown exactly once even when the selection @@ -150,26 +163,50 @@ private string GetSelectedText () if (!inCodeBlock) { - // Entering a code block: inject the opening fence with optional language tag - outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); inCodeBlock = true; currentCodeLanguage = nextCodeLanguage; + + // Only inject the opening fence when non-code content has already been + // output — that is, the selection crosses from outside into this code block. + if (selectionHasNonCodeContent) + { + outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); + codeOpenFenceEmitted = true; + } + else + { + codeOpenFenceEmitted = false; + } } else if (!string.Equals (currentCodeLanguage, nextCodeLanguage, StringComparison.Ordinal)) { - // Transitioning directly between two code blocks: close the current fence - // and open the next one so adjacent fenced blocks are preserved. - outputLines.Add ("```"); + // Transitioning directly between two adjacent code blocks of different + // languages: close the current fence (if opened) and open the next one. + if (codeOpenFenceEmitted) + { + outputLines.Add ("```"); + } + outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); + codeOpenFenceEmitted = true; currentCodeLanguage = nextCodeLanguage; } } else if (inCodeBlock) { - // Leaving a code block: inject the closing fence + // Leaving a code block into non-code content: always inject the closing fence. + // The selection crosses the block's end boundary regardless of whether the + // opening fence was emitted (e.g., when the selection started inside the block). outputLines.Add ("```"); + inCodeBlock = false; + codeOpenFenceEmitted = false; currentCodeLanguage = null; + selectionHasNonCodeContent = true; + } + else + { + selectionHasNonCodeContent = true; } if (line is { IsTable: true, TableData: { } tableData }) @@ -197,11 +234,6 @@ private string GetSelectedText () outputLines.Add (lineSb.ToString ()); } - if (inCodeBlock) - { - outputLines.Add ("```"); - } - return string.Join ("\n", outputLines); } @@ -326,7 +358,15 @@ private void CreateContextMenu () { DisposeContextMenu (); - PopoverMenu menu = new ([new MenuItem (this, Command.SelectAll), new MenuItem (this, Command.Copy)]) + List menuItems = [new MenuItem (this, Command.SelectAll), new MenuItem (this, Command.Copy)]; + + if (_contextMenuLinkUrl is { } url) + { + menuItems.Insert (0, null); + menuItems.Insert (0, new MenuItem ("Copy _Link", action: () => App?.Clipboard?.TrySetClipboardData (url))); + } + + PopoverMenu menu = new (menuItems) { #if DEBUG Id = "markdownContextMenu" @@ -366,6 +406,15 @@ private Point GetContextMenuScreenPosition () private bool ShowContextMenu (Point? screenPosition = null) { + if (screenPosition is { } pos) + { + Point viewportPos = ScreenToViewport (pos); + int contentX = Viewport.X + viewportPos.X; + int contentY = Viewport.Y + viewportPos.Y; + _contextMenuLinkUrl = FindLinkUrlAt (contentX, contentY); + CreateContextMenu (); + } + Point menuPosition = screenPosition ?? GetContextMenuScreenPosition (); ContextMenu?.MakeVisible (menuPosition); diff --git a/Terminal.Gui/Views/TextInput/ContentsChangedEventArgs.cs b/Terminal.Gui/Views/TextInput/ContentsChangedEventArgs.cs index 72c125f032..b75392dbec 100644 --- a/Terminal.Gui/Views/TextInput/ContentsChangedEventArgs.cs +++ b/Terminal.Gui/Views/TextInput/ContentsChangedEventArgs.cs @@ -4,6 +4,7 @@ namespace Terminal.Gui.Views; /// Event arguments for events for when the contents of the TextView change. E.g. the /// event. /// +[Obsolete ("ContentsChangedEventArgs is obsolete because TextView is superseded by gui-cs/Editor. See https://github.com/gui-cs/Editor.", error: false)] public class ContentsChangedEventArgs : EventArgs { /// Creates a new instance. diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.cs index b61e79ad8b..0bc370c5b4 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.cs @@ -95,6 +95,9 @@ namespace Terminal.Gui.Views; /// /// /// +[Obsolete ("TextView is superseded by gui-cs/Editor's EditorView, which provides a rope-backed document model, " + + "cell-aware rendering, multi-caret editing, undo, syntax highlighting, folding, find/replace, and soft wrap. " + + "See https://github.com/gui-cs/Editor for details.", error: false)] public partial class TextView : View, IDesignable { /// diff --git a/Terminal.Gui/Views/TextInput/TextView/TextViewAutocomplete.cs b/Terminal.Gui/Views/TextInput/TextView/TextViewAutocomplete.cs index a48755c7a0..9a5cecde1d 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextViewAutocomplete.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextViewAutocomplete.cs @@ -4,6 +4,7 @@ namespace Terminal.Gui.Views; /// Renders an overlay on another view at a given point that allows selecting from a range of 'autocomplete' /// options. An implementation on a TextView. /// +[Obsolete ("TextViewAutocomplete is obsolete because TextView is superseded by gui-cs/Editor. See https://github.com/gui-cs/Editor.", error: false)] public class TextViewAutocomplete : PopupAutocomplete { /// diff --git a/Terminal.sln b/Terminal.sln index 4c9bc79d78..de4c68a853 100644 --- a/Terminal.sln +++ b/Terminal.sln @@ -156,6 +156,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InlineSelect", "Examples\In EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers.Internal", "Terminal.Gui.Analyzers.Internal\Terminal.Gui.Analyzers.Internal.csproj", "{927CCC07-F00C-409C-BE42-458EB03DD4E8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceTests", "Tests\PerformanceTests\PerformanceTests.csproj", "{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -454,6 +456,18 @@ Global {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x64.Build.0 = Release|Any CPU {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.ActiveCfg = Release|Any CPU {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.Build.0 = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x64.ActiveCfg = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x64.Build.0 = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x86.ActiveCfg = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x86.Build.0 = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|Any CPU.Build.0 = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.ActiveCfg = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.Build.0 = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.ActiveCfg = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -484,6 +498,7 @@ Global {90A42AE4-301D-4B05-8892-60BE5209C1B5} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} {70802F77-F259-44C6-9522-46FCE2FD754E} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} {3116547F-A8F2-4189-BC22-0B47C757164C} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2} = {A589126F-C71A-4FEE-B7EA-2DCA1ADF6A46} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F8F8A4D-7B8D-4C2A-AC5E-CD7117F74C03} diff --git a/Tests/AppTestHelpers/AppTestHelper.cs b/Tests/AppTestHelpers/AppTestHelper.cs index 2056ca44df..d68aaf8268 100644 --- a/Tests/AppTestHelpers/AppTestHelper.cs +++ b/Tests/AppTestHelpers/AppTestHelper.cs @@ -1,5 +1,6 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Drawing; +using System.Runtime.ExceptionServices; using System.Text; using Microsoft.Extensions.Logging; using Terminal.Gui.Time; @@ -108,7 +109,8 @@ public AppTestHelper (string driverName, TextWriter? logWriter = null, TimeSpan? } /// - /// Constructor for tests that need to run the application with Application.Run. + /// Constructor for tests that need to run the application with IApplication.RunAsync. + /// The runnable observes the helper's linked cancellation token, including timeout cancellation. /// internal AppTestHelper (Func runnableBuilder, int width, int height, string driverName, TextWriter? logWriter = null, TimeSpan? timeout = null) { @@ -120,7 +122,7 @@ internal AppTestHelper (Func runnableBuilder, int width, int height, CommonInit (width, height, timeout); // Start the application in a background thread - _runTask = Task.Run (() => + _runTask = Task.Run (async () => { _loggerScope = Logging.PushLogger (_testLogger!); @@ -140,27 +142,36 @@ internal AppTestHelper (Func runnableBuilder, int width, int height, _booting.Release (); } - if (App is { Initialized: true }) + if (App is { Initialized: true } app) { - IRunnable runnable = runnableBuilder (); + IRunnable? runnable = null; - runnable.IsRunningChanged += (s, e) => - { - if (!e.Value) - { - Finished = true; - } - }; - App?.Run (runnable); // This will block, but it's on a background thread now + try + { + runnable = runnableBuilder (); - if (runnable is View runnableView) + runnable.IsRunningChanged += (_, e) => + { + if (!e.Value) + { + Finished = true; + } + }; + + CancellationToken helperCancellationToken = _ansiInput.ExternalCancellationTokenSource!.Token; + await app.RunAsync (runnable, helperCancellationToken); + } + finally { - runnableView.Dispose (); + if (runnable is View runnableView) + { + runnableView.Dispose (); + } + + //Logging.Trace ("Application.Run completed"); + app.Dispose (); + _runCancellationTokenSource.Cancel (); } - - //Logging.Trace ("Application.Run completed"); - App?.Dispose (); - _runCancellationTokenSource.Cancel (); } } catch (OperationCanceledException) @@ -321,29 +332,42 @@ public AppTestHelper WaitIteration (Action? action = null) { action = app => { }; } + CancellationTokenSource ctsActionCompleted = new (); + Exception? actionException = null; App?.Invoke (app => - { - try - { - action (app); - - //Logging.Trace ("Action completed"); - ctsActionCompleted.Cancel (); - } - catch (Exception e) - { - Logging.Warning ($"Action failed with exception: {e}"); - _backgroundException = e; - _ansiInput.ExternalCancellationTokenSource?.Cancel (); - } - }); - - // Blocks until either the token or the hardStopToken is cancelled. - // With linked tokens, we only need to wait on _runCancellationTokenSource and ctsLocal - // ExternalCancellationTokenSource is redundant because it's linked to _runCancellationTokenSource - WaitHandle.WaitAny ([_runCancellationTokenSource.Token.WaitHandle, ctsActionCompleted.Token.WaitHandle]); + { + try + { + action (app); + + //Logging.Trace ("Action completed"); + ctsActionCompleted.Cancel (); + } + catch (Exception e) + { + Logging.Warning ($"Action failed with exception: {e}"); + _backgroundException = e; + actionException = e; + _ansiInput.ExternalCancellationTokenSource?.Cancel (); + } + }); + + // Blocks until either the action completes, the run stops, or the timeout/hard-stop token is cancelled. + WaitHandle [] waitHandles = + [ + _runCancellationTokenSource.Token.WaitHandle, + ctsActionCompleted.Token.WaitHandle, + _ansiInput.ExternalCancellationTokenSource!.Token.WaitHandle + ]; + + WaitHandle.WaitAny (waitHandles); + + if (actionException is { }) + { + ExceptionDispatchInfo.Capture (actionException).Throw (); + } // Logging.Trace ($"Return from WaitIteration"); return this; @@ -415,6 +439,17 @@ public AppTestHelper AnsiScreenShot (string title, TextWriter? writer) => writer?.WriteLine (text); }); + /// + /// Cancels the linked token observed by the running application. + /// + /// + public AppTestHelper CancelRun () + { + _ansiInput.ExternalCancellationTokenSource?.Cancel (); + + return this; + } + /// /// Stops the application and waits for the background thread to exit. /// diff --git a/Tests/AppTestHelpers/With.cs b/Tests/AppTestHelpers/With.cs index acfeac4274..73904f89e3 100644 --- a/Tests/AppTestHelpers/With.cs +++ b/Tests/AppTestHelpers/With.cs @@ -13,14 +13,26 @@ public static class With /// /// /// + /// /// - public static AppTestHelper A (int width, int height, string driverName, TextWriter? logWriter = null) where T : IRunnable, new() + public static AppTestHelper A ( + int width, + int height, + string driverName, + TextWriter? logWriter = null, + TimeSpan? timeout = null + ) where T : IRunnable, new () { - return new (() => new T () - { - //Id = $"{typeof (T).Name}" - }, width, height, - driverName, logWriter, Timeout); + return new ( + () => new T () + { + //Id = $"{typeof (T).Name}" + }, + width, + height, + driverName, + logWriter, + timeout ?? Timeout); } /// @@ -31,11 +43,20 @@ public static class With /// /// /// + /// /// - public static AppTestHelper A (Func runnableFactory, int width, int height, string driverName, TextWriter? logWriter = null) + public static AppTestHelper A ( + Func runnableFactory, + int width, + int height, + string driverName, + TextWriter? logWriter = null, + TimeSpan? timeout = null + ) { - return new (runnableFactory, width, height, driverName, logWriter, Timeout); + return new (runnableFactory, width, height, driverName, logWriter, timeout ?? Timeout); } + /// /// The global timeout to allow for any given application to run for before shutting down. /// diff --git a/Tests/Benchmarks/Configuration/ConfigurationManagerLoadBenchmark.cs b/Tests/Benchmarks/Configuration/ConfigurationManagerLoadBenchmark.cs new file mode 100644 index 0000000000..c64f9a7d25 --- /dev/null +++ b/Tests/Benchmarks/Configuration/ConfigurationManagerLoadBenchmark.cs @@ -0,0 +1,41 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Configuration; + +namespace Terminal.Gui.Benchmarks.Configuration; + +/// +/// Measures the cold-start cost of loading the embedded library configuration: +/// ConfigurationManager.Disable (true)Enable (ConfigLocations.LibraryResources)Apply (). +/// This is the app-startup hot path. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*ConfigurationManagerLoad*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Configuration")] +public class ConfigurationManagerLoadBenchmark +{ + /// + /// Loads the embedded library configuration from scratch and applies it. + /// Captures the full deserialize + merge + apply path. + /// Calls first so every invocation + /// is a true cold start ( short-circuits when already enabled). + /// + [Benchmark] + public void LoadAndApply () + { + ConfigurationManager.Disable (true); + ConfigurationManager.Enable (ConfigLocations.LibraryResources); + ConfigurationManager.Apply (); + } + + /// Ensures ConfigurationManager is disabled after all iterations. + [GlobalCleanup] + public void Cleanup () + { + ConfigurationManager.Disable (true); + } +} diff --git a/Tests/Benchmarks/Configuration/SchemeAttributeBenchmark.cs b/Tests/Benchmarks/Configuration/SchemeAttributeBenchmark.cs new file mode 100644 index 0000000000..6e97a10e7f --- /dev/null +++ b/Tests/Benchmarks/Configuration/SchemeAttributeBenchmark.cs @@ -0,0 +1,47 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Drawing; +using TgAttribute = Terminal.Gui.Drawing.Attribute; + +namespace Terminal.Gui.Benchmarks.Configuration; + +/// +/// Measures for roles at different depths of the derivation chain: +/// +/// — explicitly set (O(1) lookup) +/// — derived from +/// — deepest derivation (Code → Editable → Normal) +/// +/// No required; operates on a standalone +/// instance. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*SchemeAttribute*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Configuration", "Scheme")] +public class SchemeAttributeBenchmark +{ + private Scheme _scheme = null!; + + /// Creates a scheme with only explicitly set. + [GlobalSetup] + public void Setup () + { + _scheme = new Scheme { Normal = new TgAttribute (Color.White, Color.Black) }; + } + + /// Lookup for an explicitly-set role — the fastest path. + [Benchmark (Baseline = true)] + public TgAttribute GetNormal () => _scheme.GetAttributeForRole (VisualRole.Normal); + + /// Lookup for a role derived from Focus (which itself is derived from Normal). + [Benchmark] + public TgAttribute GetHotFocus () => _scheme.GetAttributeForRole (VisualRole.HotFocus); + + /// Lookup for the deepest derivation path: Code → Editable → Normal. + [Benchmark] + public TgAttribute GetCode () => _scheme.GetAttributeForRole (VisualRole.Code); +} diff --git a/Tests/Benchmarks/Configuration/SchemeSerializationBenchmark.cs b/Tests/Benchmarks/Configuration/SchemeSerializationBenchmark.cs new file mode 100644 index 0000000000..4d1b419c46 --- /dev/null +++ b/Tests/Benchmarks/Configuration/SchemeSerializationBenchmark.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Configuration; +using Terminal.Gui.Drawing; +using TgAttribute = Terminal.Gui.Drawing.Attribute; + +namespace Terminal.Gui.Benchmarks.Configuration; + +/// +/// Measures serialize-then-deserialize of a representative Base via +/// and . Catches regressions in the JSON +/// code paths when future PRs add fields to . +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*SchemeSerialization*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Configuration", "Scheme")] +public class SchemeSerializationBenchmark +{ + private Scheme _scheme = null!; + private string _json = null!; + private JsonSerializerOptions _options = null!; + + /// + /// Creates a representative Base scheme with only explicitly set + /// and prepares serialization options with the . + /// + [GlobalSetup] + public void Setup () + { + _scheme = new Scheme { Normal = new TgAttribute (Color.White, Color.Black) }; + + _options = new JsonSerializerOptions + { + Converters = { new SchemeJsonConverter () }, + PropertyNameCaseInsensitive = true + }; + + // Pre-serialize to have a stable JSON string for deserialization benchmarks. + _json = JsonSerializer.Serialize (_scheme, _options); + } + + /// Serializes a to JSON. + [Benchmark] + public string Serialize () => JsonSerializer.Serialize (_scheme, _options); + + /// Deserializes a from JSON. + [Benchmark] + public Scheme? Deserialize () => JsonSerializer.Deserialize (_json, _options); + + /// Full round-trip: serialize then immediately deserialize. + [Benchmark (Baseline = true)] + public Scheme? RoundTrip () + { + string json = JsonSerializer.Serialize (_scheme, _options); + + return JsonSerializer.Deserialize (json, _options); + } +} diff --git a/Tests/Benchmarks/Configuration/ThemeSwitchBenchmark.cs b/Tests/Benchmarks/Configuration/ThemeSwitchBenchmark.cs new file mode 100644 index 0000000000..ff3ee631b9 --- /dev/null +++ b/Tests/Benchmarks/Configuration/ThemeSwitchBenchmark.cs @@ -0,0 +1,71 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Configuration; + +namespace Terminal.Gui.Benchmarks.Configuration; + +/// +/// Measures the cost of switching the active theme via +/// ThemeManager.Theme = "X"; ConfigurationManager.Apply (). +/// Parametric over every built-in theme name shipped in the embedded config.json. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*ThemeSwitch*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Configuration", "Theme")] +public class ThemeSwitchBenchmark +{ + /// The built-in theme to switch to during each benchmark invocation. + [ParamsSource (nameof (ThemeNames))] + public string ThemeName { get; set; } = ThemeManager.DEFAULT_THEME_NAME; + + /// Returns the set of built-in theme names available after loading library resources. + public static IEnumerable ThemeNames + { + get + { + ConfigurationManager.Disable (true); + ConfigurationManager.Enable (ConfigLocations.LibraryResources); + + IEnumerable names = ThemeManager.GetThemeNames (); + + ConfigurationManager.Disable (true); + + return names; + } + } + + /// Loads the embedded configuration so all built-in themes are available. + [GlobalSetup] + public void Setup () + { + ConfigurationManager.Disable (true); + ConfigurationManager.Enable (ConfigLocations.LibraryResources); + } + + /// + /// Switches the active theme and applies the change. + /// This is the user-facing hot path when cycling themes via a . + /// Resets to before each switch so every + /// invocation performs a real theme change (not a redundant reapply). + /// + [Benchmark] + public void SwitchTheme () + { + ThemeManager.Theme = ThemeManager.DEFAULT_THEME_NAME; + ConfigurationManager.Apply (); + + ThemeManager.Theme = ThemeName; + ConfigurationManager.Apply (); + } + + /// Ensures ConfigurationManager is disabled after all iterations. + [GlobalCleanup] + public void Cleanup () + { + ConfigurationManager.Disable (true); + } +} diff --git a/Tests/Benchmarks/README.md b/Tests/Benchmarks/README.md index 334ec2582b..812b6cdedf 100644 --- a/Tests/Benchmarks/README.md +++ b/Tests/Benchmarks/README.md @@ -47,6 +47,9 @@ dotnet run -c Release # Run only DimAuto benchmarks dotnet run -c Release -- --filter '*DimAuto*' +# Run only Scrolling benchmarks +dotnet run -c Release -- --filter '*Scroll*' + # Run only TextFormatter benchmarks dotnet run -c Release -- --filter '*TextFormatter*' ``` @@ -56,6 +59,9 @@ dotnet run -c Release -- --filter '*TextFormatter*' ```bash # Run only the ComplexLayout benchmark dotnet run -c Release -- --filter '*DimAutoBenchmark.ComplexLayout*' + +# Run only TextView scrolling benchmarks +dotnet run -c Release -- --filter '*TextViewScroll*' ``` ### Quick Run (Shorter but Less Accurate) @@ -63,7 +69,7 @@ dotnet run -c Release -- --filter '*DimAutoBenchmark.ComplexLayout*' For faster iteration during development: ```bash -dotnet run -c Release -- --filter '*DimAuto*' -j short +dotnet run -c Release -- --filter '*Scroll*' -j short ``` ### List Available Benchmarks @@ -80,12 +86,83 @@ The `DimAutoBenchmark` class tests layout performance with `Dim.Auto()` in vario - **ComplexLayout**: 20 subviews with mixed Pos/Dim types (tests iteration overhead) - **DeeplyNestedLayout**: 5 levels of nested views with DimAuto (tests recursive performance) +## Scrolling Benchmarks + +The `Scrolling/` directory contains end-to-end scrolling benchmarks that cover the full input → layout → draw pipeline. + +### BaselineScrollBenchmark + +Minimal `View` subclass with a large `ContentSize` and no rendering logic. Isolates framework scrolling overhead from any view-specific work. + +- **ViewportScroll_Down / Up**: Direct viewport manipulation (no key injection). Measures pure framework overhead. +- **ViewportScroll_PageDown**: Viewport-sized jump. +- Parameterized by `ContentHeight` = [1 000, 10 000] + +### TextViewScrollBenchmark + +`TextView` with read-only content of 1 000 / 5 000 lines of ~80-char text. + +- **ScrollDown_OneStep / ScrollUp_OneStep**: Single `Key.CursorDown` / `Key.CursorUp` injection. With the caret at the viewport boundary, every keystroke triggers a viewport scroll. +- **PageDown_OneStep**: Single `Key.PageDown` injection. +- Parameterized by `Lines` = [1 000, 5 000] + +### ListViewScrollBenchmark + +`ListView` with 1 000 / 10 000 string items. + +- **ScrollDown_OneStep / ScrollUp_OneStep / PageDown_OneStep** +- Parameterized by `Items` = [1 000, 10 000] + +### TableViewScrollBenchmark + +`TableView` with 100 / 1 000 rows × 10 columns. + +- **ScrollDown_OneStep / ScrollUp_OneStep / PageDown_OneStep / ScrollRight_OneStep** +- Parameterized by `Rows` = [100, 1 000] + +### Run all scrolling benchmarks + +```bash +dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*' +``` + +## Configuration Benchmarks + +The `Configuration/` directory contains benchmarks for the configuration, theming, and scheme subsystems. + +### ConfigurationManagerLoadBenchmark + +Measures the cold-start cost of `ConfigurationManager.Disable(true)` → `Enable(ConfigLocations.LibraryResources)` → `Apply()`. This is the app-startup hot path covering embedded-config load, deserialization, and apply. + +### ThemeSwitchBenchmark + +Measures `ThemeManager.Theme = "X"; ConfigurationManager.Apply()` against the embedded configuration. Parametric over all built-in theme names (`Default`, `Dark`, `Light`, `TurboPascal 5`, `Anders`, `Green Phosphor`, `Amber Phosphor`). + +### SchemeAttributeBenchmark + +Measures `Scheme.GetAttributeForRole(VisualRole)` for roles at different depths of the derivation chain: +- **GetNormal**: Explicitly-set role — the fastest path +- **GetHotFocus**: Derived from `Focus` (which itself derives from `Normal`) +- **GetCode**: Deepest derivation path (`Code` → `Editable` → `Normal`) + +No `ConfigurationManager` required; operates on a standalone `Scheme` instance. + +### SchemeSerializationBenchmark + +Measures serialize/deserialize of a representative `Base` `Scheme` via `JsonSerializer` + `SchemeJsonConverter`. Catches regressions in JSON code paths when future PRs add fields to `Scheme`. + +### Run all configuration benchmarks + +```bash +dotnet run --project Tests/Benchmarks -c Release -- --filter '*Config*' '*Scheme*' '*Theme*' +``` + ## Adding New Benchmarks -1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`) +1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`, `Scrolling/`) 2. For BenchmarkDotNet: add `[MemoryDiagnoser]`, `[BenchmarkCategory]`, `[Benchmark(Baseline = true)]` 3. For memory profilers: add a `public static void Run()` method and route it from `Program.cs` -4. Use `[GlobalSetup]`/`[GlobalCleanup]` for `Application.Init`/`Shutdown` +4. Use `[GlobalSetup]`/`[GlobalCleanup]` for application init/dispose ## Best Practices @@ -96,10 +173,37 @@ The `DimAutoBenchmark` class tests layout performance with `Dim.Auto()` in vario ## Continuous Integration -Benchmarks are not run automatically in CI. Run them locally when: -- Making performance-critical changes -- Implementing performance optimizations -- Before releasing a new version +### Layer 1: Performance Smoke Tests + +Stopwatch-based xUnit tests in `Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs` run on every CI build via the standard unit test workflow. They use generous thresholds (50–100× typical) to catch catastrophic O(n²) regressions without flaking on slow runners. + +Each test: +- Creates a large document (10 000 rows / 100 000 items) +- Measures the cost of a **single viewport draw** after scrolling to the mid-point +- Asserts completion under a generous threshold (e.g., < 200 ms for TableView) + +This detects if a draw function accidentally iterates the entire document instead of just the visible viewport. + +### Layer 2: Baseline Comparison + +The `.github/workflows/perf-gate.yml` workflow runs on every push to `main` / `develop` (not PRs) and: + +1. Runs the `*Scroll*`, `*Config*`, `*Scheme*`, and `*Theme*` benchmarks with `--job short` (~30–60 s total) +2. Compares results to `Tests/Benchmarks/baseline.json` +3. **Fails** if any benchmark exceeds **3×** the baseline +4. **Celebrates** 🎉 if any benchmark drops below **0.8×** the baseline +5. Posts a markdown comparison table to the GitHub step summary + +### Updating the Baseline + +After a deliberate performance change, re-run the focused scrolling benchmarks, then update `baseline.json`: + +```bash +# Run ShortRun and export JSON results +dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*' '*Config*' '*Scheme*' '*Theme*' -j short --exporters json + +# Inspect the JSON output in BenchmarkDotNet.Artifacts/ and update baseline.json +``` ## Resources diff --git a/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs new file mode 100644 index 0000000000..1cfe1cea72 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs @@ -0,0 +1,113 @@ +using System.Drawing; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Scrolling; + +/// +/// Measures framework-level scrolling overhead using a minimal subclass that has a large +/// but performs no custom rendering. Isolates the viewport-math, layout, and +/// draw-dispatch costs from any view-specific rendering work. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*BaselineScroll*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Scrolling", "Baseline")] +public class BaselineScrollBenchmark +{ + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 80; + + private IApplication _app = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private View _view = null!; + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is { }) + { + _app.End (_session); + } + + _app.Dispose (); + } + + /// Total virtual content height of the view (rows). + [Params (1_000, 10_000)] + public int ContentHeight { get; set; } + + /// + /// Positions the viewport at the mid-point of the document so that each benchmark iteration + /// scrolls from a stable, representative location. + /// + [IterationSetup] + public void IterationSetup () + { + _view.Viewport = _view.Viewport with { Y = ContentHeight / 2 }; + _view.SetNeedsDraw (); + } + + /// Creates the application context, view hierarchy, and performs one warm-up draw. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + _view = new View + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + ViewportSettings = ViewportSettingsFlags.HasVerticalScrollBar + }; + _view.SetContentSize (new Size (SCREEN_WIDTH, ContentHeight)); + _runnable.Add (_view); + + // Warm up: prime JIT and layout caches before measurement. + _app.LayoutAndDraw (true); + } + + /// + /// Scrolls the viewport down by one row and redraws. + /// Measures the minimal per-row cost of the layout+draw pipeline with no content rendering. + /// + [Benchmark (Baseline = true)] + public void ViewportScroll_Down () + { + _view.Viewport = _view.Viewport with { Y = _view.Viewport.Y + 1 }; + _app.LayoutAndDraw (); + } + + /// Scrolls the viewport down by one page (ScreenHeight rows) and redraws. + [Benchmark] + public void ViewportScroll_PageDown () + { + int newY = Math.Min (_view.Viewport.Y + SCREEN_HEIGHT, ContentHeight - SCREEN_HEIGHT); + _view.Viewport = _view.Viewport with { Y = newY }; + _app.LayoutAndDraw (); + } + + /// Scrolls the viewport up by one row and redraws. + [Benchmark] + public void ViewportScroll_Up () + { + _view.Viewport = _view.Viewport with { Y = _view.Viewport.Y - 1 }; + _app.LayoutAndDraw (); + } +} diff --git a/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs new file mode 100644 index 0000000000..0c93bc896b --- /dev/null +++ b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs @@ -0,0 +1,128 @@ +using System.Collections.ObjectModel; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Scrolling; + +/// +/// Measures end-to-end scrolling performance for . +/// Covers item rendering with mark glyphs, selection role highlighting, +/// and per-item draw cost at varying collection sizes. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*ListViewScroll*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Scrolling", "ListView")] +public class ListViewScrollBenchmark +{ + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 80; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private ListView _listView = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is { }) + { + _app.End (_session); + } + + _app.Dispose (); + } + + /// Total number of items loaded into the . + [Params (1_000, 10_000)] + public int Items { get; set; } + + /// + /// Positions the selection at the last visible row so the next down-arrow triggers a scroll. + /// + [IterationSetup] + public void IterationSetup () + { + _listView.SelectedItem = SCREEN_HEIGHT - 1; + _listView.SetNeedsDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void PageDown_OneStep () + { + _injector.InjectKey (Key.PageDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// With the selection at the viewport boundary this always scrolls. + /// + [Benchmark (Baseline = true)] + public void ScrollDown_OneStep () + { + _injector.InjectKey (Key.CursorDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void ScrollUp_OneStep () + { + _injector.InjectKey (Key.CursorUp); + _app.LayoutAndDraw (); + } + + /// Creates the application, populates the list, and warms up the view. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + _listView = new ListView { X = 0, Y = 0, Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _listView.SetSource (new ObservableCollection (BuildItems (Items))); + _runnable.Add (_listView); + + // Focus so key bindings resolve. + _listView.SetFocus (); + + // Cache injector to avoid per-call lookup overhead. + _injector = _app.GetInputInjector (); + + // Warm up. + _app.LayoutAndDraw (true); + } + + private static List BuildItems (int count) + { + List items = new (count); + + for (var itemIndex = 0; itemIndex < count; itemIndex++) + { + items.Add ($"Item {itemIndex,6}: data value = {itemIndex * 17 % 100:D3}"); + } + + return items; + } +} diff --git a/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs new file mode 100644 index 0000000000..ce239bff07 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs @@ -0,0 +1,156 @@ +using System.Data; +using System.Drawing; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Scrolling; + +/// +/// Measures end-to-end scrolling performance for . +/// Covers cell rendering, column alignment, and header drawing at varying row counts. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*TableViewScroll*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Scrolling", "TableView")] +public class TableViewScrollBenchmark +{ + private const int COLUMN_COUNT = 10; + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 120; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private TableView _tableView = null!; + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is { }) + { + _app.End (_session); + } + + _app.Dispose (); + } + + /// + /// Positions the selected row at the last visible data row so the next down-arrow scrolls. + /// + [IterationSetup] + public void IterationSetup () + { + // TableView reserves row 0 for the header; data rows start at display row 1. + // Place selection at the last visible data row. + int visibleDataRows = SCREEN_HEIGHT - 1; // subtract header row + _tableView.RowOffset = 0; + _tableView.Value = new TableSelection (new Point (0, Math.Min (visibleDataRows - 1, Rows - 1))); + _tableView.SetNeedsDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void PageDown_OneStep () + { + _injector.InjectKey (Key.PageDown); + _app.LayoutAndDraw (); + } + + /// Total number of data rows loaded into the . + [Params (100, 1_000)] + public int Rows { get; set; } + + /// + /// Injects a single keystroke and redraws. + /// With the selection at the viewport boundary this triggers a row scroll. + /// + [Benchmark (Baseline = true)] + public void ScrollDown_OneStep () + { + _injector.InjectKey (Key.CursorDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// Measures horizontal scrolling / column navigation. + /// + [Benchmark] + public void ScrollRight_OneStep () + { + _injector.InjectKey (Key.CursorRight); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void ScrollUp_OneStep () + { + _injector.InjectKey (Key.CursorUp); + _app.LayoutAndDraw (); + } + + /// Creates the application, builds the data table, and warms up the view. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + DataTable dt = BuildDataTable (Rows, COLUMN_COUNT); + _tableView = new TableView (new DataTableSource (dt)) { X = 0, Y = 0, Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _runnable.Add (_tableView); + + // Focus so key bindings resolve. + _tableView.SetFocus (); + + // Cache injector to avoid per-call lookup overhead. + _injector = _app.GetInputInjector (); + + // Warm up. + _app.LayoutAndDraw (true); + } + + private static DataTable BuildDataTable (int rows, int cols) + { + DataTable dt = new (); + + for (var colIndex = 0; colIndex < cols; colIndex++) + { + dt.Columns.Add ($"Col{colIndex,2}", typeof (string)); + } + + for (var rowIndex = 0; rowIndex < rows; rowIndex++) + { + var row = new object [cols]; + + for (var colIndex = 0; colIndex < cols; colIndex++) + { + row [colIndex] = $"R{rowIndex}C{colIndex}"; + } + + dt.Rows.Add (row); + } + + return dt; + } +} diff --git a/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs new file mode 100644 index 0000000000..a476def1b6 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs @@ -0,0 +1,144 @@ +using System.Drawing; +using System.Text; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Scrolling; + +/// +/// Measures end-to-end scrolling performance for . +/// Covers the most complex rendering path in Terminal.Gui: line tracking, word-wrap decisions, +/// tab expansion, selection, and caret movement. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*TextViewScroll*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Scrolling", "TextView")] +public class TextViewScrollBenchmark +{ + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 80; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private TextView _textView = null!; + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is { }) + { + _app.End (_session); + } + + _app.Dispose (); + } + + /// + /// Resets the caret to the last line in the first visible page so the next + /// call triggers a viewport scroll. + /// + [IterationSetup] + public void IterationSetup () + { + // Place caret at bottom-right of the visible viewport so CursorDown scrolls. + _textView.InsertionPoint = new Point (0, SCREEN_HEIGHT - 1); + _textView.SetNeedsDraw (); + } + + /// Total number of lines loaded into the . + [Params (1_000, 5_000)] + public int Lines { get; set; } + + /// + /// Injects a single keystroke and redraws. + /// Each iteration rebuilds a full page — measures viewport-sized jump cost. + /// + [Benchmark] + public void PageDown_OneStep () + { + _injector.InjectKey (Key.PageDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// With the caret at the bottom edge of the viewport this always triggers a viewport scroll. + /// + [Benchmark (Baseline = true)] + public void ScrollDown_OneStep () + { + _injector.InjectKey (Key.CursorDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// Symmetric reverse-scroll measurement. + /// + [Benchmark] + public void ScrollUp_OneStep () + { + _injector.InjectKey (Key.CursorUp); + _app.LayoutAndDraw (); + } + + /// Creates the application, builds the document, and warms up the view. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + string text = BuildText (Lines); + + _textView = new TextView + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Text = text, + WordWrap = false, + ReadOnly = true + }; + _runnable.Add (_textView); + + // Focus the text view so key bindings resolve to it. + _textView.SetFocus (); + + // Cache injector to avoid per-call lookup overhead. + _injector = _app.GetInputInjector (); + + // Warm up: prime JIT and layout caches. + _app.LayoutAndDraw (true); + } + + private static string BuildText (int lineCount) + { + StringBuilder sb = new (lineCount * 85); + + for (var lineIndex = 0; lineIndex < lineCount; lineIndex++) + { + // ~80-character lines matching the issue specification. + sb.AppendLine ($"Line {lineIndex,6}: The quick brown fox jumps over the lazy dog. Extra padding {lineIndex % 10}."); + } + + return sb.ToString (); + } +} diff --git a/Tests/Benchmarks/Views/ButtonDrawBenchmark.cs b/Tests/Benchmarks/Views/ButtonDrawBenchmark.cs new file mode 100644 index 0000000000..01a24884b4 --- /dev/null +++ b/Tests/Benchmarks/Views/ButtonDrawBenchmark.cs @@ -0,0 +1,126 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Views; + +/// +/// Benchmarks for draw performance, focused on the cost of +/// _interiorTextFormatter property assignments in OnDrawingText. +/// +/// +/// +/// Prior to the guards introduced in PR #5279, every call to OnDrawingText set all +/// _interiorTextFormatter properties unconditionally, which always set +/// NeedsFormat = true and forced the formatter to re-allocate on the next access — +/// even when nothing had changed. After the fix, unchanged values are skipped. +/// +/// +/// How to read results: +/// +/// represents the guard-optimized steady-state path. +/// forces a real reformat each iteration, +/// approximating the old unguarded behaviour. +/// +/// The allocation difference between the two methods shows the overhead that the guards eliminate. +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter "*ButtonDraw*" +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Views", "Button")] +public class ButtonDrawBenchmark +{ + // Four interior texts of equal display width so layout stays stable between iterations. + private static readonly string [] _changingTexts = ["_OK", "_No", "_Go", "_Up"]; + + private IApplication _app = null!; + private Button _button = null!; + private int _textIndex; + private SessionToken? _session; + + /// Fixed width of the button under test. + [Params (10, 40)] + public int Width { get; set; } + + /// Whether the button is the default button (adds extra decoration characters). + [Params (false, true)] + public bool IsDefault { get; set; } + + /// Create the application and button once per parameter combination. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (Width, 1); + + Runnable runnable = new () { Width = Width, Height = 1 }; + _session = _app.Begin (runnable); + + _button = new () + { + Text = "_OK", + X = 0, + Y = 0, + Width = Width, + Height = 1, + IsDefault = IsDefault, + ShadowStyle = ShadowStyles.None + }; + + runnable.Add (_button); + + // Warm-up draw so caches and JIT paths are primed before measurement. + _app.LayoutAndDraw (); + } + + /// Mark the button as needing a redraw before each iteration. + /// This ensures OnDrawingText is always called, while keeping all + /// formatter property values identical to the previous draw — isolating the guard benefit. + [IterationSetup] + public void MarkNeedsDraw () + { + _button.SetNeedsDraw (); + } + + /// + /// Draws the button with no property changes since the last draw. + /// Guards on _interiorTextFormatter skip all assignments, so NeedsFormat + /// is not set and the formatter does not re-allocate. + /// + [Benchmark (Baseline = true)] + public void DrawButton_Unchanged () + { + _app.LayoutAndDraw (); + } + + /// + /// Rotates the button before each draw. + /// The interior text changes, so the Text guard fires, NeedsFormat is + /// set, and the formatter re-allocates — approximating the old unguarded behaviour. + /// + [Benchmark] + public void DrawButton_TextChanging () + { + _button.Text = _changingTexts [_textIndex++ % _changingTexts.Length]; + _app.LayoutAndDraw (); + } + + /// Dispose the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is not null) + { + _app.End (_session); + } + + _app.Dispose (); + } +} diff --git a/Tests/Benchmarks/baseline.json b/Tests/Benchmarks/baseline.json new file mode 100644 index 0000000000..55031d126c --- /dev/null +++ b/Tests/Benchmarks/baseline.json @@ -0,0 +1,241 @@ +{ + "_comment": "Baseline benchmark results for the performance gate (Scrolling + Configuration). Configuration baselines captured on GitHub Actions ubuntu-latest (AMD EPYC 7763, .NET 10.0.5, ShortRun in-process).", + "_howto": "Re-run 'dotnet run --project Tests/Benchmarks -c Release -- --filter \"*Scroll*\" \"*Config*\" \"*Scheme*\" \"*Theme*\" --job short --exporters json' then update this file.", + "_version": "1", + "benchmarks": [ + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_Down", + "params": "ContentHeight=1000", + "meanNs": 150000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_Down", + "params": "ContentHeight=10000", + "meanNs": 150000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_Up", + "params": "ContentHeight=1000", + "meanNs": 150000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_Up", + "params": "ContentHeight=10000", + "meanNs": 150000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_PageDown", + "params": "ContentHeight=1000", + "meanNs": 150000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_PageDown", + "params": "ContentHeight=10000", + "meanNs": 150000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Lines=1000", + "meanNs": 500000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Lines=5000", + "meanNs": 500000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "ScrollUp_OneStep", + "params": "Lines=1000", + "meanNs": 500000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "ScrollUp_OneStep", + "params": "Lines=5000", + "meanNs": 500000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Lines=1000", + "meanNs": 500000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Lines=5000", + "meanNs": 500000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ListViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Items=1000", + "meanNs": 200000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ListViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Items=10000", + "meanNs": 200000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ListViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Items=1000", + "meanNs": 200000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ListViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Items=10000", + "meanNs": 200000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TableViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Rows=100", + "meanNs": 300000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TableViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Rows=1000", + "meanNs": 300000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TableViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Rows=100", + "meanNs": 300000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "TableViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Rows=1000", + "meanNs": 300000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + + { + "type": "ConfigurationManagerLoadBenchmark", + "method": "LoadAndApply", + "params": "", + "meanNs": 3185090 + }, + + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=8-Bit", + "meanNs": 800655 + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Amber Phosphor", + "meanNs": 754357 + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Anders", + "meanNs": 749432 + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Dark", + "meanNs": 752071 + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Default", + "meanNs": 809240 + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Green Phosphor", + "meanNs": 750257 + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Light", + "meanNs": 753552 + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=TurboPascal 5", + "meanNs": 746073 + }, + + { + "type": "SchemeAttributeBenchmark", + "method": "GetNormal", + "params": "", + "meanNs": 36 + }, + { + "type": "SchemeAttributeBenchmark", + "method": "GetHotFocus", + "params": "", + "meanNs": 92 + }, + { + "type": "SchemeAttributeBenchmark", + "method": "GetCode", + "params": "", + "meanNs": 438 + }, + + { + "type": "SchemeSerializationBenchmark", + "method": "Serialize", + "params": "", + "meanNs": 360 + }, + { + "type": "SchemeSerializationBenchmark", + "method": "Deserialize", + "params": "", + "meanNs": 1084 + }, + { + "type": "SchemeSerializationBenchmark", + "method": "RoundTrip", + "params": "", + "meanNs": 1600 + } + ] +} diff --git a/Tests/IntegrationTests/FluentTests/MenuBarTests.cs b/Tests/IntegrationTests/FluentTests/MenuBarTests.cs index 4ad5631264..56a8fbd4e8 100644 --- a/Tests/IntegrationTests/FluentTests/MenuBarTests.cs +++ b/Tests/IntegrationTests/FluentTests/MenuBarTests.cs @@ -1,5 +1,6 @@ using System.Drawing; using System.Globalization; +using System.Text; using AppTestHelpers; using AppTestHelpers.XunitHelpers; @@ -1017,4 +1018,128 @@ public void InlineMenuBarItem_MenuItem_Action_Fires () c.Dispose (); } + + // Claude - Opus 4.7 + /// + /// Reproduces a bug where, when a peer view of a has a + /// adornment whose draws via , opening + /// a that overlaps the padding region leaves the padding's drawing + /// visible — the popover's content fails to overdraw those cells. + /// + [Fact] + public void PopoverMenu_Overlapping_PaddingView_DrawingContent_Is_Not_Bled_Through () + { + var d = "ansi"; + MenuBar? menuBar = null; + View? subView = null; + IApplication? app = null; + + AppTestHelper c = With.A (80, 25, d, _out) + .Then (a => + { + app = a; + + menuBar = new MenuBar + { + Menus = + [ + new MenuBarItem ("_File", + [ + new MenuItem { Title = "_New", HelpText = "Create new" } + ]) + ] + }; + + subView = new () + { + Id = "subView", + X = 0, + Y = 1, + Width = Dim.Fill (), + Height = 10 + }; + + subView.Padding.Thickness = new (1, 0, 0, 0); + + a.TopRunnableView!.Add (menuBar); + a.TopRunnableView!.Add (subView); + + AdornmentView paddingView = subView.Padding.GetOrCreateView (); + + paddingView.DrawingContent += (sender, _) => + { + View pv = (View)sender!; + + for (int row = 0; row < pv.Viewport.Height; row++) + { + pv.AddRune (0, row, new Rune ('P')); + } + }; + }); + + c = c.WaitIteration (); + + // Open the File menu via F10. + c = c.KeyDown (MenuBar.DefaultKey) + .WaitIteration (); + + // Capture state on the UI thread, but defer assertions to avoid hanging the app loop. + Rectangle popoverScreen = Rectangle.Empty; + Rectangle padScreen = Rectangle.Empty; + Cell [,]? capturedContents = null; + var menuOpen = false; + + c = c.Then (a => + { + menuOpen = menuBar!.IsOpen (); + + if (a.Popovers!.GetActivePopover () is PopoverMenu popoverMenu) + { + // Use the actual Menu content view's frame (not the full-screen + // transparent wrapper) so the overlap is the region where the + // menu popup genuinely draws its content. + popoverScreen = popoverMenu.ContentView?.FrameToScreen () ?? Rectangle.Empty; + } + + padScreen = subView!.Padding.GetOrCreateView ().FrameToScreen (); + + // Snapshot the driver contents so we can inspect after Dispose. + Cell [,]? contents = a.Driver!.Contents; + + if (contents is { }) + { + int rows = contents.GetLength (0); + int cols = contents.GetLength (1); + capturedContents = new Cell [rows, cols]; + + for (int r = 0; r < rows; r++) + { + for (int co = 0; co < cols; co++) + { + capturedContents [r, co] = contents [r, co]; + } + } + } + }); + + c.Dispose (); + + Assert.True (menuOpen, "File menu should be open after F10."); + Assert.NotNull (capturedContents); + Assert.False (popoverScreen.IsEmpty, "Active popover should be present."); + + Rectangle overlap = Rectangle.Intersect (popoverScreen, padScreen); + + Assert.False (overlap.IsEmpty, + $"Test setup invalid: popover {popoverScreen} does not overlap padding {padScreen}."); + + // Inside the overlap, the popover must overdraw the padding — no 'P' should remain. + for (int row = overlap.Top; row < overlap.Bottom; row++) + { + for (int col = overlap.Left; col < overlap.Right; col++) + { + Assert.NotEqual ("P", capturedContents! [row, col].Grapheme); + } + } + } } diff --git a/Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs b/Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs index a3965ca35e..2a039ba2b6 100644 --- a/Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs +++ b/Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs @@ -14,12 +14,18 @@ public class TestContextKeyEventTests (ITestOutputHelper outputHelper) : TestsAl [MemberData (nameof (GetAllDriverNames))] public void QuitKey_ViaApplication_Stops (string d) { + IRunnable? top = null; + using AppTestHelper helper = With.A (40, 10, d) - .Then ((app) => - { - app?.Keyboard.RaiseKeyDownEvent (Application.GetDefaultKey (Command.Quit)); - Assert.False (app!.TopRunnable!.IsRunning); - }); + .Then (app => + { + top = app!.TopRunnable; + app.Keyboard.RaiseKeyDownEvent (Application.GetDefaultKey (Command.Quit)); + }); + + Assert.True ( + SpinWait.SpinUntil (() => top is { IsRunning: false }, TimeSpan.FromSeconds (5)), + "TopRunnable did not stop within timeout."); } [Theory] @@ -317,4 +323,4 @@ public void WithTextField_UpdatesText (string d) //Assert.Equal ("Hello", textField.Text); } -} \ No newline at end of file +} diff --git a/Tests/IntegrationTests/FluentTests/TestContextTests.cs b/Tests/IntegrationTests/FluentTests/TestContextTests.cs index 3ae7aa57f0..a41bc261a6 100644 --- a/Tests/IntegrationTests/FluentTests/TestContextTests.cs +++ b/Tests/IntegrationTests/FluentTests/TestContextTests.cs @@ -99,6 +99,36 @@ public void With_Starts_Stops_Without_Error (string d) // No actual assertions are needed — if no exceptions are thrown, it's working } + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void RunAsync_Cancellation_After_Boot_Stops_Application () + { + // Copilot + using AppTestHelper helper = With.A (40, 10, DriverRegistry.Names.ANSI, _out); + helper.CancelRun (); + + Assert.True ( + SpinWait.SpinUntil (() => helper.Finished, TimeSpan.FromSeconds (5)), + "AppTestHelper did not finish after cancellation."); + } + + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void Then_Exception_HardStops_Without_Hanging () + { + // Copilot + using AppTestHelper helper = With.A (40, 10, DriverRegistry.Names.ANSI, _out); + InvalidOperationException expectedException = new ("Expected test failure"); + + InvalidOperationException exception = Assert.Throws ( + () => helper.Then (_ => throw expectedException)); + + Assert.Same (expectedException, exception); + Assert.True ( + SpinWait.SpinUntil (() => helper.Finished, TimeSpan.FromSeconds (5)), + "AppTestHelper did not finish after action failure."); + } + [Theory] [MemberData (nameof (GetAllDriverNames))] public void With_Without_Stop_Still_Cleans_Up (string d) diff --git a/Tests/PerformanceTests/AssemblyInfo.cs b/Tests/PerformanceTests/AssemblyInfo.cs new file mode 100644 index 0000000000..13ce8a0a0c --- /dev/null +++ b/Tests/PerformanceTests/AssemblyInfo.cs @@ -0,0 +1,7 @@ +global using Terminal.Gui.App; +global using Terminal.Gui.Drivers; +global using Terminal.Gui.Input; +global using Terminal.Gui.ViewBase; +global using Terminal.Gui.Views; +global using Terminal.Gui.Drawing; +global using UnitTests; diff --git a/Tests/PerformanceTests/PerformanceTests.csproj b/Tests/PerformanceTests/PerformanceTests.csproj new file mode 100644 index 0000000000..6513b94358 --- /dev/null +++ b/Tests/PerformanceTests/PerformanceTests.csproj @@ -0,0 +1,55 @@ + + + + PerformanceTests + Exe + true + true + false + enable + enable + true + true + portable + $(DefineConstants);JETBRAINS_ANNOTATIONS;CONTRACTS_FULL + true + $(NoWarn);AD0001 + true + + + + true + $(DefineConstants);DEBUG_IDISPOSABLE + + + + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/PerformanceTests/ScrollingPerformanceTests.cs b/Tests/PerformanceTests/ScrollingPerformanceTests.cs new file mode 100644 index 0000000000..b6c0bc5c5e --- /dev/null +++ b/Tests/PerformanceTests/ScrollingPerformanceTests.cs @@ -0,0 +1,305 @@ +// Copilot + +using System.Collections.ObjectModel; +using System.Data; +using System.Diagnostics; +using System.Text; + +namespace PerformanceTests; + +/// +/// Stopwatch-based smoke tests that catch catastrophic rendering regressions. +/// Thresholds are intentionally generous (~50–100× typical) so these never flake on slow CI runners +/// but still catch accidental O(n²) regressions where a single viewport draw inadvertently +/// iterates over the entire document instead of just the visible rows. +/// +/// +/// Each test measures the cost of a SINGLE viewport draw on a large document so that an +/// O(document-size) regression is immediately detectable without needing a full scroll loop. +/// These are Layer 1 of the two-layer CI performance gate. +/// Layer 2 (BenchmarkDotNet baseline comparison) lives in Tests/Benchmarks/. +/// This project runs exclusively on Linux via the perf-gate CI workflow. +/// +[Trait ("Category", "Performance")] +public class ScrollingPerformanceTests : TestDriverBase +{ + // ────────────────────────────────────────────────────────────────────────── + // ListView smoke tests + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Builds a 100 000-item and renders a single viewport. + /// Asserts layout + one full draw completes under a generous threshold. + /// + [Fact] + public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () + { + const int ITEM_COUNT = 100_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 30; + + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); + + ListView listView = new () + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Driver = driver + }; + listView.SetSource (new ObservableCollection (BuildListItems (ITEM_COUNT))); + listView.BeginInit (); + listView.EndInit (); + listView.Layout (); + + // Warm up. + listView.SetNeedsDraw (); + listView.Draw (); + + var sw = Stopwatch.StartNew (); + listView.SetNeedsDraw (); + listView.Draw (); + sw.Stop (); + + // 300 ms is ~150× what a single viewport draw takes on a typical machine. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (300), + $"ListView layout+draw ({ITEM_COUNT} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 300 ms"); + } + + /// + /// Scrolls a 100 000-item to the mid-point and measures the cost of + /// a single redraw. Detects O(total-items) regressions in the per-draw path. + /// + [Fact] + public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () + { + const int ITEM_COUNT = 100_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 30; + + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); + + ListView listView = new () + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Driver = driver + }; + listView.SetSource (new ObservableCollection (BuildListItems (ITEM_COUNT))); + listView.BeginInit (); + listView.EndInit (); + listView.Layout (); + + // Warm up. + listView.SetNeedsDraw (); + listView.Draw (); + + // Scroll to the mid-point of the list. + listView.Viewport = listView.Viewport with { Y = ITEM_COUNT / 2 }; + + var sw = Stopwatch.StartNew (); + listView.SetNeedsDraw (); + listView.Draw (); + sw.Stop (); + + // 300 ms threshold. A genuine O(100 000) regression would scan all items and take >> 1 s. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (300), + $"ListView mid-doc viewport draw ({ITEM_COUNT} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 300 ms"); + } + + // ────────────────────────────────────────────────────────────────────────── + // TableView smoke tests + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Builds a 10 000-row and renders a single viewport. + /// Asserts layout + one full draw completes under a generous threshold. + /// + [Fact] + public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () + { + const int ROW_COUNT = 10_000; + const int COL_COUNT = 10; + const int SCREEN_WIDTH = 120; + const int SCREEN_HEIGHT = 30; + + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); + + TableView tableView = new (new DataTableSource (BuildDataTable (ROW_COUNT, COL_COUNT))) + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Driver = driver + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.Layout (); + + // Warm up. + tableView.SetNeedsDraw (); + tableView.Draw (); + + var sw = Stopwatch.StartNew (); + tableView.SetNeedsDraw (); + tableView.Draw (); + sw.Stop (); + + // 200 ms is ~50× what a single viewport draw takes on a typical machine. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200), + $"TableView layout+draw ({ROW_COUNT} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); + } + + /// + /// Scrolls a to the mid-point of a 10 000-row table and measures + /// the cost of a single redraw from that offset. + /// Detects O(total-rows) regressions in the per-draw path. + /// + [Fact] + public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () + { + const int ROW_COUNT = 10_000; + const int COL_COUNT = 10; + const int SCREEN_WIDTH = 120; + const int SCREEN_HEIGHT = 30; + + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); + + TableView tableView = new (new DataTableSource (BuildDataTable (ROW_COUNT, COL_COUNT))) + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Driver = driver + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.Layout (); + + // Warm up. + tableView.SetNeedsDraw (); + tableView.Draw (); + + // Scroll to mid-document. + tableView.RowOffset = ROW_COUNT / 2; + + var sw = Stopwatch.StartNew (); + tableView.SetNeedsDraw (); + tableView.Draw (); + sw.Stop (); + + // 200 ms threshold — an O(total-rows) regression would scan 10 000 rows. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200), + $"TableView single viewport draw at mid ({ROW_COUNT} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); + } + + // ────────────────────────────────────────────────────────────────────────── + // TextView smoke tests + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Builds a 1 000-line and measures the cost of a single viewport + /// draw after scrolling to the middle of the document. + /// An O(document-size) regression in the rendering path would exceed the threshold even + /// on a slow CI runner. + /// + [Fact] + public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () + { + const int THRESHOLD_MS = 1000; + const int LINE_COUNT = 1_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 25; + + IDriver driver = CreateTestDriver (); + + TextView tv = new () + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Text = BuildTextViewContent (LINE_COUNT), + ReadOnly = true, + WordWrap = false, + Driver = driver + }; + tv.BeginInit (); + tv.EndInit (); + tv.Layout (); + + // Warm up: prime JIT and layout caches. + tv.SetNeedsDraw (); + tv.Draw (); + + // Scroll to the middle of the document. + tv.Viewport = tv.Viewport with { Y = LINE_COUNT / 2 }; + + var sw = Stopwatch.StartNew (); + tv.SetNeedsDraw (); + tv.Draw (); + sw.Stop (); + + // 1000 ms is generous even in debug/slow-CI mode. An O(lineCount) regression + // scanning 1 000 lines in the draw path would take at least 5–10× longer. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (THRESHOLD_MS), + $"TextView single viewport draw ({LINE_COUNT} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < {THRESHOLD_MS}ms"); + } + + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + private static DataTable BuildDataTable (int rows, int cols) + { + DataTable dt = new (); + + for (var colIndex = 0; colIndex < cols; colIndex++) + { + dt.Columns.Add ($"Col{colIndex}", typeof (string)); + } + + for (var rowIndex = 0; rowIndex < rows; rowIndex++) + { + var row = new object [cols]; + + for (var colIndex = 0; colIndex < cols; colIndex++) + { + row [colIndex] = $"R{rowIndex}C{colIndex}"; + } + + dt.Rows.Add (row); + } + + return dt; + } + + private static List BuildListItems (int count) + { + List items = new (count); + + for (var itemIndex = 0; itemIndex < count; itemIndex++) + { + items.Add ($"Item {itemIndex,6}: value = {itemIndex * 17 % 100:D3}"); + } + + return items; + } + + private static string BuildTextViewContent (int lineCount) + { + StringBuilder sb = new (lineCount * 85); + + for (var lineIndex = 0; lineIndex < lineCount; lineIndex++) + { + sb.AppendLine ($"Line {lineIndex,6}: The quick brown fox jumps over the lazy dog. Extra padding {lineIndex % 10}."); + } + + return sb.ToString (); + } +} diff --git a/Tests/PerformanceTests/xunit.runner.json b/Tests/PerformanceTests/xunit.runner.json new file mode 100644 index 0000000000..608c553c4e --- /dev/null +++ b/Tests/PerformanceTests/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "stopOnFail": false +} diff --git a/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderFull/AttrProviderFull.csproj b/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderFull/AttrProviderFull.csproj new file mode 100644 index 0000000000..66b4a2976b --- /dev/null +++ b/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderFull/AttrProviderFull.csproj @@ -0,0 +1,7 @@ + + + netstandard2.0 + FakeCodeAnalysis + disable + + diff --git a/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderFull/MemberNotNullWhenAttribute.cs b/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderFull/MemberNotNullWhenAttribute.cs new file mode 100644 index 0000000000..e4425e6bae --- /dev/null +++ b/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderFull/MemberNotNullWhenAttribute.cs @@ -0,0 +1,18 @@ +using System; + +namespace System.Diagnostics.CodeAnalysis +{ + [AttributeUsage (AttributeTargets.Method | AttributeTargets.Property, Inherited = false)] + public sealed class MemberNotNullWhenAttribute : Attribute + { + public MemberNotNullWhenAttribute (bool returnValue, params string [] members) + { + ReturnValue = returnValue; + Members = members; + } + + public string [] Members { get; } + + public bool ReturnValue { get; } + } +} diff --git a/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderMissing/AttrProviderMissing.csproj b/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderMissing/AttrProviderMissing.csproj new file mode 100644 index 0000000000..66b4a2976b --- /dev/null +++ b/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderMissing/AttrProviderMissing.csproj @@ -0,0 +1,7 @@ + + + netstandard2.0 + FakeCodeAnalysis + disable + + diff --git a/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderMissing/PlaceholderType.cs b/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderMissing/PlaceholderType.cs new file mode 100644 index 0000000000..7ffe10771b --- /dev/null +++ b/Tests/TestAssets/ConfigPropertyTypeLoad/AttrProviderMissing/PlaceholderType.cs @@ -0,0 +1,6 @@ +namespace Placeholder +{ + public class PlaceholderType + { + } +} diff --git a/Tests/TestAssets/ConfigPropertyTypeLoad/ConsumerLib/ConsumerLib.csproj b/Tests/TestAssets/ConfigPropertyTypeLoad/ConsumerLib/ConsumerLib.csproj new file mode 100644 index 0000000000..83fbe4d80c --- /dev/null +++ b/Tests/TestAssets/ConfigPropertyTypeLoad/ConsumerLib/ConsumerLib.csproj @@ -0,0 +1,9 @@ + + + netstandard2.0 + disable + + + + + diff --git a/Tests/TestAssets/ConfigPropertyTypeLoad/ConsumerLib/FixtureType.cs b/Tests/TestAssets/ConfigPropertyTypeLoad/ConsumerLib/FixtureType.cs new file mode 100644 index 0000000000..c708c2e009 --- /dev/null +++ b/Tests/TestAssets/ConfigPropertyTypeLoad/ConsumerLib/FixtureType.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ConsumerLib +{ + public class FixtureType + { + [MemberNotNullWhen (true, nameof (Value))] + public bool HasValue => !string.IsNullOrEmpty (Value); + + public string Value { get; set; } = string.Empty; + } +} diff --git a/Tests/UnitTests.NonParallelizable/Configuration/ConfigPropertyAssemblyScanTests.cs b/Tests/UnitTests.NonParallelizable/Configuration/ConfigPropertyAssemblyScanTests.cs new file mode 100644 index 0000000000..d85bd147cf --- /dev/null +++ b/Tests/UnitTests.NonParallelizable/Configuration/ConfigPropertyAssemblyScanTests.cs @@ -0,0 +1,44 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Runtime.Loader; + +namespace UnitTests.NonParallelizable.ConfigurationTests; + +public class ConfigPropertyAssemblyScanTests +{ + [ConfigurationProperty (Scope = typeof (ConfigPropertyAssemblyScanTestsScope))] + public static bool? ScanSentinel { get; set; } + + private class ConfigPropertyAssemblyScanTestsScope : Scope + { + } + + [Fact] + public void ScanAssembliesForConfigPropertyHosts_Skips_TypeLoadException_From_Foreign_CustomAttribute_Metadata () + { + string fixtureRoot = Path.Combine (AppContext.BaseDirectory, "ConfigPropertyTypeLoadFixtures"); + string providerPath = Path.Combine (fixtureRoot, "FakeCodeAnalysis.dll"); + string consumerPath = Path.Combine (fixtureRoot, "ConsumerLib.dll"); + + Assert.True (File.Exists (providerPath)); + Assert.True (File.Exists (consumerPath)); + + AssemblyLoadContext loadContext = new (nameof (ConfigPropertyAssemblyScanTests), isCollectible: true); + loadContext.LoadFromAssemblyPath (providerPath); + Assembly consumerAssembly = loadContext.LoadFromAssemblyPath (consumerPath); + Type consumerType = consumerAssembly.GetType ("ConsumerLib.FixtureType", throwOnError: true)!; + PropertyInfo consumerProperty = consumerType.GetProperty ("HasValue")!; + + Assert.Throws ( + () => consumerProperty.GetCustomAttribute (typeof (ConfigurationPropertyAttribute)) + ); + + ImmutableSortedDictionary hosts = + ConfigProperty.ScanAssembliesForConfigPropertyHosts ([consumerAssembly, typeof (ConfigPropertyAssemblyScanTests).Assembly]); + + Assert.Contains (nameof (ConfigPropertyAssemblyScanTests), hosts.Keys); + Assert.Same (typeof (ConfigPropertyAssemblyScanTests), hosts [nameof (ConfigPropertyAssemblyScanTests)]); + + loadContext.Unload (); + } +} diff --git a/Tests/UnitTests.NonParallelizable/UnitTests.NonParallelizable.csproj b/Tests/UnitTests.NonParallelizable/UnitTests.NonParallelizable.csproj index b414931854..32242d2d9a 100644 --- a/Tests/UnitTests.NonParallelizable/UnitTests.NonParallelizable.csproj +++ b/Tests/UnitTests.NonParallelizable/UnitTests.NonParallelizable.csproj @@ -38,12 +38,21 @@ + + PreserveNewest + + + $(TargetDir)ConfigPropertyTypeLoadFixtures\ + + + + diff --git a/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs b/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs index 74f0a2fc5d..d84c1fe29f 100644 --- a/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs @@ -7,6 +7,7 @@ namespace ApplicationTests.Keyboard; /// /// Tests for static property. /// +[Collection ("Application Tests")] public class ApplicationDefaultKeyBindingsTests { [Fact] diff --git a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs index ff1eac2329..bd97c83c43 100644 --- a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs +++ b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs @@ -292,6 +292,76 @@ public async Task TestMainLoopCoordinator_InputCrashes_ExceptionSurfacesMainThre Assert.Equal ("Crash on boot", ex.InnerExceptions [0].Message); } + [Fact] + public async Task StartInputTaskAsync_InitiatesSixelSupportDetection () + { + // Arrange: inject a response containing "4" (sixel capability) in DAR, + // followed by a sixel resolution response "[6;20;10t". + // The SixelSupportDetector sends CSI_SendDeviceAttributes first, then + // CSI_RequestSixelResolution on success. We need to feed responses for both. + ConcurrentQueue inputQueue = new (); + TimedEvents timedEvents = new (); + ApplicationMainLoop loop = new (); + + // The TestAnsiInput only sends a single response string at startup. + // The SixelSupportDetector uses QueueAnsiRequest (async callbacks), not raw input. + // We cannot easily simulate the full DAR/resolution exchange through raw input here, + // but we CAN verify that after boot the SixelSupport property is populated + // by checking that it starts null on a non-legacy console driver. + TestAnsiInput input = new (null); + AnsiOutput output = new (); + TestAnsiComponentFactory factory = new (input, output); + MainLoopCoordinator coordinator = new (timedEvents, inputQueue, loop, factory); + Mock appMock = new (); + + appMock.SetupProperty (a => a.Driver); + appMock.SetupProperty (a => a.MainThreadId, 789); + + // Act + await coordinator.StartInputTaskAsync (appMock.Object); + + DriverImpl driver = Assert.IsType (appMock.Object.Driver); + + // Assert: SixelSupport will be null because no DAR response was received, + // but the important thing is the driver was initialized and the detection + // path did not throw. The SixelSupportDetector queued a request. + // The property remains null until a terminal response arrives. + Assert.Null (driver.SixelSupport); + + // Verify the driver is NOT legacy console (sixel detection should have been attempted) + Assert.False (driver.IsLegacyConsole); + + coordinator.Stop (); + } + + [Fact] + public async Task StartInputTaskAsync_SkipsSixelDetection_ForLegacyConsole () + { + // Arrange + ConcurrentQueue inputQueue = new (); + TimedEvents timedEvents = new (); + ApplicationMainLoop loop = new (); + TestAnsiInput input = new (null); + AnsiOutput output = new () { IsLegacyConsole = true }; + TestAnsiComponentFactory factory = new (input, output); + MainLoopCoordinator coordinator = new (timedEvents, inputQueue, loop, factory); + Mock appMock = new (); + + appMock.SetupProperty (a => a.Driver); + appMock.SetupProperty (a => a.MainThreadId, 101); + + // Act + await coordinator.StartInputTaskAsync (appMock.Object); + + DriverImpl driver = Assert.IsType (appMock.Object.Driver); + + // Assert: SixelSupport should be null (detection was skipped for legacy console) + Assert.Null (driver.SixelSupport); + Assert.True (driver.IsLegacyConsole); + + coordinator.Stop (); + } + private sealed class TestAnsiComponentFactory (TestAnsiInput input, AnsiOutput output) : ComponentFactoryImpl { public override string GetDriverName () => DriverRegistry.Names.ANSI; diff --git a/Tests/UnitTestsParallelizable/Configuration/SchemeJsonConverterTests.cs b/Tests/UnitTestsParallelizable/Configuration/SchemeJsonConverterTests.cs index e6f42ffe87..3fa653fa85 100644 --- a/Tests/UnitTestsParallelizable/Configuration/SchemeJsonConverterTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/SchemeJsonConverterTests.cs @@ -148,4 +148,22 @@ public void Deserialized_Attributes_NotSpecified_AreImplicit () Assert.False (scheme.TryGetExplicitlySetAttributeForRole (VisualRole.ReadOnly, out _)); Assert.False (scheme.TryGetExplicitlySetAttributeForRole (VisualRole.Disabled, out _)); } -} \ No newline at end of file + + [Fact] + public void CodeToken_Attributes_RoundTrip () + { + // Copilot + Scheme expected = new () + { + Normal = new (Color.White, Color.Black), + CodeKeyword = new (Color.Blue, Color.Black, TextStyle.Bold) + }; + + string json = JsonSerializer.Serialize (expected, ConfigurationManager.SerializerContext.Options); + Scheme? actual = JsonSerializer.Deserialize (json, ConfigurationManager.SerializerContext.Options); + + Assert.NotNull (actual); + Assert.True (actual.TryGetExplicitlySetAttributeForRole (VisualRole.CodeKeyword, out Attribute? attr)); + Assert.Equal (expected.CodeKeyword, attr); + } +} diff --git a/Tests/UnitTestsParallelizable/Drawing/SchemeTests.CodeRoleTests.cs b/Tests/UnitTestsParallelizable/Drawing/SchemeTests.CodeRoleTests.cs index f437159553..6cfee83113 100644 --- a/Tests/UnitTestsParallelizable/Drawing/SchemeTests.CodeRoleTests.cs +++ b/Tests/UnitTestsParallelizable/Drawing/SchemeTests.CodeRoleTests.cs @@ -129,4 +129,53 @@ public void Default_Constructor_Code_Not_Explicit () Scheme scheme = new (); Assert.False (scheme.TryGetExplicitlySetAttributeForRole (VisualRole.Code, out _)); } + + [Fact] + public void CodeToken_Roles_Derive_From_Code () + { + // Copilot + Attribute codeAttr = new ("Green", "Yellow", TextStyle.Italic); + Scheme scheme = new () { Normal = new Attribute ("Red", "Blue"), Code = codeAttr }; + + foreach (VisualRole role in Enum.GetValues ().Where (role => role > VisualRole.Code)) + { + Assert.False (scheme.TryGetExplicitlySetAttributeForRole (role, out _)); + Assert.Equal (codeAttr, scheme.GetAttributeForRole (role)); + } + } + + [Fact] + public void CodeToken_Role_Can_Be_Explicitly_Set () + { + // Copilot + Attribute keywordAttr = new ("Cyan", "Black", TextStyle.Bold); + Scheme scheme = new () { Normal = new Attribute ("Red", "Blue"), CodeKeyword = keywordAttr }; + + Assert.True (scheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeKeyword, out Attribute? retrieved)); + Assert.Equal (keywordAttr, retrieved); + Assert.Equal (keywordAttr, scheme.GetAttributeForRole (VisualRole.CodeKeyword)); + } + + [Fact] + public void HardCoded_Base_Has_Default_CodeToken_Colors () + { + // Copilot + Scheme baseScheme = Scheme.GetHardCodedSchemes () ["Base"]; + + Assert.True (baseScheme.TryGetExplicitlySetAttributeForRole (VisualRole.CodeKeyword, out Attribute? keyword)); + Assert.NotEqual (baseScheme.Code.Foreground, keyword!.Value.Foreground); + } + + [Fact] + public void DeriveAccent_Preserves_Base_CodeToken_Colors () + { + // Copilot + Attribute terminalDefault = new (Color.White, Color.Black); + Scheme baseScheme = Scheme.GetHardCodedSchemes () ["Base"]; + + Scheme accent = Scheme.DeriveAccent (baseScheme, terminalDefault); + + Assert.True (accent.TryGetExplicitlySetAttributeForRole (VisualRole.CodeKeyword, out Attribute? keyword)); + Assert.Equal (baseScheme.CodeKeyword.Foreground, keyword!.Value.Foreground); + } } diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index c433e349e6..ed3dd638a9 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; namespace DriverTests.Output; @@ -360,6 +360,215 @@ public void ToAnsi_UrlAtEndOfRow_ClosedBeforeNewline () Assert.True (secondStart < 0, "No OSC 8 start should appear on row 1"); } + // Copilot - GPT-5.4 + // Regression coverage for plain-text URL auto-linking used by TextView/Editor. + // If a previously auto-linked URL is overwritten by non-URL text, the redraw must + // explicitly clear hyperlink state before writing the replacement text. + [Fact] + public void Write_AutoDetectedUrl_ThenPlainText_EmitsOsc8CloseBeforeReplacement () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (24, 1); + + buffer.Move (0, 0); + buffer.AddStr ("https://example.com"); + output.Write (buffer); + + buffer.Move (0, 0); + buffer.AddStr ("plain replacement "); + + // Act + output.Write (buffer); + string result = output.GetLastOutput (); + + // Assert + string start = EscSeqUtils.OSC_StartHyperlink ("https://example.com"); + string end = EscSeqUtils.OSC_EndHyperlink (); + int replacementIdx = result.IndexOf ("plain replacement", StringComparison.Ordinal); + int endIdx = result.IndexOf (end, StringComparison.Ordinal); + + Assert.DoesNotContain (start, result); + Assert.True (replacementIdx >= 0, "Replacement text was not emitted"); + Assert.True (endIdx >= 0 && endIdx < replacementIdx, "OSC 8 close must be emitted before replacement text"); + } + + // Copilot - GPT-5.4 + // Regression coverage for deleting all text in the Editor scenario. + // Clearing a row that previously contained an auto-detected URL must emit an OSC 8 + // close so terminals do not keep hyperlink metadata at the former URL location. + [Fact] + public void Write_AutoDetectedUrl_ThenSpaces_EmitsOsc8CloseBeforeClearingCells () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (24, 1); + + buffer.Move (0, 0); + buffer.AddStr ("https://example.com"); + output.Write (buffer); + + buffer.Move (0, 0); + buffer.AddStr (" "); + + // Act + output.Write (buffer); + string result = output.GetLastOutput (); + string end = EscSeqUtils.OSC_EndHyperlink (); + int replacementIdx = result.IndexOf (" ", StringComparison.Ordinal); + int endIdx = result.IndexOf (end, StringComparison.Ordinal); + + // Assert + Assert.True (replacementIdx >= 0, "Replacement spaces were not emitted"); + Assert.True (endIdx >= 0 && endIdx < replacementIdx, "OSC 8 close must be emitted before replacement spaces"); + } + + // Claude - Opus 4.7 + // Regression coverage for the char-vs-column mismatch in SyncAutoUrlsForRowCore. + // When a multi-codepoint grapheme (ZWJ emoji, base + combining mark) precedes a URL + // on the same row, the URL's char offset in the concatenated row text diverges from + // its column position. The fix builds a char-to-column map so the auto-URL metadata + // lands on the actual URL cells, not shifted by the extra char count. + [Fact] + public void Write_AutoDetectedUrl_AfterMultiCharGrapheme_LinkAlignsWithUrlColumns () + { + // Arrange — combining acute (U+0301) appended to 'e' yields a 2-char grapheme + // occupying a single column. Without the fix, the URL would be tagged starting + // one column past where it actually renders. + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (40, 1); + + buffer.Move (0, 0); + buffer.AddStr ("é https://example.com"); + + // Act + output.Write (buffer); + string result = output.GetLastOutput (); + string start = EscSeqUtils.OSC_StartHyperlink ("https://example.com"); + + int startIdx = result.IndexOf (start, StringComparison.Ordinal); + + // Assert — the visible URL text must follow the OSC 8 start sequence with no + // characters between them. Search strictly after the start sequence so we don't + // match the URL embedded as the OSC parameter inside the start sequence itself. + Assert.True (startIdx >= 0, "OSC 8 start sequence was not emitted"); + + int afterStart = startIdx + start.Length; + int visibleUrlIdx = result.IndexOf ("https://example.com", afterStart, StringComparison.Ordinal); + + Assert.Equal (afterStart, visibleUrlIdx); + } + + // Claude - Opus 4.7 + // Regression coverage for stale _rowsWithUrls tracking on resize. After a SetSize call + // the buffer's URL maps are wiped, so OutputBase must drop its row tracking too. + // Otherwise the next render emits a spurious OSC 8 close at the start of any row index + // that previously contained a URL. + [Fact] + public void Write_AfterResize_DoesNotEmitSpuriousOsc8Close () + { + // Arrange — render a URL, then resize (which clears URL maps) and render plain text. + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (40, 2); + + buffer.Move (0, 0); + buffer.AddStr ("https://example.com"); + output.Write (buffer); + + // SetSize wipes URL state; row 0 used to have a URL. + buffer.SetSize (40, 2); + + buffer.Move (0, 0); + buffer.AddStr ("plain text only"); + + // Act + output.Write (buffer); + string result = output.GetLastOutput (); + string end = EscSeqUtils.OSC_EndHyperlink (); + + // Assert — no OSC 8 close should appear because row 0 no longer has any URL state + // and the buffer was reset between writes. + Assert.DoesNotContain (end, result); + } + + // Claude - Opus 4.7 + // After clearing URL state, GetCellUrl's null fast-path should be re-armed so subsequent + // cell lookups skip the lock entirely. This guards against the regression where + // ClearContents called .Clear() on the maps but left them allocated, defeating the + // fast-path for the lifetime of the buffer. + [Fact] + public void GetCellUrl_AfterUrlSetThenCleared_RestoresNullFastPath () + { + // Arrange + OutputBufferImpl buffer = new () { Rows = 1, Cols = 10 }; + buffer.SetSize (10, 1); + buffer.Move (0, 0); + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("https://x"); + buffer.CurrentUrl = null; + + Assert.NotNull (buffer.GetCellUrl (0, 0)); + + // Act + buffer.ClearContents (true); + + // Assert — second call should hit the null fast-path. We can't directly observe the + // lock skip, but we can verify state was wiped and the version counter advanced so + // OutputBase tracking is invalidated. + Assert.Null (buffer.GetCellUrl (0, 0)); + Assert.True (buffer.UrlStateVersion > 0); + } + + // Claude - Opus 4.7 + // Regression coverage for _rowsWithUrls bookkeeping when the per-row flush happens entirely + // via WriteToConsole (i.e. the end-of-row Write path takes the empty-builder early-exit). + // Before the fix, the Add/Remove of the row index lived after the early-exit, so a row that + // lost its URL via overwrite but had clean trailing cells (causing the builder to flush + // mid-loop and end empty) kept a stale row-tracking entry — leading to a spurious row-start + // OSC 8 close on subsequent frames. + [Fact] + public void Write_RowLosesUrl_BuilderFlushedMidLoop_RemovesRowTracking () + { + // Arrange — buffer with a URL on row 0, then overwrite the URL cells with non-URL + // content followed by clean cells (so the builder flushes mid-loop and the row exits + // with an empty builder, taking the early-exit). + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (40, 1); + + // Frame 1: write URL covering cols 0-18, trailing space cells stay as ' '. + buffer.Move (0, 0); + buffer.AddStr ("https://example.com"); + output.Write (buffer); + + // Frame 2: overwrite the URL cells with Y characters. Cells 0-19 become dirty (19 + // Y's plus the adjacent-dirty mark on cell 19). Cells 20-39 stay clean — they will + // trigger the WriteToConsole flush and leave the row with an empty builder. + buffer.Move (0, 0); + buffer.AddStr ("YYYYYYYYYYYYYYYYYYY"); + output.Write (buffer); + + // Frame 3: trivial change. With the fix, row 0 has been removed from _rowsWithUrls, + // so no OSC 8 close is emitted at the start of row 0. Without the fix, the stale + // entry causes a spurious OSC 8 close. + buffer.Move (0, 0); + buffer.AddStr ("X"); + + // Act + output.Write (buffer); + string result = output.GetLastOutput (); + string end = EscSeqUtils.OSC_EndHyperlink (); + + // Assert — frame 3 must NOT emit an OSC 8 close because frame 2's row no longer + // has any URL state, and _rowsWithUrls must reflect that even when the early-exit + // path is taken. + Assert.DoesNotContain (end, result); + } + // Copilot [Fact] public void ToAnsi_LegacyConsole_NoOsc8 () @@ -549,4 +758,287 @@ public void AddStr_DifferentUrl_OverwritesUrlMapping () Assert.Equal ("https://two.com", buffer.GetCellUrl (1, 0)); Assert.Equal ("https://two.com", buffer.GetCellUrl (2, 0)); } + + [Fact] + public void Write_SkipsSixel_WhenIsDirtyIsFalse () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + buffer.AddStr ("."); + + SixelToRender s = new () + { + SixelData = "SKIPPED-SIXEL", + ScreenPosition = new (0, 0), + IsDirty = false + }; + + IDriver driver = new DriverImpl ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + output, + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (output)); + + driver.GetSixels ().Enqueue (s); + + // Act + output.Write (buffer); + + // Assert: sixel data should NOT have been emitted + Assert.DoesNotContain ("SKIPPED-SIXEL", output.GetLastOutput ()); + + driver.Dispose (); + } + + [Fact] + public void Write_ClearsIsDirty_AfterWritingSixel () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + buffer.AddStr ("."); + + SixelToRender s = new () + { + SixelData = "DIRTY-SIXEL", + ScreenPosition = new (0, 0), + IsDirty = true + }; + + IDriver driver = new DriverImpl ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + output, + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (output)); + + driver.GetSixels ().Enqueue (s); + + // Act + output.Write (buffer); + + // Assert: sixel was emitted and IsDirty was cleared + Assert.Contains ("DIRTY-SIXEL", output.GetLastOutput ()); + Assert.False (s.IsDirty); + + driver.Dispose (); + } + + [Fact] + public void Write_SecondFrame_SkipsSixel_WhenIsDirtyWasClearedByFirstFrame () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + buffer.AddStr ("."); + + SixelToRender s = new () + { + SixelData = "ONCE-SIXEL", + ScreenPosition = new (0, 0), + IsDirty = true + }; + + IDriver driver = new DriverImpl ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + output, + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (output)); + + driver.GetSixels ().Enqueue (s); + + // Frame 1: should emit + output.Write (buffer); + Assert.Contains ("ONCE-SIXEL", output.GetLastOutput ()); + Assert.False (s.IsDirty); + + // Frame 2: re-dirty the buffer so Write traverses rows, but sixel should be skipped + buffer.Move (0, 0); + buffer.AddStr ("X"); + output.Write (buffer); + Assert.DoesNotContain ("ONCE-SIXEL", output.GetLastOutput ()); + + driver.Dispose (); + } + + [Fact] + public void Write_AlwaysRender_BypassesIsDirtyCheck () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + buffer.AddStr ("."); + + SixelToRender s = new () + { + SixelData = "ALWAYS-SIXEL", + ScreenPosition = new (0, 0), + IsDirty = false, + AlwaysRender = true + }; + + IDriver driver = new DriverImpl ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + output, + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (output)); + + driver.GetSixels ().Enqueue (s); + + // Act + output.Write (buffer); + + // Assert: sixel was emitted even though IsDirty was false + Assert.Contains ("ALWAYS-SIXEL", output.GetLastOutput ()); + + driver.Dispose (); + } + + [Fact] + public void Write_AlwaysRender_EmitsEveryFrame () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + buffer.AddStr ("."); + + SixelToRender s = new () + { + SixelData = "EVERY-FRAME", + ScreenPosition = new (0, 0), + IsDirty = false, + AlwaysRender = true + }; + + IDriver driver = new DriverImpl ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + output, + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (output)); + + driver.GetSixels ().Enqueue (s); + + // Frame 1 + output.Write (buffer); + Assert.Contains ("EVERY-FRAME", output.GetLastOutput ()); + + // Frame 2: re-dirty buffer so Write traverses, sixel should still emit + buffer.Move (0, 0); + buffer.AddStr ("Y"); + output.Write (buffer); + Assert.Contains ("EVERY-FRAME", output.GetLastOutput ()); + + driver.Dispose (); + } + + [Fact] + public void DriverImpl_SixelSupport_DefaultsToNull () + { + // Arrange & Act + DriverImpl driver = new ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + new AnsiOutput (), + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (new AnsiOutput ())); + + // Assert + Assert.Null (driver.SixelSupport); + + driver.Dispose (); + } + + [Fact] + public void DriverImpl_SetSixelSupport_RaisesSixelSupportChangedEvent () + { + // Arrange + using DriverImpl driver = new ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + new AnsiOutput (), + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (new AnsiOutput ())); + + SixelSupportResult firstResult = new () + { + IsSupported = true, + MaxPaletteColors = 256, + SupportsTransparency = false + }; + + SixelSupportResult secondResult = new () + { + IsSupported = true, + MaxPaletteColors = 512, + SupportsTransparency = true + }; + + List> raisedArgs = []; + + driver.SixelSupportChanged += (_, e) => raisedArgs.Add (e); + + // Act 1: first call, old value should be null + driver.SetSixelSupport (firstResult); + + // Assert 1 + Assert.Single (raisedArgs); + Assert.Null (raisedArgs [0].OldValue); + Assert.Same (firstResult, raisedArgs [0].NewValue); + + // Act 2: second call, old value should be firstResult + driver.SetSixelSupport (secondResult); + + // Assert 2 + Assert.Equal (2, raisedArgs.Count); + Assert.Same (firstResult, raisedArgs [1].OldValue); + Assert.Same (secondResult, raisedArgs [1].NewValue); + } + + [Fact] + public void DriverImpl_SetSixelSupport_StoresResult () + { + // Arrange + DriverImpl driver = new ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + new AnsiOutput (), + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (new AnsiOutput ())); + + SixelSupportResult result = new () + { + IsSupported = true, + MaxPaletteColors = 512, + SupportsTransparency = true + }; + + // Act + driver.SetSixelSupport (result); + + // Assert + Assert.NotNull (driver.SixelSupport); + Assert.True (driver.SixelSupport!.IsSupported); + Assert.Equal (512, driver.SixelSupport.MaxPaletteColors); + Assert.True (driver.SixelSupport.SupportsTransparency); + + driver.Dispose (); + } } diff --git a/Tests/UnitTestsParallelizable/Input/CommandEnumTests.cs b/Tests/UnitTestsParallelizable/Input/CommandEnumTests.cs new file mode 100644 index 0000000000..2f050cab34 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Input/CommandEnumTests.cs @@ -0,0 +1,38 @@ +// Copilot + +using Terminal.Gui.Input; + +namespace UnitTestsParallelizable.Input; + +public class CommandEnumTests +{ + [Fact] + public void EditorCommands_AreDefined () + { + // Verify the editor-oriented Command enum values exist and are distinct + Command [] editorCommands = + [ + Command.Find, + Command.FindNext, + Command.FindPrevious, + Command.Replace, + Command.InsertTab, + Command.Unindent + ]; + + // All values should be distinct + Assert.Equal (editorCommands.Length, editorCommands.Distinct ().Count ()); + } + + [Theory] + [InlineData (Command.Find)] + [InlineData (Command.FindNext)] + [InlineData (Command.FindPrevious)] + [InlineData (Command.Replace)] + [InlineData (Command.InsertTab)] + [InlineData (Command.Unindent)] + public void EditorCommand_IsDefined (Command command) + { + Assert.True (Enum.IsDefined (command)); + } +} diff --git a/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs b/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs new file mode 100644 index 0000000000..66f80cb3ce --- /dev/null +++ b/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs @@ -0,0 +1,61 @@ +// Claude - Opus 4.7 + +using Terminal.Gui.Input; + +namespace UnitTestsParallelizable.Input; + +/// +/// Tests for the multi-caret members added for #5318 +/// (consumed by gui-cs/Editor vertical multi-caret). The enum shape is part of +/// the contract: the members must exist, be distinct, carry their readable +/// names, and be appended at the end so persisted configs never silently +/// rebind when the enum grows. +/// +public class CommandInsertCaretEnumTests +{ + [Theory] + [InlineData (Command.InsertCaretAbove)] + [InlineData (Command.InsertCaretBelow)] + public void InsertCaretCommand_IsDefined (Command command) + { + Assert.True (Enum.IsDefined (command)); + } + + [Fact] + public void InsertCaretCommands_AreDistinct () + { + Assert.NotEqual (Command.InsertCaretAbove, Command.InsertCaretBelow); + } + + [Theory] + [InlineData (Command.InsertCaretAbove, "InsertCaretAbove")] + [InlineData (Command.InsertCaretBelow, "InsertCaretBelow")] + public void InsertCaretCommand_HasReadableName (Command command, string expectedName) + { + // The readable name is what serializes into config.json (see + // CommandInsertCaretKeyBindingTests). A rename would silently break + // every user's persisted binding, so pin it. + Assert.Equal (expectedName, Enum.GetName (command)); + } + + [Fact] + public void InsertCaretCommands_AreAppendedAtEnd_NoRenumbering () + { + // #5319 appends the two members at the END of the enum specifically so + // no pre-existing member's implicit value shifts (serialization-stable; + // a persisted (Command) value keeps resolving to the same command). + // Assert it positionally: every other member has a lower underlying + // value than InsertCaretAbove, and InsertCaretBelow is the maximum. + Command [] all = Enum.GetValues (); + + var aboveValue = (int)Command.InsertCaretAbove; + var belowValue = (int)Command.InsertCaretBelow; + + Assert.Equal (all.Max (c => (int)c), belowValue); + Assert.True (belowValue > aboveValue); + + Assert.All ( + all.Where (c => c != Command.InsertCaretAbove && c != Command.InsertCaretBelow), + other => Assert.True ((int)other < aboveValue, $"{other} ({(int)other}) is not below InsertCaretAbove ({aboveValue}) — the enum was renumbered, breaking persisted configs")); + } +} diff --git a/Tests/UnitTestsParallelizable/Input/Keyboard/CommandInsertCaretKeyBindingTests.cs b/Tests/UnitTestsParallelizable/Input/Keyboard/CommandInsertCaretKeyBindingTests.cs new file mode 100644 index 0000000000..80cba63c89 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Input/Keyboard/CommandInsertCaretKeyBindingTests.cs @@ -0,0 +1,99 @@ +// Claude - Opus 4.7 + +using System.Text.Json; + +namespace InputTests; + +/// +/// The acceptance criterion for #5318: the new multi-caret commands must +/// round-trip through the configuration serializer by readable name +/// (not as a bare number), so a consumer's [ConfigurationProperty] +/// default key bindings are discoverable and a user can override them in +/// config.json by name — exactly the gui-cs/Editor scenario that +/// forced the (Command)1001/1002 magic-int workaround. Mirrors +/// and uses the same canonical options. +/// +public class CommandInsertCaretKeyBindingTests +{ + private static readonly JsonSerializerOptions _jsonOptions = new () { TypeInfoResolver = SourceGenerationContext.Default }; + + [Fact] + public void InsertCaretBindings_RoundTrip_ByName () + { + // Arrange — the real chords gui-cs/Editor binds (DEC-006: VS Code parity). + Dictionary original = new () + { + [Command.InsertCaretAbove] = new PlatformKeyBinding { All = ["Ctrl+Alt+CursorUp"] }, + [Command.InsertCaretBelow] = new PlatformKeyBinding { All = ["Ctrl+Alt+CursorDown"] } + }; + + // Act + string json = JsonSerializer.Serialize (original, _jsonOptions); + Dictionary? deserialized = JsonSerializer.Deserialize> (json, _jsonOptions); + + // Assert — serialized as readable names, not numbers. + Assert.Contains ("\"InsertCaretAbove\"", json); + Assert.Contains ("\"InsertCaretBelow\"", json); + Assert.DoesNotContain ("\"" + (int)Command.InsertCaretAbove + "\"", json); + Assert.DoesNotContain ("\"" + (int)Command.InsertCaretBelow + "\"", json); + + Assert.NotNull (deserialized); + Assert.Equal (2, deserialized.Count); + Assert.Equal ((Key [])["Ctrl+Alt+CursorUp"], deserialized [Command.InsertCaretAbove].All!.AsEnumerable ()); + Assert.Equal ((Key [])["Ctrl+Alt+CursorDown"], deserialized [Command.InsertCaretBelow].All!.AsEnumerable ()); + } + + [Fact] + public void InsertCaretBindings_Deserialize_FromUserConfigFormat () + { + // Arrange — what a user (or gui-cs/Editor's shipped default) writes by hand. + var json = + """ + { + "InsertCaretAbove": { "All": ["Ctrl+Alt+CursorUp"] }, + "InsertCaretBelow": { "All": ["Ctrl+Alt+CursorDown"], "Macos": ["Alt+CursorDown"] } + } + """; + + // Act + Dictionary? result = JsonSerializer.Deserialize> (json, _jsonOptions); + + // Assert + Assert.NotNull (result); + Assert.Equal (2, result.Count); + Assert.True (result.ContainsKey (Command.InsertCaretAbove)); + Assert.True (result.ContainsKey (Command.InsertCaretBelow)); + Assert.Equal ((Key [])["Ctrl+Alt+CursorUp"], result [Command.InsertCaretAbove].All!.AsEnumerable ()); + Assert.Equal ((Key [])["Alt+CursorDown"], result [Command.InsertCaretBelow].Macos!.AsEnumerable ()); + Assert.Null (result [Command.InsertCaretAbove].Macos); + } + + [Fact] + public void ViewKeyBindings_WithInsertCaret_RoundTrips () + { + // The concrete gui-cs/Editor shape: a per-view [ConfigurationProperty] + // Dictionary> ("Editor" + // → its default bindings). This is the path the magic-int cast broke. + Dictionary> original = new () + { + ["Editor"] = new Dictionary + { + [Command.InsertCaretAbove] = new () { All = ["Ctrl+Alt+CursorUp"] }, + [Command.InsertCaretBelow] = new () { All = ["Ctrl+Alt+CursorDown"] } + } + }; + + // Act + string json = JsonSerializer.Serialize (original, _jsonOptions); + + Dictionary>? deserialized = + JsonSerializer.Deserialize>> (json, _jsonOptions); + + // Assert + Assert.Contains ("\"InsertCaretAbove\"", json); + Assert.NotNull (deserialized); + Assert.Single (deserialized); + Assert.Equal ((Key [])["Ctrl+Alt+CursorUp"], deserialized ["Editor"] [Command.InsertCaretAbove].All!.AsEnumerable ()); + Assert.Equal ((Key [])["Ctrl+Alt+CursorDown"], deserialized ["Editor"] [Command.InsertCaretBelow].All!.AsEnumerable ()); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/AppendAutocompleteTests.cs b/Tests/UnitTestsParallelizable/Views/AppendAutocompleteTests.cs index 6bf6aa56bf..5249018e0b 100644 --- a/Tests/UnitTestsParallelizable/Views/AppendAutocompleteTests.cs +++ b/Tests/UnitTestsParallelizable/Views/AppendAutocompleteTests.cs @@ -83,6 +83,49 @@ public void ProcessKey_CursorUp_CyclesSuggestions () Assert.Equal (initialIdx, ac.SelectedIdx); } + [Fact] + public void ProcessKey_End_ReturnsFalse_WhenSuggestionExists () + { + // Copilot - End should not be consumed by AppendAutocomplete. + // This lets TextField's normal End command move the cursor. + // Arrange: focused text field with "f" at end and suggestion "fish" + TextField tf = new () { Text = "f" }; + tf.SetFocus (); + tf.MoveEnd (); + + AppendAutocomplete ac = new (tf); + ((SingleWordSuggestionGenerator)ac.SuggestionGenerator).AllSuggestions = ["fish"]; + + AutocompleteContext context = new ([new Cell (Grapheme: "f")], cursorPosition: 1); + ac.GenerateSuggestions (context); + + Assert.NotEmpty (ac.Suggestions); + + // Act + bool result = ac.ProcessKey (Key.End); + + // Assert: End was not consumed by autocomplete + Assert.False (result); + Assert.Equal ("f", tf.Text); + Assert.NotEmpty (ac.Suggestions); + } + + [Fact] + public void ProcessKey_End_ReturnsFalse_WhenNoSuggestions () + { + // Copilot - End key should NOT consume the event when no suggestion is showing, + // allowing the normal MoveEnd command to run. + // Arrange: text field without suggestions + TextField tf = new () { Text = "f" }; + AppendAutocomplete ac = new (tf); + + // Act + bool result = ac.ProcessKey (Key.End); + + // Assert: End was not consumed + Assert.False (result); + } + [Fact] public void AcceptSelectionIfAny_AcceptsSuggestionWhenFocused () { diff --git a/Tests/UnitTestsParallelizable/Views/ButtonDrawingTests.cs b/Tests/UnitTestsParallelizable/Views/ButtonDrawingTests.cs new file mode 100644 index 0000000000..65b0ed602b --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/ButtonDrawingTests.cs @@ -0,0 +1,121 @@ +using UnitTests; + +namespace ViewsTests; + +public class ButtonDrawingTests (ITestOutputHelper output) : TestDriverBase +{ + // Copilot + [Theory] + [InlineData (false)] + [InlineData (true)] + public void FixedWidth_Anchors_Delimiters_To_Edges (bool isDefault) + { + const int width = 10; + string expected = isDefault + ? $"{Glyphs.LeftBracket} {Glyphs.LeftDefaultIndicator} OK {Glyphs.RightDefaultIndicator} {Glyphs.RightBracket}" + : $"{Glyphs.LeftBracket} OK {Glyphs.RightBracket}"; + + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (width, 1); + + Runnable runnable = new () { Width = width, Height = 1 }; + app.Begin (runnable); + + Button button = new () + { + Text = "_OK", + X = 0, + Y = 0, + Width = width, + Height = 1, + IsDefault = isDefault, + ShadowStyle = null + }; + + runnable.Add (button); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsWithFrameAre (expected, output, app.Driver); + } + + // Copilot + [Fact] + public void Focused_FixedWidth_Button_MultiRow_Highlight_Is_Continuous () + { + const int width = 10; + const int height = 3; + + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (width, height); + + Runnable runnable = new () { Width = width, Height = height }; + app.Begin (runnable); + + Button button = new () + { + Text = "_OK", + X = 0, + Y = 0, + Width = width, + Height = height, + ShadowStyle = null + }; + + runnable.Add (button); + button.SetFocus (); + app.LayoutAndDraw (); + + // All rows must carry the Focus attribute — verifies the highlight is continuous + // across rows above and below the text (not just the delimiter row). + DriverAssert.AssertDriverAttributesAre ( + """ + 0000000000 + 0000100000 + 0000000000 + """, + output, + app.Driver, + button.GetAttributeForRole (VisualRole.Focus), + button.GetAttributeForRole (VisualRole.HotFocus) + ); + } + + // Copilot + [Fact] + public void Focused_FixedWidth_Button_Highlight_Is_Continuous () + { + const int width = 10; + + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (width, 1); + + Runnable runnable = new () { Width = width, Height = 1 }; + app.Begin (runnable); + + Button button = new () + { + Text = "_OK", + X = 0, + Y = 0, + Width = width, + Height = 1, + ShadowStyle = null + }; + + runnable.Add (button); + button.SetFocus (); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsWithFrameAre ($"{Glyphs.LeftBracket} OK {Glyphs.RightBracket}", output, app.Driver); + DriverAssert.AssertDriverAttributesAre ( + "0000100000", + output, + app.Driver, + button.GetAttributeForRole (VisualRole.Focus), + button.GetAttributeForRole (VisualRole.HotFocus) + ); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/CodeTests.cs b/Tests/UnitTestsParallelizable/Views/CodeTests.cs new file mode 100644 index 0000000000..f9529834b1 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/CodeTests.cs @@ -0,0 +1,84 @@ +// Copilot + +namespace ViewsTests; + +public class CodeTests +{ + [Fact] + public void EnableForDesign_Shows_All_Code_Roles () + { + // Copilot + Code code = new (); + ((IDesignable)code).EnableForDesign (); + + Assert.NotNull (code.SyntaxHighlighter); + Assert.NotNull (code.Language); + + code.SyntaxHighlighter!.ResetState (); + HashSet roles = []; + + foreach (string line in code.Text.ReplaceLineEndings ("\n").Split ('\n')) + { + foreach (StyledSegment segment in code.SyntaxHighlighter.Highlight (line, code.Language)) + { + if (segment.Role is { } role) + { + roles.Add (role); + } + } + } + + foreach (VisualRole role in Enum.GetValues ().Where (role => role >= VisualRole.Code)) + { + Assert.Contains (role, roles); + } + } + + [Fact] + public void Text_Set_Renders_Highlighted_Role () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (10, 2); + + Attribute keywordAttr = new (Color.Blue, Color.Black, TextStyle.Bold); + Scheme scheme = new () + { + Normal = new Attribute (Color.White, Color.Black), + CodeKeyword = keywordAttr + }; + + Code code = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + Language = "cs", + SyntaxHighlighter = new RoleHighlighter (VisualRole.CodeKeyword), + Text = "keyword" + }; + code.SetScheme (scheme); + + using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + window.Add (code); + + app.Begin (window); + app.LayoutAndDraw (); + + Cell [,]? contents = app.Driver.Contents; + Assert.NotNull (contents); + Assert.Equal (keywordAttr, contents! [0, 0].Attribute); + } + + private sealed class RoleHighlighter (VisualRole role) : ISyntaxHighlighter + { + public IReadOnlyList Highlight (string code, string? language) => [new (code, MarkdownStyleRole.CodeBlock, role: role)]; + + public void ResetState () { } + + public string ThemeName => string.Empty; + + public Color? DefaultBackground => null; + + public Attribute? GetAttributeForScope (MarkdownStyleRole role) => null; + } +} diff --git a/Tests/UnitTestsParallelizable/Views/ColorPickerTests.cs b/Tests/UnitTestsParallelizable/Views/ColorPickerTests.cs index 4378ae9648..798c5b240f 100644 --- a/Tests/UnitTestsParallelizable/Views/ColorPickerTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ColorPickerTests.cs @@ -182,7 +182,7 @@ public void ClickingAtEndOfBar_SetsMaxValue () cp.Draw (); // Draw is needed to update TrianglePosition // Click at the end of the Red bar - cp.Focused!.RaiseMouseEvent (new Mouse + cp.Focused!.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new Point (19, 0) // Assuming 0-based indexing }); @@ -213,7 +213,7 @@ public void ClickingBeyondBar_ChangesToMaxValue () cp.Draw (); // Draw is needed to update TrianglePosition // Click beyond the bar - cp.Focused!.RaiseMouseEvent (new Mouse + cp.Focused!.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new Point (21, 0) // Beyond the bar }); @@ -243,33 +243,17 @@ public void ClickingDifferentBars_ChangesFocus () cp.Draw (); // Draw is needed to update TrianglePosition - // Click on Green bar + // Click on Green bar (press then release to complete the click cycle and release grab) cp.App!.Mouse.RaiseMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new Point (0, 1) }); - - //cp.SubViews.OfType () - // .Single () - // .OnMouseEvent ( - // new () - // { - // Flags = MouseFlags.LeftButtonPressed, - // Position = new (0, 1) - // }); + cp.App!.Mouse.RaiseMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new Point (0, 1) }); cp.Draw (); // Draw is needed to update TrianglePosition Assert.IsAssignableFrom (cp.Focused); - // Click on Blue bar + // Click on Blue bar (press then release to complete the click cycle and release grab) cp.App!.Mouse.RaiseMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new Point (0, 2) }); - - //cp.SubViews.OfType () - // .Single () - // .OnMouseEvent ( - // new () - // { - // Flags = MouseFlags.LeftButtonPressed, - // Position = new (0, 2) - // }); + cp.App!.Mouse.RaiseMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new Point (0, 2) }); cp.Draw (); // Draw is needed to update TrianglePosition @@ -706,14 +690,14 @@ public void RGB_MouseNavigation () Assert.IsAssignableFrom (cp.Focused); - cp.Focused!.RaiseMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new Point (3, 0) }); + cp.Focused!.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new Point (3, 0) }); cp.Draw (); // Draw is needed to update TrianglePosition Assert.Equal (3, r.TrianglePosition); Assert.Equal ("#0F0000", hex.Text); - cp.Focused.RaiseMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new Point (4, 0) }); + cp.Focused.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new Point (4, 0) }); cp.Draw (); // Draw is needed to update TrianglePosition @@ -1100,4 +1084,81 @@ public void ValueChanging_ReceivesOldAndNewValues () } #endregion + + #region ColorBar Mouse Binding Tests (issue #5143) + + // Copilot + + [Fact] + public void ColorBar_MousePress_UpdatesValue () + { + // Regression: pressing on a bar must still update its value after the + // value-update logic was moved from OnMouseEvent to Command.Activate. + ColorPicker cp = GetColorPicker (ColorModel.RGB, false); + cp.Draw (); + + ColorBar r = GetColorBar (cp, ColorPickerPart.Bar1); + Assert.Equal (0, r.Value); + + // Position 10 is inside the bar (bar starts at X=2 for "R:" label, width 18). + r.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new Point (10, 0) }); + + Assert.True (r.Value > 0, "Value must increase when pressing inside the bar."); + + cp.App?.Dispose (); + } + + [Fact] + public void ColorBar_MouseEvent_CanBeCancelled () + { + // Subscribing to MouseEvent and setting Handled=true must prevent the value + // update. Previously the update happened in OnMouseEvent before the event was + // raised, making cancellation impossible. + ColorPicker cp = GetColorPicker (ColorModel.RGB, false); + cp.Draw (); + + ColorBar r = GetColorBar (cp, ColorPickerPart.Bar1); + Assert.Equal (0, r.Value); + + r.MouseEvent += (_, e) => { e.Handled = true; }; + + r.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new Point (10, 0) }); + + Assert.Equal (0, r.Value); + + cp.App?.Dispose (); + } + + [Fact] + public void ColorBar_Drag_BoundedToOriginatingBar () + { + // Dragging the mouse from one bar into another bar must not alter the second + // bar's value. GrabMouse in Command.Activate (not OnMouseEvent) ensures all subsequent + // drag events are routed to the bar where the press originated. + ColorPicker cp = GetColorPicker (ColorModel.RGB, false); + cp.Draw (); + + ColorBar g = GetColorBar (cp, ColorPickerPart.Bar2); + Assert.Equal (0, g.Value); + + // Press on the Red bar row (Y=0 in screen coords). + cp.App!.Mouse.RaiseMouseEvent (new Mouse + { + Flags = MouseFlags.LeftButtonPressed, + ScreenPosition = new Point (10, 0) + }); + + // Drag into the Green bar row (Y=1) – grab in Command.Activate must route events to Red bar only. + cp.App!.Mouse.RaiseMouseEvent (new Mouse + { + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, + ScreenPosition = new Point (10, 1) + }); + + Assert.Equal (0, g.Value); + + cp.App?.Dispose (); + } + + #endregion } diff --git a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs index 69befbdf15..b8188e3dbe 100644 --- a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs +++ b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs @@ -170,6 +170,57 @@ public void OpenDialog_UsesInnerTableSeparatorsWithoutOuterBorders () Assert.False (tableView.Style.ShowVerticalCellLineForLastColumn); } + [Fact] + public void FileDialog_PathField_End_MovesInsertionPointToEnd () + { + // Copilot + MockFileSystem fs = new (); + fs.AddDirectory ("/testdir"); + using FileDialog fd = new TestableFileDialog (fs); + + FieldInfo? tbPathField = typeof (FileDialog).GetField ("_tbPath", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull (tbPathField); + + TextField tbPath = Assert.IsType (tbPathField!.GetValue (fd)); + tbPath.Text = "/testdir/example.txt"; + + tbPath.NewKeyDownEvent (Key.Home); + Assert.Equal (0, tbPath.InsertionPoint); + + tbPath.NewKeyDownEvent (Key.End); + Assert.Equal (tbPath.Text.Length, tbPath.InsertionPoint); + } + + [Theory] + [InlineData ('"')] + [InlineData ('<')] + [InlineData ('>')] + [InlineData ('|')] + [InlineData ('*')] + [InlineData ('?')] + public void FileDialog_PathField_BadChars_AreSuppressed (char badChar) + { + // Copilot + MockFileSystem fs = new (); + fs.AddDirectory ("/testdir"); + using FileDialog fd = new TestableFileDialog (fs); + + FieldInfo? tbPathField = typeof (FileDialog).GetField ("_tbPath", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull (tbPathField); + + TextField tbPath = Assert.IsType (tbPathField!.GetValue (fd)); + tbPath.Text = "/testdir/"; + tbPath.MoveEnd (); + + int insertionPointBefore = tbPath.InsertionPoint; + string textBefore = tbPath.Text; + + tbPath.NewKeyDownEvent (new Key (badChar)); + + Assert.Equal (textBefore, tbPath.Text); + Assert.Equal (insertionPointBefore, tbPath.InsertionPoint); + } + /// Testable subclass that exposes the internal file-system constructor. private sealed class TestableFileDialog : FileDialog { diff --git a/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs new file mode 100644 index 0000000000..fd33aea010 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs @@ -0,0 +1,954 @@ +namespace ViewsTests; + +/// +/// Unit tests for . +/// +public class ImageViewTests +{ + #region Construction and Defaults + + [Fact] + public void Defaults_AreExpected () + { + ImageView imageView = new (); + + Assert.Null (imageView.Image); + Assert.True (imageView.UseSixel); + Assert.Null (imageView.SixelEncoder); + Assert.False (imageView.IsUsingSixel); // No driver, so sixel not available + + imageView.Dispose (); + } + + #endregion Construction and Defaults + + #region Image Property + + [Fact] + public void Image_Set_SetsNeedsDraw () + { + ImageView imageView = new () { Width = 10, Height = 10 }; + View host = new () { Width = 10, Height = 10 }; + host.Add (imageView); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + imageView.ClearNeedsDraw (); + Assert.False (imageView.NeedsDraw); + + Color [,] pixels = CreateSolidImage (5, 5, new Color (255, 0, 0)); + imageView.Image = pixels; + + Assert.True (imageView.NeedsDraw); + + host.Dispose (); + } + + [Fact] + public void Image_SetNull_ClearsState () + { + ImageView imageView = new (); + + Color [,] pixels = CreateSolidImage (5, 5, new Color (255, 0, 0)); + imageView.Image = pixels; + Assert.NotNull (imageView.Image); + + imageView.Image = null; + Assert.Null (imageView.Image); + + imageView.Dispose (); + } + + [Fact] + public void Image_Set_ClearsCachedScaledImage () + { + ImageView imageView = new () { Width = 10, Height = 10, UseSixel = false }; + View host = new () { Width = 10, Height = 10 }; + host.Add (imageView); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + // Set an initial image + Color [,] pixels1 = CreateSolidImage (5, 5, new Color (255, 0, 0)); + imageView.Image = pixels1; + + // Set a different image — should clear cached scaled image + Color [,] pixels2 = CreateSolidImage (3, 3, new Color (0, 255, 0)); + imageView.Image = pixels2; + + Assert.Same (pixels2, imageView.Image); + + host.Dispose (); + } + + #endregion Image Property + + #region UseSixel Property + + [Fact] + public void UseSixel_DefaultsToTrue () + { + ImageView imageView = new (); + Assert.True (imageView.UseSixel); + imageView.Dispose (); + } + + [Fact] + public void IsUsingSixel_FalseWhenUseSixelFalse () + { + ImageView imageView = new () { UseSixel = false }; + Assert.False (imageView.IsUsingSixel); + imageView.Dispose (); + } + + [Fact] + public void IsUsingSixel_FalseWhenNoDriver () + { + ImageView imageView = new () { UseSixel = true }; + + // No App/Driver available, so sixel shouldn't be active + Assert.False (imageView.IsUsingSixel); + imageView.Dispose (); + } + + #endregion UseSixel Property + + #region SixelEncoder Property + + [Fact] + public void SixelEncoder_DefaultsToNull () + { + ImageView imageView = new (); + Assert.Null (imageView.SixelEncoder); + imageView.Dispose (); + } + + [Fact] + public void SixelEncoder_CanBeSet () + { + SixelEncoder encoder = new () { AvoidBottomScroll = true }; + ImageView imageView = new () { SixelEncoder = encoder }; + + Assert.Same (encoder, imageView.SixelEncoder); + + imageView.Dispose (); + } + + #endregion SixelEncoder Property + + #region ScaleNearestNeighbor + + [Fact] + public void ScaleNearestNeighbor_IdentityScale_PreservesPixels () + { + Color [,] source = CreateGradientImage (4, 4); + Color [,] destination = new Color [4, 4]; + ImageView.ScaleNearestNeighbor (source, destination); + + Assert.Equal (4, destination.GetLength (0)); + Assert.Equal (4, destination.GetLength (1)); + + for (int x = 0; x < 4; x++) + { + for (int y = 0; y < 4; y++) + { + Assert.Equal (source [x, y], destination [x, y]); + } + } + } + + [Fact] + public void ScaleNearestNeighbor_Upscale_CorrectDimensions () + { + Color [,] source = CreateSolidImage (2, 2, new Color (100, 100, 100)); + Color [,] destination = new Color [6, 6]; + ImageView.ScaleNearestNeighbor (source, destination); + + Assert.Equal (6, destination.GetLength (0)); + Assert.Equal (6, destination.GetLength (1)); + + // All pixels should be the same color since source is solid + for (int x = 0; x < 6; x++) + { + for (int y = 0; y < 6; y++) + { + Assert.Equal (new Color (100, 100, 100), destination [x, y]); + } + } + } + + [Fact] + public void ScaleNearestNeighbor_Downscale_CorrectDimensions () + { + Color [,] source = CreateSolidImage (10, 10, new Color (50, 50, 50)); + Color [,] destination = new Color [3, 3]; + ImageView.ScaleNearestNeighbor (source, destination); + + Assert.Equal (3, destination.GetLength (0)); + Assert.Equal (3, destination.GetLength (1)); + + // All pixels should still be solid + for (int x = 0; x < 3; x++) + { + for (int y = 0; y < 3; y++) + { + Assert.Equal (new Color (50, 50, 50), destination [x, y]); + } + } + } + + [Fact] + public void ScaleNearestNeighbor_1x1_Target_ProducesOnePixel () + { + Color [,] source = CreateGradientImage (10, 10); + Color [,] destination = new Color [1, 1]; + ImageView.ScaleNearestNeighbor (source, destination); + + Assert.Equal (1, destination.GetLength (0)); + Assert.Equal (1, destination.GetLength (1)); + + // Should be the top-left pixel (nearest neighbor from 0,0) + Assert.Equal (source [0, 0], destination [0, 0]); + } + + [Fact] + public void ScaleNearestNeighbor_NonSquare_ScalesCorrectly () + { + Color [,] source = CreateSolidImage (4, 2, new Color (200, 100, 50)); + Color [,] destination = new Color [8, 4]; + ImageView.ScaleNearestNeighbor (source, destination); + + Assert.Equal (8, destination.GetLength (0)); + Assert.Equal (4, destination.GetLength (1)); + + for (int x = 0; x < 8; x++) + { + for (int y = 0; y < 4; y++) + { + Assert.Equal (new Color (200, 100, 50), destination [x, y]); + } + } + } + + #endregion ScaleNearestNeighbor + + #region Cell-Based Rendering + + [Fact] + public void CellBasedRendering_DrawsBackgroundColors () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 10, Height = 10 }; + app.Begin (runnable); + + ImageView imageView = new () + { + Width = 4, + Height = 2, + UseSixel = false + }; + + Color red = new (255, 0, 0); + imageView.Image = CreateSolidImage (4, 2, red); + + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Verify the cells have background color set to red (spaces with red background) + Cell [,]? contents = app.Driver!.Contents; + Assert.NotNull (contents); + + // Check that the first cell in the image area has the correct background color + Attribute attr = contents! [0, 0].Attribute!.Value; + Assert.Equal (red, attr.Background); + + runnable.Dispose (); + } + + [Fact] + public void CellBasedRendering_WithNullImage_DrawsNothing () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 10, Height = 10 }; + app.Begin (runnable); + + ImageView imageView = new () + { + Width = 4, + Height = 2, + UseSixel = false + }; + + // Image is null — should not throw + runnable.Add (imageView); + app.LayoutAndDraw (); + + runnable.Dispose (); + } + + [Fact] + public void CellBasedRendering_ScalesImageToViewport () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 20, Height = 20 }; + app.Begin (runnable); + + // Create a 10x10 image but display in a 5x5 viewport + ImageView imageView = new () + { + Width = 5, + Height = 5, + UseSixel = false + }; + + Color blue = new (0, 0, 255); + imageView.Image = CreateSolidImage (10, 10, blue); + + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Verify cells have the blue background (image was scaled down) + Cell [,]? contents = app.Driver!.Contents; + Assert.NotNull (contents); + Assert.Equal (blue, contents! [0, 0].Attribute!.Value.Background); + + runnable.Dispose (); + } + + #endregion Cell-Based Rendering + + #region Dispose + + [Fact] + public void Dispose_CleansUpSixelData () + { + ImageView imageView = new (); + Color [,] pixels = CreateSolidImage (5, 5, new Color (128, 128, 128)); + imageView.Image = pixels; + + // Should not throw + imageView.Dispose (); + + // Accessing after dispose is a code smell but should not crash + Assert.NotNull (imageView.Image); // Still set; just disposed + } + + [Fact] + public void Dispose_WithNoImage_DoesNotThrow () + { + ImageView imageView = new (); + imageView.Dispose (); // Should not throw + } + + #endregion Dispose + + #region IDesignable + + [Fact] + public void EnableForDesign_SetsTestImage () + { + ImageView imageView = new (); + bool result = ((IDesignable)imageView).EnableForDesign (); + + Assert.True (result); + Assert.NotNull (imageView.Image); + Assert.Equal (20, imageView.Image!.GetLength (0)); // width + Assert.Equal (10, imageView.Image.GetLength (1)); // height + + imageView.Dispose (); + } + + [Fact] + public void EnableForDesign_CreatesGradientImage () + { + ImageView imageView = new (); + ((IDesignable)imageView).EnableForDesign (); + + Color [,] image = imageView.Image!; + + // Top-left should be (0, 0, 128) + Assert.Equal (new Color (0, 0, 128), image [0, 0]); + + // Bottom-right should be (255, 255, 128) + Assert.Equal (new Color (255, 255, 128), image [19, 9]); + + imageView.Dispose (); + } + + #endregion IDesignable + + #region SixelToRender IsDirty Flag + + [Fact] + public void SixelToRender_IsDirty_DefaultsToTrue () + { + SixelToRender sixel = new (); + Assert.True (sixel.IsDirty); + } + + [Fact] + public void SixelToRender_IsDirty_CanBeSetToFalse () + { + SixelToRender sixel = new () { IsDirty = false }; + Assert.False (sixel.IsDirty); + } + + [Fact] + public void SixelToRender_AlwaysRender_DefaultsToFalse () + { + SixelToRender sixel = new (); + Assert.False (sixel.AlwaysRender); + } + + #endregion SixelToRender IsDirty Flag + + #region ViewportToScreenInPixels + [Fact] + public void ViewportToScreenInPixels_Throws_WhenNoSixelSupport () + { + ImageView imageView = new () { Width = 10, Height = 5 }; + View host = new () { Width = 20, Height = 20 }; + host.Add (imageView); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + // No App/Driver — should throw + Assert.Throws (() => imageView.ViewportToScreenInPixels ()); + + host.Dispose (); + } + + [Fact] + public void ViewportToScreenInPixels_ReturnsCorrectPixelRect () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + // Configure sixel support with 10x20 pixel resolution per cell + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + ImageView imageView = new () { Width = 8, Height = 4, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Rectangle pixelRect = imageView.ViewportToScreenInPixels (); + + // Width: 8 cells * 10 px/cell = 80 px + Assert.Equal (80, pixelRect.Width); + + // Height: 4 cells * 20 px/cell = 80 px (via GetHeightInPixels) + Assert.Equal (80, pixelRect.Height); + + runnable.Dispose (); + } + + [Fact] + public void ViewportToScreenInPixels_AccountsForViewPosition () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + ImageView imageView = new () { X = 3, Y = 2, Width = 5, Height = 3, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Rectangle pixelRect = imageView.ViewportToScreenInPixels (); + + // X: 3 cells * 10 px/cell = 30 px + Assert.Equal (30, pixelRect.X); + + // Y: 2 cells * 20 px/cell = 40 px + Assert.Equal (40, pixelRect.Y); + + // Width: 5 cells * 10 px/cell = 50 px + Assert.Equal (50, pixelRect.Width); + + // Height: 3 cells * 20 px/cell = 60 px + Assert.Equal (60, pixelRect.Height); + + runnable.Dispose (); + } + + [Fact] + public void ViewportToScreenInPixels_DifferentResolution () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + // Use a non-default resolution (8x16) + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (8, 16) }); + + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Rectangle pixelRect = imageView.ViewportToScreenInPixels (); + + // Width: 10 cells * 8 px/cell = 80 px + Assert.Equal (80, pixelRect.Width); + + // Height: 5 cells * 16 px/cell = 80 px + Assert.Equal (80, pixelRect.Height); + + runnable.Dispose (); + } + + #endregion ViewportToScreenInPixels + + #region FitImageInViewportInPixels + + [Fact] + public void FitImageInViewportInPixels_ZeroSizeImage_ReturnsEmpty () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Size result = imageView.FitImageInViewportInPixels (new Size (0, 0)); + Assert.Equal (Size.Empty, result); + + result = imageView.FitImageInViewportInPixels (new Size (100, 0)); + Assert.Equal (Size.Empty, result); + + result = imageView.FitImageInViewportInPixels (new Size (0, 100)); + Assert.Equal (Size.Empty, result); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportInPixels_ImageFitsExactly () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Viewport: 10 cells * 10 px = 100 px wide, 5 cells * 20 px = 100 px tall + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Image is exactly the viewport pixel size + Size result = imageView.FitImageInViewportInPixels (new Size (100, 100)); + + Assert.Equal (100, result.Width); + Assert.Equal (100, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportInPixels_WideImage_ScalesToFitWidth () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Viewport: 10 cells * 10 px = 100 px wide, 5 cells * 20 px = 100 px tall + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Image is 200x100 (2:1 aspect) — width-constrained + // Scale = min(100/200, 100/100) = min(0.5, 1.0) = 0.5 + // Result: 200*0.5 = 100 wide, 100*0.5 = 50 tall + Size result = imageView.FitImageInViewportInPixels (new Size (200, 100)); + + Assert.Equal (100, result.Width); + Assert.Equal (50, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportInPixels_TallImage_ScalesToFitHeight () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Viewport: 10 cells * 10 px = 100 px wide, 5 cells * 20 px = 100 px tall + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Image is 100x200 (1:2 aspect) — height-constrained + // Scale = min(100/100, 100/200) = min(1.0, 0.5) = 0.5 + // Result: 100*0.5 = 50 wide, 200*0.5 = 100 tall + Size result = imageView.FitImageInViewportInPixels (new Size (100, 200)); + + Assert.Equal (50, result.Width); + Assert.Equal (100, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportInPixels_SmallImage_ScalesUp () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Viewport: 10 cells * 10 px = 100 px wide, 5 cells * 20 px = 100 px tall + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Image is 10x20 (1:2 aspect) — height-constrained + // Scale = min(100/10, 100/20) = min(10, 5) = 5 + // Result: 10*5 = 50 wide, 20*5 = 100 tall + Size result = imageView.FitImageInViewportInPixels (new Size (10, 20)); + + Assert.Equal (50, result.Width); + Assert.Equal (100, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportInPixels_VerySmallImage_ClampsToMinimumOne () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // 1x1 viewport = 10x20 pixel viewport + ImageView imageView = new () { Width = 1, Height = 1, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Even with a 1000x1 image scaled to fit 10x20, width and height should be >= 1 + Size result = imageView.FitImageInViewportInPixels (new Size (1000, 1)); + + Assert.True (result.Width >= 1); + Assert.True (result.Height >= 1); + + runnable.Dispose (); + } + + #endregion FitImageInViewportInPixels + + #region FitImageInViewportCells + + [Fact] + public void FitImageInViewportCells_ZeroSizeImage_ReturnsEmpty () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Size result = imageView.FitImageInViewportCells (new Size (0, 0)); + Assert.Equal (Size.Empty, result); + + result = imageView.FitImageInViewportCells (new Size (100, 0)); + Assert.Equal (Size.Empty, result); + + result = imageView.FitImageInViewportCells (new Size (0, 100)); + Assert.Equal (Size.Empty, result); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportCells_SquareImage_AccountsForCellAspectRatio () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + // Cell resolution 10x20: cell aspect ratio = 20/10 = 2.0 + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Viewport: 10 cells wide x 5 cells tall + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // 100x100 pixel image: + // After aspect adjustment: imageSize = (100, 100/2.0) = (100, 50) + // widthScale = 10/100 = 0.1, heightScale = 5/50 = 0.1 + // scale = 0.1, result = (100*0.1, 50*0.1) = (10, 5) + Size result = imageView.FitImageInViewportCells (new Size (100, 100)); + + Assert.Equal (10, result.Width); + Assert.Equal (5, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportCells_WideImage_ConstrainedByWidth () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Viewport: 10 cells wide x 10 cells tall + ImageView imageView = new () { Width = 10, Height = 10, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // 200x100 pixel image: + // After aspect adjustment: imageSize = (200, 100/2.0) = (200, 50) + // widthScale = 10/200 = 0.05, heightScale = 10/50 = 0.2 + // scale = 0.05, result = (200*0.05, 50*0.05) = (10, 2) + Size result = imageView.FitImageInViewportCells (new Size (200, 100)); + + Assert.Equal (10, result.Width); + Assert.Equal (2, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportCells_TallImage_ConstrainedByHeight () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Viewport: 10 cells wide x 5 cells tall + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // 100x400 pixel image: + // After aspect adjustment: imageSize = (100, 400/2.0) = (100, 200) + // widthScale = 10/100 = 0.1, heightScale = 5/200 = 0.025 + // scale = 0.025, result = (100*0.025, 200*0.025) = (2, 5) + Size result = imageView.FitImageInViewportCells (new Size (100, 400)); + + Assert.Equal (2, result.Width); + Assert.Equal (5, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportCells_SmallImage_ScalesUp () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Viewport: 20 cells wide x 10 cells tall + ImageView imageView = new () { Width = 20, Height = 10, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // 10x20 pixel image: + // After aspect adjustment: imageSize = (10, 20/2.0) = (10, 10) + // widthScale = 20/10 = 2.0, heightScale = 10/10 = 1.0 + // scale = 1.0, result = (10*1.0, 10*1.0) = (10, 10) + Size result = imageView.FitImageInViewportCells (new Size (10, 20)); + + Assert.Equal (10, result.Width); + Assert.Equal (10, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportCells_NoDriver_UsesDefaultAspectRatio () + { + // Without a driver, the fallback cell aspect ratio is 2.0 + ImageView imageView = new () { Width = 10, Height = 5 }; + View host = new () { Width = 20, Height = 20 }; + host.Add (imageView); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + // 100x100 pixel image: + // Default aspect ratio = 2.0 → imageSize = (100, 100/2.0) = (100, 50) + // widthScale = 10/100 = 0.1, heightScale = 5/50 = 0.1 + // scale = 0.1, result = (100*0.1, 50*0.1) = (10, 5) + Size result = imageView.FitImageInViewportCells (new Size (100, 100)); + + Assert.Equal (10, result.Width); + Assert.Equal (5, result.Height); + + host.Dispose (); + } + + [Fact] + public void FitImageInViewportCells_DifferentResolution_AdjustsAspectRatio () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + // Cell resolution 8x16: cell aspect ratio = 16/8 = 2.0 + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (8, 16) }); + + // Viewport: 10 cells wide x 5 cells tall + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // 80x80 pixel image: + // After aspect adjustment: imageSize = (80, 80/2.0) = (80, 40) + // widthScale = 10/80 = 0.125, heightScale = 5/40 = 0.125 + // scale = 0.125, result = (80*0.125, 40*0.125) = (10, 5) + Size result = imageView.FitImageInViewportCells (new Size (80, 80)); + + Assert.Equal (10, result.Width); + Assert.Equal (5, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportCells_VerySmallImage_ClampsToMinimumOne () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 40, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // 1x1 viewport = very small cell area + ImageView imageView = new () { Width = 1, Height = 1, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Even with a very wide image, result dimensions should be >= 1 + Size result = imageView.FitImageInViewportCells (new Size (1000, 1)); + + Assert.True (result.Width >= 1); + Assert.True (result.Height >= 1); + + runnable.Dispose (); + } + + #endregion FitImageInViewportCells + + #region Helper Methods + + /// Creates a solid-color image of the specified dimensions. + private static Color [,] CreateSolidImage (int width, int height, Color color) + { + Color [,] image = new Color [width, height]; + + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + image [x, y] = color; + } + } + + return image; + } + + /// Creates a gradient image where pixel color varies by position. + private static Color [,] CreateGradientImage (int width, int height) + { + Color [,] image = new Color [width, height]; + + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + byte r = (byte)(x * 255 / Math.Max (1, width - 1)); + byte g = (byte)(y * 255 / Math.Max (1, height - 1)); + image [x, y] = new Color (r, g, 128); + } + } + + return image; + } + + #endregion Helper Methods +} \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index 156bba53c3..e99dab4027 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -250,6 +250,49 @@ public void MouseBindings_LeftButtonPressedPositionReport_IsBoundTo_Activate () mv.Dispose (); } + // Copilot - Regression: #5272 — Ctrl+LeftButtonReleased must NOT be bound to Context + // The base View class adds this binding; Markdown must remove it so Ctrl+Click can follow + // links without triggering the context menu popover. + [Fact] + public void MouseBindings_CtrlLeftButtonReleased_IsNotBoundTo_Context () + { + Terminal.Gui.Views.Markdown mv = new (); + + bool found = mv.MouseBindings.TryGet (MouseFlags.LeftButtonReleased | MouseFlags.Ctrl, out _); + + Assert.False (found); + + mv.Dispose (); + } + + // Copilot - Regression: #5272 — Ctrl+Click on a link opens the link and does NOT show context menu + [Fact] + public void CtrlClick_On_Link_Opens_Link_And_Does_Not_Show_Context_Menu () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("[Click](https://example.com)"); + + mv.SetFocus (); + + var linkClicked = false; + + mv.LinkClicked += (_, e) => + { + linkClicked = true; + e.Handled = true; + }; + + // Simulate Ctrl+Click: press, Ctrl+release, Ctrl+clicked + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonReleased | MouseFlags.Ctrl }); + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonClicked }); + + Assert.True (linkClicked); + Assert.True (mv.ContextMenu is null || !mv.ContextMenu.Visible); + + window.Dispose (); + app.Dispose (); + } + // Copilot - verifies that a drag (press + position-report) activates the selection [Fact] public void Drag_Mouse_Creates_Selection () @@ -462,20 +505,23 @@ public void PartialSelection_TaskList_SelectedText_Preserves_Markdown_Markers () } // Copilot - partial drag selection spanning lines inside a csharp code block (with 🌍) + // The selection covers all code lines but no non-code content, so no fence delimiters + // should appear in the output (mirrors the copy-button behaviour on MarkdownCodeBlock). [Fact] - public void PartialSelection_FencedCodeBlock_SelectedText_Preserves_Fence_Context () + public void PartialSelection_FencedCodeBlock_SelectedText_DoesNotIncludeFence () { string md = "```csharp\nConsole.WriteLine (\"Hello, Terminal.Gui! 🌍\");\nvar x = 42;\n```"; (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); - // Select both code lines (rendered as lines 0 and 1 — fence lines are not in _renderedLines) + // Select both code lines (rendered as lines 0 and 1 — fence lines are not in _renderedLines). + // End at x=10 (one short of "var x = 42;" width=11) so IsFullDocumentSelected() returns false. mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); - mv.NewMouseEvent (new Mouse { Position = new Point (12, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + mv.NewMouseEvent (new Mouse { Position = new Point (10, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); string? selected = mv.SelectedText; Assert.NotNull (selected); - Assert.Contains ("```csharp", selected); + Assert.DoesNotContain ("```", selected); Assert.Contains ("Console.WriteLine", selected); Assert.Contains ("🌍", selected); @@ -764,6 +810,226 @@ public void PartialSelection_DocEndingWithTable_NotTreatedAsFullDocument () app.Dispose (); } + // Copilot - Regression test for #5270. + // When the Markdown view is scrolled (Viewport.Y > 0) and the selection overlay runs + // for table rows, DrawSelectionOverlayOnSubViewRows must read from ScreenContents at + // the CORRECT screen row. The bug passed drawRow (viewport-relative) to ContentToScreen + // instead of lineIdx (content-relative), causing ContentToScreen to double-subtract + // Viewport.Y and read from the wrong row — displaying header content where body content + // should appear. + [Fact] + public void SelectionOverlay_On_Table_Is_Synced_When_Scrolled () + { + // Layout: + // row 0 : "para" (paragraph text) + // row 1 : "" (blank between paragraph and table) + // rows 2-6 : 5-row table (top border, header, separator, body, bottom border) + const int SCREEN_WIDTH = 30; + const int SCREEN_HEIGHT = 5; // Must be less than content height (7) so scrolling is possible + + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + app.Driver.Force16Colors = true; + + Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + Scheme scheme = new (new Attribute (Color.Black, Color.White)); + window.SetScheme (scheme); + + Terminal.Gui.Views.Markdown mv = new () + { + Text = "para\n\n| H | V |\n|---|---|\n| 1 | 2 |", + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + mv.SchemeName = null; + mv.SetScheme (scheme); + window.Add (mv); + + // Initial draw at Viewport.Y=0 to populate ScreenContents with the table rows. + app.Begin (window); + app.LayoutAndDraw (); + + // Scroll past "para" and the blank line so only the table is visible. + mv.Viewport = mv.Viewport with { Y = 2 }; + + // Activate a full selection, then redraw so the overlay runs while scrolled. + mv.SelectAll (); + app.LayoutAndDraw (); + + // The body row "│ 1 │ 2 │" must be visible. + // With the bug, DrawSelectionOverlayOnSubViewRows passes drawRow (viewport-relative) + // to ContentToScreen instead of lineIdx (content-relative). ContentToScreen then + // double-subtracts Viewport.Y=2, so the body row reads from screen row 1 (the header) + // and overwrites "│ 1 │ 2 │" with "│ H │ V │" — making "1" and "2" disappear. + string screen = app.Driver.ToString (); + Assert.Contains ("1", screen); + Assert.Contains ("2", screen); + + window.Dispose (); + app.Dispose (); + } + + // --- Issue #5273: partial selection inside a code block must not include fence delimiters --- + + // Copilot - Claude Sonnet 4.6 + // Selecting only middle lines of a multi-line code block should not produce fence delimiters. + [Fact] + public void PartialSelection_InsideCodeBlock_DoesNotIncludeFenceDelimiters () + { + // 4 code lines; select only the middle two (lines 1 and 2) + string md = "```csharp\nline A\nline B\nline C\nline D\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Rendered lines 0-3 are the four code lines (fence lines are stripped during parse). + // Press on line 1, drag to end of line 2. + mv.NewMouseEvent (new Mouse { Position = new Point (0, 1), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (60, 2), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("line B", selected); + Assert.Contains ("line C", selected); + Assert.DoesNotContain ("```", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Claude Sonnet 4.6 + // Selection starts before the code block (on a paragraph line) and ends inside it. + // Only the opening fence should be present; the closing fence must be omitted. + [Fact] + public void PartialSelection_StartBeforeCodeBlock_EndInside_HasOpeningFenceOnly () + { + string md = "Before\n```csharp\nline A\nline B\nline C\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Rendered line 0 = "Before", lines 1-3 = code lines. + // Press on line 0, drag to line 2 (mid-block). + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (60, 2), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("Before", selected); + Assert.Contains ("```csharp", selected); + Assert.Contains ("line A", selected); + Assert.Contains ("line B", selected); + Assert.DoesNotContain ("line C", selected); + + // Opening fence present; closing fence absent because selection ends mid-block. + int fenceCount = CountOccurrences (selected, "```"); + Assert.Equal (1, fenceCount); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Claude Sonnet 4.6 + // Selection starts inside a code block and ends after it (on a paragraph line). + // A closing fence is expected because the selection crosses the block's end, even + // though no opening fence was emitted (the selection began inside the block). + [Fact] + public void PartialSelection_StartInsideCodeBlock_EndAfter_HasClosingFenceOnly () + { + string md = "```csharp\nline A\nline B\nline C\n```\nAfter"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Rendered lines 0-2 = code lines, line 3 = "After". + // Press on line 1 (mid-block), drag to line 3 (after block). + mv.NewMouseEvent (new Mouse { Position = new Point (0, 1), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (60, 3), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("line B", selected); + Assert.Contains ("line C", selected); + Assert.Contains ("After", selected); + Assert.DoesNotContain ("line A", selected); + + // Closing fence present because selection crosses out of the code block; no opening fence. + int fenceCount = CountOccurrences (selected, "```"); + Assert.Equal (1, fenceCount); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Claude Sonnet 4.6 + // Regression guard: selecting all lines of a code block starting from its first line should + // produce NO fence delimiters — the selection is entirely within the fenced region. + [Fact] + public void PartialSelection_AllLinesOfCodeBlock_FromFirstLine_NoFences () + { + // Three code lines; select only the first two to avoid triggering IsFullDocumentSelected(). + string md = "```csharp\nline A\nline B\nline C\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Press on line 0 (first code line), drag to end of line 1. + // end.Y=1 < lastLine=2, so IsFullDocumentSelected() returns false and partial-selection runs. + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (6, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("line A", selected); + Assert.Contains ("line B", selected); + Assert.DoesNotContain ("line C", selected); + Assert.DoesNotContain ("```", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Regression test for partial code-block selection highlight. + // When only part of a code-block line is selected (e.g. start or end of multi-line selection + // falls inside the block), DrawSelectionOverlayOnSubViewRows must NOT highlight the entire + // row — it must respect the per-column IsInSelection check, exactly as DrawRenderedLine does + // for plain text lines. The bug applied selAttr to every column unconditionally. + [Fact] + public void SelectionOverlay_On_CodeBlock_HighlightsOnlySelectedColumns () + { + // Code block with two lines; select only from column 3 of line 0 to the end. + // Line 0 columns 0-2 must NOT carry the selection background. + string md = "```\nABCDEF\nGHIJKL\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 20, height: 5); + + app.LayoutAndDraw (); + + // Anchor at col 3 of rendered line 0, drag to end of line 1. + mv.NewMouseEvent (new Mouse { Position = new Point (3, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (20, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + app.LayoutAndDraw (); + + // Inspect raw screen buffer: the first 3 columns of line 0 must use Normal (not Focus) role. + Scheme scheme = mv.GetScheme ()!; + Attribute focus = scheme.Focus; + + Cell [,]? screen = app.Driver!.Contents; + Assert.NotNull (screen); + + // Line 0 of the code block is screen row 0 (no preceding content). + for (int col = 0; col < 3; col++) + { + Assert.NotEqual (focus, screen [0, col].Attribute); + } + + // Column 3 onwards on line 0 must carry the selection (focus) attribute. + for (int col = 3; col < 6; col++) + { + Assert.Equal (focus, screen [0, col].Attribute); + } + + window.Dispose (); + app.Dispose (); + } + private static int CountOccurrences (string text, string pattern) { int count = 0; diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/SyntaxHighlighterPipelineTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/SyntaxHighlighterPipelineTests.cs index 409fdf928b..b2443a3a5c 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/SyntaxHighlighterPipelineTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/SyntaxHighlighterPipelineTests.cs @@ -340,6 +340,25 @@ public void MarkdownAttributeHelper_Fallback_Uses_Theme_Background () Assert.True (result.Style.HasFlag (TextStyle.Italic)); } + [Fact] + public void MarkdownAttributeHelper_CodeToken_Uses_Code_Background () + { + // Copilot + Terminal.Gui.Views.Markdown mv = new (); + mv.SetScheme (new Scheme + { + Normal = new Attribute (Color.White, Color.Black), + Code = new Attribute (new Color (StandardColor.LightGray), new Color (StandardColor.DarkBlue)), + CodeKeyword = new Attribute (Color.Cyan, Color.None) + }); + + StyledSegment segment = new ("public", MarkdownStyleRole.CodeBlock, role: VisualRole.CodeKeyword); + Attribute result = MarkdownAttributeHelper.GetAttributeForSegment (mv, segment); + + Assert.Equal (Color.Cyan, result.Foreground); + Assert.Equal (new Color (StandardColor.DarkBlue), result.Background); + } + [Fact] public void MarkdownAttributeHelper_No_ThemeBackground_Uses_View_Background () { diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/TextMateSyntaxHighlighterTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/TextMateSyntaxHighlighterTests.cs index 16ec2f15cf..f9d24c080a 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/TextMateSyntaxHighlighterTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/TextMateSyntaxHighlighterTests.cs @@ -58,23 +58,23 @@ public void Highlight_CSharp_Segments_Cover_Full_Line () } [Fact] - public void Highlight_CSharp_Keyword_Has_Explicit_Attribute () + public void Highlight_CSharp_Keyword_Has_CodeKeyword_Role () { TextMateSyntaxHighlighter highlighter = new (); IReadOnlyList segments = highlighter.Highlight ("using System;", "csharp"); - // At least one segment should have an explicit Attribute (non-null) - Assert.Contains (segments, s => s.Attribute is { }); + // Copilot + Assert.Contains (segments, s => s is { Text: "using", Role: VisualRole.CodeKeyword }); } [Fact] - public void Highlight_CSharp_All_Segments_Have_Attributes () + public void Highlight_CSharp_All_Segments_Have_Roles () { TextMateSyntaxHighlighter highlighter = new (); IReadOnlyList segments = highlighter.Highlight ("int x = 42;", "csharp"); - // Every segment should carry an explicit Attribute from theme resolution - Assert.All (segments, s => Assert.NotNull (s.Attribute)); + // Copilot + Assert.All (segments, s => Assert.NotNull (s.Role)); } [Fact] @@ -166,7 +166,7 @@ public void Stateful_Tokenization_Across_Lines () // --- Theme switching --- [Fact] - public void SetTheme_Changes_Colors () + public void SetTheme_Does_Not_Change_Roles () { TextMateSyntaxHighlighter highlighter = new (); IReadOnlyList darkSegments = highlighter.Highlight ("var x = 1;", "csharp"); @@ -175,21 +175,8 @@ public void SetTheme_Changes_Colors () highlighter.ResetState (); IReadOnlyList lightSegments = highlighter.Highlight ("var x = 1;", "csharp"); - // Dark and light themes should produce different foreground colors for at least some tokens - var anyDifferent = false; - - for (var i = 0; i < Math.Min (darkSegments.Count, lightSegments.Count); i++) - { - if (darkSegments [i].Attribute?.Foreground == lightSegments [i].Attribute?.Foreground) - { - continue; - } - anyDifferent = true; - - break; - } - - Assert.True (anyDifferent, "Dark and Light themes should produce different colors"); + // Copilot + Assert.Equal (darkSegments.Select (segment => segment.Role), lightSegments.Select (segment => segment.Role)); } // --- Multiple languages --- @@ -389,4 +376,30 @@ public void SetTheme_Updates_ThemeName () highlighter.SetTheme (ThemeName.SolarizedLight); Assert.Equal (ThemeName.SolarizedLight, highlighter.ThemeName); } + + [Theory] + [InlineData ("comment.line", VisualRole.CodeComment)] + [InlineData ("keyword.control", VisualRole.CodeKeyword)] + [InlineData ("string.quoted", VisualRole.CodeString)] + [InlineData ("constant.numeric", VisualRole.CodeNumber)] + [InlineData ("keyword.operator", VisualRole.CodeOperator)] + [InlineData ("entity.name.type", VisualRole.CodeType)] + [InlineData ("support.type", VisualRole.CodeType)] + [InlineData ("storage.type", VisualRole.CodeType)] + [InlineData ("meta.preprocessor", VisualRole.CodePreprocessor)] + [InlineData ("variable.other", VisualRole.CodeIdentifier)] + [InlineData ("entity.name.variable", VisualRole.CodeIdentifier)] + [InlineData ("constant.language", VisualRole.CodeConstant)] + [InlineData ("constant.character", VisualRole.CodeConstant)] + [InlineData ("punctuation.separator", VisualRole.CodePunctuation)] + [InlineData ("entity.name.function", VisualRole.CodeFunctionName)] + [InlineData ("support.function", VisualRole.CodeFunctionName)] + [InlineData ("entity.other.attribute-name", VisualRole.CodeAttribute)] + [InlineData ("meta.tag", VisualRole.CodeAttribute)] + public void ResolveRoleForScopes_Maps_TextMate_Scopes (string scope, VisualRole expected) + { + // Copilot + VisualRole actual = TextMateSyntaxHighlighter.ResolveRoleForScopes ([scope]); + Assert.Equal (expected, actual); + } } diff --git a/docfx/docs/command-diagrams.md b/docfx/docs/command-diagrams.md index 08657ce9bd..84e1f8ec18 100644 --- a/docfx/docs/command-diagrams.md +++ b/docfx/docs/command-diagrams.md @@ -130,3 +130,39 @@ flowchart TD - holds a (not a `SubMenu`). holds a `SubMenu` (a nested ). - connects non-containment boundaries (e.g., ) so / from the remote view re-enters the owner's full command pipeline with `Routing = Bridged`. - uses consume dispatch ( = true, → `Focused`) — inner activations are consumed and do not propagate to 's SuperView. + +### Level 4: Mouse Event Forwarding via CommandNotBound Bubbling + +This diagram shows how a child view can forward mouse-wheel events to an ancestor via the `CommandNotBound` → `TryBubbleUp` mechanism. The child adds a `MouseBinding` without an `AddCommand` handler, causing the command to bubble. + +```mermaid +flowchart TD + input["Mouse wheel on Gutter (in Editor.Padding)"] --> mb["MouseBindings maps
WheeledUp → Command.ScrollUp"] + mb --> invoke["Gutter.InvokeCommand(ScrollUp)"] + invoke --> lookup{"AddCommand handler
exists for ScrollUp?"} + + lookup --> |"no"| notbound["DefaultCommandNotBoundHandler"] + notbound --> raise["RaiseCommandNotBound:
OnCommandNotBound (virtual)
→ CommandNotBound event"] + raise --> |"not handled"| bubble["TryBubbleUp"] + + bubble --> check_sv{"SuperView is
AdornmentView?"} + check_sv --> |"yes (Gutter in Padding)"| adorn_check{"Adornment.Parent
.CommandsToBubbleUp
contains ScrollUp?"} + adorn_check --> |"yes"| parent_invoke["Editor.InvokeCommand(ScrollUp)
(Routing = BubblingUp)"] + parent_invoke --> editor_handler["Editor.ScrollVertical(-1)
→ returns true"] + + check_sv --> |"no (normal SubView)"| sv_check{"SuperView
.CommandsToBubbleUp
contains ScrollUp?"} + sv_check --> |"yes"| sv_invoke["SuperView.InvokeCommand(ScrollUp)
(Routing = BubblingUp)"] + sv_check --> |"no"| willbubble{"CommandWillBubbleToAncestor?"} + willbubble --> |"yes"| ret_handled["return true (consumed)"] + willbubble --> |"no"| ret_null["return null (unhandled)"] + + adorn_check --> |"no"| willbubble + raise --> |"handled (args.Handled = true)"| ret_true["return true (stop)"] + lookup --> |"yes"| exec["Execute handler directly"] +``` + +**Key Points:** +- Adding a `MouseBinding` without a corresponding `AddCommand` handler is **intentional and idiomatic** — it declares that the command should bubble to an ancestor. +- For views inside an (Padding, Border, Margin), the bubble target is `Adornment.Parent` (the owning View), not `SuperView`. +- checks both the `SuperView` path and the `AdornmentView` path. +- The event can intercept and cancel bubbling by setting `args.Handled = true`. diff --git a/docfx/docs/config.md b/docfx/docs/config.md index 60f1e33455..a89fe809f4 100644 --- a/docfx/docs/config.md +++ b/docfx/docs/config.md @@ -80,6 +80,9 @@ ThemeManager.Theme = "Dark"; ConfigurationManager.Apply(); ``` +To customize syntax highlighting colors, override the `Code*` visual roles on a scheme. See +`Examples/Themes/code-dark.config.json` for a minimal Dark theme override. + --- ## Configuration Scopes diff --git a/docfx/docs/events.md b/docfx/docs/events.md index 9e5c7bf023..4f54b42fc1 100644 --- a/docfx/docs/events.md +++ b/docfx/docs/events.md @@ -42,6 +42,37 @@ Use this decision tree to choose the right pattern: | Simple notification (no cancel) | `EventHandler` | [Recipe 3](#recipe-3-simple-notification) | | Property notification (MVVM) | `INotifyPropertyChanged` | [Recipe 4](#recipe-4-mvvm-property-notification) | +## When to Use `-ing` vs `-ed` Events + +Terminal.Gui exposes paired events on many surfaces — `Accepting`/`Accepted`, `Activating`/`Activated`, `ValueChanging`/`ValueChanged`, etc. Use this rule to choose: + +> **Use `-ed` (past-tense) events for side-effects. Use `-ing` (present-progressive) events only when you actually need to inspect or cancel the in-flight operation.** + +If your handler doesn't read or set anything on the `EventArgs` (no `e.Handled`, no `e.Cancel`, no inspection of the candidate value), you want the `-ed` event. The `-ing` event runs synchronously in the middle of the dispatch path and is heavier for both the framework and the reader of your code. + +### Concrete Examples + +```csharp +// ✅ Correct — fire-and-forget side-effect belongs on the -ed event +button.Accepted += (_, _) => DoTheThing (); + +// ✅ Correct — actually needs to cancel, so -ing is right +button.Accepting += (_, e) => { if (!CanProceed ()) e.Handled = true; }; + +// ❌ Wrong — handler ignores EventArgs; should use Accepted +button.Accepting += (_, _) => DoTheThing (); +``` + +The same rule applies to every other paired event in the framework: + +| Use `-ed` (side-effect) | Use `-ing` (inspect / cancel) | +|-------------------------|-------------------------------| +| `Accepted` | `Accepting` | +| `Activated` | `Activating` | +| `ValueChanged` | `ValueChanging` | +| `TextChanged` | `TextChanging` | +| `TitleChanged` | `TitleChanging` | + ## See Also * [Cancellable Work Pattern](cancellable-work-pattern.md) - Conceptual overview diff --git a/docfx/docs/mouse.md b/docfx/docs/mouse.md index c331ff2f61..49418feddd 100644 --- a/docfx/docs/mouse.md +++ b/docfx/docs/mouse.md @@ -604,6 +604,79 @@ Platform API ? InputProcessorImpl ? AnsiResponseParser ? MouseInterpreter ? Appl This ensures consistent mouse behavior across platforms while maintaining platform-specific optimizations. +## Mouse Event Forwarding via Command Bubbling + +A common need is forwarding mouse-wheel events from a child view to an ancestor (e.g., a gutter subview inside a scrollable editor's Padding that should scroll the editor). Terminal.Gui supports this idiomatically via the **CommandNotBound bubbling** mechanism. + +### The Pattern + +1. **Child view**: Add a `MouseBinding` for the wheel event **without** calling `AddCommand` for that command. +2. **Parent view**: Include the scroll commands in . +3. **Result**: When the child receives the wheel event, the mouse binding fires the command. Because the child has no handler (`AddCommand` was not called), `DefaultCommandNotBoundHandler` runs → finds the ancestor with the command in `CommandsToBubbleUp` → invokes it on the ancestor. + +### Example: Gutter Forwards Wheel to Editor + +```csharp +// Parent (Editor) handles scroll commands and opts into bubbling +public class Editor : View +{ + public Editor () + { + AddCommand (Command.ScrollUp, () => ScrollVertical (-1)); + AddCommand (Command.ScrollDown, () => ScrollVertical (1)); + + // Allow scroll commands from SubViews/adornment SubViews to bubble here + CommandsToBubbleUp = [Command.ScrollUp, Command.ScrollDown]; + } +} + +// Child (Gutter) binds wheel events but does NOT add command handlers +public class Gutter : View +{ + public Gutter () + { + // Bind mouse wheel to scroll commands — no AddCommand needed. + // The unhandled command will bubble up to the nearest ancestor + // whose CommandsToBubbleUp includes it. + MouseBindings.Add (MouseFlags.WheeledUp, Command.ScrollUp); + MouseBindings.Add (MouseFlags.WheeledDown, Command.ScrollDown); + } +} +``` + +### How It Works + +``` +Mouse wheel on Gutter + → MouseBindings maps WheeledUp → Command.ScrollUp + → Gutter.InvokeCommand(ScrollUp) + → No handler found (AddCommand was never called) + → DefaultCommandNotBoundHandler runs + → RaiseCommandNotBound → TryBubbleUp + → Finds ancestor (Editor) with Command.ScrollUp in CommandsToBubbleUp + → Editor.InvokeCommand(ScrollUp) → scrolls +``` + +### AdornmentView Special Case + +For views hosted inside an adornment (Padding, Border, or Margin), the bubble target is the **adornment's Parent** (the owning View), not the `SuperView`. This means a view added to `editor.Padding` will bubble commands directly to `editor`, skipping the `PaddingView` intermediary. + +```csharp +// Gutter added to Editor's Padding — bubbles to Editor automatically +editor.Padding.Add (gutter); +``` + +This is handled internally by and . + +### When to Use This Pattern + +* A SubView should forward wheel/scroll events to its container without handling them itself. +* A view in Padding/Border needs to delegate commands to the owning View. +* You want clean separation: the child declares *what* user gesture maps to *which* command, and the ancestor decides *how* to handle it. + +> [!TIP] +> Adding a `MouseBinding` without a corresponding `AddCommand` handler is **intentional and idiomatic**. It signals that the command should bubble to an ancestor rather than being handled locally. See . + ## Best Practices * **Use Mouse Bindings and Commands** for simple interactions - integrates with keyboard bindings diff --git a/docfx/docs/showcase.md b/docfx/docs/showcase.md index a9bf40afab..5b98e3e3e2 100644 --- a/docfx/docs/showcase.md +++ b/docfx/docs/showcase.md @@ -23,6 +23,9 @@ - **[TerminalGuiDesigner](https://github.com/tznind/TerminalGuiDesigner)** - Cross platform view designer for building Terminal.Gui applications. ![TerminalGuiDesigner](../images/TerminalGuiDesigner.gif) +- **[hui](https://github.com/YourRobotOverlord/hui)** - Syncs Philips Hue entertainment lights to system audio on Windows, with an interactive Terminal.Gui UI and a CLI mode. + ![hui](https://github.com/user-attachments/assets/8b4a493d-ac74-4876-88e4-fa56a6ff40f7) + - **[Capital and Cargo](https://github.com/dhorions/Capital-and-Cargo)** - A retro console game where you buy, sell, produce and transport goods built with Terminal.Gui ![image](https://github.com/gui-cs/Terminal.Gui/assets/1682004/ed89f3d6-020f-4a8a-ae18-e057514f4c43) diff --git a/docfx/schemas/tui-config-schema.json b/docfx/schemas/tui-config-schema.json index 818886f0c9..46b5c06177 100644 --- a/docfx/schemas/tui-config-schema.json +++ b/docfx/schemas/tui-config-schema.json @@ -452,6 +452,58 @@ "ReadOnly": { "$ref": "#/definitions/attribute", "description": "Appearance for a read-only element." + }, + "Code": { + "$ref": "#/definitions/attribute", + "description": "Appearance for preformatted or source code content." + }, + "CodeComment": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code comments." + }, + "CodeKeyword": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code keywords." + }, + "CodeString": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code string literals." + }, + "CodeNumber": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code numeric literals." + }, + "CodeOperator": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code operators." + }, + "CodeType": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code type names." + }, + "CodePreprocessor": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code preprocessor directives." + }, + "CodeIdentifier": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code identifiers." + }, + "CodeConstant": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code constants." + }, + "CodePunctuation": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code punctuation." + }, + "CodeFunctionName": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code function names." + }, + "CodeAttribute": { + "$ref": "#/definitions/attribute", + "description": "Appearance for source-code attributes." } }, "required": [ @@ -615,4 +667,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/specs/constitution.md b/specs/constitution.md new file mode 100644 index 0000000000..1e91d0ee3c --- /dev/null +++ b/specs/constitution.md @@ -0,0 +1,110 @@ +# Terminal.Gui — Constitution + +> The tenets in each section are listed in **precedence order**. When two tenets conflict, the one higher in this document wins. + +This document is the single authoritative source for Terminal.Gui's product mission, non-goals, engineering philosophy, and design tenets. All other documents (`CONTRIBUTING.md`, `CLAUDE.md`, `.claude/rules/`, and the deep-dive docs in `docfx/docs/`) elaborate on these tenets; they do not supersede them. + +## Table of Contents + +- [I. Mission](#i-mission) +- [II. Non-Goals](#ii-non-goals) +- [III. Tenets](#iii-tenets) +- [IV. Engineering Philosophy](#iv-engineering-philosophy) +- [V. Code Style Tenets](#v-code-style-tenets) +- [Relationship to Sub-Projects](#relationship-to-sub-projects) + +--- + +## I. Mission + +Terminal.Gui is a **cross-platform UI toolkit for building sophisticated terminal UI (TUI) applications** on .NET. It is the standard by which TUI applications on .NET are measured. + +--- + +## II. Non-Goals + +These were considered and rejected — do not accidentally pursue them: + +- **Terminal.Gui is not a web framework.** We do not pursue HTML/CSS layout models. +- **Terminal.Gui is not a replacement for ncurses.** We target .NET developers, not C developers. +- **Terminal.Gui is not a pixel renderer.** Width is measured in terminal cells, not pixels. +- **Terminal.Gui is not opinionated about application architecture.** We provide building blocks; we do not mandate MVVM, MVC, or any other application pattern. +- **Terminal.Gui does not own the terminal.** We share it with the host shell and must be good citizens (clean up on exit, respect terminal state). + +--- + +## III. Tenets + +### Users Have Final Control + +Users choose the platform, the terminal, and the key bindings. Our defaults are consistent and sensible, but everything configurable must be configurable. We never hardcode behavior that the user or developer cannot override. See the [Keyboard deep dive](../docfx/docs/keyboard.md) and [Mouse deep dive](../docfx/docs/mouse.md). + +### Keyboard First; Mouse Optional + +Terminal users expect full functionality without a mouse. Anything that can be done with the mouse must also be doable with the keyboard. We avoid mouse-only features. See the [Mouse deep dive](../docfx/docs/mouse.md). + +### More Editor Than Command Line + +Once a Terminal.Gui app starts, the user is no longer using the command line. Users expect keyboard idioms consistent with GUI apps (VS Code, Vim, Emacs, etc.), not shell idioms. See the [Keyboard deep dive](../docfx/docs/keyboard.md). + +### Be Consistent With the User's Platform + +Users choose their platform. Terminal.Gui apps must respond to keyboard and mouse input in a way consistent with platform conventions. The source of truth for default key bindings is [Wikipedia's keyboard shortcuts table](https://en.wikipedia.org/wiki/Table_of_keyboard_shortcuts). See the [Keyboard deep dive](../docfx/docs/keyboard.md). + +### If It's Hot, It Works + +If a `View` with a `HotKey` is visible and the HotKey is shown, pressing that HotKey must invoke the defined behavior. We strive to ensure that modal contexts do not leave HotKeys appearing active when they are not. See the [Keyboard deep dive](../docfx/docs/keyboard.md). + +### Separation of Concerns + +Layout, focus, input, and drawing are cleanly decoupled. We resist the urge to merge them for short-term convenience. See the [v2 Architecture overview](../docfx/docs/newinv2.md) and [Layout deep dive](../docfx/docs/layout.md). + +### Testability First + +Views must be testable in isolation without global state. `Application.Init` is required only for integration tests. We maintain ≥80% test coverage and we never decrease it. See [Testing patterns](../.claude/rules/testing-patterns.md). + +### Performance Is a Feature + +We measure rendering and event-handling overhead. We never accept regressions in the hot path without a documented justification. See the [Drawing deep dive](../docfx/docs/drawing.md). + +### Documentation Is the Spec + +API documentation is the contract. When docs and code conflict, the code is wrong. See [api-documentation rules](../.claude/rules/api-documentation.md) and Code Style Tenet 5 in [CONTRIBUTING.md](../CONTRIBUTING.md). + +### Think in Graphemes, Not Runes + +Text measurement and rendering always operate on grapheme clusters, not `char` or `Rune` values. Always use `string.GetColumns()` for width; always iterate with `GraphemeHelper.GetGraphemes()` for rendering. See [Unicode/Grapheme rules](../.claude/rules/unicode-graphemes.md). + +--- + +## IV. Engineering Philosophy + +Developers — AI agents and humans — working on Terminal.Gui strive to raise the bar as Principal Engineers. Principal Engineers are measured by how they live the [Amazon PE Community Tenets](https://www.amazon.jobs/content/en/teams/principal-engineering/tenets): + +1. **Exemplary practitioner** — set the standard through your own work. +2. **Technically fearless** — tackle the hardest, most ambiguous problems. +3. **Lead with empathy** — foster inclusion; be mindful of your impact. +4. **Balanced and pragmatic** — neither dogmatic nor reckless. +5. **Illuminate and clarify** — bring clarity to complexity; drive crisp decisions. +6. **Flexible in approach** — adapt style and methods to the problem at hand. +7. **Respect what came before** — appreciate existing systems; learn from the past. +8. **Learn, educate, and advocate** — pursue continuous learning and teach others. +9. **Have resounding impact** — results are the minimum; lasting impact is the bar. + +--- + +## V. Code Style Tenets + +*(Source of truth: [CONTRIBUTING.md](../CONTRIBUTING.md))* + +1. **Six-Year-Old Reading Level** — Readability over terseness. +2. **Consistency, Consistency, Consistency** — Follow existing patterns ruthlessly. +3. **Don't Be Weird** — Follow Microsoft/.NET conventions. +4. **Set and Forget** — Rely on automated tooling; don't fight the formatter. +5. **Documentation Is the Spec** — API docs define the contract; implementation must match. + +--- + +## Relationship to Sub-Projects + +Sub-projects (e.g., `Terminal.Gui.Text`) may extend this constitution. When a sub-project tenet conflicts with a tenet in this document, this document wins unless the sub-project explicitly documents the exception and the reason.