diff --git a/.claude/api-reference.md b/.claude/api-reference.md index 865db1ec9a..416236ffb3 100644 --- a/.claude/api-reference.md +++ b/.claude/api-reference.md @@ -49,6 +49,15 @@ public class MyDialog : Runnable **Disposal**: "Whoever creates it, owns it" - `Run()` auto-disposes, `Run(instance)` requires manual disposal. +### RunnableWrapper + +`RunnableWrapper` wraps a `View` as a runnable without adding prompt buttons. + +- Clears wrapper `KeyBindings` and `MouseBindings` so the wrapped view handles input. +- Sets `CommandsToBubbleUp = [Command.Accept]`. +- On accept, extracts result via `ResultExtractor` if set, otherwise from `IValue.Value` when implemented. +- Unlike `Prompt`, it does not add OK/Cancel buttons. + --- ## View Hierarchy @@ -443,6 +452,24 @@ using (IApplication app = Application.Create ().Init ()) ## Quick Reference +### IValue Pattern + +All typed views expose values through `IValue.Value`. Avoid guessing member names like `.Date`, `.Time`, or `.Color`. + +| View | IValue | +|------|-----------| +| TextField | `IValue` | +| NumericUpDown | `IValue` | +| DatePicker | `IValue` | +| TimeEditor | `IValue` | +| ColorPicker | `IValue` | +| AttributePicker | `IValue` | +| CheckBox | `IValue` | +| OptionSelector | `IValue` | +| FlagSelector | `IValue` | + +Implementing `IValue` requires `ValueChanging`, `ValueChanged`, and `ValueChangedUntyped`. + ### View Properties | Property | Purpose | @@ -478,3 +505,9 @@ DriverRegistry.Names.WINDOWS // "windows" DriverRegistry.Names.UNIX // "unix" DriverRegistry.Names.DOTNET // "dotnet" ``` + +### Gotchas + +- `Terminal.Gui.Drawing.Attribute` may conflict with `System.Attribute` with implicit usings. Use `using TgAttribute = Terminal.Gui.Drawing.Attribute;` or fully qualify. +- `Color.TryParse (string, out Color?)` uses nullable out; `Color.TryParse (string?, IFormatProvider?, out Color)` uses non-nullable out. +- `OpenDialog` results are exposed through `FilePaths`; related state includes `AllowsMultipleSelection`, `Canceled`, and `OpenMode`. diff --git a/.claude/rules/testing-patterns.md b/.claude/rules/testing-patterns.md index 990cb695fa..2afa674159 100644 --- a/.claude/rules/testing-patterns.md +++ b/.claude/rules/testing-patterns.md @@ -14,8 +14,12 @@ **⚠️ AI-created tests MUST follow these patterns exactly:** -1. **Add comment indicating the test was AI generated** - - Example: `// CoPilot - ChatGPT v4` +1. **Add a comment indicating the test was AI generated** + - Either of these forms is acceptable; both are well-established in the codebase: + - `// Claude - ` (e.g. `// Claude - Opus 4.7`) + - `// CoPilot - ` (e.g. `// CoPilot - ChatGPT v4`) + - Use whichever matches the agent that produced the test, with the model identifier. + - **Reviewers (human or automated) should not flag inconsistency between these two forms** — both have been used widely; consistency *of* a marker matters, not which one. 2. **Make tests granular** - Each test should cover smallest area possible diff --git a/.github/workflows/notify-clet.yml b/.github/workflows/notify-clet.yml new file mode 100644 index 0000000000..84e5fb40e6 --- /dev/null +++ b/.github/workflows/notify-clet.yml @@ -0,0 +1,77 @@ +name: Notify clet + +# Fires repository_dispatch to gui-cs/clet so that every TG develop NuGet +# publish and every TG release tag drives a matching clet build/publish. +# See gui-cs/clet#30 and gui-cs/clet D-020 for context. +# +# Design notes: +# - The version comes from the `published-version` artifact uploaded by +# publish.yml. This is deterministic — no NuGet search-API race. +# - We then poll NuGet's flat-container API (the endpoint that +# `dotnet restore` actually hits) until the package is downloadable. +# The flat-container is updated soon after publish; the search API +# (queried by `dotnet package search`) lags by minutes, which broke +# this workflow once before — see gui-cs/clet workflow run 25406348354. +# - Channel is derived from the SemVer suffix: contains '-' ⇒ develop. + +on: + workflow_run: + workflows: ["Publish Terminal.Gui to Nuget"] + types: [completed] + +permissions: + contents: read + actions: read # required to download artifacts from the triggering run + +jobs: + notify-clet: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Download published-version artifact + uses: actions/download-artifact@v4 + with: + name: published-version + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine version + channel + id: version + run: | + TG_VER=$(cat version.txt | tr -d '[:space:]') + if [ -z "$TG_VER" ]; then + echo "::error::Empty version.txt from upstream publish workflow" + exit 1 + fi + echo "tg_version=$TG_VER" >> "$GITHUB_OUTPUT" + if [[ "$TG_VER" == *-* ]]; then + echo "event_type=tg-develop-published" >> "$GITHUB_OUTPUT" + echo "Channel: develop ($TG_VER)" + else + echo "event_type=tg-released" >> "$GITHUB_OUTPUT" + echo "Channel: release ($TG_VER)" + fi + + - name: Wait for NuGet flat-container to index the version + run: | + VER="${{ steps.version.outputs.tg_version }}" + for i in $(seq 1 60); do + if curl -fsS https://api.nuget.org/v3-flatcontainer/terminal.gui/index.json \ + | jq -r '.versions[]' | grep -qx "$VER"; then + echo "Indexed on flat-container: $VER (after $((i*10))s)" + exit 0 + fi + echo "Waiting for $VER on NuGet flat-container ($i/60, 10s each)..." + sleep 10 + done + echo "::error::Timed out after 10min waiting for $VER on flat-container" + exit 1 + + - name: Dispatch to gui-cs/clet + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.CLET_DISPATCH_PAT }} + repository: gui-cs/clet + event-type: ${{ steps.version.outputs.event_type }} + client-payload: | + {"tg_version": "${{ steps.version.outputs.tg_version }}"} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 186c66b5a1..51ff221a77 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,9 @@ name: Publish Terminal.Gui to Nuget on: push: - branches: [ main, develop ] + # Main releases are triggered only by pushing a v* tag (e.g., v2.0.0). + # Develop pushes publish pre-release packages (e.g., 2.1.0-develop.1). + branches: [ develop ] tags: - v* paths-ignore: @@ -69,9 +71,24 @@ jobs: - name: Publish Terminal.Gui.${{ steps.gitversion.outputs.SemVer }} to NuGet.org run: dotnet nuget push Terminal.Gui/bin/Release/Terminal.Gui.${{ steps.gitversion.outputs.SemVer }}.nupkg --skip-duplicate --api-key ${{ secrets.NUGET_API_KEY }} + + # Deterministic version handoff to notify-clet.yml. Avoids the NuGet + # search-API indexing lag that caused gui-cs/clet to skip TG develop.37 + # (see clet workflow run 25406348354). notify-clet.yml downloads this + # artifact via workflow_run + actions/download-artifact and then polls + # NuGet flat-container until the version is restorable. + - name: Record published version for notify-clet + run: echo "${{ steps.gitversion.outputs.SemVer }}" > version.txt + + - name: Upload published-version artifact + uses: actions/upload-artifact@v4 + with: + name: published-version + path: version.txt + retention-days: 7 # - name: Delist old NuGet packages - # if: github.ref == 'refs/heads/main' + # if: startsWith(github.ref, 'refs/tags/v') # shell: pwsh # run: | # $version = "${{ steps.gitversion.outputs.SemVer }}" @@ -79,13 +96,13 @@ jobs: # ./Scripts/delist-nuget.ps1 -ApiKey "${{ secrets.NUGET_API_KEY }}" -JustPublishedVersion "$version" - name: Prepare payload for template dispatch - if: github.ref == 'refs/heads/main' + if: startsWith(github.ref, 'refs/tags/v') id: payload run: | echo "json={\"version\":\"${{ steps.gitversion.outputs.SemVer }}\"}" >> $GITHUB_OUTPUT - name: Trigger Terminal.Gui.templates update - if: github.ref == 'refs/heads/main' + if: startsWith(github.ref, 'refs/tags/v') uses: peter-evans/repository-dispatch@v4 with: token: ${{ secrets.TEMPLATE_REPO_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 4759b28b60..4e9c4efb4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -353,6 +353,32 @@ See `.claude/cookbook/` for common UI patterns: |ViewArrangement|Enum|Movable,Resizable,Overlapped ``` +### IValue Pattern (Critical) + +All typed views expose their data through `IValue.Value`. Do not guess property-specific names such as `.Date`, `.Time`, or `.Color`. + +| View | IValue | +|------|-----------| +| TextField | `IValue` | +| NumericUpDown | `IValue` | +| DatePicker | `IValue` | +| TimeEditor | `IValue` | +| ColorPicker | `IValue` | +| AttributePicker | `IValue` | +| CheckBox | `IValue` | +| OptionSelector | `IValue` | +| FlagSelector | `IValue` | + +Implementing `IValue` requires `ValueChanging`, `ValueChanged`, and `ValueChangedUntyped`. + +### RunnableWrapper + +- Wraps a `View` as a runnable with typed results. +- Clears wrapper `KeyBindings` and `MouseBindings` so the wrapped view handles input. +- Does not add OK/Cancel buttons (unlike `Prompt`). +- Sets `CommandsToBubbleUp = [Command.Accept]`. +- On accept, it extracts results via `ResultExtractor` if provided; otherwise via `IValue.Value` when available. + ### Terminal.Gui.Views (180+ types) ``` [Core Controls] @@ -364,7 +390,7 @@ See `.claude/cookbook/` for common UI patterns: |DropDownList|Class|Dropdown,Source,SelectedItem |ProgressBar|Class|Fraction,BidirectionalMarquee |ScrollBar|Class|Position,Size,Orientation -|NumericUpDown|Class|Value,Increment,Min,Max +|NumericUpDown|Class|Value,Increment [Containers] |Window|Class|Top-level,Title,MenuBar support @@ -390,7 +416,7 @@ See `.claude/cookbook/` for common UI patterns: [File Dialogs] |FileDialog|Class|Base,Path,AllowedFileTypes -|OpenDialog|Class|OpenFile,AllowsMultipleSelection +|OpenDialog|Class|FilePaths,AllowsMultipleSelection,Canceled,OpenMode |SaveDialog|Class|SaveFile,FileName [Specialized] @@ -429,6 +455,10 @@ See `.claude/cookbook/` for common UI patterns: |Gradient|Class|Colors[],Spectrum ``` +**Gotchas** +- `Terminal.Gui.Drawing.Attribute` can conflict with `System.Attribute` with implicit usings. Use `using TgAttribute = Terminal.Gui.Drawing.Attribute;` or fully qualify. +- `Color.TryParse (string, out Color?)` is nullable out. `Color.TryParse (string?, IFormatProvider?, out Color)` is non-nullable out. + ### Terminal.Gui.Drivers (80+ types) ``` |IDriver|Interface|Init,End,Refresh,AddStr,Move @@ -518,6 +548,5 @@ See `.claude/cookbook/` for common UI patterns: - diff --git a/CLAUDE.md b/CLAUDE.md index 599c0b24ad..50dcbfde13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ See `Tests/README.md` for the full list of test projects (including `Integration ## Testing - Add new tests to `UnitTestsParallelizable`; use `UnitTests.NonParallelizable` only when static state is unavoidable. Never add to `UnitTests.Legacy`. -- Add comment: `// Claude - Opus 4.5` +- Add a comment marking the test as AI-generated. Either form is acceptable: `// Claude - ` or `// CoPilot - ` — just include the agent and the model that produced the test (e.g., `// Claude - Opus 4.5` or `// CoPilot - ChatGPT v4`). Both forms are established in the codebase; which marker is used is not a style concern and reviewers should not flag inconsistency between them. - Never decrease coverage - Avoid `Application.Init` in tests diff --git a/Directory.Packages.props b/Directory.Packages.props index 8fe377edff..511ec4d415 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,48 +4,47 @@ - - - - + + - - + + + + + + + + + - + - + + + + + + - - - - + - - + - - - - - - - - + diff --git a/Examples/InlineColorPicker/Program.cs b/Examples/InlineColorPicker/Program.cs index 73290a50f0..9f57db9a9f 100644 --- a/Examples/InlineColorPicker/Program.cs +++ b/Examples/InlineColorPicker/Program.cs @@ -1,17 +1,44 @@ // Inline ColorPicker — demonstrates using RunnableWrapper in inline mode. // +// NOTE: See https://github.com/gui-cs/clet that turns every Terminal.Gui View into a CLI command +// NOTE: — typed inputs, a real file picker, a Markdown viewer — with consistent JSON output, +// NOTE: predictable exit codes, and full keyboard/mouse support. Works for humans and AI agents alike. +// // Renders a ColorPicker inline in the terminal (primary buffer) without dialog buttons. // If the user accepts (double-click), the selected color name is written to stdout. // If the user cancels (Esc), nothing is output and exit code is 1. // // Usage: // dotnet run --project Examples/InlineColorPicker +// dotnet run --project Examples/InlineColorPicker -- --initial "#FF0000" +// dotnet run --project Examples/InlineColorPicker -- --initial Red // $color = dotnet run --project Examples/InlineColorPicker # capture in shell using Terminal.Gui.App; using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; using Terminal.Gui.Views; +// Parse command-line arguments +string? initialValue = null; + +for (var i = 0; i < args.Length; i++) +{ + if (args [i] is "--initial" or "-i") + { + if (i + 1 < args.Length) + { + initialValue = args [++i]; + } + else + { + Console.Error.WriteLine ("Error: --initial requires a color value (e.g., \"#FF0000\" or \"Red\")."); + + return 1; + } + } +} + // Enable inline mode before Init Application.AppModel = AppModel.Inline; @@ -19,14 +46,23 @@ // Wrap ColorPicker in a RunnableWrapper — no dialog buttons, just the picker. // ColorPicker raises Command.Accept on double-click. -RunnableWrapper wrapper = new () -{ - Title = "Select a Color (Double-click to accept, Esc to cancel)", - ResultExtractor = cp => cp.Value -}; +RunnableWrapper wrapper = new () { Title = "Select a Color (Double-click to accept, Esc to cancel)", ResultExtractor = cp => cp.Value }; // Enable color name display wrapper.GetWrappedView ().Style.ShowColorName = true; +wrapper.GetWrappedView ().ApplyStyleChanges (); + +// Apply initial value via IValue.TrySetValueFromString if provided +if (initialValue is { }) +{ + if (!((IValue)wrapper.GetWrappedView ()).TrySetValueFromString (initialValue)) + { + Console.Error.WriteLine ($"Error: '{initialValue}' is not a valid color (use e.g., \"#FF0000\" or \"Red\")."); + app.Dispose (); + + return 1; + } +} // Run inline — blocks until user accepts or cancels app.Run (wrapper); @@ -39,9 +75,7 @@ { StandardColorsNameResolver resolver = new (); - string output = resolver.TryNameColor (selectedColor, out string? name) - ? name - : selectedColor.ToString (); + string output = resolver.TryNameColor (selectedColor, out string? name) ? name : selectedColor.ToString (); Console.WriteLine (output); diff --git a/Examples/InlineSelect/Program.cs b/Examples/InlineSelect/Program.cs index 1a44d11e0e..4c34f8e4a4 100644 --- a/Examples/InlineSelect/Program.cs +++ b/Examples/InlineSelect/Program.cs @@ -1,42 +1,69 @@ // InlineSelect — demonstrates using RunnableWrapper in inline mode. // +// NOTE: See https://github.com/gui-cs/clet that turns every Terminal.Gui View into a CLI command +// NOTE: — typed inputs, a real file picker, a Markdown viewer — with consistent JSON output, +// NOTE: predictable exit codes, and full keyboard/mouse support. Works for humans and AI agents alike. +// // Renders an OptionSelector inline in the terminal with options from the command line. // Supports horizontal or vertical orientation via --horizontal / --vertical flags. // Hot keys are auto-assigned from option text. +// Supports --timeout to auto-cancel via CancellationToken (demonstrates RunAsync). +// Supports --initial to pre-select an option via IValue.TrySetValueFromString. // // Usage: // dotnet run --project Examples/InlineSelect -- Apple Banana Cherry // dotnet run --project Examples/InlineSelect -- --horizontal Red Green Blue Yellow // dotnet run --project Examples/InlineSelect -- --vertical One Two Three +// dotnet run --project Examples/InlineSelect -- --timeout 10 Apple Banana Cherry +// dotnet run --project Examples/InlineSelect -- --initial 1 Apple Banana Cherry using Terminal.Gui.App; using Terminal.Gui.Drawing; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; +using Timeout = System.Threading.Timeout; // Parse command-line arguments Orientation orientation = Orientation.Vertical; List options = []; +int? timeoutSeconds = null; +string? initialValue = null; -foreach (string arg in args) +for (var i = 0; i < args.Length; i++) { - if (arg is "--horizontal" or "-h") - { - orientation = Orientation.Horizontal; - } - else if (arg is "--vertical" or "-v") - { - orientation = Orientation.Vertical; - } - else + string arg = args [i]; + + switch (arg) { - options.Add (arg); + case "--horizontal" or "-h": orientation = Orientation.Horizontal; break; + + case "--vertical" or "-v": orientation = Orientation.Vertical; break; + + case "--timeout" or "-t" when i + 1 < args.Length && int.TryParse (args [i + 1], out int seconds): + timeoutSeconds = seconds; + i++; // skip the next arg (the number) + + break; + + case "--timeout" or "-t": + Console.Error.WriteLine ("Error: --timeout requires a number of seconds."); + + return 1; + + case "--initial" or "-i" when i + 1 < args.Length: initialValue = args [++i]; break; + + case "--initial" or "-i": + Console.Error.WriteLine ("Error: --initial requires an index value."); + + return 1; + + default: options.Add (arg); break; } } if (options.Count == 0) { - Console.Error.WriteLine ("Usage: InlineSelect [--horizontal|--vertical] ..."); + Console.Error.WriteLine ("Usage: InlineSelect [--horizontal|--vertical] [--timeout ] ..."); return 1; } @@ -47,29 +74,74 @@ IApplication app = Application.Create ().Init (); // Build the OptionSelector with command-line options -OptionSelector selector = new () -{ - Labels = options, - Orientation = orientation, - AssignHotKeys = true -}; +OptionSelector selector = new () { Labels = options, Orientation = orientation, AssignHotKeys = true }; // Wrap in RunnableWrapper — auto-extracts Value via IValue RunnableWrapper wrapper = new (selector) { - Title = "Select an option (Enter to accept, Esc to cancel)", + Title = timeoutSeconds.HasValue + ? $"Select an option (Enter to accept, Esc to cancel, {timeoutSeconds}s timeout)" + : "Select an option (Enter to accept, Esc to cancel)", Width = Dim.Fill (), BorderStyle = LineStyle.Rounded }; -// Run inline — blocks until user accepts or cancels -app.Run (wrapper); +// Apply initial value if provided — match by label (case-insensitive) or by numeric index +if (initialValue is { }) +{ + // First try matching a label + int matchIndex = options.FindIndex (o => string.Equals (o, initialValue, StringComparison.OrdinalIgnoreCase)); + + if (matchIndex >= 0) + { + selector.Value = matchIndex; + } + else if (!((IValue)selector).TrySetValueFromString (initialValue)) + { + Console.Error.WriteLine ($"Error: '{initialValue}' does not match any option and is not a valid index."); + app.Dispose (); + + return 1; + } +} + +// Run with optional timeout via RunAsync + CancellationToken +if (timeoutSeconds.HasValue) +{ + // Use RunAsync with a CancellationToken for timeout-based cancellation + using CancellationTokenSource cts = new (TimeSpan.FromSeconds (timeoutSeconds.Value)); + + // Show terminal progress indicator counting down the timeout (OSC 9;4) + DateTime startTime = DateTime.UtcNow; + int totalMs = timeoutSeconds.Value * 1000; + + await using Timer progressTimer = new (_ => app.Invoke (_ => + { + var elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; + int percent = Math.Min (elapsedMs * 100 / totalMs, 100); + app.Driver?.ProgressIndicator?.SetValue (percent); + }), + null, + 0, + 250); + + await app.RunAsync (wrapper, cts.Token); + + // Clear the progress indicator when done + progressTimer.Change (Timeout.Infinite, Timeout.Infinite); + app.Driver?.ProgressIndicator?.Clear (); +} +else +{ + // Run synchronously — blocks until user accepts or cancels + app.Run (wrapper); +} int? result = wrapper.Result; app.Dispose (); -if (result is { } selectedIndex && selectedIndex >= 0 && selectedIndex < options.Count) +if (result is { } selectedIndex and >= 0 && selectedIndex < options.Count) { Console.WriteLine (options [selectedIndex]); diff --git a/Examples/PromptExample/Program.cs b/Examples/PromptExample/Program.cs index 321531ff93..6011553bad 100644 --- a/Examples/PromptExample/Program.cs +++ b/Examples/PromptExample/Program.cs @@ -1,4 +1,7 @@ // Example demonstrating the Prompt API for getting typed input from users +// NOTE: See https://github.com/gui-cs/clet that turns every Terminal.Gui View into a CLI command +// NOTE: — typed inputs, a real file picker, a Markdown viewer — with consistent JSON output, +// NOTE: predictable exit codes, and full keyboard/mouse support. Works for humans and AI agents alike. using Terminal.Gui.App; using Terminal.Gui.Configuration; @@ -8,6 +11,7 @@ using Terminal.Gui.ViewBase; using Terminal.Gui.Views; using Color = Terminal.Gui.Drawing.Color; + // ReSharper disable AccessToDisposedClosure ConfigurationManager.Enable (ConfigLocations.All); @@ -19,7 +23,18 @@ mainWindow.Text = "This example demonstrates various uses of the Prompt API.\nPress the buttons to try different prompt types.\nPress Esc to quit."; mainWindow.TextAlignment = Alignment.Center; -int buttonY = 0; +// Initial Value TextField — entered text is applied to prompt views via IValue.TrySetValueFromString +TextField initialValueField = new () +{ + Title = "Initial _Value (TrySetValueFromString)", + X = 0, + Y = 0, + Width = Dim.Fill (), + BorderStyle = LineStyle.Dotted +}; +mainWindow.Add (initialValueField); + +var buttonY = 2; // Example 1: TextField with string result using auto-Text extraction Button textFieldButton = new () { Title = "TextField (Auto-Text)", X = Pos.Center (), Y = buttonY++ }; @@ -31,6 +46,12 @@ prompt.Title = textFieldButton.Title; prompt.GetWrappedView ().Width = 40; prompt.GetWrappedView ().Text = "Default name"; + + if (!string.IsNullOrEmpty (initialValueField.Text)) + { + ((IValue)prompt.GetWrappedView ()) + .TrySetValueFromString (initialValueField.Text); + } }); MessageBox.Query (app, textFieldButton.Title, result is { } ? $"You entered: {result}" : "Canceled", Strings.btnOk); @@ -51,6 +72,13 @@ "Some text\nis nice."; prompt.GetWrappedView ().Width = Dim.Fill (0, 40); prompt.GetWrappedView ().Height = Dim.Fill (0, 8); + + if (!string.IsNullOrEmpty (initialValueField.Text)) + { + ((IValue)prompt.GetWrappedView ()) + .TrySetValueFromString (initialValueField + .Text); + } }); MessageBox.Query (app, textViewButton.Title, result is { } ? $"You entered: {result}" : "Canceled", Strings.btnOk); @@ -68,6 +96,12 @@ { prompt.Title = "Select a Date"; prompt.GetWrappedView ().Value = DateTime.Now; + + if (!string.IsNullOrEmpty (initialValueField.Text)) + { + ((IValue)prompt.GetWrappedView ()) + .TrySetValueFromString (initialValueField.Text); + } }); if (result is { } selectedDate) @@ -88,7 +122,16 @@ colorPickerButton.Accepting += (_, _) => { Color? result = mainWindow.Prompt (input: null, - beginInitHandler: prompt => { prompt.Title = "Pick a Color"; }); + beginInitHandler: prompt => + { + prompt.Title = "Pick a Color"; + + if (!string.IsNullOrEmpty (initialValueField.Text)) + { + ((IValue)prompt.GetWrappedView ()) + .TrySetValueFromString (initialValueField.Text); + } + }); if (result is { } selectedColor) { @@ -111,6 +154,12 @@ { prompt.Title = "Pick a Color (as text)"; prompt.GetWrappedView ().SelectedColor = Color.Red; + + if (!string.IsNullOrEmpty (initialValueField.Text)) + { + ((IValue)prompt.GetWrappedView ()) + .TrySetValueFromString (initialValueField.Text); + } }); MessageBox.Query (app, colorTextButton.Title, result is { } ? $"Color as text: {result}" : "Canceled", Strings.btnOk); @@ -178,6 +227,14 @@ beginInitHandler: prompt => { prompt.Title = "Choose Selector Styles"; + + if (!string.IsNullOrEmpty (initialValueField + .Text)) + { + ((IValue)prompt.GetWrappedView ()) + .TrySetValueFromString (initialValueField + .Text); + } }); if (result is { } styles) diff --git a/Examples/UICatalog/README.md b/Examples/UICatalog/README.md index 007733bd8c..af07666e3b 100644 --- a/Examples/UICatalog/README.md +++ b/Examples/UICatalog/README.md @@ -1,6 +1,6 @@ # Terminal.Gui UI Catalog -UI Catalog is a comprehensive sample library for Terminal.Gui. It provides: +UI Catalog is a comprehensive sample library for [Terminal.Gui](https://github.com/gui-cs/Terminal.Gui). It provides: 1. An easy-to-use, interactive, showcase for Terminal.Gui concepts and features. 2. Sample code that illustrates how to properly implement said concepts & features. diff --git a/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs b/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs index 2bb4349d66..82a8422215 100644 --- a/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs +++ b/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs @@ -23,6 +23,7 @@ public class CharacterMap : Scenario private TableView? _categoryList; private CharMap? _charMap; private OptionSelector? _unicodeCategorySelector; + private int _sortedColumn; public override List GetDemoKeyStrokes (IApplication? app) { @@ -119,6 +120,7 @@ public override void Main () _categoryList.Style.AlwaysShowHeaders = true; var isDescending = false; + _sortedColumn = 0; _categoryList.Table = CreateCategoryTable (0, isDescending); @@ -138,8 +140,9 @@ public override void Main () return; } EnumerableTableSource table = (EnumerableTableSource)_categoryList.Table!; - string prevSelection = table.Data.ElementAt (_categoryList.Value?.Cursor.Y ?? 0).Category; + string prevSelection = table.Data.ElementAt (_categoryList.Value?.SelectedCell.Y ?? 0).Category; isDescending = !isDescending; + _sortedColumn = clickedCol.Value; _categoryList.Table = CreateCategoryTable (clickedCol.Value, isDescending); @@ -155,9 +158,26 @@ public override void Main () int longestName = UnicodeRange.Ranges.Max (r => r.Category.GetColumns ()); - _categoryList.Style.ColumnStyles.Add (0, new ColumnStyle { MaxWidth = longestName, MinWidth = longestName, MinAcceptableWidth = longestName }); - _categoryList.Style.ColumnStyles.Add (1, new ColumnStyle { MaxWidth = 1, MinWidth = 6 }); - _categoryList.Style.ColumnStyles.Add (2, new ColumnStyle { MaxWidth = 1, MinWidth = 6 }); + // Apply italic styling to the sorted column header + HeaderColorGetterDelegate headerColorGetter = args => + { + if (args.Column != _sortedColumn) + { + return null; + } + + Scheme baseScheme = args.RowScheme; + + return new Scheme + { + Normal = new Attribute (baseScheme.Normal.Foreground, baseScheme.Normal.Background, TextStyle.Italic), + Focus = new Attribute (baseScheme.Focus.Foreground, baseScheme.Focus.Background, TextStyle.Italic) + }; + }; + + _categoryList.Style.ColumnStyles.Add (0, new ColumnStyle { MaxWidth = longestName, MinWidth = longestName, MinAcceptableWidth = longestName, HeaderColorGetter = headerColorGetter }); + _categoryList.Style.ColumnStyles.Add (1, new ColumnStyle { MaxWidth = 1, MinWidth = 6, HeaderColorGetter = headerColorGetter }); + _categoryList.Style.ColumnStyles.Add (2, new ColumnStyle { MaxWidth = 1, MinWidth = 6, HeaderColorGetter = headerColorGetter }); _categoryList.Width = _categoryList.Style.ColumnStyles.Sum (c => c.Value.MinWidth) + 4; @@ -169,7 +189,7 @@ public override void Main () } EnumerableTableSource table = (EnumerableTableSource)_categoryList.Table!; - _charMap.StartCodePoint = table.Data.ToArray () [args.NewValue.Cursor.Y].Start; + _charMap.StartCodePoint = table.Data.ToArray () [args.NewValue.SelectedCell.Y].Start; jumpEdit.Text = $"U+{_charMap.SelectedCodePoint:x5}"; }; diff --git a/Examples/UICatalog/Scenarios/CsvEditor.cs b/Examples/UICatalog/Scenarios/CsvEditor.cs index 21b03c7b97..d2c8659b22 100644 --- a/Examples/UICatalog/Scenarios/CsvEditor.cs +++ b/Examples/UICatalog/Scenarios/CsvEditor.cs @@ -119,7 +119,7 @@ private void AddColumn () } DataColumn col = new (colName); - int newColIdx = Math.Min (Math.Max (0, (_tableView.Value?.Cursor.X ?? 0) + 1), _tableView.Table!.Columns); + int newColIdx = Math.Min (Math.Max (0, (_tableView.Value?.SelectedCell.X ?? 0) + 1), _tableView.Table!.Columns); int? result = MessageBox.Query (_tableView.App!, "Column Type", "Pick a data type for the column", "Date", "Integer", "Double", "Text", "Cancel"); @@ -165,7 +165,7 @@ private void AddRow () DataRow newRow = _currentTable.NewRow (); - int newRowIdx = Math.Min (Math.Max (0, (_tableView.Value?.Cursor.Y ?? 0) + 1), _tableView.Table!.Rows); + int newRowIdx = Math.Min (Math.Max (0, (_tableView.Value?.SelectedCell.Y ?? 0) + 1), _tableView.Table!.Rows); _currentTable.Rows.InsertAt (newRow, newRowIdx); _tableView.Update (); @@ -178,7 +178,7 @@ private void Align (Alignment newAlignment) return; } - ColumnStyle style = _tableView.Style.GetOrCreateColumnStyle (_tableView.Value?.Cursor.X ?? 0); + ColumnStyle style = _tableView.Style.GetOrCreateColumnStyle (_tableView.Value?.SelectedCell.X ?? 0); style.Alignment = newAlignment; _miLeftCheckBox?.Value = style.Alignment == Alignment.Start ? CheckState.Checked : CheckState.UnChecked; @@ -206,7 +206,7 @@ private void DeleteColum () try { - _currentTable.Columns.RemoveAt (_tableView.Value.Cursor.X); + _currentTable.Columns.RemoveAt (_tableView.Value.SelectedCell.X); _tableView.Update (); } catch (Exception ex) @@ -222,8 +222,8 @@ private void EditCurrentCell (object? sender, CommandEventArgs e) return; } - int col = _tableView.Value?.Cursor.X ?? 0; - int row = _tableView.Value?.Cursor.Y ?? 0; + int col = _tableView.Value?.SelectedCell.X ?? 0; + int row = _tableView.Value?.SelectedCell.Y ?? 0; var oldValue = _currentTable.Rows [row] [col].ToString (); @@ -292,7 +292,7 @@ private void MoveColumn () try { - DataColumn currentCol = _currentTable.Columns [_tableView.Value.Cursor.X]; + DataColumn currentCol = _currentTable.Columns [_tableView.Value.SelectedCell.X]; if (!GetText ("Move Column", "New Index:", currentCol.Ordinal.ToString (), out string newOrdinal)) { @@ -302,7 +302,7 @@ private void MoveColumn () currentCol.SetOrdinal (newIdx); - _tableView.SetSelection (newIdx, _tableView.Value!.Cursor.Y, false); + _tableView.SetSelection (newIdx, _tableView.Value!.SelectedCell.Y, false); _tableView.EnsureCursorIsVisible (); _tableView.SetNeedsDraw (); } @@ -328,7 +328,7 @@ private void MoveRow () try { - int oldIdx = _tableView.Value.Cursor.Y; + int oldIdx = _tableView.Value.SelectedCell.Y; DataRow currentRow = _currentTable.Rows [oldIdx]; @@ -352,7 +352,7 @@ private void MoveRow () _currentTable.Rows.InsertAt (newRow, newIdx); - _tableView.SetSelection (_tableView.Value!.Cursor.X, newIdx, false); + _tableView.SetSelection (_tableView.Value!.SelectedCell.X, newIdx, false); _tableView.EnsureCursorIsVisible (); _tableView.SetNeedsDraw (); } @@ -380,8 +380,8 @@ private void OnValueChanged (object? sender, ValueChangedEventArgs _docs = []; + private readonly Dictionary _includes = new (StringComparer.OrdinalIgnoreCase); public override void Main () { @@ -65,7 +68,7 @@ public override void Main () _markdownView.LinkClicked += (_, e) => { _statusShortcut?.Title = e.Url; - + SelectDocFromLink (e.Url); e.Handled = true; }; @@ -115,7 +118,7 @@ public override void Main () { ReadOnly = true, CanFocus = false, - Value = (Enum.TryParse (_markdownView.SyntaxHighlighter.ThemeName, out ThemeName theme) ? theme : ThemeName.DarkPlus), + Value = !Enum.TryParse (_markdownView.SyntaxHighlighter.ThemeName, out ThemeName theme) ? ThemeName.DarkPlus : theme, Autocomplete = null }; @@ -133,16 +136,16 @@ public override void Main () // Auto-select a light or dark syntax theme based on the terminal's actual background color. _app.Driver!.DefaultAttributeChanged += (_, e) => - { - if (_markdownView is null || e.NewValue is not { } attr) - { - return; - } + { + if (_markdownView is null || e.NewValue is not { } attr) + { + return; + } - ThemeName autoTheme = TextMateSyntaxHighlighter.GetThemeForBackground (attr.Background); - _markdownView.SyntaxHighlighter = new TextMateSyntaxHighlighter (autoTheme); - themeDropDown.Value = autoTheme; - }; + ThemeName autoTheme = TextMateSyntaxHighlighter.GetThemeForBackground (attr.Background); + _markdownView.SyntaxHighlighter = new TextMateSyntaxHighlighter (autoTheme); + themeDropDown.Value = autoTheme; + }; CheckBox themeBgCheckBox = new () { Text = "Theme _BG", Value = _markdownView.UseThemeBackground ? CheckState.Checked : CheckState.UnChecked }; @@ -173,6 +176,7 @@ public override void Main () window.Initialized += (_, _) => { _ = LoadDocListAsync (); + _ = LoadIncludesAsync (); SyncContentWidthToViewport (); }; @@ -252,6 +256,58 @@ private void OnDocListValueChanged (object? sender, ValueChangedEventArgs _ = LoadDocContentAsync (entry); } + private void SelectDocFromLink (string url) + { + if (_docList is null || _docs.Count == 0) + { + return; + } + + string relativePath = url; + + if (relativePath.StartsWith ("~/docs/", StringComparison.Ordinal)) + { + relativePath = relativePath ["~/docs/".Length..]; + } + + int queryIndex = relativePath.IndexOf ('?'); + + if (queryIndex >= 0) + { + relativePath = relativePath [..queryIndex]; + } + + int anchorIndex = relativePath.IndexOf ('#'); + + if (anchorIndex >= 0) + { + relativePath = relativePath [..anchorIndex]; + } + + if (string.IsNullOrWhiteSpace (relativePath)) + { + return; + } + + int slashIndex = relativePath.LastIndexOf ('/'); + string docName = slashIndex >= 0 ? relativePath [(slashIndex + 1)..] : relativePath; + docName = Uri.UnescapeDataString (docName); + + int docIndex = _docs.FindIndex (d => string.Equals (d.Name, docName, StringComparison.OrdinalIgnoreCase)); + + if (docIndex < 0 && !docName.EndsWith (".md", StringComparison.OrdinalIgnoreCase)) + { + docIndex = _docs.FindIndex (d => string.Equals (d.Name, $"{docName}.md", StringComparison.OrdinalIgnoreCase)); + } + + if (docIndex < 0) + { + return; + } + _docList.SelectedItem = docIndex; + _statusShortcut?.Title = _docs [docIndex].Name; + } + private async Task LoadDocContentAsync (DocEntry entry) { ShowSpinner ($"Loading {entry.Name}..."); @@ -259,6 +315,7 @@ private async Task LoadDocContentAsync (DocEntry entry) try { string content = await _httpClient.GetStringAsync (entry.DownloadUrl).ConfigureAwait (false); + content = ExpandIncludes (content); _app?.Invoke (() => { @@ -299,5 +356,59 @@ private void HideSpinner (string message) _statusShortcut?.Title = message; } + private static readonly Regex _includeRegex = + new (@"\[!INCLUDE\s+\[.*?\]\((?:~/|\.\./)includes/(.+?)\)\]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private string ExpandIncludes (string content) => + _includeRegex.Replace (content, + match => + { + string fileName = match.Groups [1].Value; + + return _includes.TryGetValue (fileName, out string? includeContent) ? includeContent : match.Value; + }); + + private async Task LoadIncludesAsync () + { + try + { + string json = await _httpClient.GetStringAsync (INCLUDES_API_URL).ConfigureAwait (false); + + using JsonDocument doc = JsonDocument.Parse (json); + + List tasks = []; + + foreach (JsonElement element in doc.RootElement.EnumerateArray ()) + { + string? name = element.GetProperty ("name").GetString (); + string? downloadUrl = element.GetProperty ("download_url").GetString (); + + if (name is { } && downloadUrl is { } && name.EndsWith (".md", StringComparison.OrdinalIgnoreCase)) + { + tasks.Add (FetchInclude (name, downloadUrl)); + } + } + + await Task.WhenAll (tasks).ConfigureAwait (false); + } + catch + { + // Non-critical — includes just won't expand + } + } + + private async Task FetchInclude (string name, string url) + { + try + { + string content = await _httpClient.GetStringAsync (url).ConfigureAwait (false); + _includes [name] = content; + } + catch + { + // Skip failed includes + } + } + private sealed record DocEntry (string Name, string DownloadUrl); } diff --git a/Examples/UICatalog/Scenarios/DimAutoDemo.cs b/Examples/UICatalog/Scenarios/DimAutoDemo.cs index ba7acf4c9f..6c29a4dabf 100644 --- a/Examples/UICatalog/Scenarios/DimAutoDemo.cs +++ b/Examples/UICatalog/Scenarios/DimAutoDemo.cs @@ -167,11 +167,10 @@ private static FrameView CreateSliderFrameView () List options = ["One", "Two", "Three", "Four"]; - LinearRange linearRange = new (options) + LinearMultiSelector linearRange = new (options) { X = 0, Y = 0, - Type = LinearRangeType.Multiple, AllowEmpty = false, BorderStyle = LineStyle.Double, Title = "_LinearRange" diff --git a/Examples/UICatalog/Scenarios/FileDialogExamples.cs b/Examples/UICatalog/Scenarios/FileDialogExamples.cs index 5681307de3..03674bfa20 100644 --- a/Examples/UICatalog/Scenarios/FileDialogExamples.cs +++ b/Examples/UICatalog/Scenarios/FileDialogExamples.cs @@ -89,7 +89,7 @@ public override void Main () _osIcons = new OptionSelector { X = x, Y = y }; _osIcons.Labels = ["_None", "_Unicode", "Nerd_*"]; - _osIcons.Value = 2; + _osIcons.Value = 0; win.Add (_osIcons); Label label = new () { Y = Pos.AnchorEnd (), Text = "* Requires installing Nerd fonts:" }; @@ -239,12 +239,12 @@ private void CreateDialog (IApplication app) fd.Path = Environment.GetFolderPath (Environment.SpecialFolder.UserProfile); - var result = app.Run (fd) as int?; + app.Run (fd); IReadOnlyList multiSelected = fd.MultiSelected; string path = fd.Path; - if (result is null or 1) + if (fd.Canceled) { MessageBox.Query (app, "Canceled", "You canceled navigation and did not pick anything", Strings.btnOk); } diff --git a/Examples/UICatalog/Scenarios/LinearRanges.cs b/Examples/UICatalog/Scenarios/LinearRanges.cs index 852773d725..eef953c5bc 100644 --- a/Examples/UICatalog/Scenarios/LinearRanges.cs +++ b/Examples/UICatalog/Scenarios/LinearRanges.cs @@ -1,9 +1,8 @@ -using System.Collections.ObjectModel; -using System.Text; +using System.Collections.ObjectModel; namespace UICatalog.Scenarios; -[ScenarioMetadata ("LinearRanges", "Demonstrates the LinearRange view.")] +[ScenarioMetadata ("LinearRanges", "Demonstrates the LinearSelector / LinearMultiSelector / LinearRange views.")] [ScenarioCategory ("Controls")] public class LinearRanges : Scenario { @@ -16,601 +15,122 @@ public override void Main () using Window mainWindow = new (); mainWindow.Title = GetQuitKeyAndName (); - MakeSliders ( - mainWindow, - [ - 500, - 1000, - 1500, - 2000, - 2500, - 3000, - 3500, - 4000, - 4500, - 5000 - ] - ); + ObservableCollection eventSource = []; - FrameView configView = new () + ListView eventLog = new () { - Title = "Confi_guration", X = Pos.Percent (50), Y = 0, Width = Dim.Fill (), Height = Dim.Fill (), - SchemeName = "Dialog" + SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Accent), + Title = "Events", + BorderStyle = LineStyle.Single, + Source = new ListWrapper (eventSource) }; + mainWindow.Add (eventLog); - mainWindow.Add (configView); - - #region Config LinearRange - - LinearRange optionsSlider = new () + // ---- LinearSelector --------------------------------------------------------------- + LinearSelector single = new ([10, 20, 30, 40, 50]) { - Title = "Options", + Title = "_Single (LinearSelector)", X = 0, Y = 0, - Width = Dim.Fill (), - Type = LinearRangeType.Multiple, - AllowEmpty = true, - BorderStyle = LineStyle.Single - }; - - optionsSlider.Style.SetChar = optionsSlider.Style.SetChar with { Attribute = new Attribute (Color.BrightGreen, Color.Black) }; - optionsSlider.Style.LegendAttributes.SetAttribute = new Attribute (Color.Green, Color.Black); - - optionsSlider.Options = - [ - new () { Legend = "Legends" }, - new () { Legend = "RangeAllowSingle" }, - new () { Legend = "EndSpacing" }, - new () { Legend = "DimAuto" } - ]; - - configView.Add (optionsSlider); - - optionsSlider.OptionsChanged += OnOptionsSliderOnOptionsChanged; - optionsSlider.SetOption (0); // Legends - optionsSlider.SetOption (1); // RangeAllowSingle - optionsSlider.SetOption (3); // DimAuto - - CheckBox dimAutoUsesMin = new () - { - Text = "Use minimum size (vs. ideal)", - X = 0, - Y = Pos.Bottom (optionsSlider) + Width = Dim.Percent (50), + BorderStyle = LineStyle.Single, + AllowEmpty = false }; + single.Value = 30; - dimAutoUsesMin.ValueChanging += (_, _) => - { - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.UseMinimumSize = !s.UseMinimumSize; - } - }; - configView.Add (dimAutoUsesMin); + single.ValueChanged += (_, args) => + { + eventSource.Add ($"Single ValueChanged: {args.OldValue} -> {args.NewValue}"); + eventLog.MoveDown (); + }; + mainWindow.Add (single); - #region LinearRange Orientation LinearRange - - LinearRange orientationSlider = new (new () { "Horizontal", "Vertical" }) + // ---- LinearMultiSelector ------------------------------------------------------- + LinearMultiSelector multi = new (["Red", "Green", "Blue", "Yellow"]) { - Title = "LinearRange Orientation", + Title = "_Multiple (LinearMultiSelector)", X = 0, - Y = Pos.Bottom (dimAutoUsesMin) + 1, - BorderStyle = LineStyle.Single + Y = Pos.Bottom (single), + Width = Dim.Percent (50), + BorderStyle = LineStyle.Single, + AllowEmpty = true }; + multi.Value = ["Red", "Blue"]; - orientationSlider.SetOption (0); - - configView.Add (orientationSlider); - - orientationSlider.OptionsChanged += OnOrientationSliderOnOptionsChanged; - - #endregion LinearRange Orientation LinearRange - - #region Legends Orientation LinearRange + multi.ValueChanged += (_, args) => + { + eventSource.Add ($"Multi ValueChanged: [{string.Join (",", args.NewValue ?? [])}]"); + eventLog.MoveDown (); + }; + mainWindow.Add (multi); - LinearRange legendsOrientationSlider = new (["Horizontal", "Vertical"]) + // ---- LinearRange (closed) --------------------------------------------------------- + LinearRange closed = new ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { - Title = "Legends Orientation", + Title = "_Closed (LinearRange)", X = 0, - Y = Pos.Bottom (orientationSlider) + 1, - BorderStyle = LineStyle.Single - }; - - legendsOrientationSlider.SetOption (0); - - configView.Add (legendsOrientationSlider); - - legendsOrientationSlider.OptionsChanged += OnLegendsOrientationSliderOnOptionsChanged; - - #endregion Legends Orientation LinearRange - - #region Spacing Options - - FrameView spacingOptions = new () - { - Title = "Spacing Options", - X = Pos.Right (orientationSlider), - Y = Pos.Top (orientationSlider), - Width = Dim.Fill (), - Height = Dim.Auto (), - BorderStyle = LineStyle.Single - }; - - Label label = new () - { - Text = "Min _Inner Spacing:" - }; - - NumericUpDown innerSpacingUpDown = new () - { - X = Pos.Right (label) + 1 + Y = Pos.Bottom (multi), + Width = Dim.Percent (50), + BorderStyle = LineStyle.Single, + AllowEmpty = true, + RangeAllowSingle = true, + RangeKind = LinearRangeSpanKind.Closed }; - innerSpacingUpDown.Value = mainWindow.SubViews.OfType ().First ().MinimumInnerSpacing; + closed.ValueChanged += (_, args) => + { + LinearRangeSpan v = args.NewValue; - innerSpacingUpDown.ValueChanging += (_, e) => - { - if (e.NewValue < 0) - { - e.Handled = true; + eventSource.Add ( + $"Closed ValueChanged: kind={v.Kind} start={v.Start} end={v.End} (idx {v.StartIndex}..{v.EndIndex})"); + eventLog.MoveDown (); + }; + mainWindow.Add (closed); - return; - } - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.MinimumInnerSpacing = e.NewValue; - } - }; - - spacingOptions.Add (label, innerSpacingUpDown); - configView.Add (spacingOptions); - - #endregion - - #region Color LinearRange - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.Style.OptionChar = s.Style.OptionChar with { Attribute = mainWindow.GetAttributeForRole (VisualRole.Normal) }; - s.Style.SetChar = s.Style.SetChar with { Attribute = mainWindow.GetAttributeForRole (VisualRole.Normal) }; - s.Style.LegendAttributes.SetAttribute = mainWindow.GetAttributeForRole (VisualRole.Normal); - s.Style.RangeChar = s.Style.RangeChar with { Attribute = mainWindow.GetAttributeForRole (VisualRole.Normal) }; - } - - LinearRange<(Color, Color)> sliderFgColor = new () + // ---- LinearRange (left-bounded) --------------------------------------------------- + LinearRange leftBounded = new ([1, 2, 3, 4, 5]) { - Title = "FG Color", + Title = "_LeftBounded (LinearRange)", X = 0, - Y = Pos.Bottom (legendsOrientationSlider) - + 1, - Type = LinearRangeType.Single, + Y = Pos.Bottom (closed), + Width = Dim.Percent (50), BorderStyle = LineStyle.Single, - AllowEmpty = false, - Orientation = Orientation.Vertical, - LegendsOrientation = Orientation.Horizontal, - MinimumInnerSpacing = 0, - UseMinimumSize = true + RangeKind = LinearRangeSpanKind.LeftBounded, + AllowEmpty = true }; - sliderFgColor.Style.SetChar = sliderFgColor.Style.SetChar with { Attribute = new Attribute (Color.BrightGreen, Color.Black) }; - sliderFgColor.Style.LegendAttributes.SetAttribute = new Attribute (Color.Green, Color.Blue); - - List> colorOptions = []; - - colorOptions.AddRange ( - from colorIndex in Enum.GetValues () - let colorName = colorIndex.ToString () - select new LinearRangeOption<(Color, Color)> - { Data = (new (colorIndex), new (colorIndex)), Legend = colorName, LegendAbbr = (Rune)colorName [0] }); - - sliderFgColor.Options = colorOptions; - - configView.Add (sliderFgColor); - - sliderFgColor.OptionsChanged += OnSliderFgColorOnOptionsChanged; + leftBounded.ValueChanged += (_, args) => + { + eventSource.Add ($"LeftBounded ValueChanged: end={args.NewValue.End} (idx {args.NewValue.EndIndex})"); + eventLog.MoveDown (); + }; + mainWindow.Add (leftBounded); - LinearRange<(Color, Color)> sliderBgColor = new () + // ---- LinearRange (right-bounded) -------------------------------------------------- + LinearRange rightBounded = new ([1, 2, 3, 4, 5]) { - Title = "BG Color", - X = Pos.Right (sliderFgColor), - Y = Pos.Top (sliderFgColor), - Type = LinearRangeType.Single, + Title = "_RightBounded (LinearRange)", + X = 0, + Y = Pos.Bottom (leftBounded), + Width = Dim.Percent (50), BorderStyle = LineStyle.Single, - AllowEmpty = false, - Orientation = Orientation.Vertical, - LegendsOrientation = Orientation.Horizontal, - MinimumInnerSpacing = 0, - UseMinimumSize = true + RangeKind = LinearRangeSpanKind.RightBounded, + AllowEmpty = true }; - sliderBgColor.Style.SetChar = sliderBgColor.Style.SetChar with { Attribute = new Attribute (Color.BrightGreen, Color.Black) }; - sliderBgColor.Style.LegendAttributes.SetAttribute = new Attribute (Color.Green, Color.Blue); - - sliderBgColor.Options = colorOptions; - - configView.Add (sliderBgColor); - - sliderBgColor.OptionsChanged += (_, e) => - { - if (e.Options.Count == 0) - { - return; - } - - (Color, Color) data = e.Options.First ().Value.Data; - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.SetScheme ( - new (s.GetScheme ()) - { - Normal = new ( - s.GetAttributeForRole (VisualRole.Normal).Foreground, - data.Item2 - ) - }); - } - }; - - #endregion Color LinearRange - - #endregion Config LinearRange - - ObservableCollection eventSource = []; - - ListView eventLog = new () - { - X = Pos.Right (sliderBgColor), - Y = Pos.Bottom (spacingOptions), - Width = Dim.Fill (), - Height = Dim.Fill (), - SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Accent), - Source = new ListWrapper (eventSource) - }; - configView.Add (eventLog); - - foreach (View view in mainWindow.SubViews.Where (v => v is LinearRange)!) - { - var slider = (LinearRange)view; - - slider.Accepting += (_, args) => - { - eventSource.Add ($"Accept: {string.Join (",", slider.GetSetOptions ())}"); - eventLog.MoveDown (); - args.Handled = true; - }; - - slider.OptionsChanged += (_, args) => + rightBounded.ValueChanged += (_, args) => { - eventSource.Add ($"OptionsChanged: {string.Join (",", slider.GetSetOptions ())}"); + eventSource.Add ($"RightBounded ValueChanged: start={args.NewValue.Start} (idx {args.NewValue.StartIndex})"); eventLog.MoveDown (); - args.Cancel = true; }; - } + mainWindow.Add (rightBounded); mainWindow.FocusDeepest (NavigationDirection.Forward, null); app.Run (mainWindow); - - return; - - void OnSliderFgColorOnOptionsChanged (object _, LinearRangeEventArgs<(Color, Color)> e) - { - if (e.Options.Count == 0) - { - return; - } - - (Color, Color) data = e.Options.First ().Value.Data; - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.SetScheme ( - new (s.GetScheme ()) - { - Normal = new ( - data.Item2, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style) - }); - - s.Style.OptionChar = s.Style.OptionChar with - { - Attribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style) - }; - - s.Style.SetChar = s.Style.SetChar with - { - Attribute = new Attribute ( - data.Item1, - s.Style.SetChar.Attribute?.Background - ?? s.GetAttributeForRole (VisualRole.Normal).Background, - s.Style.SetChar.Attribute?.Style - ?? s.GetAttributeForRole (VisualRole.Normal).Style) - }; - - s.Style.LegendAttributes.SetAttribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style); - - s.Style.RangeChar = s.Style.RangeChar with - { - Attribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style) - }; - - s.Style.SpaceChar = s.Style.SpaceChar with - { - Attribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style) - }; - - s.Style.LegendAttributes.NormalAttribute = new Attribute ( - data.Item1, - s.GetAttributeForRole (VisualRole.Normal).Background, - s.GetAttributeForRole (VisualRole.Normal).Style); - } - } - - void OnLegendsOrientationSliderOnOptionsChanged (object _, LinearRangeEventArgs e) - { - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - if (e.Options.ContainsKey (0)) - { - s.LegendsOrientation = Orientation.Horizontal; - } - else if (e.Options.ContainsKey (1)) - { - s.LegendsOrientation = Orientation.Vertical; - } - - if (optionsSlider.GetSetOptions ().Contains (3)) - { - s.Width = Dim.Auto (DimAutoStyle.Content); - s.Height = Dim.Auto (DimAutoStyle.Content); - } - else - { - if (s.Orientation == Orientation.Horizontal) - { - s.Width = Dim.Percent (50); - - int h = s.ShowLegends && s.LegendsOrientation == Orientation.Vertical - ? s.Options.Max (o => o.Legend!.Length) + 3 - : 4; - s.Height = h; - } - else - { - int w = s.ShowLegends ? s.Options.Max (o => o.Legend!.Length) + 3 : 3; - s.Width = w; - s.Height = Dim.Fill (); - } - } - } - } - - void OnOrientationSliderOnOptionsChanged (object _, LinearRangeEventArgs e) - { - View prev = null; - - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - if (e.Options.ContainsKey (0)) - { - s.Orientation = Orientation.Horizontal; - - s.Style.SpaceChar = new () { Grapheme = Glyphs.HLine.ToString () }; - - if (prev == null) - { - s.Y = 0; - } - else - { - s.Y = Pos.Bottom (prev) + 1; - } - - s.X = 0; - prev = s; - } - else if (e.Options.ContainsKey (1)) - { - s.Orientation = Orientation.Vertical; - - s.Style.SpaceChar = new () { Grapheme = Glyphs.VLine.ToString () }; - - if (prev == null) - { - s.X = 0; - } - else - { - s.X = Pos.Right (prev) + 2; - } - - s.Y = 0; - prev = s; - } - - if (optionsSlider.GetSetOptions ().Contains (3)) - { - s.Width = Dim.Auto (DimAutoStyle.Content); - s.Height = Dim.Auto (DimAutoStyle.Content); - } - else - { - if (s.Orientation == Orientation.Horizontal) - { - s.Width = Dim.Percent (50); - - int h = s.ShowLegends && s.LegendsOrientation == Orientation.Vertical - ? s.Options.Max (o => o.Legend!.Length) + 3 - : 4; - s.Height = h; - } - else - { - int w = s.ShowLegends ? s.Options.Max (o => o.Legend!.Length) + 3 : 3; - s.Width = w; - s.Height = Dim.Fill (); - } - } - } - } - - void OnOptionsSliderOnOptionsChanged (object _, LinearRangeEventArgs e) - { - foreach (LinearRange s in mainWindow.SubViews.OfType ()) - { - s.ShowLegends = e.Options.ContainsKey (0); - s.RangeAllowSingle = e.Options.ContainsKey (1); - s.ShowEndSpacing = e.Options.ContainsKey (2); - - if (e.Options.ContainsKey (3)) - { - s.Width = Dim.Auto (DimAutoStyle.Content); - s.Height = Dim.Auto (DimAutoStyle.Content); - } - else - { - if (s.Orientation == Orientation.Horizontal) - { - s.Width = Dim.Percent (50); - - int h = s.ShowLegends && s.LegendsOrientation == Orientation.Vertical - ? s.Options.Max (o => o.Legend!.Length) + 3 - : 4; - s.Height = h; - } - else - { - int w = s.ShowLegends ? s.Options.Max (o => o.Legend!.Length) + 3 : 3; - s.Width = w; - s.Height = Dim.Fill (); - } - } - } - } - } - - private void MakeSliders (Window window, List options) - { - List types = Enum.GetValues (typeof (LinearRangeType)).Cast ().ToList (); - LinearRange prev = null; - - foreach (LinearRange view in types.Select (type => new LinearRange (options) - { - Title = type.ToString (), - X = 0, - Y = prev == null ? 0 : Pos.Bottom (prev), - BorderStyle = LineStyle.Single, - Type = type, - AllowEmpty = true - })) - { - //view.Padding.Thickness = new (0,1,0,0); - window.Add (view); - prev = view; - } - - List singleOptions = - [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 31, - 32, - 33, - 34, - 35, - 36, - 37, - 38, - 39 - ]; - - LinearRange single = new (singleOptions) - { - Title = "_Continuous", - X = 0, - Y = prev == null ? 0 : Pos.Bottom (prev), - Type = LinearRangeType.Single, - BorderStyle = LineStyle.Single, - AllowEmpty = false - }; - - single.SubViewLayout += (_, _) => - { - if (single.Orientation == Orientation.Horizontal) - { - single.Style.SpaceChar = new () { Grapheme = Glyphs.HLine.ToString () }; - single.Style.OptionChar = new () { Grapheme = Glyphs.HLine.ToString () }; - } - else - { - single.Style.SpaceChar = new () { Grapheme = Glyphs.VLine.ToString () }; - single.Style.OptionChar = new () { Grapheme = Glyphs.VLine.ToString () }; - } - }; - single.Style.SetChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; - single.Style.DragChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; - - window.Add (single); - - single.OptionsChanged += (_, e) => { single.Title = $"_Continuous {e.Options.FirstOrDefault ().Key}"; }; - - List oneOption = new () { "The Only Option" }; - - LinearRange one = new (oneOption) - { - Title = "_One Option", - X = 0, - Y = prev == null ? 0 : Pos.Bottom (single), - Type = LinearRangeType.Single, - BorderStyle = LineStyle.Single, - AllowEmpty = false - }; - window.Add (one); } } diff --git a/Examples/UICatalog/Scenarios/ListColumns.cs b/Examples/UICatalog/Scenarios/ListColumns.cs index 23b6fc6b50..220dc1f959 100644 --- a/Examples/UICatalog/Scenarios/ListColumns.cs +++ b/Examples/UICatalog/Scenarios/ListColumns.cs @@ -96,7 +96,7 @@ public override void Main () { if (_listColView is { }) { - selectedCellLabel.Text = $"{_listColView.Value?.Cursor.Y ?? 0},{_listColView.Value?.Cursor.X ?? 0}"; + selectedCellLabel.Text = $"{_listColView.Value?.SelectedCell.Y ?? 0},{_listColView.Value?.SelectedCell.X ?? 0}"; } }; _listColView.KeyDown += TableViewKeyPress; diff --git a/Examples/UICatalog/Scenarios/MarkdownTester.cs b/Examples/UICatalog/Scenarios/MarkdownTester.cs index aae64f8b01..86f6badedf 100644 --- a/Examples/UICatalog/Scenarios/MarkdownTester.cs +++ b/Examples/UICatalog/Scenarios/MarkdownTester.cs @@ -64,7 +64,7 @@ public override void Main () X = 0, Y = 0, Width = Dim.Fill (), - Height = Dim.Fill (), + Height = Dim.Fill () - 1, SyntaxHighlighter = new TextMateSyntaxHighlighter () }; diff --git a/Examples/UICatalog/Scenarios/MultiColouredTable.cs b/Examples/UICatalog/Scenarios/MultiColouredTable.cs index f92bd3dba6..b9661aeb68 100644 --- a/Examples/UICatalog/Scenarios/MultiColouredTable.cs +++ b/Examples/UICatalog/Scenarios/MultiColouredTable.cs @@ -71,8 +71,8 @@ private void EditCurrentCell (object? sender, CommandEventArgs e) return; } - int col = _tableView.Value?.Cursor.X ?? 0; - int row = _tableView.Value?.Cursor.Y ?? 0; + int col = _tableView.Value?.SelectedCell.X ?? 0; + int row = _tableView.Value?.SelectedCell.Y ?? 0; var oldValue = _tableView.Table [row, col].ToString (); diff --git a/Examples/UICatalog/Scenarios/Popovers.cs b/Examples/UICatalog/Scenarios/Popovers.cs index 95cf1ffba3..5ba8db1170 100644 --- a/Examples/UICatalog/Scenarios/Popovers.cs +++ b/Examples/UICatalog/Scenarios/Popovers.cs @@ -16,6 +16,7 @@ public class Popovers : Scenario private EventLog? _eventLog; private Button? _activateButton; private TextField? _resultTextField; + private TextField? _initialValueTextField; private readonly Dictionary _popoverInstances = []; @@ -83,15 +84,23 @@ public override void Main () _activateButton.Accepting += ShowPopover; - _resultTextField = new TextField + _initialValueTextField = new TextField { Y = Pos.Bottom (_activateButton), Width = Dim.Fill (), + Title = "_Initial Value (TrySetValueFromString)", + BorderStyle = LineStyle.Dotted + }; + + _resultTextField = new TextField + { + Y = Pos.Bottom (_initialValueTextField), + Width = Dim.Fill (), ReadOnly = true, Title = "Result", BorderStyle = LineStyle.Dotted }; - _popoverTargetFrame.Add (_activateButton, _resultTextField); + _popoverTargetFrame.Add (_activateButton, _initialValueTextField, _resultTextField); _eventLog.SetViewToLog (window); window.Add (_viewListView, _popoverTargetFrame, _eventLog); @@ -135,6 +144,22 @@ private void ShowPopover (object? sender, CommandEventArgs args) args.Handled = true; + // Apply initial value via IValue.TrySetValueFromString if the TextField has content + if (!string.IsNullOrEmpty (_initialValueTextField?.Text)) + { + View? contentView = (popover as View)?.SubViews.FirstOrDefault (); + + if (contentView is IValue iValue) + { + bool success = iValue.TrySetValueFromString (_initialValueTextField.Text); + _eventLog?.Log ($"TrySetValueFromString(\"{_initialValueTextField.Text}\") on {contentView.GetType ().Name}: {(success ? "succeeded" : "failed")}"); + } + else + { + _eventLog?.Log ($"Content view does not implement IValue; cannot apply initial value."); + } + } + try { Point idealPosition = _resultTextField?.FrameToScreen ().Location ?? Point.Empty; diff --git a/Examples/UICatalog/Scenarios/RegionScenario.cs b/Examples/UICatalog/Scenarios/RegionScenario.cs index 1fe69fd7f9..d5b5c16f0a 100644 --- a/Examples/UICatalog/Scenarios/RegionScenario.cs +++ b/Examples/UICatalog/Scenarios/RegionScenario.cs @@ -107,7 +107,45 @@ public override void Main () break; case RegionDrawStyles.OuterBoundary: - _region.DrawOuterBoundary (sendingView.LineCanvas, LineStyle.Single, tools.CurrentAttribute); + // Demo simplification: iterate the region's rectangles and add the four edges of each + // to the LineCanvas. Shared edges between adjacent rectangles will overlap; LineCanvas's + // line-style joining handles the visual result, matching how Border/Adornment draw. + foreach (Rectangle rect in _region.GetRectangles ()) + { + if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0) + { + continue; + } + + sendingView.LineCanvas.AddLine ( + new (rect.Left, rect.Top), + rect.Width, + Orientation.Horizontal, + LineStyle.Single, + tools.CurrentAttribute); + + sendingView.LineCanvas.AddLine ( + new (rect.Left, rect.Bottom - 1), + rect.Width, + Orientation.Horizontal, + LineStyle.Single, + tools.CurrentAttribute); + + sendingView.LineCanvas.AddLine ( + new (rect.Left, rect.Top), + rect.Height, + Orientation.Vertical, + LineStyle.Single, + tools.CurrentAttribute); + + sendingView.LineCanvas.AddLine ( + new (rect.Right - 1, rect.Top), + rect.Height, + Orientation.Vertical, + LineStyle.Single, + tools.CurrentAttribute); + } + _region.FillRectangles (sendingView.App?.Driver, tools.CurrentAttribute!.Value, (Rune)' '); break; diff --git a/Examples/UICatalog/Scenarios/ShadowStyleDemo.cs b/Examples/UICatalog/Scenarios/ShadowStyleDemo.cs index 189ca0ef7b..8392bb66af 100644 --- a/Examples/UICatalog/Scenarios/ShadowStyleDemo.cs +++ b/Examples/UICatalog/Scenarios/ShadowStyleDemo.cs @@ -12,30 +12,31 @@ public override void Main () using IApplication app = Application.Create (); app.Init (); - using Window window = new () - { - Id = "app", - Title = GetQuitKeyAndName () - }; + using Window window = new (); + window.Id = "app"; + window.Title = GetQuitKeyAndName (); + window.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Accent); AdornmentsEditor editor = new () { - Id = "editor", + BorderStyle = LineStyle.Single, AutoSelectViewToEdit = true, - ShowViewIdentifier = true - }; - editor.Initialized += (_, _) => editor.MarginEditor!.ExpanderButton!.Collapsed = false; - window.Add (editor); + // This is for giggles, to show that the editor can be moved around. + Arrangement = ViewArrangement.Movable, + Id = "editor" + }; + editor.Border.Thickness = new Thickness (1, 2, 1, 1); Window shadowWindow = new () { Id = "shadowWindow", - X = Pos.Right (editor), + X = Pos.Center (), Y = 0, Width = Dim.Percent (30), Height = Dim.Percent (30), Title = "Shadow Window", + SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Accent), Arrangement = ViewArrangement.Movable | ViewArrangement.Overlapped, BorderStyle = LineStyle.Double, ShadowStyle = ShadowStyles.Transparent @@ -51,11 +52,11 @@ public override void Main () { Id = "buttonInWin", X = Pos.Center (), - Y = Pos.Center (), Text = "Button in Window", + Y = Pos.Center (), + Text = "Button in Window", ShadowStyle = ShadowStyles.Opaque }; shadowWindow.Add (buttonInWin); - window.Add (shadowWindow); Window shadowWindow2 = new () { @@ -65,17 +66,18 @@ public override void Main () Width = Dim.Percent (30), Height = Dim.Percent (30), Title = "Shadow Window #2", + SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Error), Arrangement = ViewArrangement.Movable | ViewArrangement.Overlapped, BorderStyle = LineStyle.Double, ShadowStyle = ShadowStyles.Transparent }; - window.Add (shadowWindow2); Button button = new () { Id = "button", X = Pos.Right (editor) + 10, - Y = Pos.Center (), Text = "Button", + Y = Pos.Center (), + Text = "Button", ShadowStyle = ShadowStyles.Opaque }; button.Accepting += ButtonOnAccepting; @@ -84,7 +86,7 @@ public override void Main () { Title = "ColorPicker to illustrate highlight (currently broken)", BorderStyle = LineStyle.Dotted, - Id = "colorPicker16", + Id = "colorPicker", X = Pos.Center (), Y = Pos.AnchorEnd (), Width = Dim.Percent (80) @@ -93,13 +95,18 @@ public override void Main () colorPicker.ValueChanged += (_, args) => { Attribute normal = window.GetScheme ().Normal; - window.SetScheme (window.GetScheme () with { Normal = new (normal.Foreground, args.NewValue ?? Color.Black) }); + + window.SetScheme (window.GetScheme () with + { + Normal = new Attribute (normal.Foreground, args.NewValue ?? Color.Black) + }); }; - window.Add (button, colorPicker); + window.Add (button, colorPicker, editor, shadowWindow, shadowWindow2); + editor.ShowViewIdentifier = true; editor.AutoSelectViewToEdit = true; editor.AutoSelectSuperView = window; - editor.AutoSelectAdornments = false; + editor.AutoSelectAdornments = true; app.Run (window); } diff --git a/Examples/UICatalog/Scenarios/Shortcuts.cs b/Examples/UICatalog/Scenarios/Shortcuts.cs index 5c3c90aafa..f0320a678e 100644 --- a/Examples/UICatalog/Scenarios/Shortcuts.cs +++ b/Examples/UICatalog/Scenarios/Shortcuts.cs @@ -205,24 +205,24 @@ private void HandleOnIsRunningChanged (object? sender, EventArgs e) Y = Pos.Bottom (diagnosticShortcut), Width = Dim.Fill (eventLog), HelpText = "LinearRanges work!", - CommandView = new LinearRange { Id = "sliderLR", Orientation = Orientation.Horizontal, AllowEmpty = true }, + CommandView = new LinearMultiSelector { Id = "sliderLR", Orientation = Orientation.Horizontal, AllowEmpty = true }, Key = Key.F5 }; - ((LinearRange)sliderShortcut.CommandView).Options = + ((LinearMultiSelector)sliderShortcut.CommandView).Options = [ new LinearRangeOption { Legend = "A" }, new LinearRangeOption { Legend = "B" }, new LinearRangeOption { Legend = "C" } ]; - ((LinearRange)sliderShortcut.CommandView).SetOption (0); + ((LinearMultiSelector)sliderShortcut.CommandView).Value = ["A"]; - ((LinearRange)sliderShortcut.CommandView).OptionsChanged += (send, _) => + ((LinearMultiSelector)sliderShortcut.CommandView).ValueChanged += (send, args) => { - if (send is LinearRange lr) + if (send is LinearMultiSelector lr) { - eventLog.Log ($"OptionsChanged: { + eventLog.Log ($"ValueChanged: { lr.GetType ().Name } - { - string.Join (",", lr.GetSetOptions ()) + string.Join (",", args.NewValue ?? []) }"); } }; diff --git a/Examples/UICatalog/Scenarios/TableEditor.cs b/Examples/UICatalog/Scenarios/TableEditor.cs index 338d38ec51..2f1647c690 100644 --- a/Examples/UICatalog/Scenarios/TableEditor.cs +++ b/Examples/UICatalog/Scenarios/TableEditor.cs @@ -2,6 +2,7 @@ using System.Data; using System.Globalization; using System.Text; + // ReSharper disable StringLiteralTypo namespace UICatalog.Scenarios; @@ -13,7 +14,6 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("Text and Formatting")] public class TableEditor : Scenario { - private IApplication? _app; private readonly HashSet? _checkedFileSystemInfos = []; private readonly List? _toDispose = []; @@ -163,6 +163,8 @@ public class TableEditor : Scenario new UnicodeRange (0xE0000, 0xE007F, "Tags") ]; + private IApplication? _app; + private Scheme? _alternatingScheme; private DataTable? _currentTable; private Scheme? _redScheme; @@ -221,6 +223,7 @@ public override void Main () [ new MenuItem { Title = "_OpenBigExample", Action = () => OpenExample (true) }, new MenuItem { Title = "_OpenSmallExample", Action = () => OpenExample (false) }, + new MenuItem { Title = "Open_WideColumnExample", Action = OpenWideColumnExample }, new MenuItem { Title = "OpenCharacter_Map", Action = OpenUnicodeMap }, new MenuItem { Title = "OpenTreeExample", Action = OpenTreeExample }, new MenuItem { Title = "_CloseExample", Action = CloseExample }, @@ -254,11 +257,11 @@ public override void Main () appWindow.Add (_tableView); - _tableView!.ValueChanged += (_, _) => { selectedCellLabel.Text = $"{_tableView!.Value?.Cursor.Y ?? 0},{_tableView!.Value?.Cursor.X ?? 0}"; }; + _tableView!.ValueChanged += (_, _) => { selectedCellLabel.Text = $"{_tableView!.Value?.SelectedCell.Y ?? 0},{_tableView!.Value?.SelectedCell.X ?? 0}"; }; _tableView!.Accepted += EditCurrentCell; _tableView!.KeyDown += TableViewKeyPress; - //SetupScrollBar (); + // SetupScrollBar (); _redScheme = new Scheme { @@ -321,6 +324,83 @@ public override void Main () app.Run (appWindow); } + protected override void Dispose (bool disposing) + { + base.Dispose (disposing); + + foreach (IDisposable d in _toDispose!) + { + d.Dispose (); + } + } + + private DataTable BuildUnicodeMap () + { + var dt = new DataTable (); + + // add cols called 0 to 9 + for (var i = 0; i < 10; i++) + { + DataColumn col = dt.Columns.Add (i.ToString (), typeof (uint)); + ColumnStyle style = _tableView!.Style.GetOrCreateColumnStyle (col.Ordinal); + + style.RepresentationGetter = RuneToString; + } + + // add cols called a to z + for (int i = 'a'; i < 'a' + 26; i++) + { + DataColumn col = dt.Columns.Add (((char)i).ToString (), typeof (uint)); + ColumnStyle style = _tableView!.Style.GetOrCreateColumnStyle (col.Ordinal); + style.RepresentationGetter = RuneToString; + } + + // now add table contents + List runes = []; + + foreach (UnicodeRange range in _ranges!) + { + for (uint i = range.Start; i <= range.End; i++) + { + runes.Add (i); + } + } + + DataRow? dr = null; + + for (var i = 0; i < runes.Count; i++) + { + if (dr == null || i % dt.Columns.Count == 0) + { + dr = dt.Rows.Add (); + } + + dr [i % dt.Columns.Count] = runes [i]; + } + + return dt; + } + + private void CheckOrUncheckFile (FileSystemInfo info, bool check) + { + if (check) + { + _checkedFileSystemInfos!.Add (info); + } + else + { + _checkedFileSystemInfos!.Remove (info); + } + } + + private void ClearColumnStyles () + { + _tableView!.Style.ColumnStyles.Clear (); + _tableView!.Update (); + } + + private void CloseExample () => _tableView!.Table = null; + private MenuBarItem CreateViewMenu () { // Store checkbox references for the toggle methods to access @@ -642,85 +722,6 @@ MenuItem CreateOptionSelectorMenuItem (string title, T initialState, Action runes = new (); - - foreach (UnicodeRange range in _ranges!) - { - for (uint i = range.Start; i <= range.End; i++) - { - runes.Add (i); - } - } - - DataRow? dr = null; - - for (var i = 0; i < runes.Count; i++) - { - if (dr == null || i % dt.Columns.Count == 0) - { - dr = dt.Rows.Add (); - } - - dr [i % dt.Columns.Count] = runes [i].ToString (); - } - - return dt; - } - - private string RuneToString (object o) => Rune.TryCreate ((uint)o, out Rune rune) ? rune.ToString () : " "; - - private void CheckOrUncheckFile (FileSystemInfo info, bool check) - { - if (check) - { - _checkedFileSystemInfos!.Add (info); - } - else - { - _checkedFileSystemInfos!.Remove (info); - } - } - - private void ClearColumnStyles () - { - _tableView!.Style.ColumnStyles.Clear (); - _tableView!.Update (); - } - - private void CloseExample () => _tableView!.Table = null; - private void EditCurrentCell (object? sender, CommandEventArgs args) { if (_tableView?.Table is not DataTableSource || _currentTable == null) @@ -728,8 +729,8 @@ private void EditCurrentCell (object? sender, CommandEventArgs args) return; } - int col = _tableView.Value?.Cursor.X ?? 0; - int row = _tableView.Value?.Cursor.Y ?? 0; + int col = _tableView.Value?.SelectedCell.X ?? 0; + int row = _tableView.Value?.SelectedCell.Y ?? 0; int tableCol = ToTableCol (col); @@ -794,12 +795,12 @@ private IEnumerable GetChildren (FileSystemInfo arg) return null; } - if (_tableView!.Value is null || _tableView!.Value.Cursor.X > _tableView!.Table.Columns) + if (_tableView!.Value is null || _tableView!.Value.SelectedCell.X > _tableView!.Table.Columns) { return null; } - return _tableView!.Value.Cursor.X; + return _tableView!.Value.SelectedCell.X; } private string GetHumanReadableFileSize (FileSystemInfo fsi) @@ -929,6 +930,32 @@ private void OpenUnicodeMap () _tableView?.Update (); } + // Demonstrates the fix for #5072: a column with very wide content used to consume all viewport + // space and push later columns off-screen. With the fix, "Description" is clamped so "Status" and + // "Owner" remain visible at their header widths. + private void OpenWideColumnExample () + { + DataTable dt = new (); + dt.Columns.Add ("Id", typeof (int)); + dt.Columns.Add ("Description", typeof (string)); + dt.Columns.Add ("Status", typeof (string)); + dt.Columns.Add ("Owner", typeof (string)); + + string [] statuses = ["Open", "InProgress", "Blocked", "Done"]; + string [] owners = ["Alice", "Bob", "Carol", "Dan"]; + + for (var i = 0; i < 25; i++) + { + dt.Rows.Add (i, $"Row {i}: " + new string ('x', 120 + i % 40), statuses [i % statuses.Length], owners [i % owners.Length]); + } + + SetTable (dt); + + // Clear any styles inherited from a previous example + _tableView!.Style.ColumnStyles.Clear (); + _tableView!.Update (); + } + private void Quit () => _tableView?.App?.RequestStop (); private void RunColumnWidthDialog (int? col, string prompt, Action setter, Func getter) @@ -969,6 +996,8 @@ private void RunColumnWidthDialog (int? col, string prompt, Action Rune.TryCreate ((uint)o, out Rune rune) ? rune.ToString () : " "; + private void SetDemoTableStyles () { _tableView!.Style.ColumnStyles.Clear (); @@ -1053,8 +1082,8 @@ private void SetMinWidth () private void SetTable (DataTable dataTable) => _tableView!.Table = new DataTableSource (_currentTable = dataTable); - //private void SetupScrollBar () - //{ + // private void SetupScrollBar () + // { // var scrollBar = new ScrollBarView (_tableView, true); // scrollBar.ChangedPosition += (s, e) => @@ -1086,7 +1115,7 @@ private void SetMinWidth () // //scrollBar.OtherScrollBarView.Position = tableView.LeftItem; // scrollBar.Refresh (); // }; - //} + // } private void ShowAllColumns () { diff --git a/Examples/UICatalog/Scenarios/TableViewTest.cs b/Examples/UICatalog/Scenarios/TableViewTest.cs index bcfe817504..ca78731b9e 100644 --- a/Examples/UICatalog/Scenarios/TableViewTest.cs +++ b/Examples/UICatalog/Scenarios/TableViewTest.cs @@ -54,7 +54,7 @@ public override void Main () var setSelectedRowButton = new Button { X = Pos.Right (selectedRowUpDown), Y = Pos.Bottom (optionsView), Text = "Set" }; setSelectedRowButton.Padding.Thickness = new Thickness (1, 0, 1, 0); - setSelectedRowButton.Accepting += (sender, args) => tableView.SetSelection (tableView.Value?.Cursor.X ?? 0, selectedRowUpDown.Value, false); + setSelectedRowButton.Accepting += (sender, args) => tableView.SetSelection (tableView.Value?.SelectedCell.X ?? 0, selectedRowUpDown.Value, false); tableView = new TableView { @@ -89,6 +89,10 @@ public override void Main () ("ShowVerticalHeaderLines", () => tableView.Style.ShowVerticalHeaderLines, b => tableView.Style.ShowVerticalHeaderLines = b), ("ShowHorizontalHeaderUnderline", () => tableView.Style.ShowHorizontalHeaderUnderline, b => tableView.Style.ShowHorizontalHeaderUnderline = b), ("ShowVerticalCellLines", () => tableView.Style.ShowVerticalCellLines, b => tableView.Style.ShowVerticalCellLines = b), + ("ShowVerticalCellLineForFirstColumn", () => tableView.Style.ShowVerticalCellLineForFirstColumn, + b => tableView.Style.ShowVerticalCellLineForFirstColumn = b), + ("ShowVerticalCellLineForLastColumn", () => tableView.Style.ShowVerticalCellLineForLastColumn, + b => tableView.Style.ShowVerticalCellLineForLastColumn = b), ("InvertSelectedCellFirstCharacter", () => tableView.Style.InvertSelectedCellFirstCharacter, b => tableView.Style.InvertSelectedCellFirstCharacter = b), ("ShowHorizontalBottomline", () => tableView.Style.ShowHorizontalBottomLine, b => tableView.Style.ShowHorizontalBottomLine = b), diff --git a/Examples/UICatalog/Scenarios/ViewportSettings.cs b/Examples/UICatalog/Scenarios/ViewportSettings.cs index 427c651902..d92654e3d9 100644 --- a/Examples/UICatalog/Scenarios/ViewportSettings.cs +++ b/Examples/UICatalog/Scenarios/ViewportSettings.cs @@ -90,12 +90,11 @@ public override void Main () List options = ["Option 1", "Option 2", "Option 3"]; - LinearRange linearRange = new (options) + LinearMultiSelector linearRange = new (options) { X = 0, Y = Pos.Bottom (textField) + 1, Orientation = Orientation.Vertical, - Type = LinearRangeType.Multiple, AllowEmpty = false, BorderStyle = LineStyle.Double, Title = "_LinearRange" diff --git a/Examples/UICatalog/UICatalogRunnable.cs b/Examples/UICatalog/UICatalogRunnable.cs index f1a88ad060..5ca32173d1 100644 --- a/Examples/UICatalog/UICatalogRunnable.cs +++ b/Examples/UICatalog/UICatalogRunnable.cs @@ -119,7 +119,7 @@ protected override void OnIsRunningChanged (bool newIsRunning) if (_scenarioList is { } && App is { } && _scenarioList.Table is { }) { ShowScenarioErrorsDialog (App, - _scenarioList.Table [_scenarioList.Value?.Cursor.Y ?? 0, 0].ToString () ?? string.Empty, + _scenarioList.Table [_scenarioList.Value?.SelectedCell.Y ?? 0, 0].ToString () ?? string.Empty, UICatalog.LogCapture.GetScenarioLogs ()); } @@ -614,10 +614,10 @@ private void ScenarioView_OpenSelectedItem (object? sender, CommandEventArgs e) if (_scenarioList is { }) { - _cachedScenarioIndex = _scenarioList.Value?.Cursor.Y ?? 0; + _cachedScenarioIndex = _scenarioList.Value?.SelectedCell.Y ?? 0; // Set the Result to the selected scenario name - Result = _scenarioList.Table? [_scenarioList.Value?.Cursor.Y ?? 0, 0]; + Result = _scenarioList.Table? [_scenarioList.Value?.SelectedCell.Y ?? 0, 0]; } Logging.Information ($"Scenario Selected; Stopping {GetType ().Name}: {Result}"); App?.RequestStop (); diff --git a/GitVersion.yml b/GitVersion.yml index 18f1a8b6f5..b6e1287aae 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: beta # Increments patch version (x.y.z+1) on commits increment: Patch # Specifies develop as the source branch diff --git a/README.md b/README.md index 55350c9dd6..922b983af1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Cross-platform UI toolkit for building sophisticated terminal UI (TUI) applications on Windows, macOS, and Linux/Unix. -![Terminal.Gui — cross-platform TUI toolkit for .NET. Build full-featured terminal UIs with menus, forms, tables, charts, wizards and file dialogs. 30+ stars, Windows / macOS / Linux, MIT-licensed.](docfx/images/hero.gif) +![Terminal.Gui — cross-platform TUI toolkit for .NET. Build full-featured terminal UIs with menus, forms, tables, charts, wizards and file dialogs. +11k stars, +50 built-in views, +1.7M downloads, TrueColor with Unicode and mouse — Windows / macOS / Linux, MIT-licensed.](docfx/images/hero.gif) # Version 2.0 Has Been Released diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..1dcf728251 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +These releases are currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 1.x.x | :x: | +| 2.x.x | :white_check_mark: | + +## Reporting a Vulnerability + +Please submit issues via Issues. diff --git a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs index f91d86af62..c1e8a2a8ed 100644 --- a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs +++ b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using Wcwidth; using Trace = Terminal.Gui.Tracing.Trace; @@ -17,8 +16,6 @@ internal partial class ApplicationImpl public event EventHandler>? InitializedChanged; /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] public IApplication Init (string? driverName = null) { if (Initialized) diff --git a/Terminal.Gui/App/ApplicationImpl.Run.cs b/Terminal.Gui/App/ApplicationImpl.Run.cs index 523d7617f9..b2c143292b 100644 --- a/Terminal.Gui/App/ApplicationImpl.Run.cs +++ b/Terminal.Gui/App/ApplicationImpl.Run.cs @@ -55,6 +55,11 @@ internal partial class ApplicationImpl /// public void Invoke (Action? action) { + if (!Initialized) + { + throw new NotInitializedException (nameof (Invoke)); + } + // If we are already on the main UI thread if (TopRunnableView is IRunnable { IsRunning: true } && MainThreadId == Thread.CurrentThread.ManagedThreadId) { @@ -75,6 +80,13 @@ public void Invoke (Action? action) /// public void Invoke (Action action) { + ArgumentNullException.ThrowIfNull (action); + + if (!Initialized) + { + throw new NotInitializedException (nameof (Invoke)); + } + // If we are already on the main UI thread if (TopRunnableView is IRunnable { IsRunning: true } && MainThreadId == Thread.CurrentThread.ManagedThreadId) { @@ -187,8 +199,6 @@ public void Invoke (Action action) #region Session Lifecycle - Run /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] public IApplication Run (Func? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new () { if (!Initialized) @@ -267,6 +277,95 @@ public void Invoke (Action action) return token.Result; } + /// + public Task RunAsync (IRunnable runnable, CancellationToken cancellationToken, Func? errorHandler = null) + { + ArgumentNullException.ThrowIfNull (runnable); + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult (null); + } + + TaskCompletionSource tcs = new (TaskCreationOptions.RunContinuationsAsynchronously); + + // Register the cancellation token to request stop on the main loop via Invoke. + CancellationTokenRegistration registration = cancellationToken.Register (() => Invoke (() => RequestStop (runnable))); + + try + { + object? result = Run (runnable, errorHandler); + tcs.TrySetResult (result); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + tcs.TrySetCanceled (cancellationToken); + } + catch (Exception ex) + { + tcs.TrySetException (ex); + } + finally + { + registration.Dispose (); + } + + return tcs.Task; + } + + /// + public Task RunAsync (CancellationToken cancellationToken, Func? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new () + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult (this); + } + + if (!Initialized) + { + // Init() has NOT been called. Auto-initialize as per interface contract. + Init (driverName); + } + + if (Driver is null) + { + throw new InvalidOperationException (@"Driver is null after Init."); + } + + TRunnable runnable = new (); + + TaskCompletionSource tcs = new (TaskCreationOptions.RunContinuationsAsynchronously); + + // Register the cancellation token to request stop on the main loop via Invoke. + CancellationTokenRegistration registration = cancellationToken.Register (() => Invoke (() => RequestStop (runnable))); + + try + { + Run (runnable, errorHandler); + tcs.TrySetResult (this); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + tcs.TrySetCanceled (cancellationToken); + } + catch (Exception ex) + { + tcs.TrySetException (ex); + } + finally + { + registration.Dispose (); + + // We created the runnable, so dispose it if it's disposable + if (runnable is IDisposable disposable) + { + disposable.Dispose (); + } + } + + return tcs.Task; + } + private void RunLoop (IRunnable runnable, Func? errorHandler) { runnable.StopRequested = false; diff --git a/Terminal.Gui/App/ApplicationImpl.Screen.cs b/Terminal.Gui/App/ApplicationImpl.Screen.cs index 4f3f06af1e..98c4492769 100644 --- a/Terminal.Gui/App/ApplicationImpl.Screen.cs +++ b/Terminal.Gui/App/ApplicationImpl.Screen.cs @@ -1,9 +1,8 @@ +using System.Diagnostics; using Terminal.Gui.Tracing; namespace Terminal.Gui.App; -using Trace = Trace; - internal partial class ApplicationImpl { /// @@ -65,6 +64,11 @@ private void RaiseScreenChangedEvent (Rectangle screen) runnableView.SetNeedsLayout (); } } + + if (Popovers?.GetActivePopover () is View { Visible: true } visiblePopover) + { + visiblePopover.SetNeedsLayout (); + } } private void Driver_SizeChanged (object? sender, SizeChangedEventArgs e) @@ -97,7 +101,7 @@ private void Driver_SizeChanged (object? sender, SizeChangedEventArgs e) /// public void LayoutAndDraw (bool forceRedraw = false) { - Trace.Draw ("ApplicationImpl", "Start", $"forceRedraw={forceRedraw}, Screen={Screen}, _inlineScreenSized={_inlineScreenSized}"); + Tracing.Trace.Draw ("ApplicationImpl", "Start", $"forceRedraw={forceRedraw}, Screen={Screen}, _inlineScreenSized={_inlineScreenSized}"); if (ClearScreenNextIteration) { @@ -117,16 +121,9 @@ public void LayoutAndDraw (bool forceRedraw = false) List views = [.. SessionStack.Select (r => r.Runnable! as View)!]; - if (Popovers?.GetActivePopover () is { Visible: true } visiblePopover) + if (Popovers?.GetActivePopover () is View { Visible: true, NeedsLayout: true } visiblePopoverNeedingLayout) { - visiblePopover.SetNeedsDraw (); - visiblePopover.SetNeedsLayout (); - - // Need View for views.Insert - if (visiblePopover is View popoverView) - { - views.Insert (0, popoverView); - } + views.Insert (0, visiblePopoverNeedingLayout); } // Layout @@ -240,6 +237,30 @@ public void LayoutAndDraw (bool forceRedraw = false) // Draw bool needsDraw = forceRedraw || views.Any (v => v is { NeedsDraw: true } or { SubViewNeedsDraw: true }); + if (Popovers?.GetActivePopover () is View { Visible: true } visiblePopover) + { + if (needsDraw) + { + visiblePopover.SetNeedsDraw (); + + if (!views.Contains (visiblePopover)) + { + views.Insert (0, visiblePopover); + } + } + else if (visiblePopover.NeedsDraw || visiblePopover.SubViewNeedsDraw) + { + visiblePopover.SetNeedsDraw (); + + if (!views.Contains (visiblePopover)) + { + views.Insert (0, visiblePopover); + } + + needsDraw = true; + } + } + if (Driver is { } && (neededLayout || needsDraw)) { Logging.Redraws.Add (1); @@ -262,7 +283,7 @@ public void LayoutAndDraw (bool forceRedraw = false) { LayoutAndDrawComplete?.Invoke (this, EventArgs.Empty); } - Trace.Draw ("ApplicationImpl", "End", $"neededLayout={neededLayout}, needsDraw={needsDraw}"); + Tracing.Trace.Draw ("ApplicationImpl", "End", $"neededLayout={neededLayout}, needsDraw={needsDraw}"); } /// diff --git a/Terminal.Gui/App/IApplication.cs b/Terminal.Gui/App/IApplication.cs index e13872c00a..64b4d44a0e 100644 --- a/Terminal.Gui/App/IApplication.cs +++ b/Terminal.Gui/App/IApplication.cs @@ -71,8 +71,6 @@ public interface IApplication : IDisposable /// must be disposed by the caller. /// /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] public IApplication Init (string? driverName = null); /// @@ -137,12 +135,31 @@ public interface IApplication : IDisposable ConcurrentStack? SessionStack { get; } /// - /// Raised when has been called and has created a new . + /// Raised by after the new has been pushed onto + /// and has been set, but before + /// and fire. This event + /// reports a token-lifecycle moment, not a state transition on the . /// /// - /// If is , callers to - /// must also subscribe to and manually dispose of the token - /// when the application is done. + /// + /// To observe the runnable transitioning to the running state, subscribe to + /// . is not a substitute: at the moment + /// it fires, the cached value is still because + /// has not yet been called. Subscribers that need post-state-change + /// semantics must use . + /// + /// + /// and are intentionally NOT a symmetric + /// before/after pair. fires early — after the token has been pushed onto + /// but before the runnable's running/modal state has been published. + /// fires late — after all state has unwound. This asymmetry is deliberate: + /// the events mark token-lifecycle boundaries, not runnable-state transitions. + /// + /// + /// If is , callers to + /// must also subscribe to and call + /// themselves to complete the session. + /// /// public event EventHandler? SessionBegun; @@ -221,6 +238,64 @@ public interface IApplication : IDisposable /// object? Run (IRunnable runnable, Func? errorHandler = null); + /// + /// Asynchronously runs a new Session with the provided runnable view, observing a . + /// + /// The runnable to execute. + /// + /// A cancellation token that, when cancelled, will call to cleanly stop the run + /// loop. + /// + /// Optional handler for unhandled exceptions (resumes when returns true, rethrows when null). + /// + /// A that completes when the session ends. The result is the value returned by + /// . + /// + /// + /// + /// This method wraps and registers the + /// so that cancellation triggers . + /// + /// + /// If the token is already cancelled when this method is called, it returns immediately without starting the + /// session. + /// + /// + /// Cancellation is idempotent: if both the token and an internal fire, only a single + /// shutdown occurs. + /// + /// + Task RunAsync (IRunnable runnable, CancellationToken cancellationToken, Func? errorHandler = null); + + /// + /// Asynchronously runs a new Session creating a -derived object of type + /// , observing a . + /// + /// The type of runnable to create and run. + /// + /// A cancellation token that, when cancelled, will call to cleanly stop the run + /// loop. + /// + /// Optional handler for unhandled exceptions (resumes when returns true, rethrows when null). + /// + /// The driver name. If not specified the default driver for the platform will be used. + /// + /// + /// A representing the asynchronous operation. The instance is returned + /// for fluent chaining. + /// + /// + /// + /// This method wraps and registers the + /// so that cancellation triggers . + /// + /// + /// If the token is already cancelled when this method is called, it returns immediately without starting the + /// session. + /// + /// + Task RunAsync (CancellationToken cancellationToken, Func? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new (); + /// /// Runs a new Session creating a -derived object of type /// and calling . When the session is stopped, @@ -265,8 +340,6 @@ public interface IApplication : IDisposable /// The caller is responsible for disposing the object returned by this method. /// /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] public IApplication Run (Func? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new (); #region Iteration & Invoke @@ -300,6 +373,9 @@ public interface IApplication : IDisposable /// iteration. /// /// + /// + /// Thrown when has not been called or after has been called. + /// void Invoke (Action? action); /// Runs on the main UI loop thread. @@ -311,6 +387,9 @@ public interface IApplication : IDisposable /// iteration. /// /// + /// + /// Thrown when has not been called or after has been called. + /// void Invoke (Action action); #endregion Iteration & Invoke @@ -368,7 +447,8 @@ public interface IApplication : IDisposable /// /// /// This method removes the from the , - /// raises the lifecycle events, and disposes the . + /// raises the lifecycle events, and clears on + /// . /// /// /// Raises , , @@ -378,14 +458,37 @@ public interface IApplication : IDisposable void End (SessionToken sessionToken); /// - /// Raised when was called and the session is stopping. The event args contain a - /// reference to the - /// that was active during the session. This can be used to ensure the Runnable is disposed of properly. + /// Raised by after the runnable's state has fully unwound — including + /// firing with false — and after + /// has finished its state-unwinding work. The reference is + /// at this point. This event reports a token-lifecycle moment, not a state transition + /// on the . /// /// - /// If is , callers to - /// must also subscribe to and manually dispose of the token - /// when the application is done. + /// + /// At the moment fires, all state has already unwound: + /// is and + /// has already fired with false. To observe the runnable + /// transitioning out of the running state, subscribe to instead. + /// + /// + /// The token's property is at this point — it + /// is cleared immediately before the event is raised. Subscribers that need the session result must read + /// from the token; they must not attempt to access + /// . + /// + /// + /// and are intentionally NOT a symmetric + /// before/after pair. fires early — after the token has been pushed onto + /// but before the runnable's running/modal state has been published. + /// fires late — after all state has unwound. This asymmetry is deliberate: + /// the events mark token-lifecycle boundaries, not runnable-state transitions. + /// + /// + /// If is , callers to + /// must also subscribe to and call + /// themselves to complete the session. + /// /// public event EventHandler? SessionEnded; diff --git a/Terminal.Gui/App/Legacy/Application.Lifecycle.cs b/Terminal.Gui/App/Legacy/Application.Lifecycle.cs index 77f06eaaa4..022fee214b 100644 --- a/Terminal.Gui/App/Legacy/Application.Lifecycle.cs +++ b/Terminal.Gui/App/Legacy/Application.Lifecycle.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace Terminal.Gui.App; public static partial class Application // Legacy Lifecycle @@ -21,8 +19,6 @@ public static partial class Application // Legacy Lifecycle public static IApplication Instance => ApplicationImpl.Instance; /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] [Obsolete ("The legacy static Application object is going away.")] public static void Init (string? driverName = null) => diff --git a/Terminal.Gui/App/Legacy/Application.Run.cs b/Terminal.Gui/App/Legacy/Application.Run.cs index 45ec6b3592..6a80443dc1 100644 --- a/Terminal.Gui/App/Legacy/Application.Run.cs +++ b/Terminal.Gui/App/Legacy/Application.Run.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace Terminal.Gui.App; public static partial class Application // Run (Begin -> Run -> Layout/Draw -> End -> Stop) @@ -9,8 +7,6 @@ public static partial class Application // Run (Begin -> Run -> Layout/Draw -> E public static SessionToken Begin (IRunnable runnable) => ApplicationImpl.Instance.Begin (runnable)!; /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] [Obsolete ("The legacy static Application object is going away.")] public static IApplication Run (Func? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new () => ApplicationImpl.Instance.Run (errorHandler, driverName); diff --git a/Terminal.Gui/App/Mouse/ApplicationMouse.cs b/Terminal.Gui/App/Mouse/ApplicationMouse.cs index 413b18b8e7..f6c101d756 100644 --- a/Terminal.Gui/App/Mouse/ApplicationMouse.cs +++ b/Terminal.Gui/App/Mouse/ApplicationMouse.cs @@ -1,5 +1,6 @@ using System.ComponentModel; -using Terminal.Gui.Tracing; +using System.Diagnostics; +using Trace = Terminal.Gui.Tracing.Trace; namespace Terminal.Gui.App; @@ -21,21 +22,6 @@ public ApplicationMouse () => // Subscribe to Application static property change events Application.IsMouseDisabledChanged += OnIsMouseDisabledChanged; - /// - public IApplication? App { get; set; } - - /// - public Point? LastMousePosition { get; set; } - - /// - public bool IsMouseDisabled { get; set; } - - // Event handler for Application static property changes - private void OnIsMouseDisabledChanged (object? sender, ValueChangedEventArgs e) => IsMouseDisabled = e.NewValue; - - /// - public List CachedViewsUnderMouse { get; } = []; - /// /// The popover that was just dismissed by a mouse-press-outside event. /// Used to prevent re-entrant show of the same popover during the @@ -58,12 +44,26 @@ public ApplicationMouse () => /// private bool _isDismissRecursing; - /// - /// Gets the popover that was just dismissed by a mouse-press-outside event, if any. - /// Checked by to suppress re-show during the - /// same press → release → click cycle. - /// - internal IPopoverView? DismissedByMousePress => _dismissedByMousePress; + /// + public void Dispose () + { + ResetState (); + + // Unsubscribe from Application static property change events + Application.IsMouseDisabledChanged -= OnIsMouseDisabledChanged; + } + + /// + public IApplication? App { get; set; } + + /// + public Point? LastMousePosition { get; set; } + + /// + public bool IsMouseDisabled { get; set; } + + /// + public List CachedViewsUnderMouse { get; } = []; /// public event EventHandler? MouseEvent; @@ -288,13 +288,6 @@ public void RaiseMouseEvent (Mouse mouseEvent) } } - /// - /// Returns when the mouse is currently grabbed by a view - /// that belongs to 's view hierarchy. - /// - private bool IsGrabbedByViewInHierarchy (View hierarchyRoot) => - _mouseGrabViewRef?.TryGetTarget (out View? grabbed) is true && View.IsInHierarchy (hierarchyRoot, grabbed, true); - /// public void RaiseMouseEnterLeaveEvents (Point screenPosition, List currentViewsUnderMouse) { @@ -364,6 +357,34 @@ public void RaiseMouseEnterLeaveEvents (Point screenPosition, List curren } } + /// + public void ResetState () + { + // Do not clear LastMousePosition; Popovers require it to stay set with last mouse pos. + CachedViewsUnderMouse.Clear (); + MouseEvent = null; + _mouseGrabViewRef = null; + _dismissedByMousePress = null; + _isDismissRecursing = false; + } + + /// + /// Gets the popover that was just dismissed by a mouse-press-outside event, if any. + /// Checked by to suppress re-show during the + /// same press → release → click cycle. + /// + internal IPopoverView? DismissedByMousePress => _dismissedByMousePress; + + /// + /// Returns when the mouse is currently grabbed by a view + /// that belongs to 's view hierarchy. + /// + private bool IsGrabbedByViewInHierarchy (View hierarchyRoot) => + _mouseGrabViewRef?.TryGetTarget (out View? grabbed) is true && View.IsInHierarchy (hierarchyRoot, grabbed, true); + + // Event handler for Application static property changes + private void OnIsMouseDisabledChanged (object? sender, ValueChangedEventArgs e) => IsMouseDisabled = e.NewValue; + #region IMouseGrabHandler Implementation private WeakReference? _mouseGrabViewRef; @@ -546,24 +567,4 @@ public bool HandleMouseGrab (View? deepestViewUnderMouse, Mouse mouse) } #endregion IMouseGrabHandler Implementation - - /// - public void ResetState () - { - // Do not clear LastMousePosition; Popovers require it to stay set with last mouse pos. - CachedViewsUnderMouse.Clear (); - MouseEvent = null; - _mouseGrabViewRef = null; - _dismissedByMousePress = null; - _isDismissRecursing = false; - } - - /// - public void Dispose () - { - ResetState (); - - // Unsubscribe from Application static property change events - Application.IsMouseDisabledChanged -= OnIsMouseDisabledChanged; - } } diff --git a/Terminal.Gui/App/Popovers/Popover.cs b/Terminal.Gui/App/Popovers/Popover.cs index 0a2f323445..eec4d7ff4f 100644 --- a/Terminal.Gui/App/Popovers/Popover.cs +++ b/Terminal.Gui/App/Popovers/Popover.cs @@ -350,6 +350,19 @@ protected override void OnVisibleChanged () base.OnVisibleChanged (); // PopoverImpl handles Hide } + /// + protected override void OnFrameChanged (in Rectangle frame) + { + base.OnFrameChanged (in frame); + + if (!Visible || Anchor is null || ContentView is null) + { + return; + } + + SetPosition (anchor: Anchor ()); + } + /// /// Extracts the result from the content view using or . /// diff --git a/Terminal.Gui/Configuration/AppSettingsScope.cs b/Terminal.Gui/Configuration/AppSettingsScope.cs index be5f26d420..66c6af7f09 100644 --- a/Terminal.Gui/Configuration/AppSettingsScope.cs +++ b/Terminal.Gui/Configuration/AppSettingsScope.cs @@ -23,8 +23,6 @@ namespace Terminal.Gui.Configuration; /// }, /// /// -#pragma warning disable IL2026 // ScopeJsonConverter and Scope are AOT-compatible for known scope types [JsonConverter (typeof (ScopeJsonConverter))] public class AppSettingsScope : Scope -#pragma warning restore IL2026 { } diff --git a/Terminal.Gui/Configuration/AttributeJsonConverter.cs b/Terminal.Gui/Configuration/AttributeJsonConverter.cs index 2a00b556f6..445ac79385 100644 --- a/Terminal.Gui/Configuration/AttributeJsonConverter.cs +++ b/Terminal.Gui/Configuration/AttributeJsonConverter.cs @@ -1,12 +1,9 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; namespace Terminal.Gui.Configuration; /// Json converter from the class. -[RequiresUnreferencedCode ("AOT")] - internal class AttributeJsonConverter : JsonConverter { private static AttributeJsonConverter? _instance; @@ -65,22 +62,36 @@ public override Attribute Read (ref Utf8JsonReader reader, Type typeToConvert, J propertyName = reader.GetString ()!; reader.Read (); - var property = $"\"{reader.GetString ()}\""; + string property = reader.TokenType == JsonTokenType.String + ? $"\"{reader.GetString ()}\"" + : $"<{reader.TokenType}>"; try { switch (propertyName?.ToLower ()) { case "foreground": - foreground = JsonSerializer.Deserialize (property, ConfigurationManager.SerializerContext.Color); + foreground = JsonSerializer.Deserialize (ref reader, ConfigurationManager.SerializerContext.Color); break; case "background": - background = JsonSerializer.Deserialize (property, ConfigurationManager.SerializerContext.Color); + background = JsonSerializer.Deserialize (ref reader, ConfigurationManager.SerializerContext.Color); break; case "style": - style = JsonSerializer.Deserialize (property, ConfigurationManager.SerializerContext.TextStyle); + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException ($"{propertyName}: Expected a string value."); + } + + try + { + style = Enum.Parse (reader.GetString ()!, ignoreCase: true); + } + catch (ArgumentException ex) + { + throw new JsonException ("Expected a valid text style value.", ex); + } break; @@ -107,7 +118,7 @@ public override void Write (Utf8JsonWriter writer, Attribute value, JsonSerializ if (value.Style != TextStyle.None) { writer.WritePropertyName (nameof (Attribute.Style)); - JsonSerializer.Serialize (writer, value.Style, ConfigurationManager.SerializerContext.TextStyle); + writer.WriteStringValue (value.Style.ToString ()); } writer.WriteEndObject (); diff --git a/Terminal.Gui/Configuration/ConcurrentDictionaryJsonConverter.cs b/Terminal.Gui/Configuration/ConcurrentDictionaryJsonConverter.cs index a5d1861841..f584e9e9c6 100644 --- a/Terminal.Gui/Configuration/ConcurrentDictionaryJsonConverter.cs +++ b/Terminal.Gui/Configuration/ConcurrentDictionaryJsonConverter.cs @@ -1,15 +1,13 @@ #nullable disable using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; namespace Terminal.Gui.Configuration; -[RequiresUnreferencedCode("AOT")] internal class ConcurrentDictionaryJsonConverter : JsonConverter> { - public override ConcurrentDictionary Read( + public override ConcurrentDictionary Read ( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options @@ -17,7 +15,7 @@ JsonSerializerOptions options { if (reader.TokenType != JsonTokenType.StartArray) { - throw new JsonException($"Expected a JSON array (\"[ {{ ... }} ]\"), but got \"{reader.TokenType}\"."); + throw new JsonException ($"Expected a JSON array (\"[ {{ ... }} ]\"), but got \"{reader.TokenType}\"."); } // If the Json options indicate ignoring case, use the invariant culture ignore case comparer @@ -26,18 +24,22 @@ JsonSerializerOptions options ? StringComparer.InvariantCultureIgnoreCase : StringComparer.InvariantCulture); - while (reader.Read()) + while (reader.Read ()) { if (reader.TokenType == JsonTokenType.StartObject) { - reader.Read(); + reader.Read (); if (reader.TokenType == JsonTokenType.PropertyName) { - string key = reader.GetString(); - reader.Read(); - object value = JsonSerializer.Deserialize(ref reader, typeof(T), ConfigurationManager.SerializerContext); - dictionary.TryAdd(key, (T)value); + string key = reader.GetString (); + reader.Read (); + object value = JsonSerializer.Deserialize (ref reader, typeof (T), ConfigurationManager.SerializerContext); + + if (!dictionary.TryAdd (key, (T)value)) + { + throw new JsonException ($"Duplicate key '{key}' in dictionary."); + } } } else if (reader.TokenType == JsonTokenType.EndArray) diff --git a/Terminal.Gui/Configuration/ConfigProperty.cs b/Terminal.Gui/Configuration/ConfigProperty.cs index 1eb114887f..eab7feb217 100644 --- a/Terminal.Gui/Configuration/ConfigProperty.cs +++ b/Terminal.Gui/Configuration/ConfigProperty.cs @@ -3,9 +3,9 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; -using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Configuration; @@ -30,6 +30,10 @@ public class ConfigProperty /// INTERNAL: Cached value of ConfigurationPropertyAttribute.Scope; makes more AOT friendly. internal string? ScopeType { get; set; } + /// INTERNAL: Cached value of JsonConverterAttribute.ConverterType; makes more AOT friendly. + [DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + internal Type? ConverterType { get; set; } + private object? _propertyValue; /// @@ -65,8 +69,6 @@ public object? PropertyValue /// Applies the to the static property described by . /// - [RequiresDynamicCode ("Uses reflection to get and set property values")] - [RequiresUnreferencedCode ("Uses DeepCloner which requires types to be registered in SourceGenerationContext")] public bool Apply () { try @@ -117,6 +119,7 @@ internal static ConfigProperty CreateCopy (ConfigProperty source) PropertyInfo = source.PropertyInfo, OmitClassName = source.OmitClassName, ScopeType = source.ScopeType, + ConverterType = source.ConverterType, HasValue = false }; } @@ -126,27 +129,50 @@ internal static ConfigProperty CreateCopy (ConfigProperty source) /// /// The PropertyInfo to create from /// A new ConfigProperty with attribute data cached - [RequiresDynamicCode ("Uses reflection to access custom attributes")] internal static ConfigProperty CreateImmutableWithAttributeInfo (PropertyInfo propertyInfo) { var attr = propertyInfo.GetCustomAttribute (typeof (ConfigurationPropertyAttribute)) as ConfigurationPropertyAttribute; + var jsonConverterAttribute = propertyInfo.GetCustomAttribute (typeof (JsonConverterAttribute)) as JsonConverterAttribute; return new ConfigProperty { PropertyInfo = propertyInfo, OmitClassName = attr?.OmitClassName ?? false, ScopeType = attr?.Scope!.Name, + ConverterType = jsonConverterAttribute?.ConverterType ?? InferConverterType (propertyInfo), // By default, properties are immutable Immutable = true }; } + [return: DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + private static Type? InferConverterType (PropertyInfo propertyInfo) + { + Type propertyType = propertyInfo.PropertyType; + + if (propertyType == typeof (ConcurrentDictionary)) + { + return typeof (ConcurrentDictionaryJsonConverter); + } + + if (propertyType == typeof (Dictionary)) + { + return typeof (DictionaryJsonConverter); + } + + if (propertyInfo.DeclaringType == typeof (Terminal.Gui.Tracing.Trace) && propertyType == typeof (TraceCategory)) + { + return typeof (TraceCategoryJsonConverter); + } + + return null; + } + /// /// INTERNAL: Helper method to get the ConfigurationPropertyAttribute for a PropertyInfo /// /// The PropertyInfo to get the attribute from /// The ConfigurationPropertyAttribute if found; otherwise, null - [RequiresDynamicCode ("Uses reflection to access custom attributes")] internal static ConfigurationPropertyAttribute? GetConfigurationPropertyAttribute (PropertyInfo propertyInfo) { return propertyInfo.GetCustomAttribute (typeof (ConfigurationPropertyAttribute)) as ConfigurationPropertyAttribute; @@ -157,7 +183,6 @@ internal static ConfigProperty CreateImmutableWithAttributeInfo (PropertyInfo pr /// /// The PropertyInfo to check /// True if the PropertyInfo has a ConfigurationPropertyAttribute; otherwise, false - [RequiresDynamicCode ("Uses reflection to access custom attributes")] internal static bool HasConfigurationPropertyAttribute (PropertyInfo propertyInfo) { return propertyInfo.GetCustomAttribute (typeof (ConfigurationPropertyAttribute)) != null; @@ -169,7 +194,6 @@ internal static bool HasConfigurationPropertyAttribute (PropertyInfo propertyInf /// /// /// - [RequiresDynamicCode ("Uses reflection to access custom attributes")] internal static string GetJsonPropertyName (PropertyInfo pi) { var attr = pi.GetCustomAttribute (typeof (JsonPropertyNameAttribute)) as JsonPropertyNameAttribute; @@ -182,7 +206,6 @@ internal static string GetJsonPropertyName (PropertyInfo pi) /// property described in . /// /// - [RequiresDynamicCode ("Uses reflection to retrieve property values")] public object? UpdateToCurrentValue () { return PropertyValue = PropertyInfo!.GetValue (null); @@ -195,8 +218,6 @@ internal static string GetJsonPropertyName (PropertyInfo pi) /// The source object to copy values from. /// The updated property value. /// Thrown when the source type doesn't match the property type. - [RequiresUnreferencedCode ("Uses DeepCloner which requires types to be registered in SourceGenerationContext")] - [RequiresDynamicCode ("Calls Terminal.Gui.DeepCloner.DeepClone(T)")] internal object? UpdateFrom (object? source) { // If the source (higher-priority layer) doesn't provide a value, keep the existing value @@ -345,8 +366,6 @@ private void UpdateScheme (Scheme sourceScheme, Scheme destScheme) /// /// The source ThemeScope dictionary. /// The destination ThemeScope dictionary. - [RequiresUnreferencedCode ("Calls Terminal.Gui.Scope.UpdateFrom(Scope)")] - [RequiresDynamicCode ("Calls Terminal.Gui.Scope.UpdateFrom(Scope)")] private static void UpdateThemeScopeDictionary ( ConcurrentDictionary source, ConcurrentDictionary destination) @@ -368,8 +387,6 @@ private static void UpdateThemeScopeDictionary ( /// /// The source ConfigProperty dictionary. /// The destination ConfigProperty dictionary. - [RequiresUnreferencedCode ("Calls Terminal.Gui.ConfigProperty.UpdateFrom(Object)")] - [RequiresDynamicCode ("Calls Terminal.Gui.ConfigProperty.UpdateFrom(Object)")] private static void UpdateConfigPropertyConcurrentDictionary ( ConcurrentDictionary source, ConcurrentDictionary destination) @@ -399,8 +416,6 @@ private static void UpdateConfigPropertyConcurrentDictionary ( /// /// The source ConfigProperty dictionary. /// The destination ConfigProperty dictionary. - [RequiresUnreferencedCode ("Calls Terminal.Gui.ConfigProperty.UpdateFrom(Object)")] - [RequiresDynamicCode ("Calls Terminal.Gui.ConfigProperty.UpdateFrom(Object)")] private static void UpdateConfigPropertyDictionary ( Dictionary source, Dictionary destination) @@ -470,15 +485,13 @@ private static void UpdateSchemeDictionary ( /// /// /// Additional host types defined outside Terminal.Gui (test suites, plugins, embedding apps) are picked up via a - /// runtime assembly scan when dynamic code is supported. The scan is a no-op under AOT where dynamic code is disabled, - /// and any types it would find would have been trimmed anyway unless the consumer roots them. + /// runtime assembly scan. Under NativeAOT this still works for consumer-rooted metadata, so initialization must not + /// treat "no dynamic code" as "no reflection". Consumers that define their own + /// hosts are responsible for preserving those types for trimming/AOT. /// /// - [RequiresDynamicCode ("Uses reflection to scan assemblies for configuration properties. " - + "Only called during initialization and not needed during normal operation. " - + "In AOT environments, ensure all types with ConfigurationPropertyAttribute are preserved.")] - [RequiresUnreferencedCode ("Reflection requires all types with ConfigurationPropertyAttribute to be preserved in AOT. " - + "Use the SourceGenerationContext to register all configuration property types.")] + [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "The runtime scan is required to discover consumer-defined configuration hosts outside Terminal.Gui. Reflection works under NativeAOT when the consumer roots those types.")] + [UnconditionalSuppressMessage ("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "The runtime scan is required to discover consumer-defined configuration hosts outside Terminal.Gui. Consumers must preserve their config host types for trimming/AOT.")] internal static void Initialize () { if (_classesWithConfigProps is { }) @@ -494,12 +507,10 @@ internal static void Initialize () dict [type.Name] = type; } - // JIT-only supplement: discover host types defined outside Terminal.Gui (test suites, plugins). - // Under AOT, RuntimeFeature.IsDynamicCodeSupported is false and we skip the scan entirely. - if (RuntimeFeature.IsDynamicCodeSupported) - { - ScanLoadedAssembliesForConfigPropertyHosts (dict); - } + // 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); _classesWithConfigProps = dict.ToImmutableSortedDictionary (); } @@ -563,11 +574,9 @@ private static void ScanLoadedAssembliesForConfigPropertyHosts (Dictionary items have set, but not . /// is set to . /// - [RequiresDynamicCode ("Uses reflection to scan assemblies for configuration properties. " + - "Only called during initialization and not needed during normal operation. " + - "In AOT environments, ensure all types with ConfigurationPropertyAttribute are preserved.")] - [RequiresUnreferencedCode ("Reflection requires all types with ConfigurationPropertyAttribute to be preserved in AOT. " + - "Use the SourceGenerationContext to register all configuration property types.")] + [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "Called only on types preserved by ConfigPropertyHostTypes. HasConfigurationPropertyAttribute and CreateImmutableWithAttributeInfo operate only on statically-rooted types.")] + [UnconditionalSuppressMessage ("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "GetProperties is called only on types preserved by ConfigPropertyHostTypes via DynamicDependency(PublicProperties).")] + [UnconditionalSuppressMessage ("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMemberTypes' in call to 'Type' member. The return value of the source method does not have matching annotations.", Justification = "GetProperties is called only on types whose PublicProperties are preserved by ConfigPropertyHostTypes via DynamicDependency.")] internal static ImmutableSortedDictionary GetAllConfigProperties () { if (_classesWithConfigProps is null) diff --git a/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs b/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs index 4b6ce6e712..e997d42fbd 100644 --- a/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs +++ b/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs @@ -58,7 +58,7 @@ internal static class ConfigPropertyHostTypes typeof (FileDialogStyle), typeof (FrameView), typeof (HexView), - typeof (LinearRange), + typeof (LinearRangeDefaults), typeof (Menu), typeof (MenuBar), typeof (MessageBox), @@ -89,7 +89,7 @@ internal static class ConfigPropertyHostTypes [DynamicDependency (PreservedMembers, typeof (FileDialogStyle))] [DynamicDependency (PreservedMembers, typeof (FrameView))] [DynamicDependency (PreservedMembers, typeof (HexView))] - [DynamicDependency (PreservedMembers, typeof (LinearRange))] + [DynamicDependency (PreservedMembers, typeof (LinearRangeDefaults))] [DynamicDependency (PreservedMembers, typeof (Menu))] [DynamicDependency (PreservedMembers, typeof (MenuBar))] [DynamicDependency (PreservedMembers, typeof (MessageBox))] diff --git a/Terminal.Gui/Configuration/ConfigurationManager.cs b/Terminal.Gui/Configuration/ConfigurationManager.cs index ff1d021b83..e005ec3792 100644 --- a/Terminal.Gui/Configuration/ConfigurationManager.cs +++ b/Terminal.Gui/Configuration/ConfigurationManager.cs @@ -162,13 +162,8 @@ internal static FrozenDictionary GetHardCodedConfigPrope /// For ConfigurationManager to access config resources, needs to be /// set to after this method has been called. /// - [RequiresDynamicCode ( - "Uses reflection to scan assemblies for configuration properties. " - + "Only called during initialization and not needed during normal operation. " - + "In AOT environments, ensure all types with ConfigurationPropertyAttribute are preserved.")] - [RequiresUnreferencedCode ( - "Reflection requires all types with ConfigurationPropertyAttribute to be preserved in AOT. " - + "Use the SourceGenerationContext to register all configuration property types.")] + [UnconditionalSuppressMessage ("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "All config property host types are statically rooted via ConfigPropertyHostTypes and registered in SourceGenerationContext. DeepCloner and Apply use source-generated serialization for known types.")] + [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "Reflection-heavy paths are guarded by RuntimeFeature.IsDynamicCodeSupported and are dead code under AOT.")] internal static void Initialize () { lock (_initializedLock) @@ -199,14 +194,17 @@ internal static void Initialize () foreach (KeyValuePair hardCodedProperty in _hardCodedConfigPropertyCache) { - // Set the PropertyValue to the hard coded value. - // Deep-clone the value to ensure the cache stores independent copies of reference types - // (e.g. Dictionary<,>). Without cloning, in-place mutations to a static property like - // Application.DefaultKeyBindings (e.g. dict[cmd] = ...) would silently corrupt the - // cached "hard-coded default", causing test-order-dependent failures. hardCodedProperty.Value.Immutable = false; hardCodedProperty.Value.UpdateToCurrentValue (); - hardCodedProperty.Value.PropertyValue = DeepCloner.DeepClone (hardCodedProperty.Value.PropertyValue); + } + + foreach (KeyValuePair hardCodedProperty in _hardCodedConfigPropertyCache) + { + // Deep-clone after every cache entry has been populated from its current static value. + // Some getters (notably ThemeManager.Themes while uninitialized) synthesize composite + // objects from other configuration properties, so a single populate-and-clone pass can + // observe half-initialized cache entries and persist null/default values into the clone. + hardCodedProperty.Value.PropertyValue = CloneHardCodedPropertyValue (hardCodedProperty.Value.PropertyValue); hardCodedProperty.Value.Immutable = true; } } @@ -224,6 +222,58 @@ internal static void Initialize () ThemeManager.Themes? [ThemeManager.Theme].Apply (); } + // AOT trimming removes the reflective Dictionary<,> constructor that DeepCloner relies on. + // Each non-trivially-cloneable config property type needs a typed clone path here to stay AOT-safe. + private static object? CloneHardCodedPropertyValue (object? propertyValue) + { + if (propertyValue is Dictionary keyBindings) + { + return CloneKeyBindings (keyBindings); + } + + return DeepCloner.DeepClone (propertyValue); + } + + private static Dictionary CloneKeyBindings (Dictionary source) + { + Dictionary clone = new (source.Comparer); + + foreach (KeyValuePair kvp in source) + { + clone [kvp.Key] = ClonePlatformKeyBinding (kvp.Value); + } + + return clone; + } + + private static PlatformKeyBinding ClonePlatformKeyBinding (PlatformKeyBinding binding) + { + return binding with + { + All = CloneKeyArray (binding.All), + Windows = CloneKeyArray (binding.Windows), + Linux = CloneKeyArray (binding.Linux), + Macos = CloneKeyArray (binding.Macos) + }; + } + + private static Key []? CloneKeyArray (Key []? keys) + { + if (keys is null) + { + return null; + } + + Key [] clonedKeys = new Key [keys.Length]; + + for (var i = 0; i < keys.Length; i++) + { + clonedKeys [i] = new (keys [i]); + } + + return clonedKeys; + } + #endregion Initialization #region Enable/Disable @@ -258,8 +308,6 @@ public static bool IsEnabled /// ConfigurationManager will be enabled and the configuration will be loaded from the specified locations and applied. /// /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] public static void Enable (ConfigLocations locations) { if (IsEnabled) @@ -293,8 +341,6 @@ public static void Enable (ConfigLocations locations) /// initial, hard-coded /// defaults. /// - [RequiresUnreferencedCode ("Calls ResetToHardCodedDefaults")] - [RequiresDynamicCode ("Calls ResetToHardCodedDefaults")] public static void Disable (bool resetToHardCodedDefaults = false) { lock (_enabledLock) @@ -321,8 +367,6 @@ public static void Disable (bool resetToHardCodedDefaults = false) /// INTERNAL: Updates to the settings from the current /// values of the static properties. /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal static void UpdateToCurrentValues () { if (!IsInitialized ()) @@ -351,8 +395,6 @@ internal static void UpdateToCurrentValues () /// INTERNAL: Loads the hard-coded values of the /// properties and applies them. /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal static void ResetToHardCodedDefaults () { LoadHardCodedDefaults (); @@ -378,8 +420,6 @@ internal static void ResetToHardCodedDefaults () /// Only call this when you want to completely reset configuration to hard-coded defaults. /// /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal static void LoadHardCodedDefaults () { if (!IsInitialized ()) @@ -402,8 +442,6 @@ internal static void LoadHardCodedDefaults () /// be applied to the running application. /// /// Configuration manager is not enabled. - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] public static void Load (ConfigLocations locations) { if (!IsEnabled) @@ -463,8 +501,6 @@ public static void OnUpdated () /// ConfigurationManager must be Enabled. /// /// Configuration Manager is not enabled. - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] public static void Apply () { if (!IsEnabled) @@ -475,8 +511,6 @@ public static void Apply () InternalApply (); } - [RequiresUnreferencedCode ("Calls Terminal.Gui.Scope.Apply()")] - [RequiresDynamicCode ("Calls Terminal.Gui.Scope.Apply()")] private static void InternalApply () { Trace.Configuration ("ConfigurationManager", "Apply", "Start"); @@ -555,14 +589,14 @@ private static void OnApplied () AllowTrailingCommas = true, Converters = - { - // We override the standard Rune converter to support specifying Glyphs in - // a flexible way - new RuneJsonConverter (), + { + // We override the standard Rune converter to support specifying Glyphs in + // a flexible way + new RuneJsonConverter (), - // Override Key to support "Ctrl+Q" format. - new KeyJsonConverter () - }, + // Override Key to support "Ctrl+Q" format. + new KeyJsonConverter () + }, // Enables Key to be "Ctrl+Q" vs "Ctrl\u002BQ" Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, @@ -632,8 +666,7 @@ public static string? RuntimeConfig [JsonPropertyName ("AppSettings")] public static AppSettingsScope? AppSettings { - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] + [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "UpdateToCurrentValues uses reflection only to read static property values from types preserved by ConfigPropertyHostTypes.")] get { if (!IsInitialized ()) @@ -663,8 +696,6 @@ public static AppSettingsScope? AppSettings return (appSettingsConfigProperty.PropertyValue as AppSettingsScope)!; } } - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] set { if (!IsInitialized ()) diff --git a/Terminal.Gui/Configuration/DeepCloner.cs b/Terminal.Gui/Configuration/DeepCloner.cs index 90b51e8f3a..430eaec7fc 100644 --- a/Terminal.Gui/Configuration/DeepCloner.cs +++ b/Terminal.Gui/Configuration/DeepCloner.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -35,43 +36,18 @@ public static class DeepCloner /// The type of the object to clone. /// The object to clone. /// A deep copy of the source object, or default if source is null. - [RequiresUnreferencedCode ( - "Deep cloning may use reflection which might be incompatible with AOT compilation if types aren't registered in SourceGenerationContext")] - [RequiresDynamicCode ("Deep cloning may use reflection that requires runtime code generation if source generation fails")] public static T? DeepClone (T? source) { if (source is null) { return default (T?); } - //// For AOT environments, use source generation exclusively - //if (IsAotEnvironment ()) - //{ - // if (TryUseSourceGeneratedCloner (source, out T? result)) - // { - // return result; - // } - - // // If in AOT but source generation failed, throw an exception - // // instead of silently falling back to reflection - // //throw new InvalidOperationException ( - // // $"Type {typeof (T).FullName} is not properly registered in SourceGenerationContext " + - // // $"for AOT-compatible cloning."); - // Logging.Error ($"Type {typeof (T).FullName} is not properly registered in SourceGenerationContext " + - // $"for AOT-compatible cloning."); - //} - - // Use reflection-based approach, which should have better performance in non-AOT environments ConcurrentDictionary visited = new (ReferenceEqualityComparer.Instance); return (T?)DeepCloneInternal (source, visited); } - [RequiresUnreferencedCode ("Calls Terminal.Gui.DeepCloner.CreateInstance(Type)")] - [UnconditionalSuppressMessage ( - "AOT", - "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", - Justification = "")] + [UnconditionalSuppressMessage ("Trimming", "IL2075", Justification = "DeepCloner reflects over writable properties of runtime configuration/test types to preserve object graphs, circular references, and non-public setters.")] private static object? DeepCloneInternal (object? source, ConcurrentDictionary visited) { if (source is null) @@ -79,6 +55,31 @@ public static class DeepCloner return null; } + if (source is Drawing.Attribute attribute) + { + return attribute; + } + + if (source is Drawing.Scheme scheme) + { + return new Drawing.Scheme (scheme); + } + + if (source is ThemeScope themeScope) + { + return CloneScope (themeScope, visited); + } + + if (source is AppSettingsScope appSettingsScope) + { + return CloneScope (appSettingsScope, visited); + } + + if (source is SettingsScope settingsScope) + { + return CloneScope (settingsScope, visited); + } + // Handle already visited objects to avoid circular references if (visited.TryGetValue (source, out object? existingClone)) { @@ -93,12 +94,6 @@ public static class DeepCloner return source; } - // Handle strings explicitly - if (type == typeof (string)) - { - return source; - } - // Handle arrays if (type.IsArray) { @@ -136,10 +131,11 @@ public static class DeepCloner return clone; } - private static bool IsSimpleType ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicProperties)] Type type) + private static bool IsSimpleType (Type type) { if (type.IsPrimitive || type.IsEnum + || type.IsValueType || type == typeof (decimal) || type == typeof (DateTime) || type == typeof (DateTimeOffset) @@ -151,17 +147,8 @@ private static bool IsSimpleType ([DynamicallyAccessedMembers (DynamicallyAccess return true; } - // Treat structs with no writable public properties as simple types (immutable structs) - if (type.IsValueType) - { - IEnumerable writableProperties = type.GetProperties (BindingFlags.Instance | BindingFlags.Public) - .Where (p => p is { CanRead: true, CanWrite: true } && p.GetIndexParameters ().Length == 0); - - return !writableProperties.Any (); - } - - // Treat PropertyInfo (e.g., RuntimePropertyInfo) as a simple type since it's metadata and shouldn't be cloned - if (typeof (PropertyInfo).IsAssignableFrom (type)) + // Treat Type and PropertyInfo as simple metadata objects that should not be cloned. + if (typeof (Type).IsAssignableFrom (type) || typeof (PropertyInfo).IsAssignableFrom (type)) { return true; } @@ -169,9 +156,9 @@ private static bool IsSimpleType ([DynamicallyAccessedMembers (DynamicallyAccess return false; } - [RequiresUnreferencedCode ("Calls System.Text.Json.JsonSerializer.Deserialize(String, Type, JsonSerializerOptions)")] - [RequiresDynamicCode ("Calls System.Text.Json.JsonSerializer.Deserialize(String, Type, JsonSerializerOptions)")] - private static object CreateInstance ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type) + [UnconditionalSuppressMessage ("Trimming", "IL2067", Justification = "DeepCloner creates only runtime configuration/test types with parameterless constructors, and this path is covered by unit tests and NativeAOT validation.")] + [UnconditionalSuppressMessage ("Trimming", "IL2070", Justification = "DeepCloner probes for a public parameterless constructor on runtime configuration/test types before instantiating them.")] + private static object CreateInstance (Type type) { try { @@ -181,16 +168,15 @@ private static object CreateInstance ([DynamicallyAccessedMembers (DynamicallyAc return Activator.CreateInstance (type)!; } - // Record support - if (type.GetMethod ("$") != null) - { - return Activator.CreateInstance (type)!; - } - // In AOT, try using the JsonSerializer if available - if (IsAotEnvironment () && CanSerializeWithJson (type)) + if (IsAotEnvironment ()) { - return JsonSerializer.Deserialize (JsonSerializer.Serialize (new object (), type), type, ConfigurationManager.SerializerContext.Options)!; + JsonTypeInfo? jsonTypeInfo = ConfigurationManager.SerializerContext.GetTypeInfo (type); + + if (jsonTypeInfo is not null) + { + return JsonSerializer.Deserialize ("{}", jsonTypeInfo)!; + } } throw new InvalidOperationException ($"Cannot create instance of type {type.FullName}. No parameterless constructor or clone method found."); @@ -202,13 +188,10 @@ private static object CreateInstance ([DynamicallyAccessedMembers (DynamicallyAc } } - [RequiresDynamicCode ("Calls System.Array.CreateInstance(Type, Int32)")] - [RequiresUnreferencedCode ("Calls Terminal.Gui.DeepCloner.DeepCloneInternal(Object, ConcurrentDictionary)")] private static object CloneArray (object source, ConcurrentDictionary visited) { - var array = (Array)source; - Type elementType = array.GetType ().GetElementType ()!; - var newArray = Array.CreateInstance (elementType, array.Length); + Array array = (Array)source; + Array newArray = (Array)array.Clone (); visited.TryAdd (source, newArray); for (var i = 0; i < array.Length; i++) @@ -221,12 +204,10 @@ private static object CloneArray (object source, ConcurrentDictionary)")] + [UnconditionalSuppressMessage ("Trimming", "IL2072", Justification = "Collection cloning only instantiates the runtime collection type after filtering to supported IList implementations.")] private static object CloneCollection (object source, ConcurrentDictionary visited) { Type type = source.GetType (); - Type elementType = type.GetGenericArguments ().FirstOrDefault () ?? typeof (object); // Check for immutable collections and throw if found if (type.IsGenericType) @@ -239,8 +220,15 @@ private static object CloneCollection (object source, ConcurrentDictionary).MakeGenericType (elementType); - var tempList = (IList)Activator.CreateInstance (listType)!; + if (source is not IList) + { + throw new NotSupportedException ($"Cloning of collection type {type.Name} is not supported unless it implements IList."); + } + + if (Activator.CreateInstance (type) is not IList tempList) + { + throw new NotSupportedException ($"Cloning of collection type {type.Name} is not supported without a parameterless constructor."); + } // Add to visited before cloning contents to prevent circular reference issues visited.TryAdd (source, tempList); @@ -251,25 +239,50 @@ private static object CloneCollection (object source, ConcurrentDictionary)")] + [UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Dictionary cloning constructs supported runtime dictionary shapes (Dictionary<,> and ConcurrentDictionary<,>) via MakeGenericType, which is validated by NativeAOT publish tests.")] + [UnconditionalSuppressMessage ("Trimming", "IL2075", Justification = "Dictionary cloning reads the runtime dictionary comparer from supported dictionary types to preserve comparer semantics.")] private static object CloneDictionary (object source, ConcurrentDictionary visited) { - var sourceDict = (IDictionary)source; + if (source is ConcurrentDictionary themeSource) + { + ConcurrentDictionary clonedThemes = new (StringComparer.InvariantCultureIgnoreCase); + visited.TryAdd (source, clonedThemes); + + foreach (KeyValuePair kvp in themeSource) + { + object? clonedThemeObject = DeepCloneInternal (kvp.Value, visited); + + if (clonedThemeObject is not ThemeScope clonedTheme) + { + throw new InvalidOperationException ( + $"Expected cloned theme scope to be {typeof (ThemeScope).FullName}, but got {clonedThemeObject?.GetType ().FullName ?? ""}."); + } + + clonedThemes [kvp.Key] = clonedTheme; + } + + return clonedThemes; + } + + if (source is Dictionary schemeSource) + { + Dictionary clonedSchemes = new (schemeSource.Comparer); + visited.TryAdd (source, clonedSchemes); + + foreach (KeyValuePair kvp in schemeSource) + { + clonedSchemes [kvp.Key] = (Scheme?)DeepCloneInternal (kvp.Value, visited); + } + + return clonedSchemes; + } + + IDictionary sourceDict = (IDictionary)source; Type type = source.GetType (); // Check for frozen or immutable dictionaries and throw if found @@ -278,7 +291,6 @@ private static object CloneDictionary (object source, ConcurrentDictionary themeDict) + { + themeDict [(string)clonedKey!] = (ThemeScope)clonedValue!; + + continue; + } + + if (tempDict is Dictionary schemeDict) + { + schemeDict [(string)clonedKey!] = (Scheme?)clonedValue; + + continue; + } + if (tempDict.Contains (clonedKey!)) { tempDict [clonedKey!] = clonedValue; @@ -338,20 +363,32 @@ private static object CloneDictionary (object source, ConcurrentDictionary)) + { + if (comparer is IEqualityComparer stringComparer) + { + return new ConcurrentDictionary (stringComparer); + } + + return new ConcurrentDictionary (); + } + + if (dictType == typeof (Dictionary)) + { + if (comparer is IEqualityComparer stringComparer) + { + return new Dictionary (stringComparer); + } + + return new Dictionary (); + } + try { // Try to create the dictionary with the comparer @@ -387,160 +424,36 @@ private static void CheckForUnsupportedDictionaryTypes (Type type) } } - [RequiresUnreferencedCode ("Calls Terminal.Gui.DeepCloner.DeepCloneInternal(Object, ConcurrentDictionary)")] - private static object CreateFinalDictionary ( - [DynamicallyAccessedMembers ( - DynamicallyAccessedMemberTypes.NonPublicConstructors - | DynamicallyAccessedMemberTypes.PublicConstructors - | DynamicallyAccessedMemberTypes.PublicProperties - | DynamicallyAccessedMemberTypes.NonPublicProperties)] - Type type, - object? comparer, - IDictionary tempDict, - object source, - ConcurrentDictionary visited - ) - { - IDictionary newDict; - - try - { - // Try to create the dictionary with the comparer - newDict = comparer != null - ? (IDictionary)Activator.CreateInstance (type, comparer)! - : (IDictionary)Activator.CreateInstance (type)!; - } - catch (MissingMethodException) - { - // Fallback to parameterless constructor if comparer constructor is not available - newDict = (IDictionary)Activator.CreateInstance (type)!; - } - - newDict.Clear (); - visited [source] = newDict; - - // Copy cloned key-value pairs to the new dictionary - foreach (object? key in tempDict.Keys) - { - if (newDict.Contains (key)) - { - newDict [key] = tempDict [key]; - } - else - { - newDict.Add (key, tempDict [key]); - } - } - - // Clone additional properties of the derived dictionary type - foreach (PropertyInfo prop in type.GetProperties (BindingFlags.Instance | BindingFlags.Public) - .Where (p => p.CanRead && p.CanWrite && p.GetIndexParameters ().Length == 0)) - { - object? value = prop.GetValue (source); - object? clonedValue = DeepCloneInternal (value, visited); - prop.SetValue (newDict, clonedValue); - } - - return newDict; - } - #endregion Dictionary Support #region AOT Support - /// - /// Determines if a type can be serialized using System.Text.Json based on the types - /// registered in the SourceGenerationContext. - /// - /// The type to check - /// True if the type can be serialized using System.Text.Json; otherwise, false. - private static bool CanSerializeWithJson (Type type) - { - // Check if the type or any of its base types is registered in SourceGenerationContext - return typeof (SourceGenerationContext) - .GetProperties (BindingFlags.Public | BindingFlags.Static) - .Any (p => p.PropertyType.IsGenericType - && p.PropertyType.GetGenericTypeDefinition () == typeof (JsonTypeInfo<>) - && (p.PropertyType.GetGenericArguments () [0] == type || p.PropertyType.GetGenericArguments () [0].IsAssignableFrom (type))); - } - - private static bool IsAotEnvironment () => - - // Check if running in an AOT environment - Type.GetType ("System.Runtime.CompilerServices.RuntimeFeature")?.GetProperty ("IsDynamicCodeSupported")?.GetValue (null) is bool and false; - - /// - /// Attempts to clone an object using source-generated serialization from System.Text.Json. - /// This provides an AOT-compatible alternative to reflection-based deep cloning. - /// - /// The type of the object to clone - /// The source object to clone - /// The cloned result, if successful - /// True if cloning succeeded using source generation; otherwise, false - private static bool TryUseSourceGeneratedCloner (T source, [NotNullWhen (true)] out T? result) + private static TScopeT CloneScope (TScopeT scope, ConcurrentDictionary visited) + where TScopeT : Scope, new () { - result = default (T); + TScopeT clonedScope = new (); + visited.TryAdd (scope, clonedScope); - try + foreach (KeyValuePair kvp in scope) { - // Check if the type has a JsonTypeInfo in our SourceGenerationContext - JsonTypeInfo? jsonTypeInfo = GetJsonTypeInfo (); + ConfigProperty clonedProperty = ConfigProperty.CreateCopy (kvp.Value); + clonedProperty.Immutable = kvp.Value.Immutable; - if (jsonTypeInfo != null) + if (kvp.Value.HasValue) { - // Use JSON serialization for deep cloning - string json = JsonSerializer.Serialize (source, jsonTypeInfo); - result = JsonSerializer.Deserialize (json, jsonTypeInfo); - - return result is { }; + clonedProperty.PropertyValue = DeepCloneInternal (kvp.Value.PropertyValue, visited); } - return false; - } - catch - { - // If any exception occurs during serialization/deserialization, - // return false to fall back to reflection-based approach - return false; + clonedScope.TryAdd (kvp.Key, clonedProperty); } - } - - /// - /// Gets JsonTypeInfo for a type from the SourceGenerationContext, if available. - /// - /// The type to get JsonTypeInfo for - /// JsonTypeInfo if found; otherwise, null - private static JsonTypeInfo? GetJsonTypeInfo () - { - // Try to find a matching JsonTypeInfo property in the SourceGenerationContext - Type contextType = typeof (SourceGenerationContext); - // First try for an exact type match - PropertyInfo? exactProperty = contextType.GetProperty (typeof (T).Name); - - if (exactProperty != null - && exactProperty.PropertyType.IsGenericType - && exactProperty.PropertyType.GetGenericTypeDefinition () == typeof (JsonTypeInfo<>) - && exactProperty.PropertyType.GetGenericArguments () [0] == typeof (T)) - { - return (JsonTypeInfo?)exactProperty.GetValue (null); - } + return clonedScope; + } - // Then look for any compatible JsonTypeInfo - foreach (PropertyInfo prop in contextType.GetProperties (BindingFlags.Public | BindingFlags.Static)) - { - if (prop.PropertyType.IsGenericType - && prop.PropertyType.GetGenericTypeDefinition () == typeof (JsonTypeInfo<>) - && prop.PropertyType.GetGenericArguments () [0].IsAssignableFrom (typeof (T))) - { - // This is a bit tricky - we've found a compatible type but need to cast it - // Warning: This might not work for all types and is a bit of a hack - return (JsonTypeInfo?)prop.GetValue (null); - } - } + private static bool IsAotEnvironment () => - return null; - } + // Check if running in an AOT environment + Type.GetType ("System.Runtime.CompilerServices.RuntimeFeature")?.GetProperty ("IsDynamicCodeSupported")?.GetValue (null) is bool and false; #endregion AOT Support } diff --git a/Terminal.Gui/Configuration/DictionaryJsonConverter.cs b/Terminal.Gui/Configuration/DictionaryJsonConverter.cs index 2cdbbfd48f..daa5806fcc 100644 --- a/Terminal.Gui/Configuration/DictionaryJsonConverter.cs +++ b/Terminal.Gui/Configuration/DictionaryJsonConverter.cs @@ -1,11 +1,9 @@ #nullable disable -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; namespace Terminal.Gui.Configuration; -[RequiresUnreferencedCode ("AOT")] internal class DictionaryJsonConverter : JsonConverter> { public override Dictionary Read ( diff --git a/Terminal.Gui/Configuration/RuneJsonConverter.cs b/Terminal.Gui/Configuration/RuneJsonConverter.cs index 48ec575180..7c9bd989de 100644 --- a/Terminal.Gui/Configuration/RuneJsonConverter.cs +++ b/Terminal.Gui/Configuration/RuneJsonConverter.cs @@ -129,6 +129,8 @@ public override Rune Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSe throw new JsonException ($"{num}: Invalid Rune (not a scalar Unicode value)."); } + case JsonTokenType.Null: + return default; default: throw new JsonException ($"Unexpected token when parsing Rune: {reader.TokenType}."); } diff --git a/Terminal.Gui/Configuration/SchemeJsonConverter.cs b/Terminal.Gui/Configuration/SchemeJsonConverter.cs index a7a4b3de04..7039f62572 100644 --- a/Terminal.Gui/Configuration/SchemeJsonConverter.cs +++ b/Terminal.Gui/Configuration/SchemeJsonConverter.cs @@ -1,12 +1,10 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; namespace Terminal.Gui.Configuration; // ReSharper disable StringLiteralTypo /// Implements a JSON converter for . -[RequiresUnreferencedCode ("AOT")] internal class SchemeJsonConverter : JsonConverter { /// diff --git a/Terminal.Gui/Configuration/SchemeManager.cs b/Terminal.Gui/Configuration/SchemeManager.cs index 16ac2dbcb9..bcafb8b31d 100644 --- a/Terminal.Gui/Configuration/SchemeManager.cs +++ b/Terminal.Gui/Configuration/SchemeManager.cs @@ -31,15 +31,11 @@ public sealed class SchemeManager // : INotifyCollectionChanged, IDictionary, etc... instead. /// [ConfigurationProperty (Scope = typeof (ThemeScope), OmitClassName = true)] -#pragma warning disable IL2026 // DictionaryJsonConverter is AOT-compatible [JsonConverter (typeof (DictionaryJsonConverter))] -#pragma warning restore IL2026 [UsedImplicitly] public static Dictionary? Schemes { get => GetSchemes (); - [RequiresUnreferencedCode ("Calls Terminal.Gui.SchemeManager.SetSchemes(Dictionary)")] - [RequiresDynamicCode ("Calls Terminal.Gui.SchemeManager.SetSchemes(Dictionary)")] private set => SetSchemes (value); } @@ -57,8 +53,6 @@ public sealed class SchemeManager // : INotifyCollectionChanged, IDictionaryINTERNAL: The set method for . - [RequiresUnreferencedCode ("Calls Terminal.Gui.ConfigProperty.UpdateFrom(Object)")] - [RequiresDynamicCode ("Calls Terminal.Gui.ConfigProperty.UpdateFrom(Object)")] internal static void SetSchemes (Dictionary? value) { lock (_schemesLock) @@ -242,8 +236,6 @@ public static ImmutableList GetSchemeNames () } } - [RequiresUnreferencedCode ("Calls SetSchemes")] - [RequiresDynamicCode ("Calls SetSchemes")] internal static void LoadToHardCodedDefaults () => // BUGBUG: SchemeManager is broken and needs to be fixed to not have the hard coded schemes get overwritten. diff --git a/Terminal.Gui/Configuration/Scope.cs b/Terminal.Gui/Configuration/Scope.cs index 6beeef3fa7..adac046ae4 100644 --- a/Terminal.Gui/Configuration/Scope.cs +++ b/Terminal.Gui/Configuration/Scope.cs @@ -18,8 +18,6 @@ public class Scope : ConcurrentDictionary /// Creates a new instance. The dictionary will be populated with uninitialized ( /// will be ). /// - [RequiresUnreferencedCode ( - "Uses cached configuration properties filtered by type T. This is AOT-safe as long as T is one of the known scope types (SettingsScope, ThemeScope, AppSettingsScope).")] public Scope () : base (StringComparer.InvariantCultureIgnoreCase) { } /// @@ -80,7 +78,8 @@ internal ConfigProperty GetUninitializedProperty (string name) /// INTERNAL: Updates the values of the properties of this scope to their corresponding static /// properties. /// - [RequiresDynamicCode ("Uses reflection to retrieve property values")] + [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "ConfigProperty.PropertyInfo values originate from ConfigPropertyHostTypes, whose public properties are statically rooted via DynamicDependency.")] + [UnconditionalSuppressMessage ("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "ConfigProperty.PropertyInfo values originate from ConfigPropertyHostTypes, whose public properties are statically rooted via DynamicDependency.")] internal void UpdateToCurrentValues () { foreach (KeyValuePair validProperties in this.Where (cp => cp.Value.PropertyInfo is { })) @@ -107,8 +106,6 @@ internal void LoadHardCodedDefaults () /// /// /// The updated scope (this). - [RequiresUnreferencedCode ("Calls Terminal.Gui.ConfigProperty.UpdateFrom(Object)")] - [RequiresDynamicCode ("Calls Terminal.Gui.ConfigProperty.UpdateFrom(Object)")] internal Scope? UpdateFrom (Scope scope) { foreach (KeyValuePair prop in scope) @@ -143,8 +140,8 @@ internal void LoadHardCodedDefaults () /// properties. /// /// if one or more property value was applied; otherwise. - [RequiresDynamicCode ("Uses reflection to get and set property values")] - [RequiresUnreferencedCode ("Calls Terminal.Gui.DeepCloner.DeepClone(T)")] + [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "ConfigProperty.PropertyInfo values originate from ConfigPropertyHostTypes, whose public properties are statically rooted via DynamicDependency.")] + [UnconditionalSuppressMessage ("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "ConfigProperty.PropertyInfo values originate from ConfigPropertyHostTypes, whose public properties are statically rooted via DynamicDependency.")] internal bool Apply () { if (!ConfigurationManager.IsEnabled) diff --git a/Terminal.Gui/Configuration/ScopeJsonConverter.cs b/Terminal.Gui/Configuration/ScopeJsonConverter.cs index 94ed04e8fa..49a9cad067 100644 --- a/Terminal.Gui/Configuration/ScopeJsonConverter.cs +++ b/Terminal.Gui/Configuration/ScopeJsonConverter.cs @@ -1,7 +1,12 @@ -using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Terminal.Gui.Drawing; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Configuration; @@ -10,14 +15,16 @@ namespace Terminal.Gui.Configuration; /// data to/from JSON documents. /// /// -[RequiresUnreferencedCode ("AOT")] -internal class ScopeJsonConverter<[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TScopeT> : JsonConverter +internal class ScopeJsonConverter< + [DynamicallyAccessedMembers ( + DynamicallyAccessedMemberTypes.PublicParameterlessConstructor + | DynamicallyAccessedMemberTypes.PublicProperties)] + TScopeT> : JsonConverter where TScopeT : Scope { - [RequiresDynamicCode ("Calls System.Type.MakeGenericType(params Type[])")] -#pragma warning disable IL3051 // 'RequiresDynamicCodeAttribute' annotations must match across all interface implementations or overrides. + [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "Arbitrary property-level converter fallback is guarded by RuntimeFeature.IsDynamicCodeSupported and is unreachable under NativeAOT.")] + [UnconditionalSuppressMessage ("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Arbitrary property-level converter fallback is only used when a consumer opts into a custom property-level JsonConverter on JIT-supported runtimes.")] public override TScopeT Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) -#pragma warning restore IL3051 // 'RequiresDynamicCodeAttribute' annotations must match across all interface implementations or overrides. { if (reader.TokenType != JsonTokenType.StartObject) { @@ -59,45 +66,36 @@ public override TScopeT Read (ref Utf8JsonReader reader, Type typeToConvert, Jso // Figure out if it needs a JsonConverter and if so, create one Type? propertyType = configProperty?.PropertyInfo?.PropertyType!; - if (configProperty?.PropertyInfo?.GetCustomAttribute (typeof (JsonConverterAttribute)) is - JsonConverterAttribute jca) + if (configProperty?.ConverterType is { } converterType) { - object? converter = Activator.CreateInstance (jca.ConverterType!)!; - - if (converter.GetType ().BaseType == typeof (JsonConverterFactory)) + if (reader.TokenType == JsonTokenType.Null && CanAcceptNullValue (propertyType!)) { - var factory = (JsonConverterFactory)converter; + scope! [propertyName].PropertyValue = null; - if (factory.CanConvert (propertyType)) - { - converter = factory.CreateConverter (propertyType, options); - } + continue; } - try + if (TryReadWithKnownConverter (ref reader, propertyType!, converterType, options, out object? convertedValue)) { - var type = (Type?)typeof (ReadHelper<>).MakeGenericType (typeof (TScopeT), propertyType!); - var readHelper = Activator.CreateInstance (type!, converter) as ReadHelper; + scope! [propertyName].PropertyValue = convertedValue; - scope! [propertyName].PropertyValue = readHelper?.Read (ref reader, propertyType!, options); - } - catch (NotSupportedException e) - { - throw new JsonException ( - $"{propertyName}: Error reading property of type \"{propertyType?.Name}\".", - e - ); + continue; } - catch (TargetInvocationException) + + if (TryReadWithDynamicConverter (ref reader, propertyType!, converterType, options, out convertedValue)) { - // QUESTION: Should we try/catch here? - scope! [propertyName].PropertyValue = JsonSerializer.Deserialize (ref reader, propertyType!, options); + scope! [propertyName].PropertyValue = convertedValue; + + continue; } + + throw new JsonException ( + $"{propertyName}: Unsupported configuration converter type \"{converterType.FullName}\" when dynamic code is unavailable." + ); } else { - // QUESTION: Should we try/catch here? - scope! [propertyName].PropertyValue = JsonSerializer.Deserialize (ref reader, propertyType!, ConfigurationManager.SerializerContext); + scope! [propertyName].PropertyValue = DeserializePropertyValue (ref reader, propertyType!, options); } //Logging.Warning ($"{propertyName} = {scope! [propertyName].PropertyValue}"); @@ -109,11 +107,11 @@ public override TScopeT Read (ref Utf8JsonReader reader, Type typeToConvert, Jso // If so, don't add it to the dictionary but apply it to the underlying property on // the scopeT. // BUGBUG: This is terrible design. The only time it's used is for $schema though. - PropertyInfo? property = scope!.GetType () + PropertyInfo? property = typeof (TScopeT) .GetProperties () .Where (p => { - if (p.GetCustomAttribute (typeof (JsonIncludeAttribute)) is JsonIncludeAttribute { } jia) + if (p.GetCustomAttribute (typeof (JsonIncludeAttribute)) is JsonIncludeAttribute { } jia) { var jsonPropertyNameAttribute = p.GetCustomAttribute ( @@ -140,7 +138,7 @@ public override TScopeT Read (ref Utf8JsonReader reader, Type typeToConvert, Jso if (property is { }) { // Set the value of propertyName on the scopeT. - PropertyInfo prop = scope.GetType ().GetProperty (propertyName!)!; + PropertyInfo prop = typeof (TScopeT).GetProperty (propertyName!)!; prop.SetValue (scope, JsonSerializer.Deserialize (ref reader, prop.PropertyType, ConfigurationManager.SerializerContext)); } @@ -157,15 +155,13 @@ public override TScopeT Read (ref Utf8JsonReader reader, Type typeToConvert, Jso throw new JsonException ($"{propertyName}: Json error in ScopeJsonConverter"); } - [UnconditionalSuppressMessage ( - "AOT", - "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", - Justification = "")] + [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "Arbitrary property-level converter fallback is guarded by RuntimeFeature.IsDynamicCodeSupported and is unreachable under NativeAOT.")] + [UnconditionalSuppressMessage ("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Arbitrary property-level converter fallback is only used when a consumer opts into a custom property-level JsonConverter on JIT-supported runtimes.")] public override void Write (Utf8JsonWriter writer, TScopeT scope, JsonSerializerOptions options) { writer.WriteStartObject (); - IEnumerable properties = scope!.GetType () + IEnumerable properties = typeof (TScopeT) .GetProperties () .Where (p => p.GetCustomAttribute (typeof (JsonIncludeAttribute)) != null @@ -174,52 +170,52 @@ public override void Write (Utf8JsonWriter writer, TScopeT scope, JsonSerializer foreach (PropertyInfo p in properties) { writer.WritePropertyName (ConfigProperty.GetJsonPropertyName (p)); - object? prop = scope.GetType ().GetProperty (p.Name)?.GetValue (scope); - JsonSerializer.Serialize (writer, prop, prop!.GetType (), ConfigurationManager.SerializerContext); + object? prop = p.GetValue (scope); + JsonSerializer.Serialize (writer, prop, p.PropertyType, ConfigurationManager.SerializerContext); } foreach (KeyValuePair p in from p in scope - .Where (cp => - cp.Value.PropertyInfo?.GetCustomAttribute ( - typeof ( - ConfigurationPropertyAttribute) - ) - is - ConfigurationPropertyAttribute scp - && scp?.Scope == typeof (TScopeT) - ) - where p.Value.HasValue - select p) + .Where (cp => cp.Value.ScopeType == typeof (TScopeT).Name) + where p.Value.HasValue + select p) { - writer.WritePropertyName (p.Key); Type? propertyType = p.Value.PropertyInfo?.PropertyType; + object? propertyValue = p.Value.PropertyValue; - if (propertyType != null - && p.Value.PropertyInfo?.GetCustomAttribute (typeof (JsonConverterAttribute)) is JsonConverterAttribute - jca) + if (ShouldSkipNullPropertyValue (propertyType, propertyValue)) { - object converter = Activator.CreateInstance (jca.ConverterType!)!; + continue; + } - if (converter.GetType ().BaseType == typeof (JsonConverterFactory)) + writer.WritePropertyName (p.Key); + + if (propertyType != null + && p.Value.ConverterType is { } converterType) + { + if (propertyValue is null) { - var factory = (JsonConverterFactory)converter; + writer.WriteNullValue (); - if (factory.CanConvert (propertyType)) - { - converter = factory.CreateConverter (propertyType, options)!; - } + continue; } - if (p.Value.PropertyValue is { }) + if (TryWriteWithKnownConverter (writer, propertyType, converterType, propertyValue, options)) { - converter.GetType () - .GetMethod ("Write") - ?.Invoke (converter, [writer, p.Value.PropertyValue, options]); + continue; } + + if (TryWriteWithDynamicConverter (writer, propertyType, converterType, propertyValue, options)) + { + continue; + } + + throw new JsonException ( + $"{p.Key}: Unsupported configuration converter type \"{converterType.FullName}\" when dynamic code is unavailable." + ); } else { - object? prop = p.Value.PropertyValue; + object? prop = propertyValue; if (prop == null) { @@ -227,7 +223,19 @@ where p.Value.HasValue } else { - JsonSerializer.Serialize (writer, prop, prop.GetType (), ConfigurationManager.SerializerContext); + if (TryWriteEnumValue (writer, prop.GetType (), prop)) + { + continue; + } + + JsonTypeInfo? jsonTypeInfo = ConfigurationManager.SerializerContext.GetTypeInfo (prop.GetType ()); + + if (jsonTypeInfo is null) + { + throw new JsonException ($"{p.Key}: No source-generated JsonTypeInfo is registered for {prop.GetType ().FullName}."); + } + + JsonSerializer.Serialize (writer, prop, jsonTypeInfo); } } } @@ -235,14 +243,226 @@ where p.Value.HasValue writer.WriteEndObject (); } - // See: https://stackoverflow.com/questions/60830084/how-to-pass-an-argument-by-reference-using-reflection + private static bool TryReadWithKnownConverter ( + ref Utf8JsonReader reader, + Type propertyType, + Type converterType, + JsonSerializerOptions options, + out object? value) + { + if (converterType == typeof (ConcurrentDictionaryJsonConverter)) + { + value = new ConcurrentDictionaryJsonConverter ().Read (ref reader, propertyType, options); + + return true; + } + + if (converterType == typeof (DictionaryJsonConverter)) + { + value = new DictionaryJsonConverter ().Read (ref reader, propertyType, options); + + return true; + } + + if (converterType == typeof (TraceCategoryJsonConverter)) + { + value = new TraceCategoryJsonConverter ().Read (ref reader, propertyType, options); + + return true; + } + + value = null; + + return false; + } + + [RequiresDynamicCode ("Instantiates arbitrary property-level JsonConverter types for JIT-only configuration paths.")] + [RequiresUnreferencedCode ("Arbitrary property-level JsonConverter types may access unreferenced members.")] + private static bool TryReadWithDynamicConverter ( + ref Utf8JsonReader reader, + Type propertyType, + Type converterType, + JsonSerializerOptions options, + out object? value) + { + if (!RuntimeFeature.IsDynamicCodeSupported) + { + value = null; + + return false; + } + + object converter = Activator.CreateInstance (converterType)!; + + if (converter is JsonConverterFactory factory && factory.CanConvert (propertyType)) + { + converter = factory.CreateConverter (propertyType, options)!; + } + + try + { + Type helperType = typeof (ReadHelper<>).MakeGenericType (typeof (TScopeT), propertyType); + ReadHelper readHelper = (ReadHelper)Activator.CreateInstance (helperType, converter)!; + value = readHelper.Read (ref reader, propertyType, options); + + return true; + } + catch (NotSupportedException e) + { + throw new JsonException ($"{propertyType.Name}: Error reading property with converter \"{converterType.FullName}\".", e); + } + catch (TargetInvocationException) + { + value = JsonSerializer.Deserialize (ref reader, propertyType, options); + + return true; + } + } + + private static bool TryWriteWithKnownConverter ( + Utf8JsonWriter writer, + Type propertyType, + Type converterType, + object? value, + JsonSerializerOptions options) + { + if (converterType == typeof (ConcurrentDictionaryJsonConverter)) + { + new ConcurrentDictionaryJsonConverter ().Write ( + writer, + (ConcurrentDictionary)value!, + options + ); + + return true; + } + + if (converterType == typeof (DictionaryJsonConverter)) + { + new DictionaryJsonConverter ().Write (writer, (Dictionary)value!, options); + + return true; + } + + if (converterType == typeof (TraceCategoryJsonConverter)) + { + new TraceCategoryJsonConverter ().Write (writer, (TraceCategory)value!, options); + + return true; + } + + return false; + } + + [RequiresDynamicCode ("Instantiates arbitrary property-level JsonConverter types for JIT-only configuration paths.")] + [RequiresUnreferencedCode ("Arbitrary property-level JsonConverter types may access unreferenced members.")] + private static bool TryWriteWithDynamicConverter ( + Utf8JsonWriter writer, + Type propertyType, + Type converterType, + object value, + JsonSerializerOptions options) + { + if (!RuntimeFeature.IsDynamicCodeSupported) + { + return false; + } + + object converter = Activator.CreateInstance (converterType)!; + + if (converter is JsonConverterFactory factory && factory.CanConvert (propertyType)) + { + converter = factory.CreateConverter (propertyType, options)!; + } + + MethodInfo? writeMethod = converter.GetType ().GetMethod (nameof (Write), [typeof (Utf8JsonWriter), propertyType, typeof (JsonSerializerOptions)]); + + if (writeMethod is null) + { + throw new JsonException ($"{propertyType.Name}: Converter \"{converterType.FullName}\" does not expose a compatible Write method."); + } + + try + { + writeMethod.Invoke (converter, [writer, value, options]); + + return true; + } + catch (TargetInvocationException e) when (e.InnerException is not null) + { + throw new JsonException ($"{propertyType.Name}: Error writing property with converter \"{converterType.FullName}\".", e.InnerException); + } + } + + private static object? DeserializePropertyValue (ref Utf8JsonReader reader, Type propertyType, JsonSerializerOptions options) + { + Type? nullableType = Nullable.GetUnderlyingType (propertyType); + Type enumType = nullableType ?? propertyType; + + if (enumType.IsEnum) + { + if (reader.TokenType == JsonTokenType.String) + { + return Enum.Parse (enumType, reader.GetString ()!, ignoreCase: true); + } + + if (reader.TokenType == JsonTokenType.Null && nullableType is not null) + { + return null; + } + } + + JsonTypeInfo? jsonTypeInfo = ConfigurationManager.SerializerContext.GetTypeInfo (propertyType); + + if (jsonTypeInfo is null) + { + throw new JsonException ($"{propertyType.FullName}: No source-generated JsonTypeInfo is registered for this configuration property type."); + } + + return JsonSerializer.Deserialize (ref reader, jsonTypeInfo); + } + + private static bool ShouldSkipNullPropertyValue (Type? propertyType, object? propertyValue) + { + if (propertyValue is not null) + { + return false; + } + + if (propertyType is null) + { + return false; + } + + return propertyType.IsValueType && Nullable.GetUnderlyingType (propertyType) is null; + } + + private static bool CanAcceptNullValue (Type propertyType) + { + return !propertyType.IsValueType || Nullable.GetUnderlyingType (propertyType) is not null; + } + + private static bool TryWriteEnumValue (Utf8JsonWriter writer, Type propertyType, object value) + { + Type enumType = Nullable.GetUnderlyingType (propertyType) ?? propertyType; + + if (!enumType.IsEnum) + { + return false; + } + + writer.WriteStringValue (value.ToString ()); + + return true; + } + internal abstract class ReadHelper { public abstract object? Read (ref Utf8JsonReader reader, Type type, JsonSerializerOptions options); } - [method: RequiresUnreferencedCode ("Calls System.Delegate.CreateDelegate(Type, Object, String)")] - internal class ReadHelper (object converter) : ReadHelper + [method: RequiresUnreferencedCode ("Creates delegates for arbitrary property-level JsonConverter.Read methods on JIT-only paths.")] + internal class ReadHelper (object converter) : ReadHelper { private readonly ReadDelegate _readDelegate = (ReadDelegate)Delegate.CreateDelegate (typeof (ReadDelegate), converter, "Read"); @@ -251,6 +471,6 @@ internal class ReadHelper (object converter) : ReadHelper return _readDelegate.Invoke (ref reader, type, options); } - private delegate TConverter ReadDelegate (ref Utf8JsonReader reader, Type type, JsonSerializerOptions options); + private delegate TValue ReadDelegate (ref Utf8JsonReader reader, Type type, JsonSerializerOptions options); } } diff --git a/Terminal.Gui/Configuration/SettingsScope.cs b/Terminal.Gui/Configuration/SettingsScope.cs index df3aacc7c7..de9dbdeb8a 100644 --- a/Terminal.Gui/Configuration/SettingsScope.cs +++ b/Terminal.Gui/Configuration/SettingsScope.cs @@ -20,7 +20,6 @@ namespace Terminal.Gui.Configuration; /// /// /// -#pragma warning disable IL2026 // ScopeJsonConverter and Scope are AOT-compatible for known scope types [JsonConverter (typeof (ScopeJsonConverter))] public class SettingsScope : Scope { @@ -29,7 +28,6 @@ public class SettingsScope : Scope /// /// public SettingsScope () -#pragma warning restore IL2026 { ConfigProperty? configProperty = GetUninitializedProperty ("Theme"); diff --git a/Terminal.Gui/Configuration/SourceGenerationContext.cs b/Terminal.Gui/Configuration/SourceGenerationContext.cs index d18a7c0f9f..013864454d 100644 --- a/Terminal.Gui/Configuration/SourceGenerationContext.cs +++ b/Terminal.Gui/Configuration/SourceGenerationContext.cs @@ -44,6 +44,7 @@ namespace Terminal.Gui.Configuration; [JsonSerializable (typeof (Scope))] [JsonSerializable (typeof (Scope))] [JsonSerializable (typeof (ConcurrentDictionary))] +[JsonSerializable (typeof (Scheme))] [JsonSerializable (typeof (Dictionary))] [JsonSerializable (typeof (TraceCategory))] diff --git a/Terminal.Gui/Configuration/SourcesManager.cs b/Terminal.Gui/Configuration/SourcesManager.cs index e9e0bb99c5..415a45aeba 100644 --- a/Terminal.Gui/Configuration/SourcesManager.cs +++ b/Terminal.Gui/Configuration/SourcesManager.cs @@ -49,8 +49,6 @@ public class SourcesManager /// /// The Settings Scope object that will be updated. /// The locations to load from. - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal void LoadFromLocations (SettingsScope? settingsScope, ConfigLocations locations) { if (settingsScope is null) @@ -141,8 +139,6 @@ internal void LoadFromLocations (SettingsScope? settingsScope, ConfigLocations l /// The source (filename/resource name) the Json document was read from. /// The Config Location corresponding to /// if the settingsScope was updated. - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal bool Load (SettingsScope? settingsScope, Stream stream, string source, ConfigLocations location) { if (settingsScope is null) @@ -158,7 +154,7 @@ internal bool Load (SettingsScope? settingsScope, Stream stream, string source, stream.Position = 0; Debug.Assert (json != null); #endif - SettingsScope? scope = JsonSerializer.Deserialize (stream, typeof (SettingsScope), ConfigurationManager.SerializerContext.Options) as SettingsScope; + SettingsScope? scope = JsonSerializer.Deserialize (stream, ConfigurationManager.SerializerContext.SettingsScope); if (scope is null) { @@ -204,8 +200,6 @@ internal void AddSource (ConfigLocations location, string source) /// Json document to update the settings with. /// The Config Location corresponding to /// if the settingsScope was updated. - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal bool Load (SettingsScope? settingsScope, string filePath, ConfigLocations location) { string realPath = filePath.Replace ("~", Environment.GetFolderPath (Environment.SpecialFolder.UserProfile)); @@ -253,8 +247,6 @@ internal bool Load (SettingsScope? settingsScope, string filePath, ConfigLocatio /// The source (filename/resource name) the Json document was read from. /// The Config Location corresponding to /// if the settingsScope was updated. - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal bool Load (SettingsScope? settingsScope, string? json, string source, ConfigLocations location) { Debug.Assert (location != ConfigLocations.All); @@ -282,8 +274,6 @@ internal bool Load (SettingsScope? settingsScope, string? json, string source, C /// The name of the resource containing the Json document was read from. /// The Config Location corresponding to /// if the settingsScope was updated. - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal bool Load (SettingsScope? settingsScope, Assembly assembly, string resourceName, ConfigLocations location) { if (string.IsNullOrEmpty (resourceName)) @@ -309,22 +299,18 @@ internal bool Load (SettingsScope? settingsScope, Assembly assembly, string reso /// INTERNAL: Returns a JSON document with the configuration specified. /// /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal string ToJson (SettingsScope? scope) { - return JsonSerializer.Serialize (scope, typeof (SettingsScope), ConfigurationManager.SerializerContext); + return JsonSerializer.Serialize (scope, ConfigurationManager.SerializerContext.SettingsScope); } /// /// INTERNAL: Returns a stream with the configuration specified. /// /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] internal Stream ToStream (SettingsScope? scope) { - string json = JsonSerializer.Serialize (scope, typeof (SettingsScope), ConfigurationManager.SerializerContext); + string json = JsonSerializer.Serialize (scope, ConfigurationManager.SerializerContext.SettingsScope); // turn it into a stream var stream = new MemoryStream (); diff --git a/Terminal.Gui/Configuration/ThemeManager.cs b/Terminal.Gui/Configuration/ThemeManager.cs index ad3d42815d..2f0c1dcc9f 100644 --- a/Terminal.Gui/Configuration/ThemeManager.cs +++ b/Terminal.Gui/Configuration/ThemeManager.cs @@ -112,9 +112,7 @@ public static ImmutableList GetThemeNames () /// hard-coded themes. /// /// -#pragma warning disable IL2026 // ConcurrentDictionaryJsonConverter is AOT-compatible [JsonConverter (typeof (ConcurrentDictionaryJsonConverter))] -#pragma warning restore IL2026 [ConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)] public static ConcurrentDictionary? Themes { @@ -219,8 +217,6 @@ public static string Theme throw new InvalidOperationException ("Settings is null."); } - [RequiresUnreferencedCode ("Calls Terminal.Gui.ConfigurationManager.Settings")] - [RequiresDynamicCode ("Calls Terminal.Gui.ConfigurationManager.Settings")] set { if (!ConfigurationManager.IsInitialized ()) @@ -266,8 +262,6 @@ public static string Theme /// INTERNAL: Updates to the current values of the static /// properties. /// - [RequiresUnreferencedCode ("Calls Terminal.Gui.ThemeManager.Themes")] - [RequiresDynamicCode ("Calls Terminal.Gui.ThemeManager.Themes")] internal static void UpdateToCurrentValues () { // BUGBUG: This corrupts _hardCodedDefaults. See #4288 @@ -277,9 +271,6 @@ internal static void UpdateToCurrentValues () /// /// INTERNAL: Loads all Themes to their hard-coded default values. /// - [RequiresUnreferencedCode ("Calls SchemeManager.LoadToHardCodedDefaults")] - [RequiresDynamicCode ("Calls SchemeManager.LoadToHardCodedDefaults")] - internal static void LoadHardCodedDefaults () { if (!ConfigurationManager.IsInitialized ()) diff --git a/Terminal.Gui/Configuration/ThemeScope.cs b/Terminal.Gui/Configuration/ThemeScope.cs index 642e1dbb9f..f5f0cdcf7b 100644 --- a/Terminal.Gui/Configuration/ThemeScope.cs +++ b/Terminal.Gui/Configuration/ThemeScope.cs @@ -42,10 +42,8 @@ namespace Terminal.Gui.Configuration; /// } /// /// -#pragma warning disable IL2026 // ScopeJsonConverter and Scope are AOT-compatible for known scope types [JsonConverter (typeof (ScopeJsonConverter))] public class ThemeScope : Scope -#pragma warning restore IL2026 { } diff --git a/Terminal.Gui/Drawing/Attribute.cs b/Terminal.Gui/Drawing/Attribute.cs index 02c2bd7740..51c7ac7ca1 100644 --- a/Terminal.Gui/Drawing/Attribute.cs +++ b/Terminal.Gui/Drawing/Attribute.cs @@ -17,9 +17,7 @@ namespace Terminal.Gui.Drawing; /// /// /// -#pragma warning disable IL2026 // AttributeJsonConverter is AOT-compatible [JsonConverter (typeof (AttributeJsonConverter))] -#pragma warning restore IL2026 public readonly record struct Attribute : IEqualityOperators { /// Default empty attribute. diff --git a/Terminal.Gui/Drawing/Region.cs b/Terminal.Gui/Drawing/Region.cs index 21718b0503..353b8e2dce 100644 --- a/Terminal.Gui/Drawing/Region.cs +++ b/Terminal.Gui/Drawing/Region.cs @@ -215,9 +215,24 @@ private void CombineInternal (Region? region, RegionOp operation) break; case RegionOp.XOR: - Exclude (region); - region.Combine (this, RegionOp.Difference); - _rectangles.AddRange (region._rectangles); + + // Snapshot both operands before mutating either, so the symmetric + // difference is computed as (thisOriginal \ regionOriginal) ∪ + // (regionOriginal \ thisOriginal) rather than against a partially + // mutated `this`. + Region thisOriginal = Clone (); + Region regionOriginal = region.Clone (); + + // Reuse `thisOriginal` to hold (thisOriginal \ regionOriginal). + thisOriginal.Combine (regionOriginal, RegionOp.Difference); + + // Reuse a fresh clone of the unmodified `this` for (regionOriginal \ thisOriginal). + Region regionMinusThis = region.Clone (); + regionMinusThis.Combine (Clone (), RegionOp.Difference); + + _rectangles.Clear (); + _rectangles.AddRange (thisOriginal._rectangles); + _rectangles.AddRange (regionMinusThis._rectangles); break; @@ -982,203 +997,4 @@ public void DrawBoundaries (LineCanvas canvas, LineStyle style, Attribute? attri } } } - - // BUGBUG: DrawOuterBoundary does not work right. it draws all regions +1 too tall/wide. It should draw single width/height regions as just a line. - // - // Example: There are 3 regions here. the first is a rect (0,0,1,4). Second is (10, 0, 2, 4). - // This is how they should draw: - // - // |123456789|123456789|123456789 - // 1 │ ┌┐ ┌─┐ - // 2 │ ││ │ │ - // 3 │ ││ │ │ - // 4 │ └┘ └─┘ - // - // But this is what it draws: - // |123456789|123456789|123456789 - // 1┌┐ ┌─┐ ┌──┐ - // 2││ │ │ │ │ - // 3││ │ │ │ │ - // 4││ │ │ │ │ - // 5└┘ └─┘ └──┘ - // - // Example: There are two rectangles in this region. (0,0,3,3) and (3, 3, 3, 3). - // This is fill - correct: - // |123456789 - // 1░░░ - // 2░░░ - // 3░░░░░ - // 4 ░░░ - // 5 ░░░ - // 6 - // - // This is what DrawOuterBoundary should draw - // |123456789|123456789 - // 1┌─┐ - // 2│ │ - // 3└─┼─┐ - // 4 │ │ - // 5 └─┘ - // 6 - // - // This is what DrawOuterBoundary actually draws - // |123456789|123456789 - // 1┌──┐ - // 2│ │ - // 3│ └─┐ - // 4└─┐ │ - // 5 │ │ - // 6 └──┘ - - /// - /// Draws the outer perimeter of the region to using and - /// . - /// The outer perimeter follows the shape of the rectangles in the region, even if non-rectangular, by drawing - /// boundaries and excluding internal lines. - /// - /// The LineCanvas to draw on. - /// The line style to use for drawing. - /// The attribute (color/style) to use for the lines. If null. - public void DrawOuterBoundary (LineCanvas lineCanvas, LineStyle style, Attribute? attribute = null) - { - if (_rectangles.Count == 0) - { - return; - } - - // Get the bounds of the region - Rectangle bounds = GetBounds (); - - // Add protection against extremely large allocations - if (bounds.Width > 1000 || bounds.Height > 1000) - { - // Fall back to drawing each rectangle's boundary - DrawBoundaries (lineCanvas, style, attribute); - - return; - } - - // Create a grid to track which cells are inside the region - var insideRegion = new bool [bounds.Width + 1, bounds.Height + 1]; - - // Fill the grid based on rectangles - foreach (Rectangle rect in _rectangles) - { - if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0) - { - continue; - } - - for (int x = rect.Left; x < rect.Right; x++) - { - for (int y = rect.Top; y < rect.Bottom; y++) - { - // Adjust coordinates to grid space - int gridX = x - bounds.Left; - int gridY = y - bounds.Top; - - if (gridX >= 0 && gridX < bounds.Width && gridY >= 0 && gridY < bounds.Height) - { - insideRegion [gridX, gridY] = true; - } - } - } - } - - // Find horizontal boundary lines - for (var y = 0; y <= bounds.Height; y++) - { - int startX = -1; - - for (var x = 0; x <= bounds.Width; x++) - { - bool above = y > 0 && insideRegion [x, y - 1]; - bool below = y < bounds.Height && insideRegion [x, y]; - - // A boundary exists where one side is inside and the other is outside - bool isBoundary = above != below; - - if (isBoundary) - { - // Start a new segment or continue the current one - if (startX == -1) - { - startX = x; - } - } - else - { - // End the current segment if one exists - if (startX == -1) - { - continue; - } - int length = x - startX + 1; // Add 1 to make sure lines connect - - lineCanvas.AddLine (new Point (startX + bounds.Left, y + bounds.Top), length, Orientation.Horizontal, style, attribute); - startX = -1; - } - } - - // End any segment that reaches the right edge - if (startX == -1) - { - continue; - } - - { - int length = bounds.Width + 1 - startX + 1; // Add 1 to make sure lines connect - - lineCanvas.AddLine (new Point (startX + bounds.Left, y + bounds.Top), length, Orientation.Horizontal, style, attribute); - } - } - - // Find vertical boundary lines - for (var x = 0; x <= bounds.Width; x++) - { - int startY = -1; - - for (var y = 0; y <= bounds.Height; y++) - { - bool left = x > 0 && insideRegion [x - 1, y]; - bool right = x < bounds.Width && insideRegion [x, y]; - - // A boundary exists where one side is inside and the other is outside - bool isBoundary = left != right; - - if (isBoundary) - { - // Start a new segment or continue the current one - if (startY == -1) - { - startY = y; - } - } - else - { - // End the current segment if one exists - if (startY == -1) - { - continue; - } - int length = y - startY + 1; // Add 1 to make sure lines connect - - lineCanvas.AddLine (new Point (x + bounds.Left, startY + bounds.Top), length, Orientation.Vertical, style, attribute); - startY = -1; - } - } - - // End any segment that reaches the bottom edge - if (startY == -1) - { - continue; - } - - { - int length = bounds.Height + 1 - startY + 1; // Add 1 to make sure lines connect - - lineCanvas.AddLine (new Point (x + bounds.Left, startY + bounds.Top), length, Orientation.Vertical, style, attribute); - } - } - } } diff --git a/Terminal.Gui/Drawing/Scheme.cs b/Terminal.Gui/Drawing/Scheme.cs index 21e21b30c3..01ac6c9950 100644 --- a/Terminal.Gui/Drawing/Scheme.cs +++ b/Terminal.Gui/Drawing/Scheme.cs @@ -167,9 +167,7 @@ namespace Terminal.Gui.Drawing; /// the fallback is White for foreground and Black for background. /// /// -#pragma warning disable IL2026 // SchemeJsonConverter is AOT-compatible [JsonConverter (typeof (SchemeJsonConverter))] -#pragma warning restore IL2026 public record Scheme : IEqualityOperators { /// diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 776abe7e20..cf8cee1270 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -111,8 +111,20 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen) } else if (PlatformDetection.IsUnixLike ()) { - // duplicate stdout so we don't mess with Console.Out's FD - int fdCopy = UnixIOHelper.dup (UnixIOHelper.STDOUT_FILENO); + // duplicate the controlling terminal output fd so we don't mess with it. + // When stdout is redirected this is /dev/tty rather than STDOUT_FILENO, + // allowing TUI rendering to appear on the real terminal even when the + // app's stdout participates in a shell pipeline. + int outputFd = TerminalDevice.OutputFd; + + if (outputFd < 0) + { + Trace.Lifecycle (nameof (AnsiOutput), "Init", "Console output stream is not writable. Running in degraded mode."); + + return; + } + + int fdCopy = UnixIOHelper.dup (outputFd); if (fdCopy == -1) { diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs index 079d4f9ab8..4dc5c60a07 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParser.cs @@ -14,6 +14,13 @@ public class AnsiKeyboardParser new EscAsAltPattern { IsLastMinute = true } ]; + /// + /// Maximum input length for keyboard escape sequences. Real keyboard sequences are short + /// (typically under 20 characters). This guard prevents pattern evaluation against + /// pathologically large inputs accumulated by the parser. + /// + internal const int MaxKeyboardSequenceLength = 64; + /// /// Looks for any pattern that matches the and returns /// the matching pattern or if no matches. @@ -23,6 +30,11 @@ public class AnsiKeyboardParser /// public AnsiKeyboardParserPattern? IsKeyboard (string? input, bool isLastMinute = false) { + if (input is null || input.Length > MaxKeyboardSequenceLength) + { + return null; + } + return _patterns.FirstOrDefault (pattern => pattern.IsLastMinute == isLastMinute && pattern.IsMatch (input)); } } diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs index fcd4d8aae9..21bc3971ec 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiMouseParser.cs @@ -62,6 +62,13 @@ public class AnsiMouseParser // Regex patterns for button press/release, wheel scroll, and mouse position reporting private readonly Regex _mouseEventPattern = new (@"\u001b\[<(\d+);(\d+);(\d+)(M|m)", RegexOptions.Compiled); + /// + /// Maximum input length for mouse escape sequences. Real mouse sequences are short + /// (typically under 20 characters). This guard prevents regex evaluation against + /// pathologically large inputs accumulated by the parser. + /// + internal const int MaxMouseSequenceLength = 64; + /// /// Returns true if it is a mouse event /// @@ -71,7 +78,7 @@ public bool IsMouse (string? cur) => // Typically in this format // ESC [ < {button_code};{x_pos};{y_pos}{final_byte} - cur!.EndsWith ('M') || cur.EndsWith ('m'); + cur is { Length: <= MaxMouseSequenceLength } && (cur.EndsWith ('M') || cur.EndsWith ('m')); /// /// Parses a mouse ansi escape sequence into a mouse event. Returns null if input @@ -81,8 +88,13 @@ public bool IsMouse (string? cur) => /// public Mouse? ProcessMouseInput (string? input) { + if (input is null || input.Length > MaxMouseSequenceLength) + { + return null; + } + // Match mouse wheel events first - Match match = _mouseEventPattern.Match (input!); + Match match = _mouseEventPattern.Match (input); if (!match.Success) { diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs index aa8d903fa5..ddf658a7f9 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs @@ -12,6 +12,13 @@ internal abstract class AnsiResponseParserBase (IHeld heldContent, ITimeProvider private const char ESCAPE = '\x1B'; private const char BEL = '\a'; + /// + /// Maximum number of characters that can be accumulated in held content before the parser + /// abandons the current escape sequence and releases buffered content. This prevents unbounded + /// memory growth from malformed or malicious unterminated escape sequences. + /// + internal const int MaxHeldLength = 8 * 1024; + /// /// Tracks whether the parser is currently inside an OSC (Operating System Command) sequence. /// OSC responses can be terminated by ST (ESC \) which requires special handling because @@ -164,6 +171,15 @@ private void ProcessInputBaseImpl (Func getCharAtIndex, Func= MaxHeldLength) + { + ReleaseHeld (appendOutput); + appendOutput (currentObj); + + break; + } + if (_inOscSequence) { if (_oscExpectingBackslash) diff --git a/Terminal.Gui/Drivers/Driver.cs b/Terminal.Gui/Drivers/Driver.cs index 3e92150251..870aa79154 100644 --- a/Terminal.Gui/Drivers/Driver.cs +++ b/Terminal.Gui/Drivers/Driver.cs @@ -1,6 +1,4 @@ -using System.Runtime.InteropServices; - -namespace Terminal.Gui.Drivers; +namespace Terminal.Gui.Drivers; /// /// Holds global driver settings and cross-driver utility methods. @@ -45,18 +43,27 @@ public static bool Force16Colors public static SizeDetectionMode SizeDetection { get; set; } = SizeDetectionMode.AnsiQuery; /// - /// Determines whether the process is attached to a real terminal (i.e. stdin/stdout - /// are connected to a console device rather than redirected or running inside a test harness). Set the environment - /// variable "DisableRealDriverIO=1" to skip real terminal detection and force this method to return false, which is - /// required for running in test harnesses that do not have a real terminal attached. + /// Determines whether the process has a controlling terminal usable for TUI rendering and input. + /// Returns when either: + /// + /// stdin/stdout are connected to a console device, or + /// + /// stdin/stdout are redirected (e.g. via a shell pipeline such as + /// result=$(myapp) or myapp | jq) but a controlling terminal is available + /// via /dev/tty on Unix or CONIN$/CONOUT$ on Windows. + /// + /// + /// Set the environment variable DisableRealDriverIO=1 to skip real terminal detection and + /// force this method to return false, which is required for running in test harnesses that do not + /// have a real terminal attached. /// /// - /// When this method returns, if standard input is connected to a console device; - /// otherwise . + /// When this method returns, if a terminal device is available for input + /// (either stdin or the controlling terminal); otherwise . /// /// - /// When this method returns, if standard output is connected to a console device; - /// otherwise . + /// When this method returns, if a terminal device is available for output + /// (either stdout or the controlling terminal); otherwise . /// /// if both input and output are attached to a terminal; otherwise . public static bool IsAttachedToTerminal (out bool inputAttached, out bool outputAttached) @@ -69,13 +76,8 @@ public static bool IsAttachedToTerminal (out bool inputAttached, out bool output return false; } - if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) - { - return WindowsConsoleHelper.IsAttachedToTerminal (out inputAttached, out outputAttached); - } - - inputAttached = UnixIOHelper.IsTerminal (UnixIOHelper.STDIN_FILENO); - outputAttached = UnixIOHelper.IsTerminal (UnixIOHelper.STDOUT_FILENO); + inputAttached = TerminalDevice.IsInputAttached; + outputAttached = TerminalDevice.IsOutputAttached; return inputAttached && outputAttached; } diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 87e0f7eee1..7458b83f6d 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -311,6 +311,7 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, bool addNewlines = true) { var redrawTextStyle = TextStyle.None; + string? lastUrl = null; for (int row = startRow; row < endRow; row++) { @@ -321,11 +322,36 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, continue; } + // Handle OSC 8 hyperlink state transitions + string? cellUrl = buffer.GetCellUrl (col, row); + + if (cellUrl != lastUrl) + { + if (lastUrl is { }) + { + output.Append (EscSeqUtils.OSC_EndHyperlink ()); + } + + if (!string.IsNullOrEmpty (cellUrl)) + { + output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + } + + lastUrl = cellUrl; + } + Cell cell = buffer.Contents! [row, col]; int outputWidth = -1; AppendCellAnsi (cell, output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); } + // Close any open hyperlink at end of row + if (lastUrl is { }) + { + output.Append (EscSeqUtils.OSC_EndHyperlink ()); + lastUrl = null; + } + // Add newline at end of row if requested if (addNewlines) { diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index ba45bff6b7..efe5a8d00e 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -9,6 +9,27 @@ namespace Terminal.Gui.Drivers; /// public class OutputBufferImpl : IOutputBuffer { + /// + /// Stable lock object for synchronizing access to , , + /// , and related state. Unlike locking on Contents itself, this object is + /// never replaced, guaranteeing mutual exclusion across , + /// , and . + /// + private readonly Lock _contentsLock = new (); + + private int _cols; + 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. + /// + private Dictionary? _urlMap; + + private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement; + + private Region? _clip; + /// /// The contents of the application output. The driver outputs this buffer to the terminal when /// UpdateScreen is called. @@ -16,9 +37,6 @@ public class OutputBufferImpl : IOutputBuffer /// public Cell [,]? Contents { get; set; } = new Cell [0, 0]; - private int _cols; - private int _rows; - /// /// The that will be used for the next or /// call. @@ -32,30 +50,24 @@ public class OutputBufferImpl : IOutputBuffer /// public string? CurrentUrl { get; set; } - /// - /// Maps cell positions to URLs for OSC 8 hyperlink support. - /// Only stores entries for cells that actually have URLs, minimizing memory overhead. - /// - private Dictionary? _urlMap; - /// /// Gets the URL associated with the cell at the specified position. /// /// The column. /// The row. /// The URL if one exists, otherwise null. - public string? GetCellUrl (int col, int row) => _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; - - /// - /// Sets the URL for the cell at the specified position. - /// - /// The column. - /// The row. - /// The URL to associate with this cell. - private void SetCellUrl (int col, int row, string url) + public string? GetCellUrl (int col, int row) { - _urlMap ??= []; - _urlMap [new Point (col, row)] = url; + // Fast-path: skip locking when no URLs have been set + if (_urlMap is null) + { + return null; + } + + lock (_contentsLock) + { + return _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; + } } /// The leftmost column in the terminal. @@ -98,8 +110,6 @@ public int Cols /// The topmost row in the terminal. public virtual int Top { get; set; } = 0; - private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement; - /// public void SetWideGlyphReplacement (Rune column1ReplacementChar) => _column1ReplacementChar = column1ReplacementChar; @@ -108,12 +118,6 @@ public int Cols /// public bool [] DirtyLines { get; set; } = []; - // QUESTION: When non-full screen apps are supported, will this represent the app size, or will that be in Application? - /// Gets the location and size of the terminal screen. - internal Rectangle Screen => new (0, 0, Cols, Rows); - - private Region? _clip; - /// /// Gets or sets the clip rectangle that and are subject /// to. @@ -177,24 +181,166 @@ public void AddStr (string str) } } + /// Clears the of the driver. + public void ClearContents () => ClearContents (!InlineMode); + + /// Tests whether the specified coordinate are valid for drawing the specified Text. + /// Used to determine if one or two columns are required. + /// The column. + /// The row. + /// + /// if the coordinate is outside the screen bounds or outside of . + /// otherwise. + /// + public bool IsValidLocation (string text, int col, int row) + { + int textWidth = text.GetColumns (); + + return col >= 0 && row >= 0 && col + textWidth <= Cols && row < Rows && Clip!.Contains (col, row); + } + + /// + public void SetSize (int cols, int rows) + { + lock (_contentsLock) + { + _cols = cols; + _rows = rows; + ClearContentsCore (!InlineMode); + } + } + + /// + public void FillRect (Rectangle rect, Rune rune) + { + lock (_contentsLock) + { + if (Contents is null) + { + return; + } + + Clip ??= new Region (Screen); + Rectangle clipBounds = Clip!.GetBounds (); + + rect = Rectangle.Intersect (rect, clipBounds); + + for (int r = rect.Y; r < rect.Y + rect.Height; r++) + { + for (int c = rect.X; c < rect.X + rect.Width; c++) + { + if (!IsValidLocation (rune.ToString (), c, r)) + { + continue; + } + + // We could call AddGrapheme here, but that would acquire the lock again. + // So we inline the logic instead. + SetAttributeAndDirty (c, r); + InvalidateOverlappedWideGlyph (c, r); + string grapheme = rune != default (Rune) ? rune.ToString ().MakePrintable () : " "; + WriteGraphemeByWidth (c, r, grapheme, grapheme.GetColumns (), clipBounds); + } + } + } + } + + /// + public void FillRect (Rectangle rect, char rune) + { + FillRect (rect, new Rune (rune)); + } + + /// + /// Updates and to the specified column and row in . + /// Used by and to determine where to add content. + /// + /// + /// This does not move the cursor on the screen, it only updates the internal state of the driver. + /// + /// If or are negative or beyond and + /// , the method still sets those properties. + /// + /// + /// Column to move to. + /// Row to move to. + public void Move (int col, int row) + { + Col = col; + Row = row; + } + + /// Clears the of the driver. + /// + /// When (the default), all cells are marked dirty so the first render + /// overwrites the entire screen. When (used in inline mode), cells start + /// clean so only cells that are explicitly drawn will be flushed, leaving the rest of the terminal + /// untouched. + /// + public void ClearContents (bool initiallyDirty) + { + lock (_contentsLock) + { + ClearContentsCore (initiallyDirty); + } + } + + /// + /// Non-locking implementation of . + /// Caller must already hold . + /// + private void ClearContentsCore (bool initiallyDirty) + { + Contents = new Cell [Rows, Cols]; + + // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere. + Clip = new Region (Screen); + + DirtyLines = new bool [Rows]; + + // Clear the URL map + _urlMap?.Clear (); + + for (var row = 0; row < Rows; row++) + { + for (var c = 0; c < Cols; c++) + { + Contents [row, c] = new Cell { Grapheme = " ", Attribute = new Attribute (Color.White, Color.Black), IsDirty = initiallyDirty }; + } + + DirtyLines [row] = initiallyDirty; + } + } + + /// + /// Gets or sets a value indicating whether this buffer is operating in inline mode. + /// When , initialises cells with + /// IsDirty = false so that only cells explicitly drawn are flushed to the + /// terminal, leaving the rest of the visible terminal untouched. + /// + public bool InlineMode { get; set; } + + /// Gets the location and size of the terminal screen. + internal Rectangle Screen => new (0, 0, Cols, Rows); + /// /// Adds a single grapheme to the display at the current cursor position. /// /// The grapheme to add. private void AddGrapheme (string grapheme) { - if (Contents is null) + lock (_contentsLock) { - return; - } + if (Contents is null) + { + return; + } - Clip ??= new Region (Screen); - Rectangle clipRect = Clip!.GetBounds (); + Clip ??= new Region (Screen); + Rectangle clipRect = Clip!.GetBounds (); - int printableGraphemeWidth = -1; + int printableGraphemeWidth = -1; - lock (Contents) - { if (IsValidLocation (grapheme, Col, Row)) { // Set attribute and mark dirty for current cell @@ -237,6 +383,22 @@ private void AddGrapheme (string grapheme) } } + /// + /// INTERNAL: If we're writing at an odd column and there's a wide glyph to our left, + /// invalidate it since we're overwriting the second half. + /// + /// The column. + /// The row. + private void InvalidateOverlappedWideGlyph (int col, int row) + { + if (col <= 0 || Contents! [row, col - 1].Grapheme.GetColumns () <= 1) + { + return; + } + Contents [row, col - 1].Grapheme = _column1ReplacementChar.ToString (); + Contents [row, col - 1].IsDirty = true; + } + /// /// INTERNAL: Helper to set the attribute and mark the cell as dirty. /// @@ -255,19 +417,39 @@ private void SetAttributeAndDirty (int col, int row) } /// - /// INTERNAL: If we're writing at an odd column and there's a wide glyph to our left, - /// invalidate it since we're overwriting the second half. + /// Sets the URL for the cell at the specified position. /// /// The column. /// The row. - private void InvalidateOverlappedWideGlyph (int col, int row) + /// The URL to associate with this cell. + private void SetCellUrl (int col, int row, string url) { - if (col <= 0 || Contents! [row, col - 1].Grapheme.GetColumns () <= 1) + _urlMap ??= []; + _urlMap [new Point (col, row)] = url; + } + + /// + /// INTERNAL: Writes a (0 or 1 column wide) Grapheme. + /// + /// The column. + /// The row. + /// The single-width Grapheme to write. + /// The clipping rectangle. + private void WriteGrapheme (int col, int row, string grapheme, Rectangle clipRect) + { + if (grapheme is null) { return; } - Contents [row, col - 1].Grapheme = _column1ReplacementChar.ToString (); - Contents [row, col - 1].IsDirty = true; + + Debug.Assert (grapheme.GetColumns () < 2); + Contents! [row, col].Grapheme = grapheme; + + // Mark the next cell as dirty to ensure proper rendering of adjacent content + if (col < clipRect.Right - 1 && col + 1 < Cols) + { + Contents [row, col + 1].IsDirty = true; + } } /// @@ -302,25 +484,6 @@ private void WriteGraphemeByWidth (int col, int row, string text, int textWidth, } } - /// - /// INTERNAL: Writes a (0 or 1 column wide) Grapheme. - /// - /// The column. - /// The row. - /// The single-width Grapheme to write. - /// The clipping rectangle. - private void WriteGrapheme (int col, int row, string grapheme, Rectangle clipRect) - { - Debug.Assert (grapheme.GetColumns () < 2); - Contents! [row, col].Grapheme = grapheme; - - // Mark the next cell as dirty to ensure proper rendering of adjacent content - if (col < clipRect.Right - 1 && col + 1 < Cols) - { - Contents [row, col + 1].IsDirty = true; - } - } - /// /// INTERNAL: Writes a wide Grapheme (2 columns wide) handling clipping and partial overlap cases. /// @@ -349,134 +512,4 @@ private void WriteWideGrapheme (int col, int row, string grapheme) // See: https://github.com/gui-cs/Terminal.Gui/issues/4258 } } - - /// - /// Gets or sets a value indicating whether this buffer is operating in inline mode. - /// When , initialises cells with - /// IsDirty = false so that only cells explicitly drawn are flushed to the - /// terminal, leaving the rest of the visible terminal untouched. - /// - public bool InlineMode { get; set; } - - /// Clears the of the driver. - public void ClearContents () => ClearContents (!InlineMode); - - /// Clears the of the driver. - /// - /// When (the default), all cells are marked dirty so the first render - /// overwrites the entire screen. When (used in inline mode), cells start - /// clean so only cells that are explicitly drawn will be flushed, leaving the rest of the terminal - /// untouched. - /// - public void ClearContents (bool initiallyDirty) - { - Contents = new Cell [Rows, Cols]; - - // CONCURRENCY: Unsynchronized access to Clip isn't safe. - // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere. - Clip = new Region (Screen); - - DirtyLines = new bool [Rows]; - - // Clear the URL map - _urlMap?.Clear (); - - lock (Contents) - { - for (var row = 0; row < Rows; row++) - { - for (var c = 0; c < Cols; c++) - { - Contents [row, c] = new Cell { Grapheme = " ", Attribute = new Attribute (Color.White, Color.Black), IsDirty = initiallyDirty }; - } - - DirtyLines [row] = initiallyDirty; - } - } - } - - /// Tests whether the specified coordinate are valid for drawing the specified Text. - /// Used to determine if one or two columns are required. - /// The column. - /// The row. - /// - /// if the coordinate is outside the screen bounds or outside of . - /// otherwise. - /// - public bool IsValidLocation (string text, int col, int row) - { - int textWidth = text.GetColumns (); - - return col >= 0 && row >= 0 && col + textWidth <= Cols && row < Rows && Clip!.Contains (col, row); - } - - /// - public void SetSize (int cols, int rows) - { - Cols = cols; - Rows = rows; - ClearContents (); - } - - /// - public void FillRect (Rectangle rect, Rune rune) - { - Rectangle clipBounds = Clip?.GetBounds () ?? Screen; - - // BUGBUG: This should be a method on Region - rect = Rectangle.Intersect (rect, clipBounds); - - lock (Contents!) - { - for (int r = rect.Y; r < rect.Y + rect.Height; r++) - { - for (int c = rect.X; c < rect.X + rect.Width; c++) - { - if (!IsValidLocation (rune.ToString (), c, r)) - { - continue; - } - - // We could call AddGrapheme here, but that would acquire the lock again. - // So we inline the logic instead. - SetAttributeAndDirty (c, r); - InvalidateOverlappedWideGlyph (c, r); - string grapheme = rune != default (Rune) ? rune.ToString () : " "; - WriteGraphemeByWidth (c, r, grapheme, grapheme.GetColumns (), clipBounds); - } - } - } - } - - /// - public void FillRect (Rectangle rect, char rune) - { - for (int y = rect.Top; y < rect.Top + rect.Height; y++) - { - for (int x = rect.Left; x < rect.Left + rect.Width; x++) - { - Move (x, y); - AddRune (rune); - } - } - } - - /// - /// Updates and to the specified column and row in . - /// Used by and to determine where to add content. - /// - /// - /// This does not move the cursor on the screen, it only updates the internal state of the driver. - /// - /// If or are negative or beyond and - /// , the method still sets those properties. - /// - /// - /// Column to move to. - /// Row to move to. - public void Move (int col, int row) - { - Col = col; - Row = row; - } } diff --git a/Terminal.Gui/Drivers/TerminalDevice.cs b/Terminal.Gui/Drivers/TerminalDevice.cs new file mode 100644 index 0000000000..839384a2f9 --- /dev/null +++ b/Terminal.Gui/Drivers/TerminalDevice.cs @@ -0,0 +1,467 @@ +using System.Runtime.InteropServices; + +// ReSharper disable IdentifierTypo +// ReSharper disable StringLiteralTypo +// ReSharper disable InconsistentNaming + +namespace Terminal.Gui.Drivers; + +/// +/// Resolves the controlling terminal device for input and output, preferring the standard +/// streams (stdin/stdout) when they are connected to a terminal, and falling back to the +/// controlling TTY (/dev/tty on Unix, CONIN$/CONOUT$ on Windows) when +/// either stream is redirected. +/// +/// +/// +/// Tools such as fzf, gum, and dialog use this technique so a TUI +/// can still render and read input even when the application's stdout or stdin participates +/// in a shell pipeline (e.g. result=$(myapp) or myapp | jq). +/// +/// +/// All members are lazily initialized and cached for the lifetime of the process. +/// +/// +internal static class TerminalDevice +{ + private static readonly Lock _lock = new (); + private static bool _initialized; + + // Unix-side resolved file descriptors. -1 means "no terminal device available". + private static int _inputFd = -1; + private static int _outputFd = -1; + + // Did we open /dev/tty ourselves (so we should close it on dispose)? + private static int _ownedInputFd = -1; + private static int _ownedOutputFd = -1; + + // Windows-side resolved handles. nint.Zero means "no terminal device available". + private static nint _inputHandle = nint.Zero; + private static nint _outputHandle = nint.Zero; + + // Did we open CONIN$/CONOUT$ ourselves (so we should close them on dispose)? + private static nint _ownedInputHandle = nint.Zero; + private static nint _ownedOutputHandle = nint.Zero; + + private static bool _inputAttached; + private static bool _outputAttached; + + /// + /// Gets a Unix file descriptor that can be used to read terminal input. + /// Returns when stdin is a tty, the fd of an + /// opened /dev/tty when stdin is redirected but a controlling terminal exists, + /// or -1 when no terminal device is available. + /// + public static int InputFd + { + get + { + EnsureInitialized (); + + return _inputFd; + } + } + + /// + /// Gets a Unix file descriptor that can be used to write terminal output. + /// Returns when stdout is a tty, the fd of an + /// opened /dev/tty when stdout is redirected but a controlling terminal exists, + /// or -1 when no terminal device is available. + /// + public static int OutputFd + { + get + { + EnsureInitialized (); + + return _outputFd; + } + } + + /// + /// Gets a Windows handle that can be used to read terminal input. Returns the standard + /// input handle when stdin is a console, a handle opened via CONIN$ when stdin is + /// redirected but a console exists, or when no console is + /// available. + /// + public static nint InputHandle + { + get + { + EnsureInitialized (); + + return _inputHandle; + } + } + + /// + /// Gets a Windows handle that can be used to write terminal output. Returns the standard + /// output handle when stdout is a console, a handle opened via CONOUT$ when stdout + /// is redirected but a console exists, or when no console is + /// available. + /// + public static nint OutputHandle + { + get + { + EnsureInitialized (); + + return _outputHandle; + } + } + + /// + /// Gets whether a terminal input device (the standard input or /dev/tty/CONIN$) + /// is available. + /// + public static bool IsInputAttached + { + get + { + EnsureInitialized (); + + return _inputAttached; + } + } + + /// + /// Gets whether a terminal output device (the standard output or /dev/tty/CONOUT$) + /// is available. + /// + public static bool IsOutputAttached + { + get + { + EnsureInitialized (); + + return _outputAttached; + } + } + + /// + /// Resets the cached terminal device state so the next access re-resolves it. + /// Intended for testing. + /// + internal static void ResetForTesting () + { + lock (_lock) + { + CloseOwnedHandles (); + + _initialized = false; + _inputFd = -1; + _outputFd = -1; + _ownedInputFd = -1; + _ownedOutputFd = -1; + _inputHandle = nint.Zero; + _outputHandle = nint.Zero; + _ownedInputHandle = nint.Zero; + _ownedOutputHandle = nint.Zero; + _inputAttached = false; + _outputAttached = false; + } + } + + private static void EnsureInitialized () + { + if (_initialized) + { + return; + } + + lock (_lock) + { + if (_initialized) + { + return; + } + + // When the test harness sets DisableRealDriverIO, skip real terminal detection entirely. + if (string.Equals (Environment.GetEnvironmentVariable ("DisableRealDriverIO"), "1", StringComparison.Ordinal)) + { + _initialized = true; + + return; + } + + try + { + if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) + { + InitializeWindows (); + } + else + { + InitializeUnix (); + } + } + catch + { + // Best effort: any failure leaves us in the "no terminal" state. + } + + _initialized = true; + + // Make sure any owned descriptors get closed when the process exits. + try + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => CloseOwnedHandles (); + } + catch + { + // ignore + } + } + } + + private static void InitializeUnix () + { + // Prefer the standard streams when they are connected to a terminal. + if (UnixIOHelper.IsTerminal (UnixIOHelper.STDIN_FILENO)) + { + _inputFd = UnixIOHelper.STDIN_FILENO; + _inputAttached = true; + } + + if (UnixIOHelper.IsTerminal (UnixIOHelper.STDOUT_FILENO)) + { + _outputFd = UnixIOHelper.STDOUT_FILENO; + _outputAttached = true; + } + + // If either side is missing, try to open the controlling terminal directly. + if (_inputFd != -1 && _outputFd != -1) + { + return; + } + + try + { + int ttyFd = open ("/dev/tty", O_RDWR | O_NOCTTY); + + if (ttyFd < 0) + { + return; + } + + // Verify it is actually a terminal. + if (!UnixIOHelper.IsTerminal (ttyFd)) + { + _ = close (ttyFd); + + return; + } + + // Since we reached this point, at least one of (_inputFd, _outputFd) is -1 (the + // early return above would have fired otherwise), guaranteeing that ttyFd is + // assigned below. + if (_inputFd == -1) + { + // stdin was redirected: claim the /dev/tty fd we just opened for input. + _inputFd = ttyFd; + _ownedInputFd = ttyFd; + _inputAttached = true; + } + + if (_outputFd == -1) + { + if (_ownedInputFd == ttyFd) + { + // We opened /dev/tty for input above; reuse the same fd for write — it was + // opened O_RDWR and we avoid burning a second fd on the same device. + _outputFd = ttyFd; + _outputAttached = true; + } + else + { + // stdin was already a real terminal, so ttyFd was not claimed for input. + // Claim it for output now (this is the `myapp | jq` case). + _outputFd = ttyFd; + _ownedOutputFd = ttyFd; + _outputAttached = true; + } + } + } + catch (DllNotFoundException) + { + // libc not available; nothing more we can do. + } + } + + private static void InitializeWindows () + { + nint stdIn = GetStdHandle (STD_INPUT_HANDLE); + nint stdOut = GetStdHandle (STD_OUTPUT_HANDLE); + + if (stdIn != nint.Zero && stdIn != new nint (-1) && GetConsoleMode (stdIn, out _)) + { + _inputHandle = stdIn; + _inputAttached = true; + } + + if (stdOut != nint.Zero && stdOut != new nint (-1) && GetConsoleMode (stdOut, out _)) + { + _outputHandle = stdOut; + _outputAttached = true; + } + + if (_inputHandle != nint.Zero && _outputHandle != nint.Zero) + { + return; + } + + // Fall back to opening the console directly. + if (_inputHandle == nint.Zero) + { + nint h = CreateFile ( + "CONIN$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nint.Zero, + OPEN_EXISTING, + 0, + nint.Zero); + + if (h != new nint (-1) && h != nint.Zero && GetConsoleMode (h, out _)) + { + _inputHandle = h; + _ownedInputHandle = h; + _inputAttached = true; + } + else if (h != new nint (-1) && h != nint.Zero) + { + _ = CloseHandle (h); + } + } + + if (_outputHandle == nint.Zero) + { + nint h = CreateFile ( + "CONOUT$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nint.Zero, + OPEN_EXISTING, + 0, + nint.Zero); + + if (h != new nint (-1) && h != nint.Zero && GetConsoleMode (h, out _)) + { + _outputHandle = h; + _ownedOutputHandle = h; + _outputAttached = true; + } + else if (h != new nint (-1) && h != nint.Zero) + { + _ = CloseHandle (h); + } + } + } + + private static void CloseOwnedHandles () + { + if (_ownedInputFd != -1) + { + try + { + _ = close (_ownedInputFd); + } + catch + { + // ignore + } + + _ownedInputFd = -1; + } + + // Guard against double-closing: when stdin and stdout both fall back to /dev/tty we + // share a single fd between them, so it is recorded as the owned fd for both ends. + // _ownedInputFd was already closed above (and reset to -1 there), so we only need to + // close _ownedOutputFd when it refers to a distinct descriptor. + if (_ownedOutputFd != -1 && _ownedOutputFd != _ownedInputFd) + { + try + { + _ = close (_ownedOutputFd); + } + catch + { + // ignore + } + + _ownedOutputFd = -1; + } + + if (_ownedInputHandle != nint.Zero) + { + try + { + _ = CloseHandle (_ownedInputHandle); + } + catch + { + // ignore + } + + _ownedInputHandle = nint.Zero; + } + + if (_ownedOutputHandle != nint.Zero) + { + try + { + _ = CloseHandle (_ownedOutputHandle); + } + catch + { + // ignore + } + + _ownedOutputHandle = nint.Zero; + } + } + + #region P/Invoke (Unix) + + private const int O_RDWR = 2; + private const int O_NOCTTY = 0x100; + + [DllImport ("libc", SetLastError = true)] + private static extern int open (string path, int oflag); + + [DllImport ("libc", SetLastError = true)] + private static extern int close (int fd); + + #endregion + + #region P/Invoke (Windows) + + private const int STD_INPUT_HANDLE = -10; + private const int STD_OUTPUT_HANDLE = -11; + private const uint GENERIC_READ = 0x80000000; + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint OPEN_EXISTING = 3; + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle (int nStdHandle); + + [DllImport ("kernel32.dll")] + private static extern bool GetConsoleMode (nint hConsoleHandle, out uint lpMode); + + [DllImport ("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint CreateFile ( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + nint lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + nint hTemplateFile); + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle (nint hObject); + + #endregion +} diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixClipboard.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixClipboard.cs index 40fc0f118e..61df0cf797 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixClipboard.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixClipboard.cs @@ -204,7 +204,7 @@ protected override void SetClipboardDataImpl (string text) return; } - (int exitCode, string output) = ClipboardProcessRunner.Process (_powershellPath, $"-noprofile -command \"Set-Clipboard -Value \\\"{text}\\\"\""); + (int exitCode, string output) = ClipboardProcessRunner.Process (_powershellPath, "-noprofile -command \"$input | Set-Clipboard\"", input: text); } private bool CheckSupport () diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index 410e68c537..6126fe6bc4 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -245,7 +245,7 @@ public static bool IsInputAvailable (Pollfd [] pollMap, int timeoutMs = 0) } /// - /// Reads bytes from stdin. + /// Reads bytes from stdin (or the controlling TTY when stdin is redirected). /// /// Buffer to read into /// Number of bytes actually read @@ -254,7 +254,16 @@ public static bool TryReadStdin (byte [] buffer, out int bytesRead) { try { - bytesRead = read (STDIN_FILENO, buffer, buffer.Length); + int fd = TerminalDevice.InputFd; + + if (fd < 0) + { + bytesRead = 0; + + return false; + } + + bytesRead = read (fd, buffer, buffer.Length); return bytesRead >= 0; } @@ -267,7 +276,7 @@ public static bool TryReadStdin (byte [] buffer, out int bytesRead) } /// - /// Writes bytes to stdout. + /// Writes bytes to stdout (or the controlling TTY when stdout is redirected). /// /// Buffer containing data to write /// True if write was successful, false otherwise @@ -275,7 +284,14 @@ public static bool TryWriteStdout (byte [] buffer) { try { - int written = write (STDOUT_FILENO, buffer, buffer.Length); + int fd = TerminalDevice.OutputFd; + + if (fd < 0) + { + return false; + } + + int written = write (fd, buffer, buffer.Length); return written >= 0; } @@ -305,14 +321,21 @@ public static bool TryWriteStdout (string text) } /// - /// Flushes the stdin input queue. + /// Flushes the stdin (or controlling TTY) input queue. /// /// True if flush was successful, false otherwise public static bool TryFlushStdin () { try { - return tcflush (STDIN_FILENO, TCIFLUSH) == 0; + int fd = TerminalDevice.InputFd; + + if (fd < 0) + { + return false; + } + + return tcflush (fd, TCIFLUSH) == 0; } catch { @@ -321,12 +344,19 @@ public static bool TryFlushStdin () } /// - /// Waits until all output written to stdout has been transmitted to the terminal. + /// Waits until all output written to stdout (or the controlling TTY) has been transmitted to the terminal. /// Prefers tcdrain; falls back to fsync. /// public static void FlushStdout () { - if (tcdrain (STDOUT_FILENO) == 0) + int fd = TerminalDevice.OutputFd; + + if (fd < 0) + { + return; + } + + if (tcdrain (fd) == 0) { return; } @@ -334,7 +364,7 @@ public static void FlushStdout () // fallback try { - fsync (STDOUT_FILENO); + fsync (fd); } catch { @@ -350,7 +380,7 @@ public static void FlushStdout () public static bool IsTerminal (int fd) => isatty (fd) == 1; /// - /// Gets the terminal size using ioctl. + /// Gets the terminal size using ioctl on the controlling output device. /// /// Output size (width, height) /// True if size was retrieved successfully, false otherwise @@ -358,13 +388,22 @@ public static bool TryGetTerminalSize (out Size size) { try { + int fd = TerminalDevice.OutputFd; + + if (fd < 0) + { + size = new Size (80, 25); + + return false; + } + var ioctlResult = 0; WinSize ws; if (RuntimeInformation.OSArchitecture == Architecture.Arm64 && (RuntimeInformation.IsOSPlatform (OSPlatform.OSX) || RuntimeInformation.IsOSPlatform (OSPlatform.FreeBSD))) { - ioctlResult = ioctl_arm64 (STDOUT_FILENO, + ioctlResult = ioctl_arm64 (fd, TIOCGWINSZ, 0, 0, @@ -376,7 +415,7 @@ public static bool TryGetTerminalSize (out Size size) } else { - ioctlResult = ioctl (STDOUT_FILENO, TIOCGWINSZ, out ws); + ioctlResult = ioctl (fd, TIOCGWINSZ, out ws); } if (ioctlResult == 0) @@ -400,13 +439,21 @@ public static bool TryGetTerminalSize (out Size size) } /// - /// Creates a poll map for monitoring stdin. + /// Creates a poll map for monitoring terminal input (stdin or the controlling TTY when stdin is redirected). /// + /// + /// When reports no terminal device (-1), this method + /// still returns a poll map populated with rather than returning + /// . The fd will be invalid and poll will report + /// POLLNVAL, so no input will be consumed; this preserves the non-null contract + /// callers (e.g. AnsiInput) rely on for the lifetime of the input loop. + /// /// Initialized Pollfd array public static Pollfd [] CreateStdinPollMap () { Pollfd [] pollMap = new Pollfd [1]; - pollMap [0].fd = STDIN_FILENO; + int fd = TerminalDevice.InputFd; + pollMap [0].fd = fd >= 0 ? fd : STDIN_FILENO; pollMap [0].events = (short)Condition.PollIn; return pollMap; diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs index 33569cc9c5..e24994c301 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs @@ -20,11 +20,26 @@ namespace Terminal.Gui.Drivers; /// /// This allows the application to receive raw keyboard input and process all keys, /// including control sequences and special keys as ANSI escape sequences. +/// +/// The primary restore path is an explicit call to (or +/// ). To guarantee the terminal is restored when the process +/// exits without disposing the helper, also registers an +/// hook that calls . See +/// issue #5164. A handler is registered as a +/// best-effort safety net, but disabling ISIG means a keyboard Ctrl+C is delivered +/// as a 0x03 byte rather than SIGINT, so that handler is unlikely to fire while +/// raw mode is active; it still helps for externally-delivered SIGINT/CTRL_C events +/// and for hosts that do not disable ISIG. +/// /// internal sealed class UnixRawModeHelper : IDisposable { private Termios _originalTermios; + private bool _haveSavedTermios; private bool _disposed; + private int _termiosFd = -1; + private EventHandler? _processExitHandler; + private ConsoleCancelEventHandler? _cancelKeyHandler; /// /// Gets whether raw mode was successfully enabled. @@ -50,8 +65,22 @@ public bool TryEnable () try { + // Use the controlling terminal input fd: when stdin is redirected (e.g. `myapp | jq`) + // this is /dev/tty rather than STDIN_FILENO, so termios settings still apply to the + // real terminal device. + int fd = TerminalDevice.InputFd; + + if (fd < 0) + { + Logging.Warning ("No terminal input device available. Cannot enable raw mode."); + + return false; + } + + _termiosFd = fd; + // Get current terminal attributes - int result = tcgetattr (STDIN_FILENO, out _originalTermios); + int result = tcgetattr (_termiosFd, out _originalTermios); if (result != 0) { @@ -61,6 +90,11 @@ public bool TryEnable () return false; } + // Mark that _originalTermios contains a valid snapshot. Without this guard, + // a later Restore() (after a TryEnable failure path or before any successful + // call) could write uninitialized struct contents back to the terminal. + _haveSavedTermios = true; + // Create modified attributes for raw mode Termios raw = _originalTermios; @@ -80,7 +114,7 @@ public bool TryEnable () } // Apply raw mode settings - result = tcsetattr (STDIN_FILENO, TCSANOW, ref raw); + result = tcsetattr (_termiosFd, TCSANOW, ref raw); if (result != 0) { @@ -91,6 +125,11 @@ public bool TryEnable () } IsRawModeEnabled = true; + + // Wire safety nets so the terminal is restored even if the input thread + // crashes or the process exits without going through Dispose(). + HookProcessExit (); + Logging.Information ("Unix raw mode enabled successfully."); return true; @@ -111,16 +150,34 @@ public bool TryEnable () /// /// Restores the terminal to its original state. /// + /// + /// A no-op if raw mode was never successfully enabled, if the original + /// termios snapshot was never captured, or if the helper has already + /// been disposed. This guard prevents writing an uninitialized + /// termios struct back to the terminal after a failed + /// . + /// public void Restore () { - if (!IsRawModeEnabled || _disposed) + if (_disposed || !_haveSavedTermios || !IsRawModeEnabled) { return; } try { - tcsetattr (STDIN_FILENO, TCSANOW, ref _originalTermios); + int result = tcsetattr (_termiosFd, TCSANOW, ref _originalTermios); + + if (result != 0) + { + int errno = Marshal.GetLastWin32Error (); + Logging.Warning ($"tcsetattr failed during restore (errno={errno}). Terminal may still be in raw mode."); + + // Leave IsRawModeEnabled set to true so callers know the terminal was not + // successfully restored. + return; + } + IsRawModeEnabled = false; Logging.Information ("Unix terminal settings restored."); } @@ -138,10 +195,71 @@ public void Dispose () return; } + UnhookProcessExit (); Restore (); _disposed = true; } + private void HookProcessExit () + { + if (_processExitHandler is not null) + { + return; + } + + _processExitHandler = (_, _) => Restore (); + AppDomain.CurrentDomain.ProcessExit += _processExitHandler; + + try + { + // Belt-and-braces: most Unix raw-mode entry disables ISIG, so Ctrl+C produces a + // literal 0x03 byte on stdin rather than a SIGINT, meaning this handler is unlikely + // to fire from a keyboard Ctrl+C while raw mode is active. It still helps for + // externally-delivered SIGINT/CTRL_C events and for hosts that do not disable ISIG. + // The primary safety net is the AppDomain.ProcessExit hook. + _cancelKeyHandler = (_, _) => Restore (); + Console.CancelKeyPress += _cancelKeyHandler; + } + catch (Exception ex) + { + // Console.CancelKeyPress may throw on some hosts (e.g. when stdin is + // redirected in unusual ways). The ProcessExit hook is still in place. + Logging.Warning ($"Could not hook Console.CancelKeyPress: {ex.Message}"); + _cancelKeyHandler = null; + } + } + + private void UnhookProcessExit () + { + if (_processExitHandler is not null) + { + try + { + AppDomain.CurrentDomain.ProcessExit -= _processExitHandler; + } + catch + { + // Ignore: nothing useful we can do here. + } + + _processExitHandler = null; + } + + if (_cancelKeyHandler is not null) + { + try + { + Console.CancelKeyPress -= _cancelKeyHandler; + } + catch + { + // Ignore: nothing useful we can do here. + } + + _cancelKeyHandler = null; + } + } + #region P/Invoke Declarations [StructLayout (LayoutKind.Sequential)] diff --git a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs index 7fcc78be49..a122dfeac3 100644 --- a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs @@ -31,9 +31,6 @@ internal sealed class WindowsVTInputHelper : IDisposable // to use GetNumberOfConsoleInputEvents to poll for availability. With such APIs, this helper class would be unnecessary. // If this were the case, the only API the ANSI driver would require on Windows is GetStdHandle and ReadFile. - [DllImport ("kernel32.dll", SetLastError = true)] - private static extern nint GetStdHandle (int nStdHandle); - [DllImport ("kernel32.dll")] private static extern bool GetConsoleMode (nint hConsoleHandle, out uint lpMode); @@ -54,7 +51,6 @@ internal sealed class WindowsVTInputHelper : IDisposable #endregion // Console mode flags - private const int STD_INPUT_HANDLE = -10; private const uint ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; private const uint ENABLE_PROCESSED_INPUT = 0x0001; private const uint ENABLE_LINE_INPUT = 0x0002; @@ -110,7 +106,10 @@ public bool TryEnable () try { - InputHandle = GetStdHandle (STD_INPUT_HANDLE); + // Use the controlling terminal input handle: when stdin is redirected (e.g. piping + // input into the app) this is the handle obtained from CONIN$ rather than the + // standard input handle, so VT input still reaches the real console. + InputHandle = TerminalDevice.InputHandle; if (InputHandle == nint.Zero || InputHandle == new nint (-1)) { diff --git a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs index d9eddfa2f4..de89005b42 100644 --- a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs @@ -20,10 +20,6 @@ internal sealed class WindowsVTOutputHelper : IDisposable private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4; private const uint ENABLE_PROCESSED_OUTPUT = 1; - private const int STD_OUTPUT_HANDLE = -11; - - [DllImport ("kernel32.dll", SetLastError = true)] - private static extern nint GetStdHandle (int nStdHandle); [DllImport ("kernel32.dll")] private static extern bool SetConsoleMode (nint hConsoleHandle, uint dwMode); @@ -81,7 +77,10 @@ public bool TryEnable () try { - OutputHandle = GetStdHandle (STD_OUTPUT_HANDLE); + // Use the controlling terminal output handle: when stdout is redirected (e.g. + // `myapp | jq`) this is the handle obtained from CONOUT$ rather than the standard + // output handle, so VT sequences still reach the real console. + OutputHandle = TerminalDevice.OutputHandle; if (OutputHandle == nint.Zero || OutputHandle == new nint (-1)) { @@ -177,11 +176,12 @@ public void Write (StringBuilder output) } /// - /// Flushes the stdout handle via FlushFileBuffers. + /// Flushes the controlling console output handle via FlushFileBuffers. + /// No-op when no terminal output device is available. /// public static void FlushStdout () { - nint h = GetStdHandle (STD_OUTPUT_HANDLE); + nint h = TerminalDevice.OutputHandle; if (h != nint.Zero && h != new nint (-1)) { diff --git a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs index e6237a1569..80e9a740d0 100644 --- a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs +++ b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs @@ -72,5 +72,5 @@ private IEnumerable TryGetChildren (IFileSystemInfo entry) } } - private static bool IsReparsePoint (IFileSystemInfo entry) => (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + internal static bool IsReparsePoint (IFileSystemInfo entry) => (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; } diff --git a/Terminal.Gui/Input/Keyboard/Key.cs b/Terminal.Gui/Input/Keyboard/Key.cs index 1a5dee5d27..58321a99c6 100644 --- a/Terminal.Gui/Input/Keyboard/Key.cs +++ b/Terminal.Gui/Input/Keyboard/Key.cs @@ -692,11 +692,16 @@ public bool TryGetPrintableRune (out Rune rune) public static implicit operator string (Key key) => key.ToString (); /// + /// + /// Two instances are considered equal when their values match. + /// is per-event state and is intentionally excluded from identity so that + /// remains consistent with . + /// public override bool Equals (object? obj) { if (obj is Key other) { - return other._keyCode == _keyCode && other.Handled == Handled; + return other._keyCode == _keyCode; } return false; diff --git a/Terminal.Gui/Input/Keyboard/KeyBindings.cs b/Terminal.Gui/Input/Keyboard/KeyBindings.cs index f1fff58a60..8bdbc3d933 100644 --- a/Terminal.Gui/Input/Keyboard/KeyBindings.cs +++ b/Terminal.Gui/Input/Keyboard/KeyBindings.cs @@ -9,7 +9,8 @@ namespace Terminal.Gui.Input; public class KeyBindings : CommandBindingsBase { /// Initializes a new instance. - public KeyBindings () : base ((commands, key, source) => new KeyBinding (commands, source), new KeyEqualityComparer ()) { } + public KeyBindings () + : base ((commands, key, source) => new KeyBinding (commands, key, source, null, null), new KeyEqualityComparer ()) { } /// public override bool IsValid (Key eventArgs) => eventArgs.IsValid; diff --git a/Terminal.Gui/ModuleInitializers.cs b/Terminal.Gui/ModuleInitializers.cs index 371b209289..fbce733cf0 100644 --- a/Terminal.Gui/ModuleInitializers.cs +++ b/Terminal.Gui/ModuleInitializers.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using System.Diagnostics.CodeAnalysis; namespace Terminal.Gui; @@ -13,10 +12,8 @@ internal static class ModuleInitializers /// Ensures configuration properties are loaded deterministically before any part /// of the library is used. /// - [RequiresUnreferencedCode ("AOT")] #pragma warning disable CA2255 [ModuleInitializer] - [RequiresDynamicCode ("Calls Terminal.Gui.ConfigurationManager.Initialize()")] #pragma warning restore CA2255 internal static void InitializeConfigurationManager () { diff --git a/Terminal.Gui/Resources/Strings.Designer.cs b/Terminal.Gui/Resources/Strings.Designer.cs index d47ad67f22..3420c367a3 100644 --- a/Terminal.Gui/Resources/Strings.Designer.cs +++ b/Terminal.Gui/Resources/Strings.Designer.cs @@ -636,6 +636,15 @@ public static string fdNewFailed { } } + /// + /// Looks up a localized string similar to Name must not contain path separators or navigate outside the current directory.. + /// + public static string fdPathTraversalError { + get { + return ResourceManager.GetString("fdPathTraversalError", resourceCulture); + } + } + /// /// Looks up a localized string similar to New Folder. /// diff --git a/Terminal.Gui/Resources/Strings.resx b/Terminal.Gui/Resources/Strings.resx index e232d92960..7a77e89e8b 100644 --- a/Terminal.Gui/Resources/Strings.resx +++ b/Terminal.Gui/Resources/Strings.resx @@ -199,6 +199,9 @@ New Failed + + Name must not contain path separators or navigate outside the current directory. + New Folder diff --git a/Terminal.Gui/Text/RuneExtensions.cs b/Terminal.Gui/Text/RuneExtensions.cs index c8a273c834..075a525248 100644 --- a/Terminal.Gui/Text/RuneExtensions.cs +++ b/Terminal.Gui/Text/RuneExtensions.cs @@ -7,6 +7,8 @@ namespace Terminal.Gui.Text; /// Extends to support TUI text manipulation. public static class RuneExtensions { + private static readonly Lock _wcwidthLock = new Lock (); + /// Maximum Unicode code point. public static readonly int MaxUnicodeCodePoint = 0x10FFFF; @@ -125,7 +127,13 @@ public static bool EncodeSurrogatePair (char highSurrogate, char lowSurrogate, o /// The number of columns required to fit the rune, 0 if the argument is the null character, or -1 if the value is /// not printable, otherwise the number of columns that the rune occupies. /// - public static int GetColumns (this Rune rune) { return UnicodeCalculator.GetWidth (rune); } + public static int GetColumns (this Rune rune) + { + lock (_wcwidthLock) + { + return UnicodeCalculator.GetWidth (rune); + } + } /// Get number of bytes required to encode the rune, based on the provided encoding. /// This is a Terminal.Gui extension method to to support TUI text manipulation. @@ -190,11 +198,11 @@ public static bool IsSurrogatePair (this Rune rune) } /// - /// Ensures the rune is not a control character and can be displayed by translating characters below 0x20 to - /// equivalent, printable, Unicode chars. + /// Ensures the rune is not a control character and can be displayed by translating C0 controls (U+0000–U+001F), + /// DEL (U+007F), and C1 controls (U+0080–U+009F) to printable Unicode equivalents via the +U+2400 offset. /// /// This is a Terminal.Gui extension method to to support TUI text manipulation. - /// - /// + /// The rune to make printable. + /// A printable rune safe for terminal display. public static Rune MakePrintable (this Rune rune) { return Rune.IsControl (rune) ? new (rune.Value + 0x2400) : rune; } } diff --git a/Terminal.Gui/Text/StringExtensions.cs b/Terminal.Gui/Text/StringExtensions.cs index 59f433b60d..ec923de921 100644 --- a/Terminal.Gui/Text/StringExtensions.cs +++ b/Terminal.Gui/Text/StringExtensions.cs @@ -91,7 +91,7 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) /// This is a Terminal.Gui extension method to to support TUI text manipulation. /// The string to count. /// - public static int GetRuneCount (this string str) { return str.EnumerateRunes ().Count (); } + public static int GetRuneCount (this string str) => str.EnumerateRunes ().Count (); /// /// Determines if this of is composed entirely of ASCII @@ -102,7 +102,7 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) /// A indicating if all elements of the are ASCII digits ( /// ) or not ( /// - public static bool IsAllAsciiDigits (this ReadOnlySpan stringSpan) { return !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiDigit); } + public static bool IsAllAsciiDigits (this ReadOnlySpan stringSpan) => !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiDigit); /// /// Determines if this of is composed entirely of ASCII @@ -113,7 +113,65 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) /// A indicating if all elements of the are ASCII digits ( /// ) or not ( /// - public static bool IsAllAsciiHexDigits (this ReadOnlySpan stringSpan) { return !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiHexDigit); } + public static bool IsAllAsciiHexDigits (this ReadOnlySpan stringSpan) => !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiHexDigit); + + /// Reports whether a string is a surrogate code point. + /// This is a Terminal.Gui extension method to to support TUI text manipulation. + /// The string to probe. + /// if the string is a surrogate code point; otherwise. + public static bool IsSurrogatePair (this string str) + { + if (str.Length != 2) + { + return false; + } + + var rune = Rune.GetRuneAt (str, 0); + + return rune.IsSurrogatePair (); + } + + /// + /// Ensures the text does not contain control characters that could be emitted verbatim to the terminal, + /// by translating C0 controls (U+0000–U+001F), DEL (U+007F), and C1 controls (U+0080–U+009F) to + /// printable Unicode equivalents via the +U+2400 offset. Multi-character graphemes whose first + /// character is a control are replaced with a space. + /// + /// + /// This is a Terminal.Gui extension method to to support TUI text manipulation. + /// + /// Per UAX #29, control characters are always grapheme cluster boundaries. A well-formed grapheme + /// cluster produced by + /// cannot contain embedded controls, so only the first character needs to be checked. + /// + /// + /// The text. + /// A string safe for terminal display. + public static string MakePrintable (this string str) + { + if (string.IsNullOrEmpty (str)) + { + return str; + } + + char first = str [0]; + + // Fast path: single-char grapheme (covers the vast majority of calls) + if (str.Length == 1) + { + return char.IsControl (first) ? new string ((char)(first + 0x2400), 1) : str; + } + + // Multi-char grapheme: per UAX #29, control characters are grapheme cluster boundaries, + // so a well-formed cluster from GetTextElementEnumerator cannot contain embedded controls. + // We only need to check the first character defensively for malformed input. + if (char.IsControl (first)) + { + return " "; + } + + return str; + } /// Repeats the string times. /// This is a Terminal.Gui extension method to to support TUI text manipulation. @@ -132,30 +190,28 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) return str; } - return new StringBuilder (str.Length * n) - .Insert (0, str, n) - .ToString (); + return new StringBuilder (str.Length * n).Insert (0, str, n).ToString (); } /// Converts the string into a . /// This is a Terminal.Gui extension method to to support TUI text manipulation. /// The string to convert. /// - public static List ToRuneList (this string str) { return str.EnumerateRunes ().ToList (); } + public static List ToRuneList (this string str) => str.EnumerateRunes ().ToList (); /// Converts the string into a array. /// This is a Terminal.Gui extension method to to support TUI text manipulation. /// The string to convert. /// - public static Rune [] ToRunes (this string str) { return str.EnumerateRunes ().ToArray (); } + public static Rune [] ToRunes (this string str) => str.EnumerateRunes ().ToArray (); /// Converts a generic collection into a string. /// The enumerable rune to convert. /// public static string ToString (IEnumerable runes) { - const int maxCharsPerRune = 2; - const int maxStackallocTextBufferSize = 1048; // ~2 kB + const int MAX_CHARS_PER_RUNE = 2; + const int MAX_STACKALLOC_TEXT_BUFFER_SIZE = 1048; // ~2 kB // If rune count is easily available use stackalloc buffer or alternatively rented array. if (runes.TryGetNonEnumeratedCount (out int count)) @@ -165,22 +221,26 @@ public static string ToString (IEnumerable runes) return string.Empty; } - char[]? rentedBufferArray = null; + char []? rentedBufferArray = null; + try { - int maxRequiredTextBufferSize = count * maxCharsPerRune; - Span textBuffer = maxRequiredTextBufferSize <= maxStackallocTextBufferSize - ? stackalloc char[maxRequiredTextBufferSize] - : (rentedBufferArray = ArrayPool.Shared.Rent(maxRequiredTextBufferSize)); + int maxRequiredTextBufferSize = count * MAX_CHARS_PER_RUNE; + + Span textBuffer = maxRequiredTextBufferSize <= MAX_STACKALLOC_TEXT_BUFFER_SIZE + ? stackalloc char [maxRequiredTextBufferSize] + : rentedBufferArray = ArrayPool.Shared.Rent (maxRequiredTextBufferSize); Span remainingBuffer = textBuffer; + foreach (Rune rune in runes) { int charsWritten = rune.EncodeToUtf16 (remainingBuffer); remainingBuffer = remainingBuffer [charsWritten..]; } - ReadOnlySpan text = textBuffer[..^remainingBuffer.Length]; + ReadOnlySpan text = textBuffer [..^remainingBuffer.Length]; + return text.ToString (); } finally @@ -193,14 +253,16 @@ public static string ToString (IEnumerable runes) } // Fallback to StringBuilder append. - StringBuilder stringBuilder = new(); - Span runeBuffer = stackalloc char[maxCharsPerRune]; + StringBuilder stringBuilder = new (); + Span runeBuffer = stackalloc char [MAX_CHARS_PER_RUNE]; + foreach (Rune rune in runes) { int charsWritten = rune.EncodeToUtf16 (runeBuffer); ReadOnlySpan runeChars = runeBuffer [..charsWritten]; stringBuilder.Append (runeChars); } + return stringBuilder.ToString (); } @@ -218,7 +280,7 @@ public static string ToString (IEnumerable bytes, Encoding? encoding = nul /// Converts a generic collection into a string. /// The enumerable string to convert. /// - public static string ToString (IEnumerable strings) { return string.Concat (strings); } + public static string ToString (IEnumerable strings) => string.Concat (strings); /// Converts the string into a . /// This is a Terminal.Gui extension method to to support TUI text manipulation. @@ -235,39 +297,4 @@ public static List ToStringList (this string str) return strings; } - - /// Reports whether a string is a surrogate code point. - /// This is a Terminal.Gui extension method to to support TUI text manipulation. - /// The string to probe. - /// if the string is a surrogate code point; otherwise. - public static bool IsSurrogatePair (this string str) - { - if (str.Length != 2) - { - return false; - } - - Rune rune = Rune.GetRuneAt (str, 0); - - return rune.IsSurrogatePair (); - } - - /// - /// Ensures the text is not a control character and can be displayed by translating characters below 0x20 to - /// equivalent, printable, Unicode chars. - /// - /// This is a Terminal.Gui extension method to to support TUI text manipulation. - /// The text. - /// - public static string MakePrintable (this string str) - { - if (str.Length > 1) - { - return str; - } - - char ch = str [0]; - - return char.IsControl (ch) ? new ((char)(ch + 0x2400), 1) : str; - } } diff --git a/Terminal.Gui/ViewBase/Adornment/Margin.cs b/Terminal.Gui/ViewBase/Adornment/Margin.cs index 59c2281831..3e49b781bb 100644 --- a/Terminal.Gui/ViewBase/Adornment/Margin.cs +++ b/Terminal.Gui/ViewBase/Adornment/Margin.cs @@ -56,7 +56,7 @@ protected override void OnThicknessChanged () /// public ShadowStyles? ShadowStyle { - get => field ?? Parent?.SuperView?.ShadowStyle ?? null; + get => field; set { if (field == value) @@ -68,7 +68,11 @@ public ShadowStyles? ShadowStyle if (field is null) { // null means no shadow and no thickness - (View as MarginView)?.SetShadow (null); + if (View is MarginView mv) + { + mv.SetShadow (null); + mv.ShadowSize = Size.Empty; + } return; } diff --git a/Terminal.Gui/ViewBase/IValue.cs b/Terminal.Gui/ViewBase/IValue.cs index e951ba091e..8859b55712 100644 --- a/Terminal.Gui/ViewBase/IValue.cs +++ b/Terminal.Gui/ViewBase/IValue.cs @@ -27,6 +27,30 @@ public interface IValue /// Raised when has changed, providing the value as an un-typed object. /// event EventHandler>? ValueChangedUntyped; + + /// + /// Attempts to set by parsing the supplied string. + /// + /// The string representation of the value to set. + /// + /// if was successfully parsed and assigned; + /// if the value type cannot be parsed from a string or parsing failed. + /// + /// + /// + /// The default implementation supports: + /// + /// + /// values (assigned directly). + /// Any type implementing (e.g. , , , , , , ). + /// wrappers around any of the above. + /// types (case-insensitive). + /// + /// + /// Views may override this method to provide custom parsing logic. + /// + /// + bool TrySetValueFromString (string input); } /// @@ -71,4 +95,22 @@ public interface IValue : IValue /// object? IValue.GetValue () => Value; + + /// + /// + /// The default implementation handles , types implementing + /// , types, and + /// wrappers around any of those. Views with bespoke parsing should override this method. + /// + bool IValue.TrySetValueFromString (string input) + { + if (!IValueParser.TryParseValue (input, out TValue? parsed)) + { + return false; + } + + Value = parsed; + + return true; + } } diff --git a/Terminal.Gui/ViewBase/IValueParser.cs b/Terminal.Gui/ViewBase/IValueParser.cs new file mode 100644 index 0000000000..e6c4a53283 --- /dev/null +++ b/Terminal.Gui/ViewBase/IValueParser.cs @@ -0,0 +1,98 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Terminal.Gui.ViewBase; + +/// +/// Helpers for parsing string input into the value type exposed by . +/// +/// +/// +/// Used by the default implementation of and by +/// derived classes that implement multiple IValue<T> interfaces and need to +/// disambiguate the diamond-inherited default implementation. +/// +/// +public static class IValueParser +{ + /// + /// Attempts to parse into a value of type . + /// + /// The target value type. May be a reference type, value type, or . + /// The string representation to parse. + /// When this method returns , contains the parsed value; otherwise . + /// + /// if was parsed successfully; otherwise . + /// + /// + /// Supported types: + /// + /// (assigned directly). + /// Any type implementing via reflection on the static TryParse(string, IFormatProvider?, out T) method. + /// wrappers around any of the above. + /// types (case-insensitive). + /// + /// + [UnconditionalSuppressMessage ( + "Trimming", + "IL2090:'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicMethods' in call to target method.", + Justification = "Reflective lookup of static TryParse(string, IFormatProvider?, out T) is intentional. Callers using AOT/trimming with custom IParsable types should preserve the TryParse method via DynamicDependency or equivalent.")] + public static bool TryParseValue (string input, out TValue? parsed) + { + parsed = default; + + if (input is null) + { + return false; + } + + Type valueType = typeof (TValue); + Type underlyingType = Nullable.GetUnderlyingType (valueType) ?? valueType; + + // string passthrough + if (underlyingType == typeof (string)) + { + parsed = (TValue?)(object?)input; + + return true; + } + + // Enum support (case-insensitive) + if (underlyingType.IsEnum) + { + if (Enum.TryParse (underlyingType, input, ignoreCase: true, out object? parsedEnum)) + { + parsed = (TValue?)parsedEnum; + + return true; + } + + return false; + } + + // IParsable.TryParse(string, IFormatProvider?, out T) via reflection + MethodInfo? tryParse = underlyingType.GetMethod ( + "TryParse", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: [typeof (string), typeof (IFormatProvider), underlyingType.MakeByRefType ()], + modifiers: null); + + if (tryParse is null) + { + return false; + } + + object?[] args = [input, null, null]; + var success = (bool)tryParse.Invoke (null, args)!; + + if (!success) + { + return false; + } + + parsed = (TValue?)args [2]; + + return true; + } +} diff --git a/Terminal.Gui/Views/CharMap/CharMap.cs b/Terminal.Gui/Views/CharMap/CharMap.cs index a8680aafae..1ad77c5bcd 100644 --- a/Terminal.Gui/Views/CharMap/CharMap.cs +++ b/Terminal.Gui/Views/CharMap/CharMap.cs @@ -65,8 +65,6 @@ public class CharMap : View, IDesignable, IValue /// /// Initializes a new instance. /// - [RequiresUnreferencedCode ("AOT")] - [RequiresDynamicCode ("AOT")] public CharMap () { CanFocus = true; diff --git a/Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs b/Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs index ee3d1fee9f..74a70e4386 100644 --- a/Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs +++ b/Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs @@ -11,7 +11,7 @@ internal class TableCollectionNavigator : CollectionNavigatorBase /// protected override object ElementAt (int idx) { - int col = _tableView.FullRowSelect ? 0 : _tableView.Value?.Cursor.X ?? 0; + int col = _tableView.FullRowSelect ? 0 : _tableView.Value?.SelectedCell.X ?? 0; object? rawValue = _tableView.Table? [idx, col]; if (rawValue is null or DBNull) diff --git a/Terminal.Gui/Views/Color/ColorPicker.cs b/Terminal.Gui/Views/Color/ColorPicker.cs index a367b33b8b..9e97e6dd57 100644 --- a/Terminal.Gui/Views/Color/ColorPicker.cs +++ b/Terminal.Gui/Views/Color/ColorPicker.cs @@ -1,3 +1,5 @@ +using System.Collections.ObjectModel; + namespace Terminal.Gui.Views; /// @@ -40,10 +42,11 @@ public ColorPicker () private TextField? _tfHex; private Label? _lbHex; - private TextField? _tfName; + private DropDownList? _ddlName; private Label? _lbName; private Color _selectedColor = Color.Black; + private bool _syncingSubViews; // TODO: Add interface private readonly IColorNameResolver _colorNameResolver = new StandardColorsNameResolver (); @@ -172,23 +175,19 @@ private void CreateNameField () { _lbName = new Label { Text = "Name:", X = 0, Y = 3 }; - _tfName = new TextField - { - Y = 3, X = 6, Width = 20 // width of "LightGoldenRodYeexllow" - the longest w3c color name - }; - - Add (_lbName); - Add (_tfName); + ObservableCollection colorNames = new (_colorNameResolver.GetColorNames ()); - AppendAutocomplete auto = new (_tfName) + _ddlName = new DropDownList { - SuggestionGenerator = new SingleWordSuggestionGenerator { AllSuggestions = _colorNameResolver.GetColorNames ().ToList () } + Y = 3, X = 6, Width = 20, // width of "LightGoldenRodYellow" - the longest w3c color name + ReadOnly = true, + Source = new ListWrapper (colorNames) }; - _tfName.Autocomplete = auto; + Add (_lbName); + Add (_ddlName); - _tfName.HasFocusChanged += UpdateValueFromName; - _tfName.Accepting += (_, _) => UpdateValueFromName (); + _ddlName.ValueChanged += (_, _) => UpdateValueFromName (); } private void CreateTextField () @@ -251,11 +250,11 @@ private void DisposeOldViews () _lbName = null; } - if (_tfName != null) + if (_ddlName != null) { - Remove (_tfName); - _tfName.Dispose (); - _tfName = null; + Remove (_ddlName); + _ddlName.Dispose (); + _ddlName = null; } } @@ -302,23 +301,35 @@ private void SetSelectedColor (Color value, bool syncBars) private void SyncSubViewValues (bool syncBars) { - if (syncBars) - { - _strategy.SetBarsToColor (_bars, _selectedColor, Style.ColorModel); - } + _syncingSubViews = true; - foreach (KeyValuePair kvp in _textFields) + try { - kvp.Value.Text = kvp.Key.Value.ToString (); - } + if (syncBars) + { + _strategy.SetBarsToColor (_bars, _selectedColor, Style.ColorModel); + } - var colorHex = _selectedColor.ToString ($"#{SelectedColor.R:X2}{SelectedColor.G:X2}{SelectedColor.B:X2}"); + foreach (KeyValuePair kvp in _textFields) + { + kvp.Value.Text = kvp.Key.Value.ToString (); + } - _tfName?.Text = _colorNameResolver.TryNameColor (_selectedColor, out string? name) ? name : string.Empty; + string colorHex = _selectedColor.ToString ($"#{SelectedColor.R:X2}{SelectedColor.G:X2}{SelectedColor.B:X2}"); - _tfHex?.Text = colorHex; + if (_ddlName is { }) + { + _ddlName.Text = _colorNameResolver.TryNameColor (_selectedColor, out string? name) ? name : string.Empty; + } - SetNeedsLayout (); + _tfHex?.Text = colorHex; + + SetNeedsLayout (); + } + finally + { + _syncingSubViews = false; + } } private void UpdateSingleBarValueFromTextField (object? sender, HasFocusEventArgs e) @@ -349,26 +360,14 @@ private void UpdateSingleBarValueFromTextField (object? sender) } } - private void UpdateValueFromName (object? sender, HasFocusEventArgs e) - { - // if the new value of Focused is true then it is an enter event so ignore - if (e.NewValue) - { - return; - } - - // it is a leave event so update - UpdateValueFromName (); - } - private void UpdateValueFromName () { - if (_tfName == null) + if (_ddlName == null || _syncingSubViews) { return; } - if (_colorNameResolver.TryParseColor (_tfName.Text, out Color newColor)) + if (_colorNameResolver.TryParseColor (_ddlName.Text, out Color newColor)) { SelectedColor = newColor; } diff --git a/Terminal.Gui/Views/DatePicker.cs b/Terminal.Gui/Views/DatePicker.cs index b81664edc1..ec9740848b 100644 --- a/Terminal.Gui/Views/DatePicker.cs +++ b/Terminal.Gui/Views/DatePicker.cs @@ -306,7 +306,7 @@ private void SetInitialProperties (DateTime date) _calendar.Activated += (_, _) => { - object dayValue = _table!.Rows [_calendar.Value!.Cursor.Y] [_calendar.Value.Cursor.X]; + object dayValue = _table!.Rows [_calendar.Value!.SelectedCell.Y] [_calendar.Value.SelectedCell.X]; bool isDay = int.TryParse (dayValue.ToString (), out int day); diff --git a/Terminal.Gui/Views/DropDownList.cs b/Terminal.Gui/Views/DropDownList.cs index a9f198ff88..954d9f0b2b 100644 --- a/Terminal.Gui/Views/DropDownList.cs +++ b/Terminal.Gui/Views/DropDownList.cs @@ -73,6 +73,15 @@ namespace Terminal.Gui.Views; /// /// Alt+Down Toggles the dropdown list open or closed. /// +/// +/// Space Toggles the dropdown list open or closed. +/// +/// +/// Up Selects the previous item in the list (when closed). +/// +/// +/// Down Selects the next item in the list (when closed). +/// /// /// Default mouse bindings: /// @@ -96,7 +105,9 @@ public class DropDownList : TextField /// public new static Dictionary? DefaultKeyBindings { get; set; } = new () { - [Command.Toggle] = Bind.All (Key.F4, Key.CursorDown.WithAlt) + [Command.Toggle] = Bind.All (Key.F4, Key.CursorDown.WithAlt, Key.Space), + [Command.Up] = Bind.All (Key.CursorUp), + [Command.Down] = Bind.All (Key.CursorDown) }; private readonly Button? _toggleButton; @@ -155,7 +166,7 @@ public DropDownList () // This ensures the Normal attribute is always that of the host _listPopover.GettingAttributeForRole += (sender, args) => { - if (sender is not View view || args.Role != VisualRole.Normal) + if (sender is not View || args.Role != VisualRole.Normal) { return; } @@ -185,6 +196,10 @@ public DropDownList () // Add command handler for toggle AddCommand (Command.Toggle, ToggleDropDown); + // Add command handlers for navigating items when dropdown is closed + AddCommand (Command.Up, MoveSelectionUp); + AddCommand (Command.Down, MoveSelectionDown); + // Apply layered key bindings (base View layer + DropDownList-specific layer) ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); @@ -340,7 +355,19 @@ protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attri /// /// This property delegates to the property of the internal . /// - public IListDataSource? Source { get => _listPopover?.ContentView?.Source; set => _listPopover?.ContentView?.Source = value; } + public IListDataSource? Source + { + get => _listPopover?.ContentView?.Source; + set + { + if (_listPopover?.ContentView is { } contentView) + { + contentView.Source = value; + } + + KeystrokeNavigator.Collection = value?.ToList (); + } + } /// /// Provides the anchor rectangle for positioning the popover below the DropDownList. @@ -409,6 +436,148 @@ private void OpenDropDown () _listPopover.MakeVisible (); } + /// + /// Gets the that searches the collection as the + /// user types when the dropdown is closed. + /// + public IListCollectionNavigator KeystrokeNavigator { get; } = new CollectionNavigator (); + + /// + protected override bool OnKeyDown (Key key) + { + // Only handle collection navigation when dropdown is closed and in ReadOnly mode + if (_listPopover is { Visible: true } || !ReadOnly) + { + return base.OnKeyDown (key); + } + + // If the key is bound to a command, let normal processing happen + if (KeyBindings.TryGet (key, out _)) + { + return false; + } + + // Enable user to find & select an item by typing text + if (Source is null) + { + return false; + } + + if (!KeystrokeNavigator.Matcher.IsCompatibleKey (key)) + { + return false; + } + + int currentIndex = GetCurrentSelectedIndex () ?? -1; + int? newItem = KeystrokeNavigator.GetNextMatchingItem (currentIndex >= 0 ? currentIndex : null, (char)key); + + if (newItem is null or -1) + { + return false; + } + + SelectItemAtIndex (newItem.Value); + + return true; + } + + /// + /// Moves the selection to the previous item in the list. Does nothing if already at the first item. + /// + private bool? MoveSelectionUp () + { + // If the dropdown is open, let the popover handle it + if (_listPopover is { Visible: true }) + { + return null; + } + + int? currentIndex = GetCurrentSelectedIndex (); + + if (currentIndex is null or <= 0) + { + return true; // At start or no source — do nothing but consume the key + } + + SelectItemAtIndex (currentIndex.Value - 1); + + return true; + } + + /// + /// Moves the selection to the next item in the list. Does nothing if already at the last item. + /// + private bool? MoveSelectionDown () + { + // If the dropdown is open, let the popover handle it + if (_listPopover is { Visible: true }) + { + return null; + } + + int? currentIndex = GetCurrentSelectedIndex (); + int count = Source?.Count ?? 0; + + if (count == 0) + { + return true; + } + + int nextIndex = (currentIndex ?? -1) + 1; + + if (nextIndex >= count) + { + return true; // At end — do nothing but consume the key + } + + SelectItemAtIndex (nextIndex); + + return true; + } + + /// + /// Gets the index of the currently selected item based on the current . + /// + private int? GetCurrentSelectedIndex () + { + IList? items = Source?.ToList (); + + if (items is null) + { + return null; + } + + for (var i = 0; i < items.Count; i++) + { + if (string.Equals (items [i]?.ToString (), Text, StringComparison.Ordinal)) + { + return i; + } + } + + return null; + } + + /// + /// Selects the item at the specified index, updating . + /// + private void SelectItemAtIndex (int index) + { + IList? items = Source?.ToList (); + + if (items is null) + { + return; + } + + if (index < 0 || index >= items.Count) + { + return; + } + + Text = items [index]?.ToString () ?? string.Empty; + } + /// /// /// diff --git a/Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs b/Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs index 09570a9716..0f6be01782 100644 --- a/Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs +++ b/Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs @@ -5,6 +5,36 @@ namespace Terminal.Gui.Views; /// Default file operation handlers using modal dialogs. public class DefaultFileOperations : IFileOperations { + /// + /// Determines whether a candidate path is safely contained within the specified root directory. + /// Returns if the name contains path-traversal sequences that escape the root. + /// + internal static bool IsContainedIn (string root, string candidate) + { + string rootFull = Path.GetFullPath (root) + .TrimEnd (Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + string candidateFull = Path.GetFullPath (candidate); + + return candidateFull.StartsWith (rootFull, StringComparison.Ordinal); + } + + /// + /// Returns if the name contains characters that are not valid in a file or directory name, + /// including path separators, null characters, and control characters. + /// + internal static bool ContainsInvalidNameCharacters (string name) + { + if (string.IsNullOrWhiteSpace (name)) + { + return true; + } + + char [] invalidChars = Path.GetInvalidFileNameChars (); + + return name.IndexOfAny (invalidChars) >= 0; + } /// public bool Delete (IApplication? app, IEnumerable toDelete) { @@ -72,7 +102,24 @@ public bool Delete (IApplication? app, IEnumerable toDelete) { if (toRename is IFileInfo f) { - IFileInfo newLocation = fileSystem.FileInfo.New (Path.Combine (f.Directory?.FullName ?? throw new InvalidOperationException (), newName)); + if (ContainsInvalidNameCharacters (newName)) + { + MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, Strings.fdPathTraversalError, Strings.btnOk); + + return null; + } + + string parentDir = f.Directory?.FullName ?? throw new InvalidOperationException (); + string combined = Path.Combine (parentDir, newName); + + if (!IsContainedIn (parentDir, combined)) + { + MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, Strings.fdPathTraversalError, Strings.btnOk); + + return null; + } + + IFileInfo newLocation = fileSystem.FileInfo.New (combined); f.MoveTo (newLocation.FullName); return newLocation; @@ -81,8 +128,24 @@ public bool Delete (IApplication? app, IEnumerable toDelete) { var d = (IDirectoryInfo)toRename; - IDirectoryInfo newLocation = - fileSystem.DirectoryInfo.New (Path.Combine (d.Parent?.FullName ?? throw new InvalidOperationException (), newName)); + if (ContainsInvalidNameCharacters (newName)) + { + MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, Strings.fdPathTraversalError, Strings.btnOk); + + return null; + } + + string parentDir = d.Parent?.FullName ?? throw new InvalidOperationException (); + string combined = Path.Combine (parentDir, newName); + + if (!IsContainedIn (parentDir, combined)) + { + MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, Strings.fdPathTraversalError, Strings.btnOk); + + return null; + } + + IDirectoryInfo newLocation = fileSystem.DirectoryInfo.New (combined); d.MoveTo (newLocation.FullName); return newLocation; @@ -113,7 +176,23 @@ public bool Delete (IApplication? app, IEnumerable toDelete) try { - IDirectoryInfo newDir = fileSystem.DirectoryInfo.New (Path.Combine (inDirectory.FullName, result)); + if (ContainsInvalidNameCharacters (result)) + { + MessageBox.ErrorQuery (app, Strings.fdNewFailed, Strings.fdPathTraversalError, Strings.btnOk); + + return null; + } + + string combined = Path.Combine (inDirectory.FullName, result); + + if (!IsContainedIn (inDirectory.FullName, combined)) + { + MessageBox.ErrorQuery (app, Strings.fdNewFailed, Strings.fdPathTraversalError, Strings.btnOk); + + return null; + } + + IDirectoryInfo newDir = fileSystem.DirectoryInfo.New (combined); newDir.Create (); return newDir; diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs index 77786871c2..e74db10c65 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs @@ -14,7 +14,32 @@ public partial class FileDialog public bool IsCompatibleWithAllowedExtensions (IFileInfo file) => AllowedTypes.Count == 0 || MatchesAllowedTypes (file); /// - protected override bool OnAccepting (CommandEventArgs args) => Accept (true) && base.OnAccepting (args); + protected override bool OnAccepting (CommandEventArgs args) + { + // Check if the source is the Cancel button - if so, leave Result as null and stop + View? sourceView = null; + args.Context?.Source?.TryGetTarget (out sourceView); + + if (sourceView == _btnCancel) + { + // Explicitly clear Result so Canceled == true even if the dialog was previously accepted + Result = null; + + if (IsModal) + { + App?.RequestStop (); + } + + return true; + } + + if (Accept (true)) + { + return base.OnAccepting (args); + } + + return false; + } private void Accept (IEnumerable toMultiAccept) { @@ -112,7 +137,15 @@ private bool FinishAccept () MultiSelected = string.IsNullOrWhiteSpace (Path) ? Enumerable.Empty ().ToList ().AsReadOnly () : new List { Path }.AsReadOnly (); } - Result = 2; // Ok button index + // Set the typed result to the selected paths + if (AllowsMultipleSelection) + { + Result = MultiSelected; + } + else + { + Result = string.IsNullOrWhiteSpace (Path) ? new List ().AsReadOnly () : new List { Path }.AsReadOnly (); + } if (!IsModal) { diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs index 2480e9fb5b..9d3edf7ae5 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs @@ -42,7 +42,7 @@ private void TableViewHandleCommandNotBound (object? sender, CommandEventArgs e) // and the context menu is disposed when it is closed. App!.Popovers?.Register (contextMenu); - Point pos = new (_tableView.FrameToScreen ().X + 15, _tableView.FrameToScreen ().Y + (_tableView.Value?.Cursor.Y ?? 0) + _tableView.GetHeaderHeight ()); + Point pos = new (_tableView.FrameToScreen ().X + 15, _tableView.FrameToScreen ().Y + (_tableView.Value?.SelectedCell.Y ?? 0) + _tableView.GetHeaderHeight ()); contextMenu.MakeVisible (pos); } @@ -121,7 +121,7 @@ private void TableViewOnAccepted (object? sender, CommandEventArgs e) return; } - FileSystemInfoStats stats = RowToStats (_tableView.Value!.Cursor.Y); + FileSystemInfoStats stats = RowToStats (_tableView.Value!.SelectedCell.Y); if (stats.FileSystemInfo is IDirectoryInfo d) { @@ -299,7 +299,7 @@ private void TableViewOnValueChanged (object? sender, ValueChangedEventArgs /// The base-class for and /// -public partial class FileDialog : Dialog, IDesignable +public partial class FileDialog : Dialog?>, IDesignable { /// Gets the Path separators for the operating system @@ -31,9 +32,15 @@ public partial class FileDialog : Dialog, IDesignable private readonly Button _btnCancel; /// - /// Gets the index of the cancel button for the dialog. This is useful for checking if the user canceled the dialog by - /// comparing - /// the to the index of this button in the array. + /// Gets whether the dialog was canceled (i.e., the user dismissed it without accepting a selection). + /// + /// + /// Returns if is . + /// + public bool Canceled => Result is null; + + /// + /// Gets the index of the cancel button for the dialog. /// public int CancelButtonIndex => Buttons.IndexOf (_btnCancel); @@ -201,7 +208,9 @@ internal FileDialog (IFileSystem? fileSystem) _tableView.Style.ShowHorizontalHeaderOverline = false; _tableView.Style.ShowVerticalCellLines = true; - _tableView.Style.ShowVerticalHeaderLines = false; + _tableView.Style.ShowVerticalCellLineForFirstColumn = false; + _tableView.Style.ShowVerticalCellLineForLastColumn = false; + _tableView.Style.ShowVerticalHeaderLines = true; _tableView.Style.AlwaysShowHeaders = true; _tableView.Style.ShowHorizontalHeaderUnderline = false; _tableView.Style.ShowHorizontalBottomLine = false; @@ -516,7 +525,7 @@ private void RecursiveFind (IDirectoryInfo directory) } } - if (f.FileSystemInfo is IDirectoryInfo sub) + if (f.FileSystemInfo is IDirectoryInfo sub && !FileSystemTreeBuilder.IsReparsePoint (sub)) { RecursiveFind (sub); } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogCollectionNavigator.cs b/Terminal.Gui/Views/FileDialogs/FileDialogCollectionNavigator.cs index 23a80eb512..fc2f35bb34 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogCollectionNavigator.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogCollectionNavigator.cs @@ -4,7 +4,7 @@ internal class FileDialogCollectionNavigator (FileDialog fileDialog, TableView t { protected override object ElementAt (int idx) { - object val = FileDialogTableSource.GetRawColumnValue (tableView.Value?.Cursor.X ?? 0, fileDialog.State?.Children [idx]); + object val = FileDialogTableSource.GetRawColumnValue (tableView.Value?.SelectedCell.X ?? 0, fileDialog.State?.Children [idx]); return val.ToString ()?.Trim ('.') ?? string.Empty; } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogStyle.cs b/Terminal.Gui/Views/FileDialogs/FileDialogStyle.cs index 4917d80596..e97f166de5 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogStyle.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogStyle.cs @@ -153,9 +153,6 @@ public FileDialogStyle (IFileSystem? fileSystem) /// public bool PreserveFilenameOnDirectoryChanges { get; set; } - [UnconditionalSuppressMessage ("AOT", - "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", - Justification = "")] private Dictionary DefaultTreeRootGetter () { if (_fileSystem is null) @@ -180,7 +177,7 @@ private Dictionary DefaultTreeRootGetter () try { - foreach (Environment.SpecialFolder special in Enum.GetValues (typeof (Environment.SpecialFolder)).Cast ()) + foreach (Environment.SpecialFolder special in Enum.GetValues ()) { try { diff --git a/Terminal.Gui/Views/FileDialogs/OpenDialog.cs b/Terminal.Gui/Views/FileDialogs/OpenDialog.cs index c6df91ee9b..f0a587670f 100644 --- a/Terminal.Gui/Views/FileDialogs/OpenDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/OpenDialog.cs @@ -24,7 +24,7 @@ public OpenDialog () { } /// Returns the selected files, or an empty list if nothing has been selected /// The file paths. public IReadOnlyList FilePaths => - ((IRunnable)this).Result is null || Result == CancelButtonIndex ? Enumerable.Empty ().ToList ().AsReadOnly () : + Result is null ? Enumerable.Empty ().ToList ().AsReadOnly () : AllowsMultipleSelection ? MultiSelected : new ReadOnlyCollection ([Path]); /// diff --git a/Terminal.Gui/Views/FileDialogs/SaveDialog.cs b/Terminal.Gui/Views/FileDialogs/SaveDialog.cs index b0af109e7c..daf56ba568 100644 --- a/Terminal.Gui/Views/FileDialogs/SaveDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/SaveDialog.cs @@ -25,7 +25,7 @@ public class SaveDialog : FileDialog /// . /// /// The name of the file. - public string? FileName => (this as IRunnable).Result is null || Result == CancelButtonIndex ? null : Path; + public string? FileName => Result is null ? null : Path; /// Gets the default title for the . /// diff --git a/Terminal.Gui/Views/LinearRange/LinearMultiSelector.cs b/Terminal.Gui/Views/LinearRange/LinearMultiSelector.cs new file mode 100644 index 0000000000..db3a838a14 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearMultiSelector.cs @@ -0,0 +1,24 @@ +namespace Terminal.Gui.Views; + +/// +/// Convenience non-generic closed over . +/// Allows designer scenarios (e.g. AllViewsTester) and reflection-based instantiation to +/// discover and create the view without supplying a type argument. +/// +/// +/// +/// To work with non-string option types, use directly. +/// +/// +public class LinearMultiSelector : LinearMultiSelector +{ + /// Initializes a new instance of . + public LinearMultiSelector () { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearMultiSelector (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation) + { } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearMultiSelectorT.cs b/Terminal.Gui/Views/LinearRange/LinearMultiSelectorT.cs new file mode 100644 index 0000000000..69fc45cb61 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearMultiSelectorT.cs @@ -0,0 +1,165 @@ +namespace Terminal.Gui.Views; + +/// +/// A linear range view that allows selection of zero or more options from a typed list. +/// +/// The data type of the options. +/// +/// +/// Exposes the current selection through as +/// ; the list is empty when no options are selected. +/// A defensive copy is taken when is set, so the caller may mutate +/// the list passed in without affecting subsequent reads. +/// +/// +/// Equality between the current value and a new value uses +/// , +/// so two distinct list instances with the same elements in the same order are considered equal. +/// +/// +public class LinearMultiSelector : LinearRangeViewBase>, IDesignable +{ + private static readonly IReadOnlyList _emptyList = new List (0).AsReadOnly (); + + private IReadOnlyList _value = _emptyList; + + /// Initializes a new instance of . + public LinearMultiSelector () : base (LinearRangeRenderMode.Multiple) { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearMultiSelector (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation, LinearRangeRenderMode.Multiple) { } + + /// + /// + /// The setter accepts as a synonym for an empty list. The getter never + /// returns . + /// + public override IReadOnlyList? Value + { + get => _value; + set + { + IReadOnlyList incoming = value is null ? _emptyList : new List (value).AsReadOnly (); + IReadOnlyList current = _value; + + if (SequenceEqualByDefault (current, incoming)) + { + return; + } + + if (RaiseValueChanging (current, incoming)) + { + return; + } + + _value = incoming; + + // Sync indices: find the option index for each element of incoming. + // Use a HashSet to dedupe in O(1) per item rather than O(n) List.Contains scans. + List indices = new (incoming.Count); + HashSet seen = new (incoming.Count); + + foreach (T item in incoming) + { + int idx = IndexOfData (item); + + if (idx >= 0 && seen.Add (idx)) + { + indices.Add (idx); + } + } + + ApplySelectedIndices (indices); + + RaiseValueChanged (current, _value); + } + } + + /// + protected override void OnSelectionChanged () + { + IReadOnlyList previous = _value; + + // Build the new value from current indices in the order they appear in Options + // (rather than the order they were selected) for stable, predictable output. + IReadOnlyList indices = SelectedIndices; + List next = new (indices.Count); + List ordered = new (indices); + ordered.Sort (); + + foreach (int i in ordered) + { + if (i >= 0 && i < Options.Count) + { + next.Add (Options [i].Data!); + } + } + + IReadOnlyList newValue = next.AsReadOnly (); + + if (SequenceEqualByDefault (previous, newValue)) + { + return; + } + + _value = newValue; + RaiseValueChanged (previous, newValue); + } + + private static bool SequenceEqualByDefault (IReadOnlyList a, IReadOnlyList b) + { + if (ReferenceEquals (a, b)) + { + return true; + } + + if (a.Count != b.Count) + { + return false; + } + + EqualityComparer cmp = EqualityComparer.Default; + + for (var i = 0; i < a.Count; i++) + { + if (!cmp.Equals (a [i], b [i])) + { + return false; + } + } + + return true; + } + + /// + /// Loads demo data suitable for a designer preview: a multi-select + /// of the seven days of the week, with the five weekdays + /// (Mon–Fri) preselected. Only populated when is ; + /// for any other type, the view is left untouched and is returned. + /// + /// if demo data was loaded. + public virtual bool EnableForDesign () + { + if (typeof (T) != typeof (string)) + { + return false; + } + + Title = "Active Days"; + AssignHotKeys = true; + ShowLegends = true; + + string [] days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + + Options = days.Select ( + d => new LinearRangeOption (d, (Rune)d [0], (T)(object)d)) + .ToList (); + + Value = days.Take (5).Select (d => (T)(object)d).ToList (); + + return true; + } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRange.cs b/Terminal.Gui/Views/LinearRange/LinearRange.cs index 5fdf4de5ea..2f352c0447 100644 --- a/Terminal.Gui/Views/LinearRange/LinearRange.cs +++ b/Terminal.Gui/Views/LinearRange/LinearRange.cs @@ -1,2024 +1,24 @@ namespace Terminal.Gui.Views; /// -/// Provides a linear range control letting the user navigate from a set of typed options in a linear manner using the -/// keyboard or mouse. +/// Convenience non-generic closed over . Allows +/// designer scenarios (e.g. AllViewsTester) and reflection-based instantiation to discover +/// and create the view without supplying a type argument. /// /// -/// Default key bindings (when is ): -/// -/// -/// Key Action -/// -/// -/// Left / Right Moves to the previous or next option. -/// -/// -/// Ctrl+Left / Ctrl+Right Moves by a larger step. -/// -/// -/// Default key bindings (when is ): -/// -/// -/// Key Action -/// -/// -/// Up / Down Moves to the previous or next option. -/// -/// -/// Ctrl+Up / Ctrl+Down Moves by a larger step. -/// -/// -/// Common key bindings (both orientations): -/// -/// -/// Key Action -/// -/// -/// Home / End Moves to the first or last option. -/// -/// -/// Enter Accepts the current selection (). -/// -/// -/// Space -/// Activates the current selection (). -/// -/// -/// -public class LinearRange : LinearRange -{ - /// - /// Gets or sets the default cursor style. - /// - [ConfigurationProperty (Scope = typeof (ThemeScope))] - public static CursorStyle DefaultCursorStyle { get; set; } = CursorStyle.BlinkingBlock; - - /// Initializes a new instance of the class. - public LinearRange () => Cursor = new Cursor { Style = DefaultCursorStyle }; - - /// Initializes a new instance of the class. - /// Initial options. - /// Initial orientation. - public LinearRange (List options, Orientation orientation = Orientation.Horizontal) : base (options, orientation) { } -} - -/// -/// Provides a type-safe linear range control letting the user navigate from a set of typed options in a linear manner -/// using the keyboard or mouse. -/// -/// The type of the options. -/// -/// Default key bindings (when is ): -/// -/// -/// Key Action -/// -/// -/// Left / Right Moves to the previous or next option. -/// -/// -/// Ctrl+Left / Ctrl+Right Moves by a larger step. -/// -/// -/// Default key bindings (when is ): -/// -/// -/// Key Action -/// -/// -/// Up / Down Moves to the previous or next option. -/// -/// -/// Ctrl+Up / Ctrl+Down Moves by a larger step. -/// -/// -/// Common key bindings (both orientations): -/// -/// -/// Key Action -/// -/// -/// Home / End Moves to the first or last option. -/// -/// -/// Enter Accepts the current selection (). -/// -/// -/// Space -/// Activates the current selection (). -/// -/// /// -/// Common bindings (Home, End, Enter, Space) are configurable via and -/// . Orientation-dependent cursor bindings are set dynamically -/// and cannot be reconfigured. +/// To work with non-string option types, use directly. /// /// -public class LinearRange : View, IOrientation +public class LinearRange : LinearRange { - /// - /// Gets or sets the view-specific default key bindings for . Contains only bindings - /// unique to this view; shared bindings come from . - /// - /// IMPORTANT: This is a process-wide static property. Change with care. - /// Do not set in parallelizable unit tests. - /// - /// - /// - /// - /// No is applied because is a generic - /// type. Use with key "LinearRange" to override bindings via - /// configuration. - /// - /// - public new static Dictionary? DefaultKeyBindings { get; set; } = new () - { - [Command.Accept] = Bind.All (Key.Enter), - [Command.Activate] = Bind.All (Key.Space), - }; - - private readonly LinearRangeConfiguration _config = new (); - - // List of the current set options. - private readonly List _setOptions = []; - - // Options - private List>? _options; - - private OrientationHelper? _orientationHelper; - - #region Initialize - - private void SetInitialProperties (List> options, Orientation orientation = Orientation.Horizontal) - { - Width = Dim.Auto (DimAutoStyle.Content); - Height = Dim.Auto (DimAutoStyle.Content); - CanFocus = true; - - _options = options; - - // ReSharper disable once UseObjectOrCollectionInitializer - _orientationHelper = new OrientationHelper (this); // Do not use object initializer! - _orientationHelper.Orientation = _config._linearRangeOrientation = orientation; - _orientationHelper.OrientationChanging += (_, e) => OrientationChanging?.Invoke (this, e); - _orientationHelper.OrientationChanged += (_, e) => OrientationChanged?.Invoke (this, e); - - SetDefaultStyle (); - SetCommands (); - SetContentSize (); - - SubViewLayout += (_, _) => { SetContentSize (); }; - } - - // TODO: Make configurable via ConfigurationManager - private void SetDefaultStyle () - { - _config._showLegends = true; - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal: - Style.SpaceChar = new Cell { Grapheme = Glyphs.HLine.ToString () }; // '─' - Style.OptionChar = new Cell { Grapheme = Glyphs.BlackCircle.ToString () }; // '┼●🗹□⏹' + /// Initializes a new instance of . + public LinearRange () { } - break; - - case Orientation.Vertical: - Style.SpaceChar = new Cell { Grapheme = Glyphs.VLine.ToString () }; - Style.OptionChar = new Cell { Grapheme = Glyphs.BlackCircle.ToString () }; - - break; - } - - _config._legendsOrientation = _config._linearRangeOrientation; - Style.EmptyChar = new Cell { Grapheme = " " }; - Style.SetChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; // ■ - Style.RangeChar = new Cell { Grapheme = Glyphs.Stipple.ToString () }; // ░ ▒ ▓ // Medium shade not blinking on curses. - Style.StartRangeChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; - Style.EndRangeChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; - Style.DragChar = new Cell { Grapheme = Glyphs.Diamond.ToString () }; - } - - #endregion - - #region Constructors - - /// Initializes a new instance of the class. - public LinearRange () : this (new List ()) { } - - /// Initializes a new instance of the class. + /// Initializes a new instance of . /// Initial options. /// Initial orientation. - public LinearRange (List? options, Orientation orientation = Orientation.Horizontal) - { - Cursor = new Cursor { Style = LinearRange.DefaultCursorStyle }; - - if (options is null) - { - return; - } - - if (options is { Count: 0 }) - { - SetInitialProperties ([], orientation); - } - else - { - SetInitialProperties (options.Select (e => - { - var legend = e?.ToString (); - - return new LinearRangeOption - { - Data = e, Legend = legend, LegendAbbr = (Rune)(legend?.Length > 0 ? legend [0] : ' ') - }; - }) - .ToList (), - orientation); - } - } - - #endregion - - #region Properties - - /// - /// Setting the Text of a linear range is a shortcut to setting options. The text is a CSV string of the options. - /// - public override string Text - { - // Return labels as a CSV string - get => _options is null or { Count: 0 } ? string.Empty : string.Join (",", _options); - set - { - if (string.IsNullOrEmpty (value)) - { - Options = []; - } - else - { - IEnumerable list = value.Split (',').Select (x => x.Trim ()); - Options = list.Select (x => new LinearRangeOption { Legend = x }).ToList (); - } - } - } - - /// Allow no selection. - public bool AllowEmpty - { - get => _config._allowEmpty; - set - { - _config._allowEmpty = value; - - if (!value && _options!.Count > 0 && _setOptions.Count == 0) - { - SetOption (0); - } - } - } - - /// Gets or sets the minimum number of rows/columns between . The default is 1. - public int MinimumInnerSpacing - { - get => _config._minInnerSpacing; - set - { - int current = _config._minInnerSpacing; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnMinimumInnerSpacingChanging, - MinimumInnerSpacingChanging, - newValue => - { - _config._minInnerSpacing = newValue; - SetContentSize (); - }, - OnMinimumInnerSpacingChanged, - MinimumInnerSpacingChanged, - out int _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? MinimumInnerSpacingChanging; - - /// Event raised after the property has changed. - public event EventHandler>? MinimumInnerSpacingChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnMinimumInnerSpacingChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnMinimumInnerSpacingChanged (ValueChangedEventArgs args) { } - - /// LinearRange Type. - public LinearRangeType Type - { - get => _config._type; - set - { - LinearRangeType current = _config._type; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnTypeChanging, - TypeChanging, - newValue => - { - _config._type = newValue; - - // Todo: Custom logic to preserve options. - _setOptions.Clear (); - SetNeedsDraw (); - }, - OnTypeChanged, - TypeChanged, - out LinearRangeType _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? TypeChanging; - - /// Event raised after the property has changed. - public event EventHandler>? TypeChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnTypeChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnTypeChanged (ValueChangedEventArgs args) { } - - /// - /// Gets or sets the . The default is . - /// - public Orientation Orientation { get => _orientationHelper!.Orientation; set => _orientationHelper!.Orientation = value; } - - #region IOrientation members - - /// - public event EventHandler>? OrientationChanging; - - /// - public event EventHandler>? OrientationChanged; - - /// - public void OnOrientationChanged (Orientation newOrientation) - { - _config._linearRangeOrientation = newOrientation; - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal: - Style.SpaceChar = new Cell { Grapheme = Glyphs.HLine.ToString () }; // '─' - - break; - - case Orientation.Vertical: - Style.SpaceChar = new Cell { Grapheme = Glyphs.VLine.ToString () }; - - break; - } - - SetKeyBindings (); - SetContentSize (); - } - - #endregion - - /// Legends Orientation. - public Orientation LegendsOrientation - { - get => _config._legendsOrientation; - set - { - Orientation current = _config._legendsOrientation; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnLegendsOrientationChanging, - LegendsOrientationChanging, - newValue => - { - _config._legendsOrientation = newValue; - SetContentSize (); - }, - OnLegendsOrientationChanged, - LegendsOrientationChanged, - out Orientation _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? LegendsOrientationChanging; - - /// Event raised after the property has changed. - public event EventHandler>? LegendsOrientationChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnLegendsOrientationChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnLegendsOrientationChanged (ValueChangedEventArgs args) { } - - /// LinearRange styles. - public LinearRangeStyle Style { get; set; } = new (); - - /// Set the linear range options. - public List> Options - { - get => _options ?? []; - set - { - // _options should never be null - _options = value ?? throw new ArgumentNullException (nameof (value)); - - if (_options.Count == 0) - { - return; - } - - SetContentSize (); - } - } - - /// Allow range start and end be in the same option, as a single option. - public bool RangeAllowSingle { get => _config._rangeAllowSingle; set => _config._rangeAllowSingle = value; } - - /// Show/Hide spacing before and after the first and last option. - public bool ShowEndSpacing - { - get => _config._showEndSpacing; - set - { - bool current = _config._showEndSpacing; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnShowEndSpacingChanging, - ShowEndSpacingChanging, - newValue => - { - _config._showEndSpacing = newValue; - SetContentSize (); - }, - OnShowEndSpacingChanged, - ShowEndSpacingChanged, - out bool _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? ShowEndSpacingChanging; - - /// Event raised after the property has changed. - public event EventHandler>? ShowEndSpacingChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnShowEndSpacingChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnShowEndSpacingChanged (ValueChangedEventArgs args) { } - - /// Show/Hide the options legends. - public bool ShowLegends - { - get => _config._showLegends; - set - { - bool current = _config._showLegends; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnShowLegendsChanging, - ShowLegendsChanging, - newValue => - { - _config._showLegends = newValue; - SetContentSize (); - }, - OnShowLegendsChanged, - ShowLegendsChanged, - out bool _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? ShowLegendsChanging; - - /// Event raised after the property has changed. - public event EventHandler>? ShowLegendsChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnShowLegendsChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnShowLegendsChanged (ValueChangedEventArgs args) { } - - /// - /// Gets or sets whether the minimum or ideal size will be used when calculating the size of the linear range. - /// - public bool UseMinimumSize - { - get => _config._useMinimumSize; - set - { - bool current = _config._useMinimumSize; - - CWPPropertyHelper.ChangeProperty (this, - ref current, - value, - OnUseMinimumSizeChanging, - UseMinimumSizeChanging, - newValue => - { - _config._useMinimumSize = newValue; - SetContentSize (); - }, - OnUseMinimumSizeChanged, - UseMinimumSizeChanged, - out bool _); - } - } - - /// Event raised before the property changes. Can be cancelled. - public event EventHandler>? UseMinimumSizeChanging; - - /// Event raised after the property has changed. - public event EventHandler>? UseMinimumSizeChanged; - - /// Called before changes. Return true to cancel the change. - protected virtual bool OnUseMinimumSizeChanging (ValueChangingEventArgs args) => false; - - /// Called after has changed. - protected virtual void OnUseMinimumSizeChanged (ValueChangedEventArgs args) { } - - #endregion - - #region Events - - /// Event raised when the linear range option/s changed. The dictionary contains: key = option index, value = T - public event EventHandler>? OptionsChanged; - - /// - /// Overridable method called when the linear range options have changed. Raises the - /// event. - /// - public virtual void OnOptionsChanged () - { - OptionsChanged?.Invoke (this, new LinearRangeEventArgs (GetSetOptionDictionary ())); - SetNeedsDraw (); - } - - /// Event raised When the option is hovered with the keys or the mouse. - public event EventHandler>? OptionFocused; - - private int _lastFocusedOption; // for Range type; the most recently focused option. Used to determine shrink direction - - /// Overridable function that fires the event. - /// - /// if the focus change was cancelled. - /// - public virtual bool OnOptionFocused (int newFocusedOption, LinearRangeEventArgs args) - { - if (newFocusedOption > _options!.Count - 1 || newFocusedOption < 0) - { - return true; - } - - OptionFocused?.Invoke (this, args); - - if (args.Cancel) - { - return args.Cancel; - } - _lastFocusedOption = FocusedOption; - FocusedOption = newFocusedOption; - - return args.Cancel; - } - - #endregion Events - - #region Public Methods - - /// The focused option (has the cursor). - public int FocusedOption - { - get; - set - { - if (field == value) - { - return; - } - field = value; - UpdateCursor (); - } - } - - /// Causes the specified option to be set and be focused. - public bool SetOption (int optionIndex) - { - // TODO: Handle range type. - // Note: Maybe return false only when optionIndex doesn't exist, otherwise true. - - if (_setOptions.Contains (optionIndex) || optionIndex < 0 || optionIndex >= _options!.Count) - { - return false; - } - FocusedOption = optionIndex; - SetFocusedOption (); - - return true; - } - - /// Causes the specified option to be un-set and be focused. - public bool UnSetOption (int optionIndex) - { - if (AllowEmpty || _setOptions.Count <= 2 || !_setOptions.Contains (optionIndex)) - { - return false; - } - FocusedOption = optionIndex; - SetFocusedOption (); - - return true; - } - - /// Get the indexes of the set options. - public List GetSetOptions () => _setOptions.OrderBy (e => e).ToList (); - - #endregion Public Methods - - #region Helpers - - private void MoveAndAdd (int x, int y, Rune rune) - { - Move (x, y); - AddRune (rune); - } - - private void MoveAndAdd (int x, int y, string str) - { - Move (x, y); - AddStr (str); - } - - /// Sets the dimensions of the LinearRange to the ideal values. - private void SetContentSize () - { - if (_options is { Count: 0 }) - { - return; - } - - bool horizontal = _config._linearRangeOrientation == Orientation.Horizontal; - - if (UseMinimumSize) - { - CalcSpacingConfig (CalcMinLength ()); - } - else - { - CalcSpacingConfig (horizontal ? Viewport.Width : Viewport.Height); - } - - SetContentSize (new Size (GetIdealWidth (), GetIdealHeight ())); - - return; - - void CalcSpacingConfig (int size) - { - _config._cachedInnerSpacing = 0; - _config._startSpacing = 0; - _config._endSpacing = 0; - - int maxLegend; // Because the legends are centered, the longest one determines inner spacing - - if (_config._linearRangeOrientation == _config._legendsOrientation) - { - maxLegend = int.Max (_options!.Max (s => s.Legend?.GetColumns () ?? 1), 1); - } - else - { - maxLegend = 1; - } - - int minSizeThatFitsLegends = _options!.Count == 1 ? maxLegend : _options.Sum (o => o.Legend!.GetColumns ()); - - string? first; - string? last; - - _config._showLegendsAbbr = false; - - if (minSizeThatFitsLegends > size) - { - if (_config._linearRangeOrientation == _config._legendsOrientation) - { - _config._showLegendsAbbr = true; - - foreach (LinearRangeOption o in _options.Where (op => op.LegendAbbr == default (Rune))) - { - o.LegendAbbr = (Rune)(o.Legend?.GetColumns () > 0 ? o.Legend [0] : ' '); - } - } - - first = "x"; - last = "x"; - } - else - { - first = _options.First ().Legend; - last = _options.Last ().Legend; - } - - // --o-- - // Hello - // Left = He - // Right = lo - int firstLeft = (first!.Length - 1) / 2; // Chars count of the first option to the left. - int lastRight = last!.Length / 2; // Chars count of the last option to the right. - - if (_config._linearRangeOrientation != _config._legendsOrientation) - { - firstLeft = 0; - lastRight = 0; - } - - // -1 because it's better to have an extra space at right than to clip - int width = size - firstLeft - lastRight - 1; - - _config._startSpacing = firstLeft; - - if (_options.Count == 1) - { - _config._cachedInnerSpacing = maxLegend; - } - else - { - _config._cachedInnerSpacing = Math.Max (0, (int)Math.Floor ((double)width / (_options.Count - 1)) - 1); - } - - _config._cachedInnerSpacing = Math.Max (_config._minInnerSpacing, _config._cachedInnerSpacing); - - _config._endSpacing = lastRight; - } - } - - /// Calculates the min dimension required for all options and inner spacing with abbreviated legends - /// - private int CalcMinLength () - { - if (_options is { Count: 0 }) - { - return 0; - } - - var length = 0; - length += _config._startSpacing + _config._endSpacing; - length += _options!.Count; - length += (_options.Count - 1) * _config._minInnerSpacing; - - return length; - } - - /// - /// Gets the ideal width of the linear range. The ideal width is the minimum width required to display all options and - /// inner - /// spacing. - /// - /// - public int GetIdealWidth () - { - if (UseMinimumSize) - { - return Orientation == Orientation.Horizontal ? CalcMinLength () : CalcIdealThickness (); - } - - return Orientation == Orientation.Horizontal ? CalcIdealLength () : CalcIdealThickness (); - } - - /// - /// Gets the ideal height of the linear range. The ideal height is the minimum height required to display all options - /// and - /// inner spacing. - /// - /// - public int GetIdealHeight () - { - if (UseMinimumSize) - { - return Orientation == Orientation.Horizontal ? CalcIdealThickness () : CalcMinLength (); - } - - return Orientation == Orientation.Horizontal ? CalcIdealThickness () : CalcIdealLength (); - } - - /// - /// Calculates the ideal dimension required for all options, inner spacing, and legends (non-abbreviated, with one - /// space between). - /// - /// - private int CalcIdealLength () - { - if (_options is { Count: 0 }) - { - return 0; - } - - var length = 0; - - if (!_config._showLegends) - { - return Math.Max (length, CalcMinLength ()); - } - - if (_config._legendsOrientation == _config._linearRangeOrientation && _options!.Count > 0) - { - // Each legend should be centered in a space the width of the longest legend, with one space between. - // Calculate the total length required for all legends. - int maxLegend = int.Max (_options.Max (s => s.Legend?.GetColumns () ?? 1), 1); - length = maxLegend * _options.Count + (_options.Count - 1); - } - else - { - length = CalcMinLength (); - } - - return Math.Max (length, CalcMinLength ()); - } - - /// - /// Calculates the minimum dimension required for the linear range and legends. - /// - /// - private int CalcIdealThickness () - { - var thickness = 1; // Always show the linear range. - - if (!_config._showLegends) - { - return thickness; - } - - if (_config._legendsOrientation != _config._linearRangeOrientation && _options!.Count > 0) - { - thickness += _options.Max (s => s.Legend?.GetColumns () ?? 0); - } - else - { - thickness += 1; - } - - return thickness; - } - - #endregion Helpers - - #region Cursor and Position - - internal bool TryGetPositionByOption (int option, out (int x, int y) position) - { - position = (-1, -1); - - if (option < 0 || option >= _options!.Count) - { - return false; - } - - var offset = 0; - offset += _config._startSpacing; - offset += option * (_config._cachedInnerSpacing + 1); - - position = _config._linearRangeOrientation == Orientation.Vertical ? (0, offset) : (offset, 0); - - return true; - } - - /// Tries to get the option index by the position. - /// - /// - /// - /// - /// - internal bool TryGetOptionByPosition (int x, int y, int threshold, out int optionIdx) - { - optionIdx = -1; - - if (Orientation == Orientation.Horizontal) - { - if (y != 0) - { - return false; - } - - for (int xx = x - threshold; xx < x + threshold + 1; xx++) - { - int cx = xx; - cx -= _config._startSpacing; - - int option = cx / (_config._cachedInnerSpacing + 1); - bool valid = cx % (_config._cachedInnerSpacing + 1) == 0; - - if (!valid || option < 0 || option > _options!.Count - 1) - { - continue; - } - - optionIdx = option; - - return true; - } - } - else - { - if (x != 0) - { - return false; - } - - for (int yy = y - threshold; yy < y + threshold + 1; yy++) - { - int cy = yy; - cy -= _config._startSpacing; - - int option = cy / (_config._cachedInnerSpacing + 1); - bool valid = cy % (_config._cachedInnerSpacing + 1) == 0; - - if (!valid || option < 0 || option > _options!.Count - 1) - { - continue; - } - - optionIdx = option; - - return true; - } - } - - return false; - } - - /// Updates the cursor position based on the focused option. - /// - /// This method calculates the cursor position and calls . - /// The framework automatically handles hiding the cursor when the view loses focus. - /// - private void UpdateCursor () - { - if (!TryGetPositionByOption (FocusedOption, out (int x, int y) position) || !IsInitialized || !Viewport.Contains (position.x, position.y)) - { - Cursor = Cursor with { Position = null }; // Hide cursor - - return; - } - - Cursor = Cursor with { Position = ViewportToScreen (new Point (position.x, position.y)) }; - } - - #endregion Cursor and Position - - #region Drawing - - /// - protected override bool OnDrawingContent (DrawContext? context) - { - // TODO: make this more surgical to reduce repaint - - if (_options is null || _options.Count == 0) - { - return true; - } - - // Draw LinearRange - DrawLinearRange (); - - // Draw Legends. - if (_config._showLegends) - { - DrawLegends (); - } - - if (_dragPosition.HasValue && _moveRenderPosition.HasValue) - { - AddStr (_moveRenderPosition.Value.X, _moveRenderPosition.Value.Y, Style.DragChar.Grapheme); - } - - return true; - } - - private static string AlignText (string? text, int width, Alignment alignment) - { - if (string.IsNullOrEmpty (text)) - { - return ""; - } - - if (text.Length > width) - { - text = text [..width]; - } - - int w = width - text.Length; - string s1 = new (' ', w / 2); - string s2 = new (' ', w % 2); - - // Note: The formatter doesn't handle all of this ??? - switch (alignment) - { - case Alignment.Fill: - return TextFormatter.Justify (text, width); - - case Alignment.Start: - return text + s1 + s1 + s2; - - case Alignment.Center: - if (text.Length % 2 != 0) - { - return s1 + text + s1 + s2; - } - - return s1 + s2 + text + s1; - - case Alignment.End: - return s1 + s1 + s2 + text; - - default: - return text; - } - } - - private void DrawLinearRange () - { - // TODO: be more surgical on clear - ClearViewport (); - - // Attributes - var normalAttr = new Attribute (Color.White, Color.Black); - var setAttr = new Attribute (Color.Black, Color.White); - - if (IsInitialized) - { - normalAttr = GetAttributeForRole (VisualRole.Normal); - setAttr = Style.SetChar.Attribute ?? GetAttributeForRole (VisualRole.HotNormal); - } - - bool isVertical = _config._linearRangeOrientation == Orientation.Vertical; - - var x = 0; - var y = 0; - - bool isSet = _setOptions.Count > 0; - - // Left Spacing - if (_config is { _showEndSpacing: true, _startSpacing: > 0 }) - { - SetAttribute (isSet && _config._type == LinearRangeType.LeftRange - ? Style.RangeChar.Attribute ?? normalAttr - : Style.SpaceChar.Attribute ?? normalAttr); - string text = isSet && _config._type == LinearRangeType.LeftRange ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme; - - for (var i = 0; i < _config._startSpacing; i++) - { - MoveAndAdd (x, y, text); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - else - { - SetAttribute (Style.EmptyChar.Attribute ?? normalAttr); - - for (var i = 0; i < _config._startSpacing; i++) - { - MoveAndAdd (x, y, Style.EmptyChar.Grapheme); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - - // LinearRange - if (_options!.Count > 0) - { - for (var i = 0; i < _options.Count; i++) - { - var drawRange = false; - - if (isSet) - { - switch (_config._type) - { - case LinearRangeType.LeftRange when i <= _setOptions [0]: - drawRange = i < _setOptions [0]; - - break; - - case LinearRangeType.RightRange when i >= _setOptions [0]: - drawRange = i >= _setOptions [0]; - - break; - - case LinearRangeType.Range when _setOptions.Count == 1: - drawRange = false; - - break; - - case LinearRangeType.Range when _setOptions.Count == 2: - if ((i >= _setOptions [0] && i <= _setOptions [1]) || (i >= _setOptions [1] && i <= _setOptions [0])) - { - drawRange = (i >= _setOptions [0] && i < _setOptions [1]) || (i >= _setOptions [1] && i < _setOptions [0]); - } - - break; - } - } - - // Draw Option - SetAttribute (isSet && _setOptions.Contains (i) ? Style.SetChar.Attribute ?? setAttr : - drawRange ? Style.RangeChar.Attribute ?? setAttr : Style.OptionChar.Attribute ?? normalAttr); - - string text = drawRange ? Style.RangeChar.Grapheme : Style.OptionChar.Grapheme; - - if (isSet) - { - if (_setOptions [0] == i) - { - text = Style.StartRangeChar.Grapheme; - } - else if (_setOptions.Count > 1 && _setOptions [1] == i) - { - text = Style.EndRangeChar.Grapheme; - } - else if (_setOptions.Contains (i)) - { - text = Style.SetChar.Grapheme; - } - } - - MoveAndAdd (x, y, text); - - if (isVertical) - { - y++; - } - else - { - x++; - } - - // Draw Spacing - if (!_config._showEndSpacing && i >= _options.Count - 1) - { - continue; - } - - // Skip if is the Last Spacing. - SetAttribute (drawRange && isSet ? Style.RangeChar.Attribute ?? setAttr : Style.SpaceChar.Attribute ?? normalAttr); - - for (var s = 0; s < _config._cachedInnerSpacing; s++) - { - MoveAndAdd (x, y, drawRange && isSet ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - } - - int remaining = isVertical ? Viewport.Height - y : Viewport.Width - x; - - // Right Spacing - if (_config._showEndSpacing) - { - SetAttribute (isSet && _config._type == LinearRangeType.RightRange - ? Style.RangeChar.Attribute ?? normalAttr - : Style.SpaceChar.Attribute ?? normalAttr); - string text = isSet && _config._type == LinearRangeType.RightRange ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme; - - for (var i = 0; i < remaining; i++) - { - MoveAndAdd (x, y, text); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - else - { - SetAttribute (Style.EmptyChar.Attribute ?? normalAttr); - - for (var i = 0; i < remaining; i++) - { - MoveAndAdd (x, y, Style.EmptyChar.Grapheme); - - if (isVertical) - { - y++; - } - else - { - x++; - } - } - } - } - - private void DrawLegends () - { - // Attributes - var normalAttr = new Attribute (Color.White, Color.Black); - var setAttr = new Attribute (Color.Black, Color.White); - Attribute spaceAttr = normalAttr; - - if (IsInitialized) - { - normalAttr = Style.LegendAttributes.NormalAttribute ?? GetAttributeForRole (VisualRole.Normal); - setAttr = Style.LegendAttributes.SetAttribute ?? GetAttributeForRole (VisualRole.HotNormal); - spaceAttr = Style.LegendAttributes.EmptyAttribute ?? normalAttr; - } - - bool isTextVertical = _config._legendsOrientation == Orientation.Vertical; - bool isSet = _setOptions.Count > 0; - - var x = 0; - var y = 0; - - Move (x, y); - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal when _config._legendsOrientation == Orientation.Vertical: - x += _config._startSpacing; - - break; - - case Orientation.Vertical when _config._legendsOrientation == Orientation.Horizontal: - y += _config._startSpacing; - - break; - } - - if (_config._linearRangeOrientation == Orientation.Horizontal) - { - y += 1; - } - else - { - // Vertical - x += 1; - } - - for (var i = 0; i < _options!.Count; i++) - { - var isOptionSet = false; - - // Check if the Option is Set. - switch (_config._type) - { - case LinearRangeType.Single: - case LinearRangeType.Multiple: - if (isSet && _setOptions.Contains (i)) - { - isOptionSet = true; - } - - break; - - case LinearRangeType.LeftRange: - if (isSet && i <= _setOptions [0]) - { - isOptionSet = true; - } - - break; - - case LinearRangeType.RightRange: - if (isSet && i >= _setOptions [0]) - { - isOptionSet = true; - } - - break; - - case LinearRangeType.Range when _setOptions.Count == 1: - if (isSet && i == _setOptions [0]) - { - isOptionSet = true; - } - - break; - - case LinearRangeType.Range: - if (isSet && ((i >= _setOptions [0] && i <= _setOptions [1]) || (i >= _setOptions [1] && i <= _setOptions [0]))) - { - isOptionSet = true; - } - - break; - - default: - throw new ArgumentOutOfRangeException (); - } - - // Text || Abbreviation - - string text = (_config._showLegendsAbbr ? _options [i].LegendAbbr.ToString () : _options [i].Legend)!; - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal: - switch (_config._legendsOrientation) - { - case Orientation.Horizontal: - text = AlignText (text, _config._cachedInnerSpacing + 1, Alignment.Center); - - break; - - case Orientation.Vertical: - y = 1; - - break; - } - - break; - - case Orientation.Vertical: - switch (_config._legendsOrientation) - { - case Orientation.Horizontal: - x = 1; - - break; - - case Orientation.Vertical: - text = AlignText (text, _config._cachedInnerSpacing + 1, Alignment.Center); - - break; - } - - break; - } - - // Text - int legendLeftSpacesCount = text.TakeWhile (e => e == ' ').Count (); - int legendRightSpacesCount = text.Reverse ().TakeWhile (e => e == ' ').Count (); - text = text.Trim (); - - // Calculate Start Spacing - if (_config._linearRangeOrientation == _config._legendsOrientation) - { - if (i == 0) - { - // The spacing for the linear range use the StartSpacing but... - // The spacing for the legends is the StartSpacing MINUS the total chars to the left of the first options. - // ●────●────● - // Hello Bye World - // - // chars_left is 2 for Hello => (5 - 1) / 2 - // - // then the spacing is 2 for the linear range but 0 for the legends. - - int charsLeft = (text.Length - 1) / 2; - legendLeftSpacesCount = _config._startSpacing - charsLeft; - } - - // Option Left Spacing - if (isTextVertical) - { - y += legendLeftSpacesCount; - } - else - { - x += legendLeftSpacesCount; - } - } - - // Legend - SetAttribute (isOptionSet ? setAttr : normalAttr); - - foreach (Rune c in text.EnumerateRunes ()) - { - MoveAndAdd (x, y, c); - - if (isTextVertical) - { - y += 1; - } - else - { - x += 1; - } - } - - // Calculate End Spacing - if (i == _options.Count - 1) - { - // See Start Spacing explanation. - int charsRight = text.Length / 2; - legendRightSpacesCount = _config._endSpacing - charsRight; - } - - // Option Right Spacing of Option - SetAttribute (spaceAttr); - - if (isTextVertical) - { - y += legendRightSpacesCount; - } - else - { - x += legendRightSpacesCount; - } - - switch (_config._linearRangeOrientation) - { - case Orientation.Horizontal when _config._legendsOrientation == Orientation.Vertical: - x += _config._cachedInnerSpacing + 1; - - break; - - case Orientation.Vertical when _config._legendsOrientation == Orientation.Horizontal: - y += _config._cachedInnerSpacing + 1; - - break; - } - } - } - - #endregion Drawing - - #region Keys and Mouse - - // Mouse coordinates of current drag - private Point? _dragPosition; - - // Coordinates of where the "move cursor" is drawn (in OnDrawContent) - private Point? _moveRenderPosition; - - /// - protected override bool OnMouseEvent (Mouse mouse) - { - if (!(mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked) - || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) - || mouse.Flags.FastHasFlags (MouseFlags.PositionReport) - || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased))) - { - return false; - } - - SetFocus (); - - if (!_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) - { - if (mouse.Flags.FastHasFlags (MouseFlags.PositionReport)) - { - _dragPosition = mouse.Position; - _moveRenderPosition = ClampMovePosition ((Point)_dragPosition!); - App?.Mouse.GrabMouse (this); - } - - SetNeedsDraw (); - - return true; - } - - bool success; - int option; - - if (_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.PositionReport) && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) - { - // Continue Drag - _dragPosition = mouse.Position; - _moveRenderPosition = ClampMovePosition ((Point)_dragPosition!); - - // how far has user dragged from original location? - if (Orientation == Orientation.Horizontal) - { - success = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out option); - } - else - { - success = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out option); - } - - if (!_config._allowEmpty && success) - { - if (!OnOptionFocused (option, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - SetFocusedOption (); - } - } - - SetNeedsDraw (); - - return true; - } - - if ((_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked)) - { - return mouse.Handled; - } - - // End Drag - App?.Mouse.UngrabMouse (); - _dragPosition = null; - _moveRenderPosition = null; - - switch (Orientation) - { - case Orientation.Horizontal: - success = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out option); - - break; - - default: - success = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out option); - - break; - } - - if (success) - { - if (!OnOptionFocused (option, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - SetFocusedOption (); - } - } - - SetNeedsDraw (); - - mouse.Handled = true; - - return mouse.Handled; - - Point ClampMovePosition (Point position) - { - if (Orientation == Orientation.Horizontal) - { - int left = _config._startSpacing; - int width = _options!.Count + (_options.Count - 1) * _config._cachedInnerSpacing; - int right = left + width - 1; - int clampedX = Clamp (position.X, left, right); - position = new Point (clampedX, 0); - } - else - { - int top = _config._startSpacing; - int height = _options!.Count + (_options.Count - 1) * _config._cachedInnerSpacing; - int bottom = top + height - 1; - int clampedY = Clamp (position.Y, top, bottom); - position = new Point (0, clampedY); - } - - return position; - - static int Clamp (int value, int min, int max) => Math.Max (min, Math.Min (max, value)); - } - } - - private void SetCommands () - { - AddCommand (Command.Right, () => MovePlus ()); - AddCommand (Command.Down, () => MovePlus ()); - AddCommand (Command.Left, () => MoveMinus ()); - AddCommand (Command.Up, () => MoveMinus ()); - AddCommand (Command.LeftStart, () => MoveStart ()); - AddCommand (Command.RightEnd, () => MoveEnd ()); - AddCommand (Command.RightExtend, () => ExtendPlus ()); - AddCommand (Command.LeftExtend, () => ExtendMinus ()); - - ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); - - SetKeyBindings (); - } - - // This is called during initialization and anytime orientation changes. - // Orientation-dependent bindings cannot be in DefaultKeyBindings because they vary per instance. - private void SetKeyBindings () - { - // Remove Shift+Cursor extend bindings inherited from View.DefaultKeyBindings; - // LinearRange uses Ctrl+Cursor for extend operations instead. - KeyBindings.Remove (Key.CursorLeft.WithShift); - KeyBindings.Remove (Key.CursorRight.WithShift); - KeyBindings.Remove (Key.CursorUp.WithShift); - KeyBindings.Remove (Key.CursorDown.WithShift); - - if (_config._linearRangeOrientation == Orientation.Horizontal) - { - // Remove before Add: ApplyKeyBindings already bound CursorRight/CursorLeft from View.DefaultKeyBindings - KeyBindings.Remove (Key.CursorRight); - KeyBindings.Add (Key.CursorRight, Command.Right); - KeyBindings.Remove (Key.CursorDown); - KeyBindings.Remove (Key.CursorLeft); - KeyBindings.Add (Key.CursorLeft, Command.Left); - KeyBindings.Remove (Key.CursorUp); - - KeyBindings.Add (Key.CursorRight.WithCtrl, Command.RightExtend); - KeyBindings.Remove (Key.CursorDown.WithCtrl); - KeyBindings.Add (Key.CursorLeft.WithCtrl, Command.LeftExtend); - KeyBindings.Remove (Key.CursorUp.WithCtrl); - } - else - { - KeyBindings.Remove (Key.CursorRight); - // Remove before Add: ApplyKeyBindings already bound CursorDown/CursorUp from View.DefaultKeyBindings - KeyBindings.Remove (Key.CursorDown); - KeyBindings.Add (Key.CursorDown, Command.Down); - KeyBindings.Remove (Key.CursorLeft); - KeyBindings.Remove (Key.CursorUp); - KeyBindings.Add (Key.CursorUp, Command.Up); - - KeyBindings.Remove (Key.CursorRight.WithCtrl); - KeyBindings.Add (Key.CursorDown.WithCtrl, Command.RightExtend); - KeyBindings.Remove (Key.CursorLeft.WithCtrl); - KeyBindings.Add (Key.CursorUp.WithCtrl, Command.LeftExtend); - } - } - - private Dictionary> GetSetOptionDictionary () => _setOptions.ToDictionary (e => e, e => _options! [e]); - - /// - /// Sets or unsets based on . - /// - /// The option to change. - /// If , sets the option. Unsets it otherwise. - public void ChangeOption (int optionIndex, bool set) - { - if (set) - { - if (!_setOptions.Contains (optionIndex)) - { - _setOptions.Add (optionIndex); - - _options? [optionIndex].OnSet (); - } - } - else - { - if (_setOptions.Contains (optionIndex)) - { - _setOptions.Remove (optionIndex); - - _options? [optionIndex].OnUnSet (); - } - } - - // Raise slider changed event. - OnOptionsChanged (); - } - - private bool SetFocusedOption () - { - if (_options is null or { Count: 0 }) - { - return false; - } - - var changed = false; - - switch (_config._type) - { - case LinearRangeType.Single: - case LinearRangeType.LeftRange: - case LinearRangeType.RightRange: - - if (_setOptions.Count == 1) - { - int prev = _setOptions [0]; - - if (!_config._allowEmpty && prev == FocusedOption) - { - break; - } - - _setOptions.Clear (); - _options [FocusedOption].OnUnSet (); - - if (FocusedOption != prev) - { - _setOptions.Add (FocusedOption); - _options [FocusedOption].OnSet (); - } - } - else - { - _setOptions.Add (FocusedOption); - _options [FocusedOption].OnSet (); - } - - // Raise slider changed event. - OnOptionsChanged (); - changed = true; - - break; - - case LinearRangeType.Multiple: - if (_setOptions.Contains (FocusedOption)) - { - if (!_config._allowEmpty && _setOptions.Count == 1) - { - break; - } - - _setOptions.Remove (FocusedOption); - _options [FocusedOption].OnUnSet (); - } - else - { - _setOptions.Add (FocusedOption); - _options [FocusedOption].OnSet (); - } - - OnOptionsChanged (); - changed = true; - - break; - - case LinearRangeType.Range: - if (_config._rangeAllowSingle) - { - if (_setOptions.Count == 1) - { - int prev = _setOptions [0]; - - if (!_config._allowEmpty && prev == FocusedOption) - { - break; - } - - if (FocusedOption == prev) - { - // un-set - _setOptions.Clear (); - _options [FocusedOption].OnUnSet (); - } - else - { - _setOptions [0] = FocusedOption; - _setOptions.Add (prev); - _setOptions.Sort (); - _options [FocusedOption].OnSet (); - } - } - else if (_setOptions.Count == 0) - { - _setOptions.Add (FocusedOption); - _options [FocusedOption].OnSet (); - } - else - { - // Extend/Shrink - if (FocusedOption < _setOptions [0]) - { - // extend left - _options [_setOptions [0]].OnUnSet (); - _setOptions [0] = FocusedOption; - } - else if (FocusedOption > _setOptions [1]) - { - // extend right - _options [_setOptions [1]].OnUnSet (); - _setOptions [1] = FocusedOption; - } - else if (FocusedOption >= _setOptions [0] && FocusedOption <= _setOptions [1]) - { - if (FocusedOption < _lastFocusedOption) - { - // shrink to the left - _options [_setOptions [1]].OnUnSet (); - _setOptions [1] = FocusedOption; - } - else if (FocusedOption > _lastFocusedOption) - { - // shrink to the right - _options [_setOptions [0]].OnUnSet (); - _setOptions [0] = FocusedOption; - } - - if (_setOptions.Count > 1 && _setOptions [0] == _setOptions [1]) - { - _setOptions.Clear (); - _setOptions.Add (FocusedOption); - } - } - } - } - else - { - if (_setOptions.Count == 1) - { - int prev = _setOptions [0]; - - if (!_config._allowEmpty && prev == FocusedOption) - { - break; - } - - _setOptions [0] = FocusedOption; - _setOptions.Add (prev); - _setOptions.Sort (); - _options [FocusedOption].OnSet (); - } - else if (_setOptions.Count == 0) - { - _setOptions.Add (FocusedOption); - _options [FocusedOption].OnSet (); - int next = FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption - 1; - _setOptions.Add (next); - _options [next].OnSet (); - } - else - { - // Extend/Shrink - if (FocusedOption < _setOptions [0]) - { - // extend left - _options [_setOptions [0]].OnUnSet (); - _setOptions [0] = FocusedOption; - } - else if (FocusedOption > _setOptions [1]) - { - // extend right - _options [_setOptions [1]].OnUnSet (); - _setOptions [1] = FocusedOption; - } - else if (FocusedOption >= _setOptions [0] && FocusedOption <= _setOptions [1] && _setOptions [1] - _setOptions [0] > 1) - { - if (FocusedOption < _lastFocusedOption) - { - // shrink to the left - _options [_setOptions [1]].OnUnSet (); - _setOptions [1] = FocusedOption; - } - else if (FocusedOption > _lastFocusedOption) - { - // shrink to the right - _options [_setOptions [0]].OnUnSet (); - _setOptions [0] = FocusedOption; - } - } - } - } - - // Raise LinearRange Option Changed Event. - OnOptionsChanged (); - changed = true; - - break; - - default: - throw new ArgumentOutOfRangeException (_config._type.ToString ()); - } - - return changed; - } - - internal bool ExtendPlus () - { - int next = _options is { } && FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption; - - if (next != FocusedOption && !OnOptionFocused (next, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - SetFocusedOption (); - } - - return true; - } - - internal bool ExtendMinus () - { - int prev = FocusedOption > 0 ? FocusedOption - 1 : FocusedOption; - - if (prev != FocusedOption && !OnOptionFocused (prev, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - SetFocusedOption (); - } - - return true; - } - - /// - protected override void OnActivated (ICommandContext? ctx) - { - base.OnActivated (ctx); - SetFocusedOption (); - } - - /// - protected override bool OnAccepting (CommandEventArgs args) - { - SetFocusedOption (); - - return false; - } - - internal bool Select () => SetFocusedOption (); - - internal bool Accept (ICommandContext? commandContext) - { - SetFocusedOption (); - - return RaiseAccepting (commandContext) == true; - } - - internal bool MovePlus () - { - bool cancelled = OnOptionFocused (FocusedOption + 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption)); - - if (cancelled) - { - return false; - } - - if (!AllowEmpty) - { - SetFocusedOption (); - } - - return true; - } - - internal bool MoveMinus () - { - bool cancelled = OnOptionFocused (FocusedOption - 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption)); - - if (cancelled) - { - return false; - } - - if (!AllowEmpty) - { - SetFocusedOption (); - } - - return true; - } - - internal bool MoveStart () - { - if (OnOptionFocused (0, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - return false; - } - - if (!AllowEmpty) - { - SetFocusedOption (); - } - - return true; - } - - internal bool MoveEnd () - { - if (OnOptionFocused (_options!.Count - 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) - { - return false; - } - - if (!AllowEmpty) - { - SetFocusedOption (); - } - - return true; - } - - #endregion + public LinearRange (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation) + { } } diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeConfiguration.cs b/Terminal.Gui/Views/LinearRange/LinearRangeConfiguration.cs index f0a94b62d0..33036fb087 100644 --- a/Terminal.Gui/Views/LinearRange/LinearRangeConfiguration.cs +++ b/Terminal.Gui/Views/LinearRange/LinearRangeConfiguration.cs @@ -1,6 +1,6 @@ namespace Terminal.Gui.Views; -/// All configuration are grouped in this class. +/// All configuration is grouped in this class. internal class LinearRangeConfiguration { internal bool _allowEmpty; @@ -14,6 +14,6 @@ internal class LinearRangeConfiguration internal bool _showLegendsAbbr; internal Orientation _linearRangeOrientation = Orientation.Horizontal; internal int _startSpacing; - internal LinearRangeType _type = LinearRangeType.Single; + internal LinearRangeRenderMode _renderMode = LinearRangeRenderMode.Single; internal bool _useMinimumSize; } diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeDefaults.cs b/Terminal.Gui/Views/LinearRange/LinearRangeDefaults.cs new file mode 100644 index 0000000000..445c3f749d --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeDefaults.cs @@ -0,0 +1,13 @@ +namespace Terminal.Gui.Views; + +/// +/// Theme-scoped defaults shared by all +/// subclasses (, , +/// ). +/// +public static class LinearRangeDefaults +{ + /// Gets or sets the default cursor style applied to a new linear range view. + [ConfigurationProperty (Scope = typeof (ThemeScope))] + public static CursorStyle DefaultCursorStyle { get; set; } = CursorStyle.BlinkingBlock; +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeOption.cs b/Terminal.Gui/Views/LinearRange/LinearRangeOption.cs index 47fc52580c..dc9cad34e9 100644 --- a/Terminal.Gui/Views/LinearRange/LinearRangeOption.cs +++ b/Terminal.Gui/Views/LinearRange/LinearRangeOption.cs @@ -25,7 +25,7 @@ public LinearRangeOption (string legend, Rune legendAbbr, T data) public string? Legend { get; set; } /// - /// Abbreviation of the Legend. When the too small to fit + /// Abbreviation of the Legend. Used when the inner spacing is too small to fit /// . /// public Rune LegendAbbr { get; set; } diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeRenderMode.cs b/Terminal.Gui/Views/LinearRange/LinearRangeRenderMode.cs new file mode 100644 index 0000000000..12adf55e90 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeRenderMode.cs @@ -0,0 +1,30 @@ +namespace Terminal.Gui.Views; + +/// +/// Selection rendering mode used by to +/// drive selection drawing and hit-testing. Each concrete subclass sets this once +/// in its constructor (or, for , whenever +/// changes). +/// +/// +/// This enum is exposed publicly only because it appears in the protected constructor +/// signature of ; library consumers should +/// pick a concrete subclass rather than instantiate the base directly. +/// +public enum LinearRangeRenderMode +{ + /// One option may be selected at a time. + Single, + + /// Any number of options may be selected at the same time. + Multiple, + + /// A range bounded only by an end point: "everything ≤ End". + LeftSpan, + + /// A range bounded only by a start point: "everything ≥ Start". + RightSpan, + + /// A range bounded by both a start and an end point. + Span +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeSpan.cs b/Terminal.Gui/Views/LinearRange/LinearRangeSpan.cs new file mode 100644 index 0000000000..497439cce4 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeSpan.cs @@ -0,0 +1,59 @@ +namespace Terminal.Gui.Views; + +/// +/// Represents the value of a . +/// +/// The data type of the underlying option. +/// +/// +/// A span is one of four kinds: +/// , +/// , +/// , +/// or . +/// +/// +/// To create an empty span, use . +/// To create a closed span between two bounds, use the corresponding constructor and pass the +/// option indices and data values for both ends. +/// +/// +/// StartIndex and EndIndex are option indices into ; +/// they are -1 when not relevant for the current . +/// +/// +public readonly record struct LinearRangeSpan +{ + /// Initializes a new instance of . + /// The kind of span. + /// The start data value (meaningful when is or ). + /// The end data value (meaningful when is or ). + /// The index of in the options list, or -1. + /// The index of in the options list, or -1. + public LinearRangeSpan (LinearRangeSpanKind kind, T? start, T? end, int startIndex, int endIndex) + { + Kind = kind; + Start = start; + End = end; + StartIndex = startIndex; + EndIndex = endIndex; + } + + /// Gets an empty span ( = ). + public static LinearRangeSpan Empty { get; } = new (LinearRangeSpanKind.None, default, default, -1, -1); + + /// Gets the kind of span. + public LinearRangeSpanKind Kind { get; } + + /// Gets the start data value (meaningful when is or ). + public T? Start { get; } + + /// Gets the end data value (meaningful when is or ). + public T? End { get; } + + /// Gets the index of in the options list, or -1. + public int StartIndex { get; } + + /// Gets the index of in the options list, or -1. + public int EndIndex { get; } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeSpanKind.cs b/Terminal.Gui/Views/LinearRange/LinearRangeSpanKind.cs new file mode 100644 index 0000000000..e004403357 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeSpanKind.cs @@ -0,0 +1,25 @@ +namespace Terminal.Gui.Views; + +/// +/// Identifies the shape of a . +/// +/// +/// +/// To represent the kind of range a currently holds, set the value via +/// . +/// +/// +public enum LinearRangeSpanKind +{ + /// The span is empty; no option is selected. + None, + + /// The span is bounded only on the right; conceptually "everything ≤ End". + LeftBounded, + + /// The span is bounded only on the left; conceptually "everything ≥ Start". + RightBounded, + + /// The span is closed; both Start and End are bounded. + Closed +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeT.cs b/Terminal.Gui/Views/LinearRange/LinearRangeT.cs new file mode 100644 index 0000000000..46541e60a6 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeT.cs @@ -0,0 +1,308 @@ +namespace Terminal.Gui.Views; + +/// +/// A linear range view representing a contiguous range of options. The current value is a +/// whose is one of +/// , , +/// , or . +/// +/// The data type of the options. +/// +/// +/// To switch between left-bounded, right-bounded, and closed range modes, set +/// . Setting migrates the current +/// , dropping fields that are no longer relevant. +/// +/// +/// To change the selection programmatically, set . Empty selections may be +/// represented either by or by a span of any +/// with no matching options. +/// +/// +public class LinearRange : LinearRangeViewBase>, IDesignable +{ + private LinearRangeSpan _value = LinearRangeSpan.Empty; + private LinearRangeSpanKind _rangeKind = LinearRangeSpanKind.Closed; + + /// Initializes a new instance of . + public LinearRange () : base (LinearRangeRenderMode.Span) { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearRange (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation, LinearRangeRenderMode.Span) { } + + /// + /// Gets or sets whether the range is allowed to collapse to a single option (only meaningful + /// when is ). + /// + public bool RangeAllowSingle + { + get => RangeAllowSingleInternal; + set => RangeAllowSingleInternal = value; + } + + /// + /// Gets or sets the kind of range. The default is . + /// + /// + /// + /// Setting this property re-renders the view in the new shape and migrates the current + /// : e.g. switching from to + /// drops the + /// and ; switching to + /// drops the + /// and ; switching to + /// clears the value. + /// + /// + public LinearRangeSpanKind RangeKind + { + get => _rangeKind; + set + { + if (_rangeKind == value) + { + return; + } + + _rangeKind = value; + + // Update internal render mode to match. + RenderMode = value switch + { + LinearRangeSpanKind.LeftBounded => LinearRangeRenderMode.LeftSpan, + LinearRangeSpanKind.RightBounded => LinearRangeRenderMode.RightSpan, + LinearRangeSpanKind.Closed => LinearRangeRenderMode.Span, + _ => LinearRangeRenderMode.Span + }; + + // Migrate current Value to the new kind. + LinearRangeSpan migrated = MigrateValueToKind (_value, value); + + if (!_value.Equals (migrated)) + { + LinearRangeSpan previous = _value; + _value = migrated; + + // Sync indices to reflect the migrated value. + ApplySelectedIndices (IndicesForValue (migrated)); + RaiseValueChanged (previous, migrated); + } + } + } + + /// + public override LinearRangeSpan Value + { + get => _value; + set + { + LinearRangeSpan current = _value; + + if (current.Equals (value)) + { + return; + } + + if (RaiseValueChanging (current, value)) + { + return; + } + + _value = value; + + ApplySelectedIndices (IndicesForValue (value)); + RaiseValueChanged (current, _value); + } + } + + /// + protected override void OnSelectionChanged () + { + LinearRangeSpan previous = _value; + LinearRangeSpan next = SpanFromIndices (SelectedIndices); + + if (previous.Equals (next)) + { + return; + } + + _value = next; + RaiseValueChanged (previous, next); + } + + private LinearRangeSpan SpanFromIndices (IReadOnlyList indices) + { + if (indices.Count == 0) + { + return new LinearRangeSpan (_rangeKind == LinearRangeSpanKind.None ? LinearRangeSpanKind.None : _rangeKind, + default, + default, + -1, + -1); + } + + // Sort to get logical [low, high] + List sorted = new (indices); + sorted.Sort (); + int lo = sorted [0]; + int hi = sorted [^1]; + + switch (_rangeKind) + { + case LinearRangeSpanKind.LeftBounded: + // Only the end is bounded + return new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, + default, + Options [hi].Data, + -1, + hi); + case LinearRangeSpanKind.RightBounded: + // Only the start is bounded + return new LinearRangeSpan (LinearRangeSpanKind.RightBounded, + Options [lo].Data, + default, + lo, + -1); + case LinearRangeSpanKind.Closed: + default: + // Closed (or fallback): both bounds + if (sorted.Count == 1) + { + return new LinearRangeSpan (LinearRangeSpanKind.Closed, + Options [lo].Data, + Options [lo].Data, + lo, + lo); + } + + return new LinearRangeSpan (LinearRangeSpanKind.Closed, + Options [lo].Data, + Options [hi].Data, + lo, + hi); + } + } + + private List IndicesForValue (LinearRangeSpan span) + { + switch (span.Kind) + { + case LinearRangeSpanKind.None: + return []; + case LinearRangeSpanKind.LeftBounded: + { + int end = span.EndIndex >= 0 ? span.EndIndex : IndexOfData (span.End); + + return end >= 0 ? [end] : []; + } + case LinearRangeSpanKind.RightBounded: + { + int start = span.StartIndex >= 0 ? span.StartIndex : IndexOfData (span.Start); + + return start >= 0 ? [start] : []; + } + case LinearRangeSpanKind.Closed: + default: + { + int start = span.StartIndex >= 0 ? span.StartIndex : IndexOfData (span.Start); + int end = span.EndIndex >= 0 ? span.EndIndex : IndexOfData (span.End); + + if (start < 0 && end < 0) + { + return []; + } + + if (start < 0) + { + return [end]; + } + + if (end < 0 || start == end) + { + return [start]; + } + + return [start, end]; + } + } + } + + private static LinearRangeSpan MigrateValueToKind (LinearRangeSpan value, LinearRangeSpanKind newKind) + { + if (newKind == LinearRangeSpanKind.None) + { + return LinearRangeSpan.Empty; + } + + if (value.Kind == LinearRangeSpanKind.None) + { + return value; + } + + return newKind switch + { + LinearRangeSpanKind.LeftBounded => new LinearRangeSpan ( + LinearRangeSpanKind.LeftBounded, + default, + value.Kind == LinearRangeSpanKind.RightBounded ? value.Start : value.End, + -1, + value.Kind == LinearRangeSpanKind.RightBounded ? value.StartIndex : value.EndIndex), + LinearRangeSpanKind.RightBounded => new LinearRangeSpan ( + LinearRangeSpanKind.RightBounded, + value.Kind == LinearRangeSpanKind.LeftBounded ? value.End : value.Start, + default, + value.Kind == LinearRangeSpanKind.LeftBounded ? value.EndIndex : value.StartIndex, + -1), + LinearRangeSpanKind.Closed => value.Kind == LinearRangeSpanKind.Closed + ? value + : value.Kind == LinearRangeSpanKind.LeftBounded + ? new LinearRangeSpan (LinearRangeSpanKind.Closed, value.End, value.End, value.EndIndex, value.EndIndex) + : new LinearRangeSpan (LinearRangeSpanKind.Closed, value.Start, value.Start, value.StartIndex, value.StartIndex), + _ => value + }; + } + + /// + /// Loads demo data suitable for a designer preview: a closed range of work hours + /// (8 AM through 6 PM in one-hour increments) with the range preset to "9 AM"–"5 PM". + /// Only populated when is ; for any other type, + /// the view is left untouched and is returned. + /// + /// if demo data was loaded. + public virtual bool EnableForDesign () + { + if (typeof (T) != typeof (string)) + { + return false; + } + + Title = "Work Hours"; + AssignHotKeys = true; + ShowLegends = true; + RangeKind = LinearRangeSpanKind.Closed; + RangeAllowSingle = true; + + string [] hours = + [ + "8 AM", "9 AM", "10 AM", "11 AM", "12 PM", + "1 PM", "2 PM", "3 PM", "4 PM", "5 PM", "6 PM" + ]; + + Options = hours.Select (h => new LinearRangeOption (h, (Rune)h [0], (T)(object)h)).ToList (); + + const int startIdx = 1; // "9 AM" + const int endIdx = 9; // "5 PM" + + Value = new LinearRangeSpan ( + LinearRangeSpanKind.Closed, + (T)(object)hours [startIdx], + (T)(object)hours [endIdx], + startIdx, + endIdx); + + return true; + } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeType.cs b/Terminal.Gui/Views/LinearRange/LinearRangeType.cs deleted file mode 100644 index 0633a416e0..0000000000 --- a/Terminal.Gui/Views/LinearRange/LinearRangeType.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Terminal.Gui.Views; - -/// Types -public enum LinearRangeType -{ - /// - /// - /// ├─┼─┼─┼─┼─█─┼─┼─┼─┼─┼─┼─┤ - /// - /// - Single, - - /// - /// - /// ├─┼─█─┼─┼─█─┼─┼─┼─┼─█─┼─┤ - /// - /// - Multiple, - - /// - /// - /// ├▒▒▒▒▒▒▒▒▒█─┼─┼─┼─┼─┼─┼─┤ - /// - /// - LeftRange, - - /// - /// - /// ├─┼─┼─┼─┼─█▒▒▒▒▒▒▒▒▒▒▒▒▒┤ - /// - /// - RightRange, - - /// - /// - /// ├─┼─┼─┼─┼─█▒▒▒▒▒▒▒█─┼─┼─┤ - /// - /// - Range -} diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs b/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs new file mode 100644 index 0000000000..522b8eac67 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs @@ -0,0 +1,2289 @@ +namespace Terminal.Gui.Views; + +/// +/// Abstract base for linear range views (, +/// , ) that present a list of typed options +/// navigable by keyboard or mouse, and expose the current selection as a strongly-typed value via +/// . +/// +/// The data type carried by each . +/// The shape of ; defined by the concrete subclass. +/// +/// Default key bindings (when is ): +/// +/// +/// Key Action +/// +/// +/// Left / Right Moves to the previous or next option. +/// +/// +/// Ctrl+Left / Ctrl+Right Moves by a larger step. +/// +/// +/// Default key bindings (when is ): +/// +/// +/// Key Action +/// +/// +/// Up / Down Moves to the previous or next option. +/// +/// +/// Ctrl+Up / Ctrl+Down Moves by a larger step. +/// +/// +/// Common key bindings (both orientations): +/// +/// +/// Key Action +/// +/// +/// Home / End Moves to the first or last option. +/// +/// +/// Enter Accepts the current selection (). +/// +/// +/// Space +/// Activates the current selection (). +/// +/// +/// +/// Common bindings (Home, End, Enter, Space) are configurable via and +/// . Orientation-dependent cursor bindings are set dynamically +/// and cannot be reconfigured. +/// +/// +public abstract class LinearRangeViewBase : View, IOrientation, IValue +{ + /// + /// Gets or sets the view-specific default key bindings shared by all linear range views. + /// Contains only bindings unique to this family; shared bindings come from . + /// + /// IMPORTANT: This is a process-wide static property. Change with care. + /// Do not set in parallelizable unit tests. + /// + /// + /// + /// + /// No is applied because this is a generic + /// type. Use with key "LinearRange" to override bindings via + /// configuration. + /// + /// + public new static Dictionary? DefaultKeyBindings { get; set; } = new () + { + [Command.Accept] = Bind.All (Key.Enter), + [Command.Activate] = Bind.All (Key.Space), + }; + + private readonly LinearRangeConfiguration _config = new (); + + // List of the current set options. + private readonly List _setOptions = []; + + // Options + private List>? _options; + + private OrientationHelper? _orientationHelper; + + #region Initialize + + private void SetInitialProperties (List> options, Orientation orientation = Orientation.Horizontal) + { + Width = Dim.Auto (DimAutoStyle.Content); + Height = Dim.Auto (DimAutoStyle.Content); + CanFocus = true; + + _options = options; + + // ReSharper disable once UseObjectOrCollectionInitializer + _orientationHelper = new OrientationHelper (this); // Do not use object initializer! + _orientationHelper.Orientation = _config._linearRangeOrientation = orientation; + _orientationHelper.OrientationChanging += (_, e) => OrientationChanging?.Invoke (this, e); + _orientationHelper.OrientationChanged += (_, e) => OrientationChanged?.Invoke (this, e); + + SetDefaultStyle (); + SetCommands (); + SetContentSize (); + + SubViewLayout += (_, _) => { SetContentSize (); }; + } + + // TODO: Make configurable via ConfigurationManager + private void SetDefaultStyle () + { + _config._showLegends = true; + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal: + Style.SpaceChar = new Cell { Grapheme = Glyphs.HLine.ToString () }; // '─' + Style.OptionChar = new Cell { Grapheme = Glyphs.BlackCircle.ToString () }; // '┼●🗹□⏹' + + break; + + case Orientation.Vertical: + Style.SpaceChar = new Cell { Grapheme = Glyphs.VLine.ToString () }; + Style.OptionChar = new Cell { Grapheme = Glyphs.BlackCircle.ToString () }; + + break; + } + + _config._legendsOrientation = _config._linearRangeOrientation; + Style.EmptyChar = new Cell { Grapheme = " " }; + Style.SetChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; // ■ + Style.RangeChar = new Cell { Grapheme = Glyphs.Stipple.ToString () }; // ░ ▒ ▓ // Medium shade not blinking on curses. + Style.StartRangeChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; + Style.EndRangeChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; + Style.DragChar = new Cell { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; + } + + #endregion + + #region Constructors + + /// Initializes a new instance of the class. + /// The selection rendering mode used by this concrete subclass. + protected LinearRangeViewBase (LinearRangeRenderMode renderMode) : this (new List (), Orientation.Horizontal, renderMode) { } + + /// Initializes a new instance of the class. + /// Initial options. + /// Initial orientation. + /// The selection rendering mode used by this concrete subclass. + protected LinearRangeViewBase (List? options, Orientation orientation, LinearRangeRenderMode renderMode) + { + _config._renderMode = renderMode; + Cursor = new Cursor { Style = LinearRangeDefaults.DefaultCursorStyle }; + + if (options is null) + { + return; + } + + if (options is { Count: 0 }) + { + SetInitialProperties ([], orientation); + } + else + { + SetInitialProperties (options.Select (e => + { + var legend = e?.ToString (); + + return new LinearRangeOption + { + Data = e, Legend = legend, LegendAbbr = (Rune)(legend?.Length > 0 ? legend [0] : ' ') + }; + }) + .ToList (), + orientation); + } + } + + #endregion + + #region Properties + + /// + /// Setting the Text of a linear range is a shortcut to setting options. The text is a CSV string of the options. + /// + public override string Text + { + // Return labels as a CSV string + get => _options is null or { Count: 0 } ? string.Empty : string.Join (",", _options); + set + { + if (string.IsNullOrEmpty (value)) + { + Options = []; + } + else + { + IEnumerable list = value.Split (',').Select (x => x.Trim ()); + Options = list.Select (x => new LinearRangeOption { Legend = x }).ToList (); + } + } + } + + /// Allow no selection. + public bool AllowEmpty + { + get => _config._allowEmpty; + set + { + _config._allowEmpty = value; + + if (!value && _options!.Count > 0 && _setOptions.Count == 0) + { + FocusedOption = 0; + SetFocusedOption (); + } + } + } + + /// Gets or sets the minimum number of rows/columns between . The default is 1. + public int MinimumInnerSpacing + { + get => _config._minInnerSpacing; + set + { + int current = _config._minInnerSpacing; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnMinimumInnerSpacingChanging, + MinimumInnerSpacingChanging, + newValue => + { + _config._minInnerSpacing = newValue; + SetContentSize (); + }, + OnMinimumInnerSpacingChanged, + MinimumInnerSpacingChanged, + out int _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? MinimumInnerSpacingChanging; + + /// Event raised after the property has changed. + public event EventHandler>? MinimumInnerSpacingChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnMinimumInnerSpacingChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnMinimumInnerSpacingChanged (ValueChangedEventArgs args) { } + + /// + /// Gets the internal selection rendering mode set by the concrete subclass. Drives drawing, + /// hit-testing, and the behaviour. + /// + internal LinearRangeRenderMode RenderMode + { + get => _config._renderMode; + set + { + if (_config._renderMode == value) + { + return; + } + + ApplySelectedIndices ([]); + _config._renderMode = value; + SetNeedsDraw (); + } + } + + /// + /// Gets or sets the . The default is . + /// + public Orientation Orientation { get => _orientationHelper!.Orientation; set => _orientationHelper!.Orientation = value; } + + #region IOrientation members + + /// + public event EventHandler>? OrientationChanging; + + /// + public event EventHandler>? OrientationChanged; + + /// + public void OnOrientationChanged (Orientation newOrientation) + { + _config._linearRangeOrientation = newOrientation; + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal: + Style.SpaceChar = new Cell { Grapheme = Glyphs.HLine.ToString () }; // '─' + + break; + + case Orientation.Vertical: + Style.SpaceChar = new Cell { Grapheme = Glyphs.VLine.ToString () }; + + break; + } + + SetKeyBindings (); + SetContentSize (); + } + + #endregion + + /// Legends Orientation. + public Orientation LegendsOrientation + { + get => _config._legendsOrientation; + set + { + Orientation current = _config._legendsOrientation; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnLegendsOrientationChanging, + LegendsOrientationChanging, + newValue => + { + _config._legendsOrientation = newValue; + SetContentSize (); + }, + OnLegendsOrientationChanged, + LegendsOrientationChanged, + out Orientation _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? LegendsOrientationChanging; + + /// Event raised after the property has changed. + public event EventHandler>? LegendsOrientationChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnLegendsOrientationChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnLegendsOrientationChanged (ValueChangedEventArgs args) { } + + /// LinearRange styles. + public LinearRangeStyle Style { get; set; } = new (); + + /// + /// Set the linear range options. When the new options no longer contain the previously selected + /// value(s), the selection is dropped (event semantics depend on the concrete subclass). + /// + public List> Options + { + get => _options ?? []; + set + { + // _options should never be null + _options = value ?? throw new ArgumentNullException (nameof (value)); + + // Drop any selected indices that are no longer valid + _setOptions.RemoveAll (i => i < 0 || i >= _options.Count); + + if (_options.Count == 0) + { + return; + } + + SetContentSize (); + } + } + + /// + /// Internal accessor for whether a range is allowed to collapse to a single option (only meaningful + /// when is ). Exposed publicly only + /// by . + /// + internal bool RangeAllowSingleInternal { get => _config._rangeAllowSingle; set => _config._rangeAllowSingle = value; } + + /// Show/Hide spacing before and after the first and last option. + public bool ShowEndSpacing + { + get => _config._showEndSpacing; + set + { + bool current = _config._showEndSpacing; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnShowEndSpacingChanging, + ShowEndSpacingChanging, + newValue => + { + _config._showEndSpacing = newValue; + SetContentSize (); + }, + OnShowEndSpacingChanged, + ShowEndSpacingChanged, + out bool _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? ShowEndSpacingChanging; + + /// Event raised after the property has changed. + public event EventHandler>? ShowEndSpacingChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnShowEndSpacingChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnShowEndSpacingChanged (ValueChangedEventArgs args) { } + + /// Show/Hide the options legends. + public bool ShowLegends + { + get => _config._showLegends; + set + { + bool current = _config._showLegends; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnShowLegendsChanging, + ShowLegendsChanging, + newValue => + { + _config._showLegends = newValue; + SetContentSize (); + }, + OnShowLegendsChanged, + ShowLegendsChanged, + out bool _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? ShowLegendsChanging; + + /// Event raised after the property has changed. + public event EventHandler>? ShowLegendsChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnShowLegendsChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnShowLegendsChanged (ValueChangedEventArgs args) { } + + /// + /// Gets or sets whether the minimum or ideal size will be used when calculating the size of the linear range. + /// + public bool UseMinimumSize + { + get => _config._useMinimumSize; + set + { + bool current = _config._useMinimumSize; + + CWPPropertyHelper.ChangeProperty (this, + ref current, + value, + OnUseMinimumSizeChanging, + UseMinimumSizeChanging, + newValue => + { + _config._useMinimumSize = newValue; + SetContentSize (); + }, + OnUseMinimumSizeChanged, + UseMinimumSizeChanged, + out bool _); + } + } + + /// Event raised before the property changes. Can be cancelled. + public event EventHandler>? UseMinimumSizeChanging; + + /// Event raised after the property has changed. + public event EventHandler>? UseMinimumSizeChanged; + + /// Called before changes. Return true to cancel the change. + protected virtual bool OnUseMinimumSizeChanging (ValueChangingEventArgs args) => false; + + /// Called after has changed. + protected virtual void OnUseMinimumSizeChanged (ValueChangedEventArgs args) { } + + #endregion + + #region Events + + /// + /// Internal hook fired whenever changes due to user input + /// (keyboard, mouse, command). Concrete subclasses override + /// to compute and publish their . + /// + internal void RaiseSelectionChanged () + { + OnSelectionChanged (); + SetNeedsDraw (); + } + + /// + /// Called by the base when the selected indices have changed due to user input. + /// Concrete subclasses must compute their from the current + /// selection and raise / . + /// + /// + /// Subclasses should not raise from this hook; + /// is reserved for direct writes to . + /// + protected abstract void OnSelectionChanged (); + + /// Event raised When the option is hovered with the keys or the mouse. + public event EventHandler>? OptionFocused; + + private int _lastFocusedOption; // for Range type; the most recently focused option. Used to determine shrink direction + + /// Overridable function that fires the event. + /// + /// if the focus change was cancelled. + /// + public virtual bool OnOptionFocused (int newFocusedOption, LinearRangeEventArgs args) + { + if (newFocusedOption > _options!.Count - 1 || newFocusedOption < 0) + { + return true; + } + + OptionFocused?.Invoke (this, args); + + if (args.Cancel) + { + return args.Cancel; + } + _lastFocusedOption = FocusedOption; + FocusedOption = newFocusedOption; + + return args.Cancel; + } + + #endregion Events + + #region IValue Implementation + + /// + public abstract TValue? Value { get; set; } + + /// + public event EventHandler>? ValueChanging; + + /// + public event EventHandler>? ValueChanged; + + /// + public event EventHandler>? ValueChangedUntyped; + + /// Raises . Returns if cancelled. + protected bool RaiseValueChanging (TValue? currentValue, TValue? newValue) + { + ValueChangingEventArgs args = new (currentValue, newValue); + ValueChanging?.Invoke (this, args); + + return args.Handled; + } + + /// Raises and . + protected void RaiseValueChanged (TValue? previousValue, TValue? newValue) + { + ValueChanged?.Invoke (this, new ValueChangedEventArgs (previousValue, newValue)); + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (previousValue, newValue)); + } + + #endregion + + #region Selection Helpers (subclass support) + + /// Gets the currently selected option indices, in selection order, as a read-only snapshot. + /// To enumerate the selected option data values, project this list against . + protected internal IReadOnlyList SelectedIndices => _setOptions.AsReadOnly (); + + /// + /// Replaces the selected indices without raising . + /// Used by concrete subclass setters to apply their value back to the index model. + /// + /// The new selected indices. Out-of-range entries are ignored. + protected internal void ApplySelectedIndices (IReadOnlyList indices) + { + if (_options is null) + { + return; + } + + // Unset previous + foreach (int i in _setOptions) + { + if (i >= 0 && i < _options.Count) + { + _options [i].OnUnSet (); + } + } + + _setOptions.Clear (); + + // Set new + foreach (int i in indices) + { + if (i < 0 || i >= _options.Count || _setOptions.Contains (i)) + { + continue; + } + + _setOptions.Add (i); + _options [i].OnSet (); + } + + SetNeedsDraw (); + } + + /// + /// Finds the first option whose equals + /// using the default equality comparer for . + /// + /// The option index, or -1 if no match is found. + protected internal int IndexOfData (TOption? data) + { + if (_options is null) + { + return -1; + } + + EqualityComparer cmp = EqualityComparer.Default; + + for (var i = 0; i < _options.Count; i++) + { + if (cmp.Equals (_options [i].Data, data)) + { + return i; + } + } + + return -1; + } + + #endregion + + #region Public Methods + + /// The focused option (has the cursor). + public int FocusedOption + { + get; + set + { + if (field == value) + { + return; + } + field = value; + UpdateCursor (); + } + } + + #endregion Public Methods + + #region Helpers + + private void MoveAndAdd (int x, int y, Rune rune) + { + Move (x, y); + AddRune (rune); + } + + private void MoveAndAdd (int x, int y, string str) + { + Move (x, y); + AddStr (str); + } + + /// Sets the dimensions of the LinearRange to the ideal values. + private void SetContentSize () + { + if (_options is { Count: 0 }) + { + return; + } + + bool horizontal = _config._linearRangeOrientation == Orientation.Horizontal; + + if (UseMinimumSize) + { + CalcSpacingConfig (CalcMinLength ()); + } + else + { + CalcSpacingConfig (horizontal ? Viewport.Width : Viewport.Height); + } + + SetContentSize (new Size (GetIdealWidth (), GetIdealHeight ())); + + return; + + void CalcSpacingConfig (int size) + { + _config._cachedInnerSpacing = 0; + _config._startSpacing = 0; + _config._endSpacing = 0; + + int maxLegend; // Because the legends are centered, the longest one determines inner spacing + + if (_config._linearRangeOrientation == _config._legendsOrientation) + { + maxLegend = int.Max (_options!.Max (s => s.Legend?.GetColumns () ?? 1), 1); + } + else + { + maxLegend = 1; + } + + int minSizeThatFitsLegends = _options!.Count == 1 ? maxLegend : _options.Sum (o => o.Legend!.GetColumns ()); + + string? first; + string? last; + + _config._showLegendsAbbr = false; + + if (minSizeThatFitsLegends > size) + { + if (_config._linearRangeOrientation == _config._legendsOrientation) + { + _config._showLegendsAbbr = true; + + foreach (LinearRangeOption o in _options.Where (op => op.LegendAbbr == default (Rune))) + { + o.LegendAbbr = (Rune)(o.Legend?.GetColumns () > 0 ? o.Legend [0] : ' '); + } + } + + first = "x"; + last = "x"; + } + else + { + first = _options.First ().Legend; + last = _options.Last ().Legend; + } + + // --o-- + // Hello + // Left = He + // Right = lo + int firstLeft = (first!.Length - 1) / 2; // Chars count of the first option to the left. + int lastRight = last!.Length / 2; // Chars count of the last option to the right. + + if (_config._linearRangeOrientation != _config._legendsOrientation) + { + firstLeft = 0; + lastRight = 0; + } + + // -1 because it's better to have an extra space at right than to clip + int width = size - firstLeft - lastRight - 1; + + _config._startSpacing = firstLeft; + + if (_options.Count == 1) + { + _config._cachedInnerSpacing = maxLegend; + } + else + { + _config._cachedInnerSpacing = Math.Max (0, (int)Math.Floor ((double)width / (_options.Count - 1)) - 1); + } + + _config._cachedInnerSpacing = Math.Max (_config._minInnerSpacing, _config._cachedInnerSpacing); + + _config._endSpacing = lastRight; + } + } + + /// Calculates the min dimension required for all options and inner spacing with abbreviated legends + /// + private int CalcMinLength () + { + if (_options is { Count: 0 }) + { + return 0; + } + + var length = 0; + length += _config._startSpacing + _config._endSpacing; + length += _options!.Count; + length += (_options.Count - 1) * _config._minInnerSpacing; + + return length; + } + + /// + /// Gets the ideal width of the linear range. The ideal width is the minimum width required to display all options and + /// inner + /// spacing. + /// + /// + public int GetIdealWidth () + { + if (UseMinimumSize) + { + return Orientation == Orientation.Horizontal ? CalcMinLength () : CalcIdealThickness (); + } + + return Orientation == Orientation.Horizontal ? CalcIdealLength () : CalcIdealThickness (); + } + + /// + /// Gets the ideal height of the linear range. The ideal height is the minimum height required to display all options + /// and + /// inner spacing. + /// + /// + public int GetIdealHeight () + { + if (UseMinimumSize) + { + return Orientation == Orientation.Horizontal ? CalcIdealThickness () : CalcMinLength (); + } + + return Orientation == Orientation.Horizontal ? CalcIdealThickness () : CalcIdealLength (); + } + + /// + /// Calculates the ideal dimension required for all options, inner spacing, and legends (non-abbreviated, with one + /// space between). + /// + /// + private int CalcIdealLength () + { + if (_options is { Count: 0 }) + { + return 0; + } + + var length = 0; + + if (!_config._showLegends) + { + return Math.Max (length, CalcMinLength ()); + } + + if (_config._legendsOrientation == _config._linearRangeOrientation && _options!.Count > 0) + { + // Each legend should be centered in a space the width of the longest legend, with one space between. + // Calculate the total length required for all legends. + int maxLegend = int.Max (_options.Max (s => s.Legend?.GetColumns () ?? 1), 1); + length = maxLegend * _options.Count + (_options.Count - 1); + } + else + { + length = CalcMinLength (); + } + + return Math.Max (length, CalcMinLength ()); + } + + /// + /// Calculates the minimum dimension required for the linear range and legends. + /// + /// + private int CalcIdealThickness () + { + var thickness = 1; // Always show the linear range. + + if (!_config._showLegends) + { + return thickness; + } + + if (_config._legendsOrientation != _config._linearRangeOrientation && _options!.Count > 0) + { + thickness += _options.Max (s => s.Legend?.GetColumns () ?? 0); + } + else + { + thickness += 1; + } + + return thickness; + } + + #endregion Helpers + + #region Cursor and Position + + internal bool TryGetPositionByOption (int option, out (int x, int y) position) + { + position = (-1, -1); + + if (option < 0 || option >= _options!.Count) + { + return false; + } + + var offset = 0; + offset += _config._startSpacing; + offset += option * (_config._cachedInnerSpacing + 1); + + position = _config._linearRangeOrientation == Orientation.Vertical ? (0, offset) : (offset, 0); + + return true; + } + + /// Tries to get the option index by the position. + /// + /// + /// + /// + /// + internal bool TryGetOptionByPosition (int x, int y, int threshold, out int optionIdx) + { + optionIdx = -1; + + if (Orientation == Orientation.Horizontal) + { + if (y != 0) + { + return false; + } + + for (int xx = x - threshold; xx < x + threshold + 1; xx++) + { + int cx = xx; + cx -= _config._startSpacing; + + int option = cx / (_config._cachedInnerSpacing + 1); + bool valid = cx % (_config._cachedInnerSpacing + 1) == 0; + + if (!valid || option < 0 || option > _options!.Count - 1) + { + continue; + } + + optionIdx = option; + + return true; + } + } + else + { + if (x != 0) + { + return false; + } + + for (int yy = y - threshold; yy < y + threshold + 1; yy++) + { + int cy = yy; + cy -= _config._startSpacing; + + int option = cy / (_config._cachedInnerSpacing + 1); + bool valid = cy % (_config._cachedInnerSpacing + 1) == 0; + + if (!valid || option < 0 || option > _options!.Count - 1) + { + continue; + } + + optionIdx = option; + + return true; + } + } + + return false; + } + + /// Updates the cursor position based on the focused option. + /// + /// This method calculates the cursor position and calls . + /// The framework automatically handles hiding the cursor when the view loses focus. + /// + private void UpdateCursor () + { + if (!TryGetPositionByOption (FocusedOption, out (int x, int y) position) || !IsInitialized || !Viewport.Contains (position.x, position.y)) + { + Cursor = Cursor with { Position = null }; // Hide cursor + + return; + } + + Cursor = Cursor with { Position = ViewportToScreen (new Point (position.x, position.y)) }; + } + + #endregion Cursor and Position + + #region Drawing + + /// + protected override bool OnDrawingContent (DrawContext? context) + { + // TODO: make this more surgical to reduce repaint + + if (_options is null || _options.Count == 0) + { + return true; + } + + // Draw LinearRange + DrawLinearRange (); + + // Draw Legends. + if (_config._showLegends) + { + DrawLegends (); + } + + if (_dragPosition.HasValue && _moveRenderPosition.HasValue) + { + AddStr (_moveRenderPosition.Value.X, _moveRenderPosition.Value.Y, Style.DragChar.Grapheme); + } + + return true; + } + + private static string AlignText (string? text, int width, Alignment alignment) + { + if (string.IsNullOrEmpty (text)) + { + return ""; + } + + if (text.Length > width) + { + text = text [..width]; + } + + int w = width - text.Length; + string s1 = new (' ', w / 2); + string s2 = new (' ', w % 2); + + // Note: The formatter doesn't handle all of this ??? + switch (alignment) + { + case Alignment.Fill: + return TextFormatter.Justify (text, width); + + case Alignment.Start: + return text + s1 + s1 + s2; + + case Alignment.Center: + if (text.Length % 2 != 0) + { + return s1 + text + s1 + s2; + } + + return s1 + s2 + text + s1; + + case Alignment.End: + return s1 + s1 + s2 + text; + + default: + return text; + } + } + + private void DrawLinearRange () + { + // The base View pipeline already calls ClearViewport before OnDrawingContent + // (see View.DoClearViewport). DrawLinearRange + DrawLegends together repaint every cell + // in the Viewport, so a second ClearViewport here is redundant work that — because + // ClearViewport calls SetNeedsDraw — also triggered another draw cycle, causing visible + // flicker during mouse drag on the LinearRange family. + + // Attributes + var normalAttr = new Attribute (Color.White, Color.Black); + var setAttr = new Attribute (Color.Black, Color.White); + + if (IsInitialized) + { + normalAttr = GetAttributeForRole (VisualRole.Normal); + setAttr = Style.SetChar.Attribute ?? GetAttributeForRole (VisualRole.HotNormal); + } + + bool isVertical = _config._linearRangeOrientation == Orientation.Vertical; + + var x = 0; + var y = 0; + + bool isSet = _setOptions.Count > 0; + + // Left Spacing + if (_config is { _showEndSpacing: true, _startSpacing: > 0 }) + { + SetAttribute (isSet && _config._renderMode == LinearRangeRenderMode.LeftSpan + ? Style.RangeChar.Attribute ?? normalAttr + : Style.SpaceChar.Attribute ?? normalAttr); + string text = isSet && _config._renderMode == LinearRangeRenderMode.LeftSpan ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme; + + for (var i = 0; i < _config._startSpacing; i++) + { + MoveAndAdd (x, y, text); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + else + { + SetAttribute (Style.EmptyChar.Attribute ?? normalAttr); + + for (var i = 0; i < _config._startSpacing; i++) + { + MoveAndAdd (x, y, Style.EmptyChar.Grapheme); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + + // LinearRange + if (_options!.Count > 0) + { + for (var i = 0; i < _options.Count; i++) + { + var drawRange = false; + + if (isSet) + { + switch (_config._renderMode) + { + case LinearRangeRenderMode.LeftSpan when i <= _setOptions [0]: + drawRange = i < _setOptions [0]; + + break; + + case LinearRangeRenderMode.RightSpan when i >= _setOptions [0]: + drawRange = i >= _setOptions [0]; + + break; + + case LinearRangeRenderMode.Span when _setOptions.Count == 1: + drawRange = false; + + break; + + case LinearRangeRenderMode.Span when _setOptions.Count == 2: + if ((i >= _setOptions [0] && i <= _setOptions [1]) || (i >= _setOptions [1] && i <= _setOptions [0])) + { + drawRange = (i >= _setOptions [0] && i < _setOptions [1]) || (i >= _setOptions [1] && i < _setOptions [0]); + } + + break; + } + } + + // Draw Option + SetAttribute (isSet && _setOptions.Contains (i) ? Style.SetChar.Attribute ?? setAttr : + drawRange ? Style.RangeChar.Attribute ?? setAttr : Style.OptionChar.Attribute ?? normalAttr); + + string text = drawRange ? Style.RangeChar.Grapheme : Style.OptionChar.Grapheme; + + if (isSet) + { + if (_setOptions [0] == i) + { + text = Style.StartRangeChar.Grapheme; + } + else if (_setOptions.Count > 1 && _setOptions [1] == i) + { + text = Style.EndRangeChar.Grapheme; + } + else if (_setOptions.Contains (i)) + { + text = Style.SetChar.Grapheme; + } + } + + MoveAndAdd (x, y, text); + + if (isVertical) + { + y++; + } + else + { + x++; + } + + // Draw Spacing + if (!_config._showEndSpacing && i >= _options.Count - 1) + { + continue; + } + + // Skip if is the Last Spacing. + SetAttribute (drawRange && isSet ? Style.RangeChar.Attribute ?? setAttr : Style.SpaceChar.Attribute ?? normalAttr); + + for (var s = 0; s < _config._cachedInnerSpacing; s++) + { + MoveAndAdd (x, y, drawRange && isSet ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + } + + int remaining = isVertical ? Viewport.Height - y : Viewport.Width - x; + + // Right Spacing + if (_config._showEndSpacing) + { + SetAttribute (isSet && _config._renderMode == LinearRangeRenderMode.RightSpan + ? Style.RangeChar.Attribute ?? normalAttr + : Style.SpaceChar.Attribute ?? normalAttr); + string text = isSet && _config._renderMode == LinearRangeRenderMode.RightSpan ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme; + + for (var i = 0; i < remaining; i++) + { + MoveAndAdd (x, y, text); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + else + { + SetAttribute (Style.EmptyChar.Attribute ?? normalAttr); + + for (var i = 0; i < remaining; i++) + { + MoveAndAdd (x, y, Style.EmptyChar.Grapheme); + + if (isVertical) + { + y++; + } + else + { + x++; + } + } + } + } + + private void DrawLegends () + { + // Attributes + var normalAttr = new Attribute (Color.White, Color.Black); + Attribute spaceAttr = normalAttr; + + if (IsInitialized) + { + normalAttr = Style.LegendAttributes.NormalAttribute ?? GetAttributeForRole (VisualRole.Normal); + spaceAttr = Style.LegendAttributes.EmptyAttribute ?? normalAttr; + } + + bool isTextVertical = _config._legendsOrientation == Orientation.Vertical; + + var x = 0; + var y = 0; + + Move (x, y); + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal when _config._legendsOrientation == Orientation.Vertical: + x += _config._startSpacing; + + break; + + case Orientation.Vertical when _config._legendsOrientation == Orientation.Horizontal: + y += _config._startSpacing; + + break; + } + + if (_config._linearRangeOrientation == Orientation.Horizontal) + { + y += 1; + } + else + { + // Vertical + x += 1; + } + + for (var i = 0; i < _options!.Count; i++) + { + // Text || Abbreviation + + string text = (_config._showLegendsAbbr ? _options [i].LegendAbbr.ToString () : _options [i].Legend)!; + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal: + switch (_config._legendsOrientation) + { + case Orientation.Horizontal: + text = AlignText (text, _config._cachedInnerSpacing + 1, Alignment.Center); + + break; + + case Orientation.Vertical: + y = 1; + + break; + } + + break; + + case Orientation.Vertical: + switch (_config._legendsOrientation) + { + case Orientation.Horizontal: + x = 1; + + break; + + case Orientation.Vertical: + text = AlignText (text, _config._cachedInnerSpacing + 1, Alignment.Center); + + break; + } + + break; + } + + // Text + int legendLeftSpacesCount = text.TakeWhile (e => e == ' ').Count (); + int legendRightSpacesCount = text.Reverse ().TakeWhile (e => e == ' ').Count (); + text = text.Trim (); + + // Calculate Start Spacing + if (_config._linearRangeOrientation == _config._legendsOrientation) + { + if (i == 0) + { + // The spacing for the linear range use the StartSpacing but... + // The spacing for the legends is the StartSpacing MINUS the total chars to the left of the first options. + // ●────●────● + // Hello Bye World + // + // chars_left is 2 for Hello => (5 - 1) / 2 + // + // then the spacing is 2 for the linear range but 0 for the legends. + + int charsLeft = (text.Length - 1) / 2; + legendLeftSpacesCount = _config._startSpacing - charsLeft; + } + + // Option Left Spacing + if (isTextVertical) + { + y += legendLeftSpacesCount; + } + else + { + x += legendLeftSpacesCount; + } + } + + // Legend - no special styling for set/focused options; the range bar itself indicates selection. + SetAttribute (normalAttr); + + foreach (Rune c in text.EnumerateRunes ()) + { + MoveAndAdd (x, y, c); + + if (isTextVertical) + { + y += 1; + } + else + { + x += 1; + } + } + + // Calculate End Spacing + if (i == _options.Count - 1) + { + // See Start Spacing explanation. + int charsRight = text.Length / 2; + legendRightSpacesCount = _config._endSpacing - charsRight; + } + + // Option Right Spacing of Option + SetAttribute (spaceAttr); + + if (isTextVertical) + { + y += legendRightSpacesCount; + } + else + { + x += legendRightSpacesCount; + } + + switch (_config._linearRangeOrientation) + { + case Orientation.Horizontal when _config._legendsOrientation == Orientation.Vertical: + x += _config._cachedInnerSpacing + 1; + + break; + + case Orientation.Vertical when _config._legendsOrientation == Orientation.Horizontal: + y += _config._cachedInnerSpacing + 1; + + break; + } + } + } + + #endregion Drawing + + #region Keys and Mouse + + // Mouse coordinates of current drag + private Point? _dragPosition; + + // Coordinates of where the "move cursor" is drawn (in OnDrawContent) + private Point? _moveRenderPosition; + + // For Span (Closed) drag: the option that stays fixed while dragging the active end. + // -1 means no drag in progress. + private int _dragAnchorOption = -1; + + /// + protected override bool OnMouseEvent (Mouse mouse) + { + if (!(mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked) + || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) + || mouse.Flags.FastHasFlags (MouseFlags.PositionReport) + || mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased))) + { + return false; + } + + SetFocus (); + + if (!_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) + { + if (mouse.Flags.FastHasFlags (MouseFlags.PositionReport)) + { + _dragPosition = mouse.Position; + _moveRenderPosition = ClampMovePosition ((Point)_dragPosition!); + App?.Mouse.GrabMouse (this); + + // Anchor the selection at the press position so a subsequent drag can extend a range. + // Resolve the option under the press position and set it as the focused option. + bool pressSuccess; + int pressOption; + + if (Orientation == Orientation.Horizontal) + { + pressSuccess = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out pressOption); + } + else + { + pressSuccess = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out pressOption); + } + + if (pressSuccess) + { + if (!OnOptionFocused (pressOption, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + ApplyMouseSelection (pressOption, dragStart: true); + } + } + + // Mark handled so the View pipeline does not later invoke Command.Activate + // on the synthesized Released/Clicked events (which would re-toggle the selection). + mouse.Handled = true; + } + + SetNeedsDraw (); + + return true; + } + + bool success; + int option; + + if (_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.PositionReport) && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed)) + { + // Continue Drag + _dragPosition = mouse.Position; + _moveRenderPosition = ClampMovePosition ((Point)_dragPosition!); + + // how far has user dragged from original location? + if (Orientation == Orientation.Horizontal) + { + success = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out option); + } + else + { + success = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out option); + } + + // Update the selection on drag regardless of AllowEmpty so the user can drag the + // end of a range (or move a single selection) continuously. + if (success) + { + if (!OnOptionFocused (option, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + ApplyMouseSelection (option, dragStart: false); + } + } + + SetNeedsDraw (); + + return true; + } + + if (_dragPosition.HasValue && mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + { + // End of a drag we initiated. Selection was already updated during drag continues; + // just release the grab. Mark the event handled so the View pipeline does not + // re-invoke Command.Activate (which would toggle the selection back off). + App?.Mouse.UngrabMouse (); + _dragPosition = null; + _moveRenderPosition = null; + _dragAnchorOption = -1; + mouse.Handled = true; + SetNeedsDraw (); + + return true; + } + + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked)) + { + // Click events from the synthesizer that follow a drag are redundant — the drag + // already updated selection. Otherwise (a "real" click without prior press), + // let the View pipeline raise Command.Activate via the default mouse bindings. + return mouse.Handled; + } + + // End Drag + App?.Mouse.UngrabMouse (); + _dragPosition = null; + _moveRenderPosition = null; + _dragAnchorOption = -1; + + switch (Orientation) + { + case Orientation.Horizontal: + success = TryGetOptionByPosition (mouse.Position!.Value.X, 0, Math.Max (0, _config._cachedInnerSpacing / 2), out option); + + break; + + default: + success = TryGetOptionByPosition (0, mouse.Position!.Value.Y, Math.Max (0, _config._cachedInnerSpacing / 2), out option); + + break; + } + + if (success) + { + if (!OnOptionFocused (option, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + SetFocusedOption (); + } + } + + SetNeedsDraw (); + + mouse.Handled = true; + + return mouse.Handled; + + Point ClampMovePosition (Point position) + { + if (Orientation == Orientation.Horizontal) + { + int left = _config._startSpacing; + int width = _options!.Count + (_options.Count - 1) * _config._cachedInnerSpacing; + int right = left + width - 1; + int clampedX = Clamp (position.X, left, right); + position = new Point (clampedX, 0); + } + else + { + int top = _config._startSpacing; + int height = _options!.Count + (_options.Count - 1) * _config._cachedInnerSpacing; + int bottom = top + height - 1; + int clampedY = Clamp (position.Y, top, bottom); + position = new Point (0, clampedY); + } + + return position; + + static int Clamp (int value, int min, int max) => Math.Max (min, Math.Min (max, value)); + } + } + + private void SetCommands () + { + AddCommand (Command.Right, () => MovePlus ()); + AddCommand (Command.Down, () => MovePlus ()); + AddCommand (Command.Left, () => MoveMinus ()); + AddCommand (Command.Up, () => MoveMinus ()); + AddCommand (Command.LeftStart, () => MoveStart ()); + AddCommand (Command.RightEnd, () => MoveEnd ()); + AddCommand (Command.RightExtend, () => ExtendPlus ()); + AddCommand (Command.LeftExtend, () => ExtendMinus ()); + + ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); + + SetKeyBindings (); + } + + // This is called during initialization and anytime orientation changes. + // Orientation-dependent bindings cannot be in DefaultKeyBindings because they vary per instance. + private void SetKeyBindings () + { + // Remove Shift+Cursor extend bindings inherited from View.DefaultKeyBindings; + // LinearRange uses Ctrl+Cursor for extend operations instead. + KeyBindings.Remove (Key.CursorLeft.WithShift); + KeyBindings.Remove (Key.CursorRight.WithShift); + KeyBindings.Remove (Key.CursorUp.WithShift); + KeyBindings.Remove (Key.CursorDown.WithShift); + + if (_config._linearRangeOrientation == Orientation.Horizontal) + { + // Remove before Add: ApplyKeyBindings already bound CursorRight/CursorLeft from View.DefaultKeyBindings + KeyBindings.Remove (Key.CursorRight); + KeyBindings.Add (Key.CursorRight, Command.Right); + KeyBindings.Remove (Key.CursorDown); + KeyBindings.Remove (Key.CursorLeft); + KeyBindings.Add (Key.CursorLeft, Command.Left); + KeyBindings.Remove (Key.CursorUp); + + KeyBindings.Add (Key.CursorRight.WithCtrl, Command.RightExtend); + KeyBindings.Remove (Key.CursorDown.WithCtrl); + KeyBindings.Add (Key.CursorLeft.WithCtrl, Command.LeftExtend); + KeyBindings.Remove (Key.CursorUp.WithCtrl); + } + else + { + KeyBindings.Remove (Key.CursorRight); + // Remove before Add: ApplyKeyBindings already bound CursorDown/CursorUp from View.DefaultKeyBindings + KeyBindings.Remove (Key.CursorDown); + KeyBindings.Add (Key.CursorDown, Command.Down); + KeyBindings.Remove (Key.CursorLeft); + KeyBindings.Remove (Key.CursorUp); + KeyBindings.Add (Key.CursorUp, Command.Up); + + KeyBindings.Remove (Key.CursorRight.WithCtrl); + KeyBindings.Add (Key.CursorDown.WithCtrl, Command.RightExtend); + KeyBindings.Remove (Key.CursorLeft.WithCtrl); + KeyBindings.Add (Key.CursorUp.WithCtrl, Command.LeftExtend); + } + } + + private Dictionary> GetSetOptionDictionary () => _setOptions.ToDictionary (e => e, e => _options! [e]); + + /// + /// Applies a mouse-press or mouse-drag selection at . Unlike + /// (which has toggle/extend/shrink semantics designed + /// for keyboard activation), this performs set semantics suitable for a continuous + /// mouse drag: a single-bounded view's endpoint follows the cursor without toggling off + /// when the cursor returns to the existing value, and a Closed range tracks an anchor on + /// the opposite end so the range never collapses unexpectedly while dragging. + /// + /// The option index under the mouse. + /// + /// for the initial press of a drag; + /// for subsequent drag-continue events. + /// + private void ApplyMouseSelection (int option, bool dragStart) + { + if (_options is null or { Count: 0 } || option < 0 || option >= _options.Count) + { + return; + } + + var changed = false; + + switch (_config._renderMode) + { + case LinearRangeRenderMode.Single: + case LinearRangeRenderMode.LeftSpan: + case LinearRangeRenderMode.RightSpan: + changed = ApplyMouseSelectionSingle (option); + + break; + + case LinearRangeRenderMode.Multiple: + if (dragStart) + { + // Toggle on the press option. + changed = ToggleSetOption (option); + } + else + { + // During drag, only ensure the option becomes set (don't toggle it off + // each time the cursor revisits a previously-set option). + if (!_setOptions.Contains (option)) + { + _setOptions.Add (option); + _options [option].OnSet (); + changed = true; + } + } + + break; + + case LinearRangeRenderMode.Span: + changed = ApplyMouseSelectionSpan (option, dragStart); + + break; + + default: + throw new ArgumentOutOfRangeException (_config._renderMode.ToString ()); + } + + if (changed) + { + RaiseSelectionChanged (); + } + } + + private bool ApplyMouseSelectionSingle (int option) + { + if (_setOptions.Count == 1 && _setOptions [0] == option) + { + // Already the single set option; no change. Critically, do NOT toggle off here: + // a drag through the same option must not clear the selection. + return false; + } + + foreach (int existing in _setOptions) + { + _options! [existing].OnUnSet (); + } + + _setOptions.Clear (); + _setOptions.Add (option); + _options! [option].OnSet (); + + return true; + } + + private bool ToggleSetOption (int option) + { + if (_setOptions.Contains (option)) + { + if (!_config._allowEmpty && _setOptions.Count == 1) + { + return false; + } + + _setOptions.Remove (option); + _options! [option].OnUnSet (); + } + else + { + _setOptions.Add (option); + _options! [option].OnSet (); + } + + return true; + } + + private bool ApplyMouseSelectionSpan (int option, bool dragStart) + { + // Empty range: just place a single point at option. + if (_setOptions.Count == 0) + { + if (_config._rangeAllowSingle) + { + _setOptions.Add (option); + _options! [option].OnSet (); + _dragAnchorOption = option; + + return true; + } + + // Closed range with rangeAllowSingle = false: span [option, option+1] (or option-1). + int next = option < _options!.Count - 1 ? option + 1 : option - 1; + int lo = Math.Min (option, next); + int hi = Math.Max (option, next); + _setOptions.Add (lo); + _setOptions.Add (hi); + _options! [lo].OnSet (); + _options! [hi].OnSet (); + + // Anchor at the press location so a subsequent drag moves the OTHER end. + _dragAnchorOption = option; + + return true; + } + + if (dragStart) + { + // Choose anchor for the upcoming drag. + if (_setOptions.Count == 1) + { + int existing = _setOptions [0]; + + if (option == existing) + { + // Press on the single existing point: anchor stays here; no change yet. + _dragAnchorOption = existing; + + return false; + } + + int lo = Math.Min (existing, option); + int hi = Math.Max (existing, option); + _setOptions.Clear (); + _setOptions.Add (lo); + _setOptions.Add (hi); + _options! [option].OnSet (); + _dragAnchorOption = existing; + + return true; + } + + // Count == 2: pick anchor as the endpoint farther from the press; the closer + // endpoint becomes the active end and is moved to the press position. + int lowEnd = _setOptions [0]; + int highEnd = _setOptions [1]; + + if (option <= lowEnd) + { + // Press at or to the left of the range: anchor=high, active=low. + _dragAnchorOption = highEnd; + + if (option == lowEnd) + { + return false; + } + + _options! [lowEnd].OnUnSet (); + _setOptions [0] = option; + _options! [option].OnSet (); + + return true; + } + + if (option >= highEnd) + { + _dragAnchorOption = lowEnd; + + if (option == highEnd) + { + return false; + } + + _options! [highEnd].OnUnSet (); + _setOptions [1] = option; + _options! [option].OnSet (); + + return true; + } + + // Inside the range: pick the closer endpoint as active; the other is the anchor. + int distLow = option - lowEnd; + int distHigh = highEnd - option; + + if (distLow <= distHigh) + { + _options! [lowEnd].OnUnSet (); + _setOptions [0] = option; + _options! [option].OnSet (); + _dragAnchorOption = highEnd; + } + else + { + _options! [highEnd].OnUnSet (); + _setOptions [1] = option; + _options! [option].OnSet (); + _dragAnchorOption = lowEnd; + } + + return true; + } + + // Drag continue: keep _dragAnchorOption fixed, move the active end to option. + if (_dragAnchorOption < 0) + { + // Anchor was never set (e.g. drag without prior press in our pipeline). Treat as start. + return ApplyMouseSelectionSpan (option, dragStart: true); + } + + int anchor = _dragAnchorOption; + int newLo = Math.Min (anchor, option); + int newHi = Math.Max (anchor, option); + + if (newLo == newHi) + { + // Collapsed to a single point. + if (!_config._rangeAllowSingle) + { + // Closed range with rangeAllowSingle=false cannot collapse; keep current state. + return false; + } + + if (_setOptions.Count == 1 && _setOptions [0] == newLo) + { + return false; + } + + foreach (int s in _setOptions) + { + _options! [s].OnUnSet (); + } + + _setOptions.Clear (); + _setOptions.Add (newLo); + _options! [newLo].OnSet (); + + return true; + } + + if (_setOptions.Count == 2 && _setOptions [0] == newLo && _setOptions [1] == newHi) + { + return false; + } + + foreach (int s in _setOptions) + { + _options! [s].OnUnSet (); + } + + _setOptions.Clear (); + _setOptions.Add (newLo); + _setOptions.Add (newHi); + _options! [newLo].OnSet (); + _options! [newHi].OnSet (); + + return true; + } + + private bool SetFocusedOption () + { + if (_options is null or { Count: 0 }) + { + return false; + } + + var changed = false; + + switch (_config._renderMode) + { + case LinearRangeRenderMode.Single: + case LinearRangeRenderMode.LeftSpan: + case LinearRangeRenderMode.RightSpan: + + if (_setOptions.Count == 1) + { + int prev = _setOptions [0]; + + if (!_config._allowEmpty && prev == FocusedOption) + { + break; + } + + _setOptions.Clear (); + _options [FocusedOption].OnUnSet (); + + if (FocusedOption != prev) + { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + } + } + else + { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + } + + // Raise slider changed event. + RaiseSelectionChanged (); + changed = true; + + break; + + case LinearRangeRenderMode.Multiple: + if (_setOptions.Contains (FocusedOption)) + { + if (!_config._allowEmpty && _setOptions.Count == 1) + { + break; + } + + _setOptions.Remove (FocusedOption); + _options [FocusedOption].OnUnSet (); + } + else + { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + } + + RaiseSelectionChanged (); + changed = true; + + break; + + case LinearRangeRenderMode.Span: + if (_config._rangeAllowSingle) + { + if (_setOptions.Count == 1) + { + int prev = _setOptions [0]; + + if (!_config._allowEmpty && prev == FocusedOption) + { + break; + } + + if (FocusedOption == prev) + { + // un-set + _setOptions.Clear (); + _options [FocusedOption].OnUnSet (); + } + else + { + _setOptions [0] = FocusedOption; + _setOptions.Add (prev); + _setOptions.Sort (); + _options [FocusedOption].OnSet (); + } + } + else if (_setOptions.Count == 0) + { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + } + else + { + // Extend/Shrink + if (FocusedOption < _setOptions [0]) + { + // extend left + _options [_setOptions [0]].OnUnSet (); + _setOptions [0] = FocusedOption; + } + else if (FocusedOption > _setOptions [1]) + { + // extend right + _options [_setOptions [1]].OnUnSet (); + _setOptions [1] = FocusedOption; + } + else if (FocusedOption >= _setOptions [0] && FocusedOption <= _setOptions [1]) + { + if (FocusedOption < _lastFocusedOption) + { + // shrink to the left + _options [_setOptions [1]].OnUnSet (); + _setOptions [1] = FocusedOption; + } + else if (FocusedOption > _lastFocusedOption) + { + // shrink to the right + _options [_setOptions [0]].OnUnSet (); + _setOptions [0] = FocusedOption; + } + + if (_setOptions.Count > 1 && _setOptions [0] == _setOptions [1]) + { + _setOptions.Clear (); + _setOptions.Add (FocusedOption); + } + } + } + } + else + { + if (_setOptions.Count == 1) + { + int prev = _setOptions [0]; + + if (!_config._allowEmpty && prev == FocusedOption) + { + break; + } + + _setOptions [0] = FocusedOption; + _setOptions.Add (prev); + _setOptions.Sort (); + _options [FocusedOption].OnSet (); + } + else if (_setOptions.Count == 0) + { + _setOptions.Add (FocusedOption); + _options [FocusedOption].OnSet (); + int next = FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption - 1; + _setOptions.Add (next); + _options [next].OnSet (); + } + else + { + // Extend/Shrink + if (FocusedOption < _setOptions [0]) + { + // extend left + _options [_setOptions [0]].OnUnSet (); + _setOptions [0] = FocusedOption; + } + else if (FocusedOption > _setOptions [1]) + { + // extend right + _options [_setOptions [1]].OnUnSet (); + _setOptions [1] = FocusedOption; + } + else if (FocusedOption >= _setOptions [0] && FocusedOption <= _setOptions [1] && _setOptions [1] - _setOptions [0] > 1) + { + if (FocusedOption < _lastFocusedOption) + { + // shrink to the left + _options [_setOptions [1]].OnUnSet (); + _setOptions [1] = FocusedOption; + } + else if (FocusedOption > _lastFocusedOption) + { + // shrink to the right + _options [_setOptions [0]].OnUnSet (); + _setOptions [0] = FocusedOption; + } + } + } + } + + // Raise LinearRange Option Changed Event. + RaiseSelectionChanged (); + changed = true; + + break; + + default: + throw new ArgumentOutOfRangeException (_config._renderMode.ToString ()); + } + + return changed; + } + + internal bool ExtendPlus () + { + int next = _options is { } && FocusedOption < _options.Count - 1 ? FocusedOption + 1 : FocusedOption; + + if (next != FocusedOption && !OnOptionFocused (next, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + SetFocusedOption (); + } + + return true; + } + + internal bool ExtendMinus () + { + int prev = FocusedOption > 0 ? FocusedOption - 1 : FocusedOption; + + if (prev != FocusedOption && !OnOptionFocused (prev, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + SetFocusedOption (); + } + + return true; + } + + /// + protected override void OnActivated (ICommandContext? ctx) + { + base.OnActivated (ctx); + SetFocusedOption (); + } + + /// + protected override bool OnAccepting (CommandEventArgs args) + { + SetFocusedOption (); + + return false; + } + + internal bool Select () => SetFocusedOption (); + + internal bool Accept (ICommandContext? commandContext) + { + SetFocusedOption (); + + return RaiseAccepting (commandContext) == true; + } + + internal bool MovePlus () + { + bool cancelled = OnOptionFocused (FocusedOption + 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption)); + + if (cancelled) + { + return false; + } + + if (!AllowEmpty) + { + SetFocusedOption (); + } + + return true; + } + + internal bool MoveMinus () + { + bool cancelled = OnOptionFocused (FocusedOption - 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption)); + + if (cancelled) + { + return false; + } + + if (!AllowEmpty) + { + SetFocusedOption (); + } + + return true; + } + + internal bool MoveStart () + { + if (OnOptionFocused (0, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + return false; + } + + if (!AllowEmpty) + { + SetFocusedOption (); + } + + return true; + } + + internal bool MoveEnd () + { + if (OnOptionFocused (_options!.Count - 1, new LinearRangeEventArgs (GetSetOptionDictionary (), FocusedOption))) + { + return false; + } + + if (!AllowEmpty) + { + SetFocusedOption (); + } + + return true; + } + + #endregion +} diff --git a/Terminal.Gui/Views/LinearRange/LinearSelector.cs b/Terminal.Gui/Views/LinearRange/LinearSelector.cs new file mode 100644 index 0000000000..e6d12592f2 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearSelector.cs @@ -0,0 +1,24 @@ +namespace Terminal.Gui.Views; + +/// +/// Convenience non-generic closed over . Allows +/// designer scenarios (e.g. AllViewsTester) and reflection-based instantiation to discover +/// and create the view without supplying a type argument. +/// +/// +/// +/// To work with non-string option types, use directly. +/// +/// +public class LinearSelector : LinearSelector +{ + /// Initializes a new instance of . + public LinearSelector () { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearSelector (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation) + { } +} diff --git a/Terminal.Gui/Views/LinearRange/LinearSelectorT.cs b/Terminal.Gui/Views/LinearRange/LinearSelectorT.cs new file mode 100644 index 0000000000..3159b7ecc6 --- /dev/null +++ b/Terminal.Gui/Views/LinearRange/LinearSelectorT.cs @@ -0,0 +1,185 @@ +namespace Terminal.Gui.Views; + +/// +/// A linear range view that allows selection of a single option from a typed list of options. +/// +/// The data type of the options. +/// +/// +/// Exposes the current selection through . When is a +/// reference type, unambiguously represents "no selection". When +/// is a value type, is default(T) when no +/// option is selected — which can be indistinguishable from a legitimately selected default value +/// (e.g. 0 for ). To test for empty selection unambiguously for both +/// reference and value types, use , which is +/// only when nothing is selected. +/// +/// +/// To switch the current selection programmatically, set or +/// . To observe selection changes, subscribe to +/// . +/// +/// +public class LinearSelector : LinearRangeViewBase, IDesignable +{ + private T? _value; + + /// Initializes a new instance of . + public LinearSelector () : base (LinearRangeRenderMode.Single) { } + + /// Initializes a new instance of . + /// Initial options. + /// Initial orientation. + public LinearSelector (List? options, Orientation orientation = Orientation.Horizontal) + : base (options, orientation, LinearRangeRenderMode.Single) { } + + /// + public override T? Value + { + get => _value; + set + { + T? current = _value; + + if (EqualityComparer.Default.Equals (current, value)) + { + return; + } + + if (RaiseValueChanging (current, value)) + { + return; + } + + _value = value; + + // Sync indices to match value. + if (value is null) + { + ApplySelectedIndices ([]); + } + else + { + int idx = IndexOfData (value); + + if (idx >= 0) + { + ApplySelectedIndices ([idx]); + } + else + { + // Value not present among options: clear selection but keep field. + ApplySelectedIndices ([]); + } + } + + RaiseValueChanged (current, _value); + } + } + + /// + /// Gets or sets the index of the currently selected option, or if no option is + /// selected. This is the unambiguous "no selection" surface for both reference and value types + /// (compare with , where default(T) for value types may collide with a + /// legitimately selected option). + /// + /// + /// + /// To clear the selection, set to . Requires + /// to be ; + /// otherwise the clear is silently ignored (mirrors how behaves). + /// + /// + /// To select an option, set to its index in . + /// Out-of-range values throw . + /// + /// + public int? SelectedIndex + { + get => SelectedIndices.Count > 0 ? SelectedIndices [0] : null; + set + { + if (value is null) + { + if (!AllowEmpty) + { + return; + } + + _value = default; + ApplySelectedIndices ([]); + + return; + } + + if (Options is null || value < 0 || value >= Options.Count) + { + throw new ArgumentOutOfRangeException (nameof (value)); + } + + // Sync indices first so SelectedIndex can select an option whose Data equals the current + // _value (e.g. selecting option 0 in a value-type selector where _value is already + // default(int)=0 — Value setter would short-circuit on equality, leaving SelectedIndex null). + T? newValue = Options [value.Value].Data; + T? current = _value; + bool valueChanged = !EqualityComparer.Default.Equals (current, newValue); + + if (valueChanged && RaiseValueChanging (current, newValue)) + { + return; + } + + _value = newValue; + ApplySelectedIndices ([value.Value]); + + if (valueChanged) + { + RaiseValueChanged (current, _value); + } + } + } + + /// + protected override void OnSelectionChanged () + { + T? previous = _value; + T? newValue = SelectedIndices.Count > 0 ? Options [SelectedIndices [0]].Data : default; + + if (EqualityComparer.Default.Equals (previous, newValue)) + { + return; + } + + _value = newValue; + RaiseValueChanged (previous, newValue); + } + + /// + /// Loads demo data suitable for a designer preview: a single-select + /// of T-shirt sizes (XS through XXL) with "M" preselected. Only populated when + /// is ; for any other type, the view is left untouched + /// and is returned. + /// + /// if demo data was loaded. + public virtual bool EnableForDesign () + { + if (typeof (T) != typeof (string)) + { + return false; + } + + Title = "T-Shirt Size"; + AssignHotKeys = true; + ShowLegends = true; + + string [] sizes = ["XS", "S", "M", "L", "XL", "XXL"]; + + Options = sizes.Select ( + s => new LinearRangeOption (s, (Rune)s [0], (T)(object)s)) + .ToList (); + + Value = (T)(object)"M"; + + return true; + } +} diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index 219b141edb..6ac2b7ca3c 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -103,48 +103,114 @@ protected override void OnActivated (ICommandContext? ctx) base.OnActivated (ctx); } + /// + /// The set of URI schemes that is permitted to open. Only http, https, and + /// mailto are allowed by default. Callers that explicitly require additional schemes may modify this set, + /// but doing so widens the attack surface when is populated from untrusted input. + /// + /// + /// + /// file:// URIs are intentionally excluded from the default set because they allow local filesystem + /// access and can be used to invoke registered shell handlers on Windows. Applications that display + /// user-controlled content (Markdown, RSS, log output, etc.) are therefore protected by default. + /// + /// + /// Migration path for applications that need file:// or other non-default schemes: + /// + /// + /// Option 1 — Per-link handling via . Handle the URL in the event + /// and set e.Handled = true to prevent from being called: + /// + /// markdownView.LinkClicked += (_, e) => + /// { + /// if (e.Url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + /// { + /// // Handle the file link yourself. + /// e.Handled = true; + /// } + /// }; + /// + /// + /// + /// Option 2 — Global opt-in at application startup. To allow file:// links across the entire + /// application, add the scheme to this set before any links are activated: + /// + /// Link.SafeSchemes.Add("file"); + /// + /// Only do this in applications where file:// URIs originate from trusted content. + /// + /// + public static readonly HashSet SafeSchemes = new (StringComparer.OrdinalIgnoreCase) + { + "http", "https", "mailto" + }; + /// /// Opens the specified URL in the default web browser using a platform-specific mechanism. /// /// /// - /// On Windows, uses cmd /c start. On macOS, uses open. On Linux, uses xdg-open. + /// Only URLs whose scheme is listed in (http, https, mailto by + /// default) are opened. URLs with any other scheme (e.g. file, ftp, or Windows protocol + /// handlers such as ms-msdt) are silently ignored. + /// + /// + /// On Windows, uses so the URL is dispatched directly by the + /// OS shell without passing through cmd.exe. On macOS, uses open. On Linux, uses + /// xdg-open. /// /// - /// Ampersands in the URL are escaped on Windows to prevent shell interpretation. + /// Any exception thrown by the underlying process launch (e.g. no default browser registered, + /// permission denied) is caught and logged via ; the method never + /// propagates such exceptions to the caller. + /// + /// + /// Callers that populate from untrusted input (markdown, RSS feeds, network data, etc.) + /// must ensure the value is validated before it reaches this method. /// /// - /// The URL to open. Should be a well-formed absolute URI. + /// The URL to open. Must be a well-formed absolute URI with an allowed scheme. public static void OpenUrl (string url) { - if (!Uri.IsWellFormedUriString (url, UriKind.Absolute) || Environment.GetEnvironmentVariable ("DisableRealDriverIO") == "1") + if (!Uri.TryCreate (url, UriKind.Absolute, out Uri? parsed) || parsed is null || !SafeSchemes.Contains (parsed.Scheme)) { return; } - if (PlatformDetection.IsWindows ()) + if (Environment.GetEnvironmentVariable ("DisableRealDriverIO") == "1") { - url = url.Replace ("&", "^&"); - Process.Start (new ProcessStartInfo ("cmd", $"/c start {url}") { CreateNoWindow = true }); + return; } - else if (PlatformDetection.IsMac ()) + + try { - Process.Start ("open", url); + if (PlatformDetection.IsWindows ()) + { + Process.Start (new ProcessStartInfo (url) { UseShellExecute = true }); + } + else if (PlatformDetection.IsMac ()) + { + Process.Start ("open", url); + } + else if (PlatformDetection.IsLinux ()) + { + using Process process = new (); + + process.StartInfo = new ProcessStartInfo + { + FileName = "xdg-open", + Arguments = url, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true, + UseShellExecute = false + }; + process.Start (); + } } - else if (PlatformDetection.IsLinux ()) + catch (Exception ex) { - using Process process = new (); - - process.StartInfo = new ProcessStartInfo - { - FileName = "xdg-open", - Arguments = url, - RedirectStandardError = true, - RedirectStandardOutput = true, - CreateNoWindow = true, - UseShellExecute = false - }; - process.Start (); + Logging.Warning ($"OpenUrl failed for '{url}': {ex.Message}"); } } diff --git a/Terminal.Gui/Views/ListView/ListViewT.cs b/Terminal.Gui/Views/ListView/ListViewT.cs index c9ddb252b4..d77298fc9b 100644 --- a/Terminal.Gui/Views/ListView/ListViewT.cs +++ b/Terminal.Gui/Views/ListView/ListViewT.cs @@ -122,6 +122,23 @@ public void SetSource (ObservableCollection? source) /// object? IValue.GetValue () => Value; + /// + /// + /// Resolves the diamond between 's IValue<int?> and + /// this view's IValue<T> by parsing into . + /// + bool IValue.TrySetValueFromString (string input) + { + if (!IValueParser.TryParseValue (input, out T? parsed)) + { + return false; + } + + Value = parsed; + + return true; + } + /// /// Gets or sets the currently selected object. /// This is a convenience property that is an alias for . diff --git a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs index 6f1b8eb819..90eb42c686 100644 --- a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs +++ b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs @@ -1,6 +1,14 @@ namespace Terminal.Gui.Views; -internal sealed class IntermediateBlock (IReadOnlyList runs, bool wrap, string prefix = "", string continuationPrefix = "", bool isCodeBlock = false, string? anchor = null, bool isThematicBreak = false, TableData? tableData = null) +internal sealed class IntermediateBlock (IReadOnlyList runs, + bool wrap, + string prefix = "", + string continuationPrefix = "", + bool isCodeBlock = false, + string? anchor = null, + bool isThematicBreak = false, + TableData? tableData = null, + string? language = null) { public IReadOnlyList Runs { get; } = runs; public bool Wrap { get; } = wrap; @@ -13,8 +21,14 @@ internal sealed class IntermediateBlock (IReadOnlyList runs, bool wra public TableData? TableData { get; } = tableData; /// Gets whether this block represents a Markdown table. - public bool IsTable => TableData is not null; + public bool IsTable => TableData is { }; /// The GitHub-style anchor slug for heading blocks, or for non-heading blocks. public string? Anchor { get; } = anchor; + + /// + /// The fenced code block language specifier (e.g. "cs", "python"), or + /// when this is not a code block or no language was given. + /// + public string? Language { get; } = language; } diff --git a/Terminal.Gui/Views/Markdown/Markdown.cs b/Terminal.Gui/Views/Markdown/Markdown.cs index af8adfd52c..a2abbbfe17 100644 --- a/Terminal.Gui/Views/Markdown/Markdown.cs +++ b/Terminal.Gui/Views/Markdown/Markdown.cs @@ -18,6 +18,43 @@ namespace Terminal.Gui.Views; /// Hyperlinks raise the event. Anchor links (URLs beginning with /// #) are handled automatically by scrolling to the matching heading. /// +/// Default key bindings: +/// +/// +/// Key Action +/// +/// +/// Ctrl+A +/// Selects all rendered content (). +/// +/// +/// Ctrl+C +/// +/// Copies the current selection to the clipboard, or the entire markdown source if nothing is selected +/// (). +/// +/// +/// +/// Shift+F10 / Right-click +/// Opens a context menu with Select All and Copy items. +/// +/// +/// Default mouse bindings: +/// +/// +/// Mouse Event Action +/// +/// +/// Left-button drag Selects text by dragging the mouse. +/// +/// +/// Left-button click +/// Clears the selection and activates a hyperlink if one is under the cursor. +/// +/// +/// Right-button click Opens the context menu. +/// +/// /// public partial class Markdown : View, IDesignable { @@ -50,10 +87,58 @@ public Markdown () SetupBindingsAndCommands (); } + /// + /// + /// If is and a valid + /// is set, the hotkey is forwarded to the next peer in 's + /// — mirroring so that a non-focusable + /// describing a focusable view (e.g. a ) moves + /// focus to that view when its hotkey is pressed. + /// + protected override bool OnActivating (CommandEventArgs args) + { + // If Markdown can't focus, forward HotKey to the next peer in the SubView list + if (CanFocus || !HotKey.IsValid) + { + return base.OnActivating (args); + } + int me = SuperView?.SubViews.IndexOf (this) ?? -1; + + if (me == -1 || !(me < SuperView?.SubViews.Count - 1)) + { + return base.OnActivating (args); + } + bool handled = SuperView?.SubViews.ElementAt (me + 1).InvokeCommand (Command.HotKey) == true; + + if (!handled) + { + return base.OnActivating (args); + } + args.Handled = true; + + return true; + } + /// Gets or sets the Markdown-formatted text displayed by this view. /// The raw Markdown string. Setting this property triggers reparsing, re-layout, and a redraw. public override string Text { get => _markdown; set => SetMarkdown (value); } + /// + /// + /// Unlike , derives + /// from (the raw markdown) rather than , + /// because does not flow through Title. + /// + public override Rune HotKeySpecifier + { + get => base.HotKeySpecifier; + set + { + TitleTextFormatter.HotKeySpecifier = TextFormatter.HotKeySpecifier = value; + UpdateHotKeyFromMarkdown (); + } + } + /// Gets or sets the Markdig used for parsing. /// /// A custom pipeline, or to use the default pipeline @@ -229,6 +314,7 @@ private void SetMarkdown (string value) } _markdown = value; + UpdateHotKeyFromMarkdown (); _scrollToTopPending = true; InvalidateParsedAndLayout (); @@ -236,6 +322,28 @@ private void SetMarkdown (string value) MarkdownChanged?.Invoke (this, EventArgs.Empty); } + private void UpdateHotKeyFromMarkdown () + { + if (HotKeySpecifier == new Rune ('\xFFFF')) + { + HotKey = Key.Empty; + + return; + } + + if (TextFormatter.FindHotKey (_markdown, HotKeySpecifier, out _, out Key hotKey)) + { + if (HotKey != hotKey) + { + HotKey = hotKey; + } + + return; + } + + HotKey = Key.Empty; + } + private void InvalidateParsedAndLayout () { _parsed = false; @@ -249,6 +357,7 @@ private void InvalidateParsedAndLayout () RemoveTableViews (); RemoveThematicBreakViews (); _maxLineWidth = 0; + _isSelecting = false; SetNeedsLayout (); SetNeedsDraw (); @@ -373,49 +482,60 @@ bool IDesignable.EnableForDesign () SyntaxHighlighter = new TextMateSyntaxHighlighter (); Text = DefaultMarkdownSample; + // Opt-in: prevent Link.OpenUrl from being called in the designer. + LinkClicked += (_, e) => e.Handled = true; + return true; } /// Gets a short but comprehensive Markdown sample covering common features. public static string DefaultMarkdownSample { get; } = """ - # Terminal.GuiMarkdown Sample 🚀 + # Terminal.Gui Markdown Sample 🚀 - Rich text with **bold**, *italic*, `inline code`, and ~~strikethrough~~. + ## TOC + + * [Basic Formatting](#basic-formatting) + * [Links](#links) + * [Checklist](#checklist) + * [Code Blocks](#code-blocks) + * [Tables](#tables) + * [Separators](#separators) + * [Block Quotes](#block-quotes) + + ## Basic Formatting - ## Links & Images + Rich text with **bold**, *italic*, `inline code`, and ~~strikethrough~~. - API Docs: + ## Links - * [Markdown](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Views.Markdown.html) for more info. - * [MarkdownTable](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Views.MarkdownTable.html) for more info. - * [MarkdownCodeBlock](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Views.MarkdownCodeBlock.html) for more info. + * [Markdown API docs](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Views.Markdown.html) for more info. ## Checklist - - [x] Bold & italic ✅ - - [x] Code blocks 🔧 - - [ ] Emojis 🎉 + - [x] Text with **bold**, *italic*, `inline code`, and ~~strikethrough~~ ✅ + - [x] Inline `Code` 🔧 + - [x] [Links](https://github.com/gui-cs) 🎉 + - [ ] Images 😒 + + ## Code Blocks - ## Code Block (csharp) + **csharp** code block with syntax highlighting: ```csharp Console.WriteLine ("Hello, Terminal.Gui! 🌍"); var x = 42; ``` - ## Code Block (markdown) + **markdown** code block illustrating nested markdown: ```md # Heading 1 - Text - - ## Heading 2 + Plain text. *Formatted text* with **bold** and `inline code`. Link: [SyntaxHighlighting](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.SyntaxHighlighting.html). - [x] Checked - - [ ] Not Checked | Col | Col2 | |-----|:----:| @@ -423,26 +543,31 @@ bool IDesignable.EnableForDesign () | B | Two | ``` - ## Table + ## Tables - | Feature | Status | - |---------------|---------------| - | Markdown | ✅ Totally! | - | Tables | ✅ For sure! | - | Code blocks | ✅ Awesome! | - | Emojis 🎉 | ✅ Whoa! | + **table** with links, emojis, and markdown in cells: - ### Table (centered column 2) + | Feature | Status | + |----------------|---------------| + | [Links](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Views.MarkdownTable.html) | ✅ Totally! | + | Inline `code` | ✅ *Awesome!* | + | Emojis 🎉 | ✅ **Whoa!** | - ## Table + **table** with different alignments: | First | Second | |---------------|:------:| - | Row 1 | Czech: ✅ me out. I'm long. | - | Row 2 👋 | ✅ Shorter | + | Row 1 | Czech (✅) me out. I'm long and centered. | + | Row 2 👋 | 🔛 I'm shorter but still centered 🔛 | + + ## Separators + + This text is before the thematic break. --- + And this text is after. Thematic breaks are rendered as full-width horizontal lines that automatically adjust to the layout width. + ## Block Quotes > **Tip:** This is a block quote with *inline formatting*. diff --git a/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs b/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs index 33b78bc947..77bb700e76 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs @@ -240,7 +240,7 @@ protected override bool OnMouseEvent (Mouse mouse) int copyGlyphX = Viewport.Width - 2; - if (pos.X != copyGlyphX && pos.X != copyGlyphX + 1 || pos.Y != 0) + if ((pos.X != copyGlyphX && pos.X != copyGlyphX + 1) || pos.Y != 0) { return false; } diff --git a/Terminal.Gui/Views/Markdown/MarkdownTable.cs b/Terminal.Gui/Views/Markdown/MarkdownTable.cs index 56348a59a5..b92c46f26e 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownTable.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownTable.cs @@ -40,6 +40,10 @@ public sealed class MarkdownTable : View, IDesignable // Last width used for column computation — tracks when recalculation is needed private int _lastComputedWidth; + // Link regions within the table, built after layout + private readonly List _linkRegions = []; + private int _activeLinkIndex = -1; + private static readonly TableData _emptyData = new ([], [], []); /// Initializes a new empty . @@ -53,8 +57,17 @@ public MarkdownTable () Border.Thickness = new Thickness (0); Padding.Thickness = new Thickness (0); Margin.Thickness = new Thickness (0); + + MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); + AddCommand (Command.Accept, () => ActivateCurrentLink ()); } + /// + /// Raised when a hyperlink inside a table cell is clicked. + /// Set to prevent default navigation. + /// + public event EventHandler? LinkClicked; + /// /// Gets or sets an optional syntax highlighter used to resolve theme-based attributes for /// inline styling roles (emphasis, code spans, links, etc.). When set, the table queries @@ -174,9 +187,18 @@ internal void Recalculate (int maxWidth) _bodyRowHeights [r] = ComputeRowHeight (_rowSegments [r], _columnWidths); } - Height = CalculateTableHeightWrapped (_headerRowHeight, _bodyRowHeights); + RenderedHeight = CalculateTableHeightWrapped (_headerRowHeight, _bodyRowHeights); + Height = RenderedHeight; + + BuildLinkRegions (); } + /// + /// Gets the computed rendered height (in lines) after runs. + /// This value is always current, unlike which updates only after layout. + /// + internal int RenderedHeight { get; private set; } + /// Gets the total rendered height of this table in lines (simple estimate). /// /// This simple estimation assumes single-line rows. Used by external callers that don't have @@ -195,6 +217,276 @@ protected override void OnSubViewLayout (LayoutEventArgs args) Recalculate (Frame.Width); } + /// + protected override void OnActivated (ICommandContext? ctx) + { + if (ctx?.Binding is not MouseBinding { MouseEvent: { } mouse, MouseEvent.Position: { } pos }) + { + return; + } + + if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonClicked)) + { + return; + } + + if (!HasFocus && CanFocus) + { + SetFocus (); + } + + int linkIndex = HitTestLinkIndex (pos.X, pos.Y); + + if (linkIndex < 0) + { + return; + } + + // Set active link to the clicked link region + _activeLinkIndex = linkIndex; + SetNeedsDraw (); + + RaiseLinkClicked (_linkRegions [linkIndex].Url); + } + + /// + /// + /// Cycles through link regions on Tab / Shift+Tab. Returns + /// when there are no more links in that direction, allowing focus to leave the view. + /// + protected override bool OnAdvancingFocus (NavigationDirection direction, TabBehavior? behavior) + { + if (behavior is { } && behavior != TabStop) + { + return false; + } + + if (_linkRegions.Count == 0) + { + return false; + } + + int delta = direction == NavigationDirection.Forward ? 1 : -1; + + if (_activeLinkIndex < 0) + { + // First entry — select first or last link + _activeLinkIndex = delta > 0 ? 0 : _linkRegions.Count - 1; + SetNeedsDraw (); + + return true; + } + + int next = _activeLinkIndex + delta; + + // If we've gone past either end, clear selection and let focus leave + if (next < 0 || next >= _linkRegions.Count) + { + _activeLinkIndex = -1; + SetNeedsDraw (); + + return false; + } + + _activeLinkIndex = next; + SetNeedsDraw (); + + return true; + } + + /// + protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView) + { + if (!newHasFocus) + { + _activeLinkIndex = -1; + SetNeedsDraw (); + } + + base.OnHasFocusChanged (newHasFocus, previousFocusedView, focusedView); + } + + /// Activates the currently highlighted link (Enter key). + private bool ActivateCurrentLink () + { + if (_activeLinkIndex < 0 || _activeLinkIndex >= _linkRegions.Count) + { + return false; + } + + RaiseLinkClicked (_linkRegions [_activeLinkIndex].Url); + + return true; + } + + /// Raises and opens the URL if not handled. + private void RaiseLinkClicked (string url) + { + MarkdownLinkEventArgs args = new (url); + LinkClicked?.Invoke (this, args); + + if (!args.Handled && !url.StartsWith ('#')) + { + Link.OpenUrl (url); + } + } + + /// + /// Returns the link region index at the given viewport position, or -1 if none. + /// + private int HitTestLinkIndex (int viewportX, int viewportY) + { + (string url, int rowIndex, int colIndex)? hit = HitTestLinkRegion (viewportX, viewportY); + + if (hit is null) + { + return -1; + } + + for (var i = 0; i < _linkRegions.Count; i++) + { + TableLinkRegion region = _linkRegions [i]; + + if (region.RowIndex == hit.Value.rowIndex && region.ColIndex == hit.Value.colIndex && region.Url == hit.Value.url) + { + return i; + } + } + + return -1; + } + + /// + /// Returns the URL and cell position (row/col) of the link at the given viewport position, + /// or if no link segment occupies that position. + /// + private (string url, int rowIndex, int colIndex)? HitTestLinkRegion (int viewportX, int viewportY) + { + if (_columnWidths.Length == 0) + { + return null; + } + + // Determine which row the click is in. + // Layout: row 0 = top border, rows 1..headerRowHeight = header, + // headerRowHeight+1 = separator, then body rows, last row = bottom border. + int y = viewportY; + y--; // skip top border + + if (y < 0) + { + return null; + } + + List []? cellSegments = null; + int rowIndex = -1; // -1 = header + + if (y < _headerRowHeight) + { + // Click in the header row + cellSegments = _headerSegments; + rowIndex = -1; + } + else + { + y -= _headerRowHeight; + y--; // skip separator line + + if (y < 0) + { + return null; + } + + // Walk body rows + for (var r = 0; r < _bodyRowHeights.Length; r++) + { + if (y < _bodyRowHeights [r]) + { + cellSegments = _rowSegments [r]; + rowIndex = r; + + break; + } + + y -= _bodyRowHeights [r]; + } + } + + if (cellSegments is null) + { + return null; + } + + // Determine which column the click is in. + int x = viewportX; + x--; // skip left border + int col = -1; + + for (var c = 0; c < _columnWidths.Length; c++) + { + if (x < _columnWidths [c]) + { + col = c; + + break; + } + + x -= _columnWidths [c] + 1; // column width + separator + } + + if (col < 0 || col >= cellSegments.Length) + { + return null; + } + + // Word-wrap the cell and find the segment at the click position within the line. + int innerWidth = _columnWidths [col] - 2; + List> wrappedLines = WrapSegments (cellSegments [col], innerWidth); + + // 'y' is now the line-in-row offset (already computed above for multi-line rows) + if (y < 0 || y >= wrappedLines.Count) + { + return null; + } + + List lineSegs = wrappedLines [y]; + + // Compute text width for alignment padding + var textWidth = 0; + + foreach (StyledSegment seg in lineSegs) + { + textWidth += seg.Text.GetColumns (); + } + + Alignment alignment = col < _data.ColumnAlignments.Length ? _data.ColumnAlignments [col] : Alignment.Start; + int padLeft = CalculateLeftPadding (_columnWidths [col], Math.Min (textWidth, innerWidth), alignment); + + // x is relative to column start; subtract padding to get text-relative position. + int textX = x - padLeft; + + if (textX < 0) + { + return null; + } + + var runX = 0; + + foreach (StyledSegment seg in lineSegs) + { + int segWidth = seg.Text.GetColumns (); + + if (textX >= runX && textX < runX + segWidth && !string.IsNullOrWhiteSpace (seg.Url)) + { + return (seg.Url, rowIndex, col); + } + + runX += segWidth; + } + + return null; + } + /// protected override bool OnDrawingContent (DrawContext? context) { @@ -219,7 +511,7 @@ private void DrawCellContents () var y = 1; // Below top border // Header row - DrawWrappedRow (_headerSegments, _data.ColumnAlignments, y, _headerRowHeight, true); + DrawWrappedRow (_headerSegments, _data.ColumnAlignments, y, _headerRowHeight, true, -1); y += _headerRowHeight; // Skip header separator (1 line) @@ -228,12 +520,12 @@ private void DrawCellContents () // Body rows for (var r = 0; r < _rowSegments.Length; r++) { - DrawWrappedRow (_rowSegments [r], _data.ColumnAlignments, y, _bodyRowHeights [r], false); + DrawWrappedRow (_rowSegments [r], _data.ColumnAlignments, y, _bodyRowHeights [r], false, r); y += _bodyRowHeights [r]; } } - private void DrawWrappedRow (List [] cellSegments, Alignment [] alignments, int startY, int rowHeight, bool isHeader) + private void DrawWrappedRow (List [] cellSegments, Alignment [] alignments, int startY, int rowHeight, bool isHeader, int rowIndex) { Attribute normal = GetAttributeForRole (VisualRole.Normal); Color? themeBg = UseThemeBackground ? SyntaxHighlighter?.DefaultBackground : null; @@ -299,19 +591,44 @@ private void DrawWrappedRow (List [] cellSegments, Alignment [] a attr = attr with { Style = attr.Style | TextStyle.Bold }; } - SetAttribute (attr); + bool hasUrl = !string.IsNullOrWhiteSpace (seg.Url); + bool isAbsoluteUrl = hasUrl && Uri.IsWellFormedUriString (seg.Url, UriKind.Absolute); + bool isActive = hasUrl && HasFocus && IsActiveLinkAt (rowIndex, col, seg.Url!); + + if (isActive) + { + attr = new Attribute (attr.Background, attr.Foreground, attr.Style); + } + + // Set OSC8 URL for link segments (only absolute URIs) + if (isAbsoluteUrl && Driver is { }) + { + Driver.CurrentUrl = seg.Url; + } - foreach (string grapheme in GraphemeHelper.GetGraphemes (seg.Text)) + try { - int gw = Math.Max (grapheme.GetColumns (), 1); + SetAttribute (attr); - if (drawX - x >= colWidth - 1) + foreach (string grapheme in GraphemeHelper.GetGraphemes (seg.Text)) { - break; - } + int gw = Math.Max (grapheme.GetColumns (), 1); - AddStr (drawX, y, grapheme); - drawX += gw; + if (drawX - x >= colWidth - 1) + { + break; + } + + AddStr (drawX, y, grapheme); + drawX += gw; + } + } + finally + { + if (isAbsoluteUrl && Driver is { }) + { + Driver.CurrentUrl = null; + } } } @@ -789,6 +1106,86 @@ private static string TruncateToWidth (string text, int maxWidth) return text [..charCount]; } + /// + /// Returns if the segment at the given row/col with the given URL is the currently active + /// link. + /// + private bool IsActiveLinkAt (int rowIndex, int colIndex, string url) + { + if (_activeLinkIndex < 0 || _activeLinkIndex >= _linkRegions.Count) + { + return false; + } + + TableLinkRegion active = _linkRegions [_activeLinkIndex]; + + return active.RowIndex == rowIndex && active.ColIndex == colIndex && active.Url == url; + } + + /// + /// Builds the list of navigable link regions by scanning all cell segments. + /// Called after . + /// + private void BuildLinkRegions () + { + _linkRegions.Clear (); + _activeLinkIndex = -1; + + ScanCellSegments (_headerSegments, -1); + + for (var r = 0; r < _rowSegments.Length; r++) + { + ScanCellSegments (_rowSegments [r], r); + } + + // Make the table focusable and navigable when it contains links + bool hasLinks = _linkRegions.Count > 0; + CanFocus = hasLinks; + TabStop = hasLinks ? TabBehavior.TabStop : TabBehavior.NoStop; + + return; + + void ScanCellSegments (List [] cellSegments, int rowIndex) + { + for (var col = 0; col < cellSegments.Length; col++) + { + string? lastUrlInCell = null; + + foreach (StyledSegment seg in cellSegments [col]) + { + if (string.IsNullOrWhiteSpace (seg.Url)) + { + lastUrlInCell = null; + + continue; + } + + // Avoid duplicates from multi-segment rendering of the same link within one cell + if (seg.Url == lastUrlInCell) + { + continue; + } + + lastUrlInCell = seg.Url; + _linkRegions.Add (new TableLinkRegion { Url = seg.Url!, RowIndex = rowIndex, ColIndex = col }); + } + } + } + } + + /// A navigable link within the table. Tracks URL and cell position for unique identification. + private sealed class TableLinkRegion + { + /// The target URL of the link. + public string Url { get; init; } = ""; + + /// Row index: -1 for header, 0..N for body rows. + public int RowIndex { get; init; } + + /// Column index within the row. + public int ColIndex { get; init; } + } + /// bool IDesignable.EnableForDesign () { @@ -797,11 +1194,14 @@ bool IDesignable.EnableForDesign () Text = """ | Feature | *Status (centered)* | **Owner** | |---------|:-----------------:|-------| - | **Markdown** | ✅ Totally! | @tig | + | [Markdown](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Views.MarkdownTable.html) | ✅ Totally! | @tig | | *Tables* | ✅ For **sure!** | [tig](https://github.com/tig) | | `Code` | ✅ `printf ("Awesome!");` | ??? | """; + // Opt-in: prevent Link.OpenUrl from being called in the designer. + LinkClicked += (_, e) => e.Handled = true; + return true; } } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs index c361bf21b0..b9c4c46f0d 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs @@ -38,9 +38,108 @@ protected override bool OnDrawingContent (DrawContext? context) // All visible content was drawn in OnDrawingSubViews; just register the drawn region. context?.AddDrawnRegion (new Region (new Rectangle (ContentToScreen (Point.Empty), Viewport.Size))); + // OnDrawingContent is called AFTER SubViews have drawn. Plain rendered lines are + // highlighted during DrawRenderedLine (in OnDrawingSubViews), but table and code-block + // rows are owned by their SubViews and don't receive that pass. Draw the selection + // overlay for those rows here, on top of what the SubViews rendered. + if (_isSelecting) + { + DrawSelectionOverlayOnSubViewRows (); + } + return true; } + /// + /// Draws the selection highlight over table and fenced-code-block rows. + /// Those rows are owned by SubViews ( / + /// ) that draw after + /// returns. This pass reads the graphemes + /// that the SubViews already placed in the screen buffer and re-draws them + /// with the selection attribute, preserving the rendered characters while + /// applying the selection background. + /// + private void DrawSelectionOverlayOnSubViewRows () + { + Cell [,]? contents = ScreenContents; + + if (contents is null) + { + return; + } + + Attribute selAttr = GetAttributeForRole (VisualRole.Focus); + (Point start, Point end) = GetNormalizedSelection (); + + int startRow = Math.Max (start.Y, Viewport.Y); + int endRow = Math.Min (end.Y, Viewport.Y + Viewport.Height - 1); + + var anySubViewRows = false; + + for (int lineIdx = startRow; lineIdx <= Math.Min (endRow, _renderedLines.Count - 1); lineIdx++) + { + if (!_renderedLines [lineIdx].IsTable && !_renderedLines [lineIdx].IsCodeBlock) + { + continue; + } + anySubViewRows = true; + + break; + } + + if (!anySubViewRows) + { + return; + } + + // After DoDrawSubViews each SubView calls DoDrawComplete which excludes its screen + // area from Driver.Clip. DoDrawContent (OnDrawingContent) runs with those exclusions + // still active, so drawing would silently no-op on SubView areas. + // Reset the clip to the raw viewport rectangle to allow the overlay to appear. + Region? savedClip = GetClip (); + Rectangle viewportScreen = ViewportToScreen (new Rectangle (Point.Empty, Viewport.Size)); + SetClip (new Region (viewportScreen)); + + SetAttribute (selAttr); + + for (int lineIdx = startRow; lineIdx <= Math.Min (endRow, _renderedLines.Count - 1); lineIdx++) + { + RenderedLine line = _renderedLines [lineIdx]; + + if (line is { IsTable: false, IsCodeBlock: false }) + { + continue; + } + + int drawRow = lineIdx - Viewport.Y; + Point screenOrigin = ContentToScreen (new Point (0, drawRow)); + int screenRow = screenOrigin.Y; + int screenStartCol = screenOrigin.X; + int cols = Viewport.Width; + + for (var col = 0; col < cols; col++) + { + int sc = screenStartCol + col; + + if (screenRow < 0 || screenRow >= contents.GetLength (0) || sc < 0 || sc >= contents.GetLength (1)) + { + continue; + } + + string grapheme = contents [screenRow, sc].Grapheme; + + if (string.IsNullOrEmpty (grapheme)) + { + grapheme = " "; + } + + AddStr (col, drawRow, grapheme); + } + } + + SetClip (savedClip); + } + private void DrawRenderedLine (RenderedLine line, int contentRow, int drawRow) { // Thematic breaks are drawn by Line SubViews @@ -112,6 +211,13 @@ private void DrawRenderedLine (RenderedLine line, int contentRow, int drawRow) AddStr (drawCol, drawRow, grapheme); } } + else if (IsInSelection (contentRow, contentX)) + { + // Use the scheme's Focus attribute for selection highlight — it provides + // reliable contrast regardless of per-segment colours. + SetAttribute (GetAttributeForRole (VisualRole.Focus)); + AddStr (drawCol, drawRow, grapheme); + } else { DrawGrapheme (segment, grapheme, drawCol, drawRow); diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs index a0b7a7fe49..9ff33f995d 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs @@ -10,6 +10,30 @@ private void BuildRenderedLines () int viewportWidth = Math.Max (GetEffectiveLayoutWidth (), MIN_WRAP_WIDTH); + // Pre-scan: compute _maxLineWidth from unwrapped blocks so that tables can be + // sized using the final content width (which may be wider than the viewport when + // long unwrapped lines — e.g. code — expand the content area). + foreach (IntermediateBlock block in _blocks) + { + if (block.IsThematicBreak || block.IsTable || block.Wrap) + { + continue; + } + + int width = CalculateWidth (block.Runs.Select (r => new StyledSegment (r.Text, r.StyleRole, r.Url, r.ImageSource, r.Attribute)).ToList ()); + + if (!string.IsNullOrEmpty (block.Prefix)) + { + width += block.Prefix.GetColumns (); + } + + _maxLineWidth = Math.Max (_maxLineWidth, width); + } + + // The effective width for table layout: tables use Dim.Fill() against content width, + // so if unwrapped lines expand the content beyond the viewport, tables get that width. + int tableLayoutWidth = Math.Max (viewportWidth, _maxLineWidth); + foreach (IntermediateBlock block in _blocks) { // Record heading anchor → rendered-line index before adding lines @@ -62,18 +86,36 @@ private void BuildRenderedLines () Y = startLine, Width = Dim.Fill () }; - tableView.Recalculate (viewportWidth); + tableView.Recalculate (tableLayoutWidth); + + // Capture the rendered height BEFORE Add() — Add triggers EndInit → Layout + // which may recalculate the table at a stale content width, corrupting RenderedHeight. + int tableHeight = tableView.RenderedHeight; + + // Forward link clicks from the table to this Markdown view's LinkClicked event. + tableView.LinkClicked += (_, e) => + { + // Handle anchor links the same way as paragraph links + if (e.Url.StartsWith ('#')) + { + ScrollToAnchor (e.Url); + } + + if (!RaiseLinkClicked (e.Url)) + { + return; + } + + e.Handled = true; + }; _tableViews.Add (tableView); Add (tableView); - // Use actual table height (accounts for word-wrapped rows) - int tableHeight = tableView.Frame.Height; - // Reserve placeholder lines so content height is correct for (var i = 0; i < tableHeight; i++) { - _renderedLines.Add (new RenderedLine ([new StyledSegment ("", MarkdownStyleRole.Table)], false, 0, isTable: true)); + _renderedLines.Add (new RenderedLine ([new StyledSegment ("", MarkdownStyleRole.Table)], false, 0, isTable: true, tableData: tableData)); } continue; @@ -188,7 +230,7 @@ private static RenderedLine CreateUnwrappedLine (IntermediateBlock block) int width = CalculateWidth (segments); - return new RenderedLine (segments, false, width, block.IsCodeBlock, block.IsThematicBreak); + return new RenderedLine (segments, false, width, block.IsCodeBlock, block.IsThematicBreak, codeLanguage: block.Language); } private static List WrapBlock (IntermediateBlock block, int viewportWidth) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index ccd0d074bd..62e68ba58f 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -43,6 +43,11 @@ private void SetupBindingsAndCommands () AddCommand (Command.Accept, () => ActivateCurrentLink ()); + // Selection and clipboard commands + AddCommand (Command.SelectAll, () => SelectAll ()); + AddCommand (Command.Copy, () => Copy ()); + AddCommand (Command.Context, () => ShowContextMenu ()); + // Apply default key bindings (maps CursorUp→Up, CursorDown→Down, etc.) ApplyKeyBindings (DefaultKeyBindings, DefaultKeyBindings); @@ -51,14 +56,68 @@ private void SetupBindingsAndCommands () MouseBindings.ReplaceCommands (MouseFlags.WheeledUp, Command.ScrollUp); MouseBindings.ReplaceCommands (MouseFlags.WheeledRight, Command.ScrollRight); MouseBindings.ReplaceCommands (MouseFlags.WheeledLeft, Command.ScrollLeft); + + // The base class binds LeftButtonReleased → Activate; remove that so Activate + // fires only on LeftButtonClicked (not twice per click which would clear selection). + MouseBindings.Remove (MouseFlags.LeftButtonReleased); MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); + + // Right-click is handled directly in OnMouseEvent so that the view can be focused + // and the context menu created before trying to show it, even when not yet focused. + + // Press anchors the drag-selection; drag extends it — both routed through OnActivated. + MouseBindings.Add (MouseFlags.LeftButtonPressed, Command.Activate); + MouseBindings.Add (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, Command.Activate); + } + + /// + protected override bool OnMouseEvent (Mouse mouse) + { + // Right-click: focus the view first (which creates ContextMenu) then show the menu at + // the click's screen position. Handled here rather than via a Command binding so that + // focus and menu creation are guaranteed even when the view is not yet focused. + if (mouse.Flags.FastHasFlags (MouseFlags.RightButtonClicked)) + { + if (!HasFocus && CanFocus) + { + SetFocus (); + } + + ShowContextMenu (mouse.ScreenPosition); + + return true; + } + + if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + { + return false; + } + + App?.Mouse.UngrabMouse (); + + return false; } /// protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView) { - if (!newHasFocus) + if (newHasFocus) + { + CreateContextMenu (); + + if (ContextMenu?.Key is { }) + { + KeyBindings.Add (ContextMenu.Key, Command.Context); + } + } + else { + if (ContextMenu?.Key is { }) + { + KeyBindings.Remove (ContextMenu.Key); + } + + DisposeContextMenu (); _activeLinkIndex = -1; SetNeedsDraw (); } @@ -119,30 +178,92 @@ protected override bool OnAdvancingFocus (NavigationDirection direction, TabBeha /// protected override void OnActivated (ICommandContext? ctx) { - // Only process mouse clicks — keyboard activation is handled via Command.Accept - if (ctx?.Binding is not MouseBinding { MouseEvent.Position: { } pos }) + // Only process mouse input — keyboard activation is handled via Command.Accept + if (ctx?.Binding is not MouseBinding { MouseEvent: { } mouse, MouseEvent.Position: { } pos }) { return; } + // Button-down: anchor the drag-selection start + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) && !mouse.Flags.HasFlag (MouseFlags.PositionReport)) + { + int contentX = Viewport.X + pos.X; + int contentY = Math.Min (Viewport.Y + pos.Y, Math.Max (_renderedLines.Count - 1, 0)); + _selectionAnchor = new Point (contentX, contentY); + _selectionCurrent = _selectionAnchor; + _isDragging = false; + + if (App is { } && !App.Mouse.IsGrabbed (this)) + { + App.Mouse.GrabMouse (this); + } + + if (!HasFocus && CanFocus) + { + SetFocus (); + } + + return; + } + + // Drag: extend selection and auto-scroll when the pointer leaves the viewport. + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) + { + // Auto-scroll: if the pointer has left the top or bottom edge, scroll one line + // in that direction so the user can extend the selection beyond the visible area. + if (pos.Y < 0) + { + ScrollVertical (-1); + } + else if (pos.Y >= Viewport.Height) + { + ScrollVertical (1); + } + + // Clamp both axes to the actual content bounds to prevent negative indices or + // indices beyond the last rendered line (possible when the mouse is grabbed and + // moves outside the view's frame). + int maxLine = Math.Max (_renderedLines.Count - 1, 0); + int contentX = Math.Max (Viewport.X + pos.X, 0); + int contentY = Math.Clamp (Viewport.Y + pos.Y, 0, maxLine); + _selectionCurrent = new Point (contentX, contentY); + _isDragging = true; + _isSelecting = true; + SetNeedsDraw (); + + return; + } + + // LeftButtonClicked: a drag ended — the click fires after release, but the user was + // selecting text, so don't activate a link. + if (_isDragging) + { + _isDragging = false; + + return; + } + + // Plain click clears any existing text selection. + ClearSelection (); + if (!HasFocus && CanFocus) { SetFocus (); } - int contentX = Viewport.X + pos.X; - int contentY = Viewport.Y + pos.Y; + int clickX = Viewport.X + pos.X; + int clickY = Viewport.Y + pos.Y; for (var i = 0; i < _linkRegions.Count; i++) { MarkdownLinkRegion region = _linkRegions [i]; - if (region.Line != contentY) + if (region.Line != clickY) { continue; } - if (contentX < region.StartX || contentX >= region.EndXExclusive) + if (clickX < region.StartX || clickX >= region.EndXExclusive) { continue; } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs index 31833d2ed7..19151b1149 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs @@ -180,7 +180,7 @@ private void HandleListBlock (ListBlock list, string prefix, string contPrefix, string marker = list.IsOrdered ? $"{listItem.Order}. " : "• "; string itemPrefix = prefix + marker; - string itemCont = contPrefix + new string (' ', marker.Length); + string itemCont = contPrefix + new string (' ', marker.GetColumns ()); var isFirst = true; @@ -194,9 +194,10 @@ private void HandleListBlock (ListBlock list, string prefix, string contPrefix, { bool done = tl.Checked; MarkdownStyleRole role = done ? MarkdownStyleRole.TaskDone : MarkdownStyleRole.TaskTodo; - string checkbox = done ? "[x] " : "[ ] "; + string checkbox = done ? $"{Glyphs.CheckStateChecked} " : $"{Glyphs.CheckStateUnChecked} "; string taskPrefix = itemPrefix + checkbox; - string taskCont = contPrefix + new string (' ', marker.Length + 4); + // To align wrapped task text after variable-width glyph markers, include the trailing space in the continuation indent. + string taskCont = contPrefix + new string (' ', marker.GetColumns () + checkbox.GetColumns ()); List runs = WalkInlines (firstInline.NextSibling, role); TrimLeadingSpace (runs); @@ -534,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)); + _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.CodeBlock)], false, isCodeBlock: true, language: language)); return; } @@ -563,7 +564,7 @@ private void AddCodeBlockLines (IReadOnlyList codeLines, string? languag runs = converted; } - _blocks.Add (new IntermediateBlock (runs, false, isCodeBlock: true)); + _blocks.Add (new IntermediateBlock (runs, false, isCodeBlock: true, language: language)); } } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs new file mode 100644 index 0000000000..12b89a31f9 --- /dev/null +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -0,0 +1,385 @@ +namespace Terminal.Gui.Views; + +public partial class Markdown +{ + private bool _isSelecting; + private Point _selectionAnchor; + private Point _selectionCurrent; + private bool _isDragging; + + /// Gets the context menu for this view. + public PopoverMenu? ContextMenu { get; private set; } + + /// Selects all rendered content. + /// if the operation succeeded. + public bool SelectAll () + { + if (_renderedLines.Count == 0) + { + return true; + } + + _isSelecting = true; + _selectionAnchor = new Point (0, 0); + int lastLine = _renderedLines.Count - 1; + _selectionCurrent = new Point (GetLineDisplayWidth (lastLine), lastLine); + SetNeedsDraw (); + + return true; + } + + /// + /// Gets the text that corresponds to the current selection, rendered as plain text from + /// the displayed content. Returns when no selection is active. + /// + /// + /// The returned string reflects the on-screen representation (display text) of the selected + /// region — not the original markdown source. Markdown structure such as bullet-list + /// markers (- ), fenced code-block delimiters (```), and heading hashes + /// (#) may differ from the source document. + /// + public string? SelectedText => _isSelecting ? GetSelectedText () : null; + + /// + /// Copies the current selection, or the entire markdown document if nothing is selected, to the clipboard. + /// + /// if the copy was performed. + public bool Copy () + { + string text = _isSelecting ? GetSelectedText () : _markdown; + App?.Clipboard?.TrySetClipboardData (text); + + return true; + } + + /// Clears the current selection. + public void ClearSelection () + { + if (!_isSelecting) + { + return; + } + + _isSelecting = false; + SetNeedsDraw (); + } + + /// + /// Returns if the cell at (, ) + /// falls within the current selection. + /// + /// The rendered-line index (content Y coordinate). + /// The display column (content X coordinate). + internal bool IsInSelection (int lineIdx, int x) + { + if (!_isSelecting) + { + return false; + } + + (Point start, Point end) = GetNormalizedSelection (); + + if (lineIdx < start.Y || lineIdx > end.Y) + { + return false; + } + + if (start.Y == end.Y) + { + return x >= start.X && x < end.X; + } + + if (lineIdx == start.Y) + { + return x >= start.X; + } + + if (lineIdx == end.Y) + { + return x < end.X; + } + + return true; + } + + private (Point Start, Point End) GetNormalizedSelection () + { + if (_selectionAnchor.Y < _selectionCurrent.Y || (_selectionAnchor.Y == _selectionCurrent.Y && _selectionAnchor.X <= _selectionCurrent.X)) + { + return (_selectionAnchor, _selectionCurrent); + } + + return (_selectionCurrent, _selectionAnchor); + } + + private string GetSelectedText () + { + if (_renderedLines.Count == 0) + { + return string.Empty; + } + + // When the entire document is selected return the original source markdown. + // _renderedLines is a display buffer: it cannot carry inline formatting markers, + // heading hashes, table syntax, or thematic-break syntax. Returning _markdown + // preserves everything and is always correct for a full-document selection. + if (IsFullDocumentSelected ()) + { + return _markdown; + } + + (Point start, Point end) = GetNormalizedSelection (); + List outputLines = []; + var inCodeBlock = false; + + string? currentCodeLanguage = null; + + // 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 + // covers multiple placeholder rows belonging to the same table. + TableData? lastOutputtedTable = null; + + for (int lineIdx = start.Y; lineIdx <= Math.Min (end.Y, _renderedLines.Count - 1); lineIdx++) + { + RenderedLine line = _renderedLines [lineIdx]; + + if (line.IsCodeBlock) + { + string? nextCodeLanguage = line.CodeLanguage; + + if (!inCodeBlock) + { + // Entering a code block: inject the opening fence with optional language tag + outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); + inCodeBlock = true; + currentCodeLanguage = nextCodeLanguage; + } + 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 ("```"); + outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); + currentCodeLanguage = nextCodeLanguage; + } + } + else if (inCodeBlock) + { + // Leaving a code block: inject the closing fence + outputLines.Add ("```"); + inCodeBlock = false; + currentCodeLanguage = null; + } + + if (line is { IsTable: true, TableData: { } tableData }) + { + // Each table occupies several zero-width placeholder rows that all share the + // same TableData instance. Only reconstruct the table markdown the first time + // we encounter each distinct instance; skip subsequent placeholder rows. + if (!ReferenceEquals (tableData, lastOutputtedTable)) + { + foreach (string tableLine in RenderTableAsMarkdown (tableData)) + { + outputLines.Add (tableLine); + } + + lastOutputtedTable = tableData; + } + + continue; + } + + int lineStartX = lineIdx == start.Y ? start.X : 0; + int lineEndX = lineIdx == end.Y ? end.X : int.MaxValue; + StringBuilder lineSb = new (); + AppendLineText (lineSb, line, lineStartX, lineEndX); + outputLines.Add (lineSb.ToString ()); + } + + if (inCodeBlock) + { + outputLines.Add ("```"); + } + + return string.Join ("\n", outputLines); + } + + /// + /// Returns when the selection spans the entire rendered document + /// from the first character to the last, so that can + /// return the original markdown source instead of the lossy display representation. + /// + private bool IsFullDocumentSelected () + { + (Point start, Point end) = GetNormalizedSelection (); + + if (start.X != 0 || start.Y != 0) + { + return false; + } + + int lastLine = _renderedLines.Count - 1; + + // For a document ending with a zero-width placeholder (table rows, thematic breaks), + // GetLineDisplayWidth(lastLine) returns 0, so end.X >= 0 is always satisfied. + // This is intentional: any position on a zero-width row is equivalent to the end of + // that row (there is no content there), so reaching the last row from (0,0) means the + // entire document is selected. + return end.Y >= lastLine && end.X >= GetLineDisplayWidth (lastLine); + } + + /// Reconstructs GFM pipe-table markdown lines from a instance. + private static IEnumerable RenderTableAsMarkdown (TableData tableData) + { + // Header row + yield return "| " + string.Join (" | ", tableData.Headers) + " |"; + + // Separator row — encode column alignment + IEnumerable separators = tableData.ColumnAlignments.Select (alignment => alignment switch + { + Alignment.Center => ":---:", + Alignment.End => "---:", + _ => "---" + }); + + yield return "| " + string.Join (" | ", separators) + " |"; + + // Body rows + foreach (string [] row in tableData.Rows) + { + yield return "| " + string.Join (" | ", row) + " |"; + } + } + + private static void AppendLineText (StringBuilder sb, RenderedLine line, int startX, int endX) + { + var contentX = 0; + + foreach (StyledSegment segment in line.Segments) + { + string text = segment.StyleRole == MarkdownStyleRole.ListMarker ? TranslateListMarkerText (segment.Text) : segment.Text; + + foreach (string grapheme in GraphemeHelper.GetGraphemes (text)) + { + int gw = Math.Max (grapheme.GetColumns (), 1); + + if (contentX + gw <= startX) + { + contentX += gw; + + continue; + } + + if (contentX >= endX) + { + return; + } + + sb.Append (grapheme); + contentX += gw; + } + } + } + + /// Converts rendered list marker text back to Markdown source marker text for selection and clipboard operations. + /// The rendered list marker text. + /// The Markdown source marker text, or when it is not a rendered list marker. + private static string TranslateListMarkerText (string text) + { + const string BULLET_PREFIX = "• "; + + if (!text.StartsWith (BULLET_PREFIX, StringComparison.Ordinal)) + { + return text; + } + + string remainder = text [BULLET_PREFIX.Length..]; + + var checkedGlyph = $"{Glyphs.CheckStateChecked} "; + var uncheckedGlyph = $"{Glyphs.CheckStateUnChecked} "; + + if (remainder.StartsWith (checkedGlyph, StringComparison.Ordinal)) + { + return "- [x] " + remainder [checkedGlyph.Length..]; + } + + if (remainder.StartsWith (uncheckedGlyph, StringComparison.Ordinal)) + { + return "- [ ] " + remainder [uncheckedGlyph.Length..]; + } + + return "- " + remainder; + } + + private int GetLineDisplayWidth (int lineIdx) + { + if (lineIdx < 0 || lineIdx >= _renderedLines.Count) + { + return 0; + } + + return _renderedLines [lineIdx].Width; + } + + private void CreateContextMenu () + { + DisposeContextMenu (); + + PopoverMenu menu = new ([new MenuItem (this, Command.SelectAll), new MenuItem (this, Command.Copy)]) + { +#if DEBUG + Id = "markdownContextMenu" +#endif + }; + + HotKeyBindings.Remove (menu.Key); + HotKeyBindings.Add (menu.Key, Command.Context); + menu.KeyChanged += ContextMenuOnKeyChanged; + + ContextMenu = menu; + App?.Popovers?.Register (ContextMenu); + } + + private void DisposeContextMenu () + { + if (ContextMenu is null) + { + return; + } + + ContextMenu.Visible = false; + App?.Popovers?.DeRegister (ContextMenu); + ContextMenu.KeyChanged -= ContextMenuOnKeyChanged; + ContextMenu.Dispose (); + ContextMenu = null; + } + + private void ContextMenuOnKeyChanged (object? sender, KeyChangedEventArgs e) => KeyBindings.Replace (e.OldKey.KeyCode, e.NewKey.KeyCode); + + private Point GetContextMenuScreenPosition () + { + Point viewportPosition = _isSelecting ? _selectionCurrent : new Point (0, 0); + + return ViewportToScreen (viewportPosition); + } + + private bool ShowContextMenu (Point? screenPosition = null) + { + Point menuPosition = screenPosition ?? GetContextMenuScreenPosition (); + ContextMenu?.MakeVisible (menuPosition); + + return true; + } + + /// + protected override void Dispose (bool disposing) + { + if (disposing) + { + DisposeContextMenu (); + } + + base.Dispose (disposing); + } +} diff --git a/Terminal.Gui/Views/Markdown/RenderedLine.cs b/Terminal.Gui/Views/Markdown/RenderedLine.cs index e5b04516dc..c2e0668352 100644 --- a/Terminal.Gui/Views/Markdown/RenderedLine.cs +++ b/Terminal.Gui/Views/Markdown/RenderedLine.cs @@ -1,6 +1,6 @@ namespace Terminal.Gui.Views; -internal sealed class RenderedLine (IReadOnlyList segments, bool wrapEligible, int width, bool isCodeBlock = false, bool isThematicBreak = false, bool isTable = false) +internal sealed class RenderedLine (IReadOnlyList segments, bool wrapEligible, int width, bool isCodeBlock = false, bool isThematicBreak = false, bool isTable = false, string? codeLanguage = null, TableData? tableData = null) { public IReadOnlyList Segments { get; } = segments; public bool WrapEligible { get; } = wrapEligible; @@ -8,4 +8,17 @@ internal sealed class RenderedLine (IReadOnlyList segments, bool public bool IsCodeBlock { get; } = isCodeBlock; public bool IsThematicBreak { get; } = isThematicBreak; public bool IsTable { get; } = isTable; + + /// + /// The fenced code-block language specifier (e.g. "cs"), or + /// when this line is not part of a code block or no language was given. + /// + public string? CodeLanguage { get; } = codeLanguage; + + /// + /// The parsed table data when is ; + /// otherwise . Used to reconstruct pipe-table markdown + /// when this line falls within a partial selection. + /// + public TableData? TableData { get; } = tableData; } diff --git a/Terminal.Gui/Views/Menu/MenuItem.cs b/Terminal.Gui/Views/Menu/MenuItem.cs index eab6a75e64..ab67500d43 100644 --- a/Terminal.Gui/Views/Menu/MenuItem.cs +++ b/Terminal.Gui/Views/Menu/MenuItem.cs @@ -147,6 +147,19 @@ public Menu? SubMenu /// public object GetValue () => Title; + /// + public bool TrySetValueFromString (string input) + { + if (input is null) + { + return false; + } + + Title = input; + + return true; + } + /// event EventHandler>? IValue.ValueChangedUntyped { diff --git a/Terminal.Gui/Views/ScrollBar/ScrollSlider.cs b/Terminal.Gui/Views/ScrollBar/ScrollSlider.cs index deb0b876f4..1a915ea8b9 100644 --- a/Terminal.Gui/Views/ScrollBar/ScrollSlider.cs +++ b/Terminal.Gui/Views/ScrollBar/ScrollSlider.cs @@ -7,7 +7,7 @@ namespace Terminal.Gui.Views; /// Can be dragged with the mouse, constrained by the size of the Viewport of it's superview. Can be /// oriented either vertically or horizontally. /// -public sealed class ScrollSlider : View, IOrientation, IDesignable +public sealed class ScrollSlider : View, IOrientation, IDesignable, IValue { /// /// Initializes a new instance. @@ -208,6 +208,23 @@ internal void MoveToPosition (int position) private void RaisePositionChangeEvents (int newPosition) { + int oldPosition = _position; + + // Raise IValue ValueChanging event + ValueChangingEventArgs valueChangingArgs = new (oldPosition, newPosition); + + if (OnValueChanging (valueChangingArgs) || valueChangingArgs.Handled) + { + return; + } + + ValueChanging?.Invoke (this, valueChangingArgs); + + if (valueChangingArgs.Handled) + { + return; + } + CancelEventArgs args = new (ref _position, ref newPosition); PositionChanging?.Invoke (this, args); @@ -223,6 +240,12 @@ private void RaisePositionChangeEvents (int newPosition) PositionChanged?.Invoke (this, new (in _position)); + // Raise IValue ValueChanged and ValueChangedUntyped events + ValueChangedEventArgs valueChangedArgs = new (oldPosition, _position); + OnValueChanged (valueChangedArgs); + ValueChanged?.Invoke (this, valueChangedArgs); + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldPosition, _position)); + Scrolled?.Invoke (this, new (in distance)); RaiseActivating (new CommandContext (Command.Activate, new WeakReference (this), new CommandBinding ([Command.Activate], null, distance))); @@ -240,6 +263,42 @@ private void RaisePositionChangeEvents (int newPosition) /// Raised when the has changed. Indicates how much to scroll. public event EventHandler>? Scrolled; + #region IValue Implementation + + /// + /// Gets or sets the position of the ScrollSlider. This is an alias for + /// provided to satisfy the interface. + /// + public int Value + { + get => Position; + set => Position = value; + } + + /// + public event EventHandler>? ValueChanging; + + /// + public event EventHandler>? ValueChanged; + + /// + public event EventHandler>? ValueChangedUntyped; + + /// + /// Called when is changing. Return to cancel the change. + /// + /// The event arguments containing old and new values. + /// to cancel the change; otherwise . + private bool OnValueChanging (ValueChangingEventArgs args) => false; + + /// + /// Called when has changed. + /// + /// The event arguments containing old and new values. + private void OnValueChanged (ValueChangedEventArgs args) { } + + #endregion + /// protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attribute currentAttribute) { diff --git a/Terminal.Gui/Views/Selectors/FlagSelector.cs b/Terminal.Gui/Views/Selectors/FlagSelector.cs index 932e396f8b..5ebbf00b4d 100644 --- a/Terminal.Gui/Views/Selectors/FlagSelector.cs +++ b/Terminal.Gui/Views/Selectors/FlagSelector.cs @@ -7,7 +7,7 @@ namespace Terminal.Gui.Views; // Item HotKey - Focus item. Activate (Toggle) item. Do NOT Accept. // Focused: // Space key - Activate (Toggle) focused item. Do NOT Accept. -// Enter key - Activate (Toggle) and Accept the focused item. +// Enter key - Accept. Do NOT Toggle. // HotKey - No-op. // Item HotKey - Focus item, Activate (Toggle), and do NOT Accept. @@ -60,6 +60,11 @@ public FlagSelector () /// protected override bool ConsumeDispatch => true; + /// + /// FlagSelector does not toggle on Enter — Enter only accepts. + /// + protected override bool ActivateOnAccept => false; + // Set by OnHandlingHotKey to suppress the Activate that DefaultHotKeyHandler // fires after RaiseHandlingHotKey. Checked and cleared in GetDispatchTarget. private bool _suppressHotKeyActivate; diff --git a/Terminal.Gui/Views/Selectors/OptionSelector.cs b/Terminal.Gui/Views/Selectors/OptionSelector.cs index 45edda196f..aabcc05883 100644 --- a/Terminal.Gui/Views/Selectors/OptionSelector.cs +++ b/Terminal.Gui/Views/Selectors/OptionSelector.cs @@ -175,6 +175,22 @@ public override void UpdateChecked () Debug.Assert (SubViews.OfType ().Count (cb => cb.Value == CheckState.Checked) <= 1); } + /// + protected override void OnValueChanged (int? value, int? previousValue) + { + if (value is null || Values is null) + { + return; + } + + int index = Values.IndexOf (v => v == value); + + if (index >= 0 && index < SubViews.OfType ().Count ()) + { + FocusedItem = index; + } + } + /// /// Gets or sets the index for the focused item. The active item may or may not be /// the selected diff --git a/Terminal.Gui/Views/Selectors/SelectorBase.cs b/Terminal.Gui/Views/Selectors/SelectorBase.cs index 1fc8423991..3bdefd2dca 100644 --- a/Terminal.Gui/Views/Selectors/SelectorBase.cs +++ b/Terminal.Gui/Views/Selectors/SelectorBase.cs @@ -159,6 +159,13 @@ public SelectorStyles Styles } } + /// + /// Gets whether the Accept command (Enter key) should also invoke Activate (toggle/select) before accepting. + /// The default is (OptionSelector behavior). Override to return + /// to accept without activating (FlagSelector behavior). + /// + protected virtual bool ActivateOnAccept => true; + /// protected override bool OnAccepting (CommandEventArgs args) { @@ -167,7 +174,7 @@ protected override bool OnAccepting (CommandEventArgs args) return true; } - // Per spec: Enter key should Activate AND Accept for both OptionSelector and FlagSelector. + // Per spec: Enter key should Activate AND Accept for OptionSelector. // Enter only triggers Command.Accept (View's default key binding), so invoke Activate here // before continuing with Accept processing. Also handle direct programmatic Accept invocations // (Binding is null) by activating the currently focused checkbox. @@ -178,7 +185,7 @@ protected override bool OnAccepting (CommandEventArgs args) bool directAccept = args.Context?.Binding is null && Focused is CheckBox; - if (!enterFromCheckBox && !directAccept) + if ((!enterFromCheckBox && !directAccept) || !ActivateOnAccept) { return args.Context?.Binding switch { diff --git a/Terminal.Gui/Views/TableView/ColumnStyle.cs b/Terminal.Gui/Views/TableView/ColumnStyle.cs index da23327888..13a5917013 100644 --- a/Terminal.Gui/Views/TableView/ColumnStyle.cs +++ b/Terminal.Gui/Views/TableView/ColumnStyle.cs @@ -19,6 +19,12 @@ public class ColumnStyle /// public CellColorGetterDelegate? ColorGetter { get; set; } + /// + /// Defines a delegate for returning a custom scheme for this column's header. Return to + /// fall back to or the view's default scheme. + /// + public HeaderColorGetterDelegate? HeaderColorGetter { get; set; } + /// /// Defines a delegate for returning custom representations of cell values. If not set then /// is used. Return values from your delegate may be truncated e.g. based on @@ -62,6 +68,22 @@ public class ColumnStyle /// If is 0 then will always return false. public bool Visible { get => MaxWidth >= 0 && field; set; } = true; + /// + /// Gets or sets the string appended to a cell value (or column header) when its rendered representation does + /// not fit in the available column width. Defaults to ("…"). + /// + /// + /// + /// Set to or an empty string to suppress the indicator and silently clip the value at + /// the available width. + /// + /// + /// If the indicator itself is wider than the available column width the value is silently clipped (no + /// indicator is drawn). + /// + /// + public string? TruncationIndicator { get; set; } = Glyphs.HorizontalEllipsis.ToString (); + /// /// Returns the alignment for the cell based on and / /// diff --git a/Terminal.Gui/Views/TableView/HeaderColorGetterArgs.cs b/Terminal.Gui/Views/TableView/HeaderColorGetterArgs.cs new file mode 100644 index 0000000000..d1c1dc539b --- /dev/null +++ b/Terminal.Gui/Views/TableView/HeaderColorGetterArgs.cs @@ -0,0 +1,30 @@ +#nullable enable + +namespace Terminal.Gui.Views; + +/// +/// Arguments for a . Describes a column header for which a rendering +/// is being sought. +/// +public class HeaderColorGetterArgs +{ + internal HeaderColorGetterArgs (ITableSource table, int column, string columnName, Scheme rowScheme) + { + Table = table; + Column = column; + ColumnName = columnName; + RowScheme = rowScheme; + } + + /// The index of the column in for which header color is needed. + public int Column { get; } + + /// The name of the column header being rendered. + public string ColumnName { get; } + + /// The default scheme that would be used if no override is provided. + public Scheme RowScheme { get; } + + /// The data table hosted by the control. + public ITableSource Table { get; } +} diff --git a/Terminal.Gui/Views/TableView/TableSelection.cs b/Terminal.Gui/Views/TableView/TableSelection.cs index 3f479405f0..06f07e3474 100644 --- a/Terminal.Gui/Views/TableView/TableSelection.cs +++ b/Terminal.Gui/Views/TableView/TableSelection.cs @@ -7,7 +7,7 @@ namespace Terminal.Gui.Views; /// /// A (as the value of ) means /// "no selection" — either no is assigned or the selection was explicitly cleared. -/// A non-null always has a non-null . +/// A non-null always has a non-null . /// public class TableSelection : IEquatable { @@ -16,7 +16,7 @@ public class TableSelection : IEquatable /// All extended selection regions (may be empty for cursor-only selection). public TableSelection (Point cursor, IReadOnlyList? regions) { - Cursor = cursor; + SelectedCell = cursor; Regions = regions ?? []; } @@ -24,8 +24,8 @@ public TableSelection (Point cursor, IReadOnlyList? region /// The cursor cell position. public TableSelection (Point cursor) : this (cursor, []) { } - /// The cursor cell used for navigation. Always non-null on a non-null . - public Point Cursor { get; } + /// The selected cell used for navigation. Always non-null on a non-null . + public Point SelectedCell { get; } /// All extended selection regions. May be empty if only the cursor cell is selected. public IReadOnlyList Regions { get; } @@ -41,7 +41,7 @@ public bool Equals (TableSelection? other) return false; } - if (Cursor != other.Cursor) + if (SelectedCell != other.SelectedCell) { return false; } @@ -69,7 +69,7 @@ public bool Equals (TableSelection? other) public override int GetHashCode () { HashCode hash = new (); - hash.Add (Cursor); + hash.Add (SelectedCell); foreach (TableSelectionRegion region in Regions) { diff --git a/Terminal.Gui/Views/TableView/TableStyle.cs b/Terminal.Gui/Views/TableView/TableStyle.cs index d05ebaf71c..cd193df22f 100644 --- a/Terminal.Gui/Views/TableView/TableStyle.cs +++ b/Terminal.Gui/Views/TableView/TableStyle.cs @@ -41,6 +41,12 @@ public class TableStyle /// public RowColorGetterDelegate? RowColorGetter { get; set; } + /// + /// Gets or sets a base applied to all column headers. Falls back to the view's scheme if + /// . Per-column overrides can be specified via . + /// + public Scheme? HeaderScheme { get; set; } + /// /// Gets or sets a flag indicating whether to render headers of a . Defaults to /// . @@ -66,6 +72,18 @@ public class TableStyle /// True to render a solid line vertical line between cells public bool ShowVerticalCellLines { get; set; } = true; + /// + /// Gets or sets whether the left-most visible column renders a vertical line on its left side when vertical + /// lines are enabled. + /// + public bool ShowVerticalCellLineForFirstColumn { get; set; } = true; + + /// + /// Gets or sets whether the right-most visible column renders a vertical line on its right side when vertical + /// lines are enabled. + /// + public bool ShowVerticalCellLineForLastColumn { get; set; } = true; + /// True to render a solid line vertical line between headers public bool ShowVerticalHeaderLines { get; set; } = true; diff --git a/Terminal.Gui/Views/TableView/TableView.Content.cs b/Terminal.Gui/Views/TableView/TableView.Content.cs index 93bd81176f..dec5623ea6 100644 --- a/Terminal.Gui/Views/TableView/TableView.Content.cs +++ b/Terminal.Gui/Views/TableView/TableView.Content.cs @@ -161,6 +161,8 @@ public void EnsureValidScrollOffsets () { int headerHeight = GetHeaderHeightIfAny (); int headerHeightVisible = CurrentHeaderHeightVisible (); + int leftOuterBorderWidth = ShouldRenderFirstOuterVerticalLine () ? 1 : 0; + int rightOuterBorderWidth = ShouldRenderLastOuterVerticalLine () ? 1 : 0; contentSize.Height += headerHeight + Table?.Rows ?? 0; if (Style.ShowHorizontalBottomLine) @@ -181,8 +183,26 @@ public void EnsureValidScrollOffsets () int lastColIdx = nonHiddenColumns.Any () ? nonHiddenColumns.Last ().colIdx : -1; - //right border - contentSize.Width += Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0; + // Precompute per-column minimum widths and a suffix sum so that "space reserved for remaining + // columns" is O(1) per column during reservation/min-width bookkeeping. Later width calculations + // may still inspect row data. + int columnCount = nonHiddenColumns.Count; + int [] minWidths = new int [columnCount]; + int [] reservedFromIndex = new int [columnCount + 1]; + + for (var i = 0; i < columnCount; i++) + { + (int colIdx, ColumnStyle? colStyle) = nonHiddenColumns [i]; + minWidths [i] = MinimumWidthFor (colIdx, colStyle); + } + + for (int i = columnCount - 1; i >= 0; i--) + { + int separator = i < columnCount - 1 ? 1 : 0; + reservedFromIndex [i] = minWidths [i] + separator + reservedFromIndex [i + 1]; + } + + contentSize.Width += leftOuterBorderWidth; var startRow = 0; int rowsToRender = Table.Rows; @@ -195,6 +215,8 @@ public void EnsureValidScrollOffsets () } // Calculate the content size based on the table's data + var columnIndex = 0; + foreach ((int colIdx, ColumnStyle? colStyle) in nonHiddenColumns) { int maxContentSize = CalculateMaxCellWidth (colIdx, colStyle, startRow, rowsToRender) + padding; @@ -212,21 +234,33 @@ public void EnsureValidScrollOffsets () } } - // ToDo: MinAcceptableWidth handling? - // if (colStyle is { MinAcceptableWidth: > 0 } - bool isVeryLast = colIdx == lastColIdx; if (isVeryLast) { //remaining space for last column - int remainingSpace = Viewport.Width - contentSize.Width - (Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0); + int remainingSpace = Viewport.Width - contentSize.Width - rightOuterBorderWidth; if (Style.ExpandLastColumn && colWidth < remainingSpace) { colWidth = remainingSpace; } } + else if (Viewport.Width > 0) + { + // Reserve at least the header width for each subsequent visible column so that a wide + // column does not consume all viewport space and push later columns off-screen. + int reservedForRemaining = reservedFromIndex [columnIndex + 1]; + int availableForThisCol = Viewport.Width - contentSize.Width - reservedForRemaining - rightOuterBorderWidth - 1; // -1 for this column's separator + + // Don't shrink below this column's own minimum (header width or configured minimum) + int thisColMin = minWidths [columnIndex]; + + if (colWidth > availableForThisCol && availableForThisCol >= thisColMin) + { + colWidth = availableForThisCol; + } + } columnsToRender.Add (new ColumnToRender (colIdx, contentSize.Width, colWidth + 1, lastColIdx == colIdx)); @@ -237,10 +271,11 @@ public void EnsureValidScrollOffsets () // for separator symbols between columns contentSize.Width += 1; } + + columnIndex++; } - // for left border - contentSize.Width += Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0; + contentSize.Width += rightOuterBorderWidth; } else { @@ -267,4 +302,70 @@ public void EnsureValidScrollOffsets () return contentSize; } + + /// + /// Returns the minimum render width to reserve for a column that has not yet been laid out, based on its header + /// width and any configured minimum (clamped to and + /// ). This intentionally does not inspect cell data — it is O(1) per column to + /// keep large or paginated implementations performant. + /// + private int MinimumWidthFor (int colIdx, ColumnStyle? colStyle) + { + int min = _table!.ColumnNames [colIdx].GetColumns (); + + if (min < 1) + { + min = 1; + } + + if (colStyle is { MinWidth: > 0 } && colStyle.MinWidth > min) + { + min = colStyle.MinWidth; + } + + if (MinCellWidth > 0 && MinCellWidth > min) + { + min = MinCellWidth; + } + + // Don't reserve more than the column's own ceiling + int ceiling = MaxCellWidth; + + if (colStyle is { } && colStyle.MaxWidth < ceiling) + { + ceiling = colStyle.MaxWidth; + } + + if (ceiling < 1) + { + ceiling = 1; + } + + if (min > ceiling) + { + min = ceiling; + } + + return min; + } + + private bool ShouldRenderFirstOuterVerticalLine () + { + if (!Style.ShowVerticalCellLineForFirstColumn) + { + return false; + } + + return Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines; + } + + private bool ShouldRenderLastOuterVerticalLine () + { + if (!Style.ShowVerticalCellLineForLastColumn) + { + return false; + } + + return Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines; + } } diff --git a/Terminal.Gui/Views/TableView/TableView.Drawing.cs b/Terminal.Gui/Views/TableView/TableView.Drawing.cs index 321be8d6e1..d704a843a2 100644 --- a/Terminal.Gui/Views/TableView/TableView.Drawing.cs +++ b/Terminal.Gui/Views/TableView/TableView.Drawing.cs @@ -185,6 +185,9 @@ private void RenderBottomLine (int row, int availableWidth, ColumnToRender [] co { // Renders a line at the bottom of the table after all the data like: // └─────────────────────────────────┴──────────┴──────┴──────────┴────────┴────────────────────────────────────────────┘ + bool renderFirstOuterVerticalLine = Style.ShowVerticalCellLines && Style.ShowVerticalCellLineForFirstColumn; + bool renderLastOuterVerticalLine = Style.ShowVerticalCellLines && Style.ShowVerticalCellLineForLastColumn; + for (var c = 0; c < availableWidth; c++) { // Start by assuming we just draw a straight line the @@ -195,8 +198,10 @@ private void RenderBottomLine (int row, int availableWidth, ColumnToRender [] co { if (c == 0) { - // for first character render line - rune = Glyphs.LLCorner; + if (renderFirstOuterVerticalLine) + { + rune = Glyphs.LLCorner; + } } else if (columnsToRender.Any (r => r.X == c + 1)) { @@ -205,8 +210,10 @@ private void RenderBottomLine (int row, int availableWidth, ColumnToRender [] co } else if (c == availableWidth - 1) { - // for the last character in the table - rune = Glyphs.LRCorner; + if (renderLastOuterVerticalLine) + { + rune = Glyphs.LRCorner; + } } else if (!Style.ExpandLastColumn && columnsToRender.Any (r => r.IsVeryLast && r.X + r.Width - 1 == c)) { @@ -234,7 +241,7 @@ private void RenderHeaderMidline (int row, int availableWidth, ColumnToRender [] ClearLine (row, Viewport.Width); // render start of line - if (_style.ShowVerticalHeaderLines) + if (_style.ShowVerticalHeaderLines && Style.ShowVerticalCellLineForFirstColumn) { RenderRune (0, row, Glyphs.VLine); } @@ -243,18 +250,34 @@ private void RenderHeaderMidline (int row, int availableWidth, ColumnToRender [] { ColumnStyle? colStyle = Style.GetColumnStyleIfAny (current.Column); string colName = _table!.ColumnNames [current.Column]; + + // Determine header color + Scheme baseScheme = Style.HeaderScheme ?? GetScheme (); + Scheme? headerScheme = colStyle?.HeaderColorGetter?.Invoke ( + new HeaderColorGetterArgs (_table, current.Column, colName, baseScheme)); + Scheme effectiveScheme = headerScheme ?? baseScheme; + + // Draw separator lines with normal attribute so focus colors don't bleed into lines + SetAttribute (GetAttributeForRole (VisualRole.Normal)); RenderSeparator (current.X - 1, row, true); + + // Now set the header text attribute + SetAttribute (HasFocus ? effectiveScheme.Focus : effectiveScheme.Normal); Move (current.X - Viewport.X, row); AddStr (TruncateOrPad (colName, colName, current.Width, colStyle)); - if (!Style.ExpandLastColumn && current.IsVeryLast) + if (!Style.ExpandLastColumn && current.IsVeryLast && Style.ShowVerticalCellLineForLastColumn) { + SetAttribute (GetAttributeForRole (VisualRole.Normal)); RenderSeparator (current.X + current.Width - 1, row, true); } } + // Reset attribute after headers + SetAttribute (GetAttributeForRole (VisualRole.Normal)); + // render end of line - if (_style.ShowVerticalHeaderLines) + if (_style.ShowVerticalHeaderLines && Style.ShowVerticalCellLineForLastColumn) { RenderRune (availableWidth - 1, row, Glyphs.VLine); } @@ -264,6 +287,9 @@ private void RenderHeaderOverline (int row, int availableWidth, ColumnToRender [ { // Renders a line above table headers (when visible) like: // ┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐ + bool renderFirstOuterVerticalLine = Style.ShowVerticalHeaderLines && Style.ShowVerticalCellLineForFirstColumn; + bool renderLastOuterVerticalLine = Style.ShowVerticalHeaderLines && Style.ShowVerticalCellLineForLastColumn; + for (var c = 0; c < availableWidth; c++) { Rune rune = Glyphs.HLine; @@ -272,7 +298,10 @@ private void RenderHeaderOverline (int row, int availableWidth, ColumnToRender [ { if (c == 0) { - rune = Glyphs.ULCorner; + if (renderFirstOuterVerticalLine) + { + rune = Glyphs.ULCorner; + } } // if the next column is the start of a header @@ -282,7 +311,10 @@ private void RenderHeaderOverline (int row, int availableWidth, ColumnToRender [ } else if (c == availableWidth - 1) { - rune = Glyphs.URCorner; + if (renderLastOuterVerticalLine) + { + rune = Glyphs.URCorner; + } } // if the next console column is the last column's end @@ -306,6 +338,9 @@ private void RenderHeaderUnderline (int row, int availableWidth, ColumnToRender */ // Renders a line below the table headers (when visible) like: // ├──────────┼───────────┼───────────────────┼──────────┼────────┼─────────────┤ + bool renderFirstOuterVerticalLine = Style.ShowVerticalHeaderLines && Style.ShowVerticalCellLineForFirstColumn; + bool renderLastOuterVerticalLine = Style.ShowVerticalHeaderLines && Style.ShowVerticalCellLineForLastColumn; + for (var c = 0; c < availableWidth; c++) { // Start by assuming we just draw a straight line the @@ -317,8 +352,10 @@ private void RenderHeaderUnderline (int row, int availableWidth, ColumnToRender { if (c == 0) { - // for first character render line - rune = Style.ShowVerticalCellLines ? Glyphs.LeftTee : Glyphs.LLCorner; + if (renderFirstOuterVerticalLine) + { + rune = Style.ShowVerticalCellLines ? Glyphs.LeftTee : Glyphs.LLCorner; + } } // if the next column is the start of a header @@ -328,8 +365,10 @@ private void RenderHeaderUnderline (int row, int availableWidth, ColumnToRender } else if (c == availableWidth - 1) { - // for the last character in the table - rune = Style.ShowVerticalCellLines ? Glyphs.RightTee : Glyphs.LRCorner; + if (renderLastOuterVerticalLine) + { + rune = Style.ShowVerticalCellLines ? Glyphs.RightTee : Glyphs.LRCorner; + } } // if the next console column is the last column's end @@ -430,10 +469,27 @@ private void RenderRow (int row, int rowToRender, ColumnToRender [] columnsToRen RenderSeparator (current.X - 1, row, false); - if (!Style.ExpandLastColumn && current.IsVeryLast) + if (!Style.ExpandLastColumn && current.IsVeryLast && Style.ShowVerticalCellLineForLastColumn) { RenderSeparator (current.X + current.Width - 1, row, false); } + + // When vertical cell lines are not rendered AND the separator symbol is the default + // invisible space, extend the cell's background color into the 1-char gap at the + // cell's right edge. The cell render itself only fills (Width - 1) chars (TruncateOrPad + // intentionally leaves 1 char for the cell boundary). Without this, the gap retains + // the row-clear's rowScheme color, producing visible "stripes" between cells when a + // custom ColumnStyle.ColorGetter is in use (e.g. FileDialog). See issue #5075. + if (!_style.ShowVerticalCellLines && SeparatorSymbol == ' ') + { + int gapX = current.X + current.Width - 1; + + if (gapX >= Viewport.X && gapX < Viewport.X + Viewport.Width) + { + SetAttribute (cellColor); + AddRuneAt (gapX - Viewport.X, row, (Rune)' '); + } + } } if (!_style.ShowVerticalCellLines) @@ -444,11 +500,14 @@ private void RenderRow (int row, int rowToRender, ColumnToRender [] columnsToRen SetAttribute (rowScheme.Normal); // render start and end of line - RenderRune (0, row, Glyphs.VLine); + if (Style.ShowVerticalCellLineForFirstColumn) + { + RenderRune (0, row, Glyphs.VLine); + } ColumnToRender? lastCol = columnsToRender.LastOrDefault (); - if (lastCol != null) + if (lastCol != null && Style.ShowVerticalCellLineForLastColumn) { RenderRune (lastCol.X + lastCol.Width - 1, row, Glyphs.VLine); } @@ -462,6 +521,16 @@ private void RenderSeparator (int col, int row, bool isHeader) } bool renderLines = isHeader ? _style.ShowVerticalHeaderLines : _style.ShowVerticalCellLines; + + // When vertical lines are not rendered AND the separator symbol is the default space, + // skip drawing here. RenderRow handles the gap between cells separately by extending + // the cell's own background into the gap position (preserves custom ColorGetter colors). + // Drawing a space here with the row's normal scheme would overwrite that. See issue #5075. + if (!renderLines && SeparatorSymbol == ' ') + { + return; + } + Rune symbol = renderLines ? Glyphs.VLine : (Rune)SeparatorSymbol; RenderRune (col, row, symbol); } @@ -565,6 +634,14 @@ private static string TruncateOrPad (object originalCellValue, string representa // if value is too wide, truncate by grapheme cluster to avoid splitting surrogate pairs if (representation.GetColumns () >= availableHorizontalSpace) { + string indicator = colStyle?.TruncationIndicator ?? string.Empty; + int indicatorWidth = indicator.GetColumns (); + + // Only draw the indicator if it leaves room for at least one cell of content + // (otherwise fall back to silent clipping) + bool useIndicator = indicatorWidth > 0 && indicatorWidth < availableHorizontalSpace - 1; + int reserve = useIndicator ? indicatorWidth : 0; + StringBuilder sb = new (); int remaining = availableHorizontalSpace; @@ -572,7 +649,7 @@ private static string TruncateOrPad (object originalCellValue, string representa { int w = grapheme.GetColumns (); - if (remaining - w <= 0) + if (remaining - w - reserve <= 0) { break; } @@ -581,6 +658,11 @@ private static string TruncateOrPad (object originalCellValue, string representa remaining -= w; } + if (useIndicator) + { + sb.Append (indicator); + } + return sb.ToString (); } diff --git a/Terminal.Gui/Views/TableView/TableView.Navigation.cs b/Terminal.Gui/Views/TableView/TableView.Navigation.cs index d2776a9dd4..a00a469663 100644 --- a/Terminal.Gui/Views/TableView/TableView.Navigation.cs +++ b/Terminal.Gui/Views/TableView/TableView.Navigation.cs @@ -101,6 +101,11 @@ public bool PageUp (bool extend, ICommandContext? ctx) private bool CycleToNextTableEntryBeginningWith (Key key) { + // Type-to-search disabled + if (CollectionNavigator is null) + { + return false; + } int row = _cursorRow; // There is a multi select going on and not just for the current row diff --git a/Terminal.Gui/Views/TableView/TableView.Selection.cs b/Terminal.Gui/Views/TableView/TableView.Selection.cs index 0298b0787b..889cdbb5a6 100644 --- a/Terminal.Gui/Views/TableView/TableView.Selection.cs +++ b/Terminal.Gui/Views/TableView/TableView.Selection.cs @@ -203,8 +203,8 @@ private void SyncCursorFromValue () return; } - _cursorColumn = _value.Cursor.X; - _cursorRow = _value.Cursor.Y; + _cursorColumn = _value.SelectedCell.X; + _cursorRow = _value.SelectedCell.Y; // Rebuild MultiSelectedRegions from Value.Regions (deep copy) MultiSelectedRegions.Clear (); diff --git a/Terminal.Gui/Views/TableView/TableView.cs b/Terminal.Gui/Views/TableView/TableView.cs index ba9d834bcb..9e7c5bbc76 100644 --- a/Terminal.Gui/Views/TableView/TableView.cs +++ b/Terminal.Gui/Views/TableView/TableView.cs @@ -13,6 +13,11 @@ namespace Terminal.Gui.Views; /// public delegate Scheme? RowColorGetterDelegate (RowColorGetterArgs args); +/// Delegate for providing color to column headers based on contextual information. +/// Contains information about the column header for which color is needed. +/// A to use for rendering the header, or to use the default. +public delegate Scheme? HeaderColorGetterDelegate (HeaderColorGetterArgs args); + /// /// Displays and enables infinite scrolling through tabular data based on a . /// See the TableView Deep Dive for more. @@ -160,8 +165,12 @@ public ITableSource? Table } } - /// Navigator for cycling the selected item in the table by typing. Set to null to disable this feature. - public ICollectionNavigator CollectionNavigator { get; set; } + /// + /// Navigator for cycling the selected row in the table by typing. Set to to disable + /// type-to-search navigation; printable keys not otherwise bound will then bubble through normal key handling + /// instead of being consumed for incremental row navigation. + /// + public ICollectionNavigator? CollectionNavigator { get; set; } /// /// The maximum number of characters to render in any given column. This prevents one long column from pushing @@ -233,6 +242,25 @@ private bool IsColumnVisible (int columnIndex) return Style.GetColumnStyleIfAny (columnIndex)?.Visible ?? true; } + /// + protected override bool OnActivating (CommandEventArgs args) + { + // Cancel Activate when a mouse click lands outside any cell (e.g. below the + // last row or in the header area). Without this, the Activating event would + // fire even though no cell was actually selected, surprising subscribers. + if (!TryGetMouseCellHit (args.Context, out Point? hit)) + { + return false; + } + + if (hit is null) + { + return true; + } + + return false; + } + /// protected override void OnActivated (ICommandContext? ctx) { @@ -243,26 +271,65 @@ protected override void OnActivated (ICommandContext? ctx) return; } - if (ctx?.Binding is not MouseBinding mouseBinding || mouseBinding.MouseEvent is null) + if (!TryGetMouseCellHit (ctx, out Point? hit) || hit is null) { return; } - int boundsX = mouseBinding.MouseEvent.Position!.Value.X; - int boundsY = mouseBinding.MouseEvent.Position!.Value.Y; + + MouseBinding mouseBinding = (MouseBinding)ctx!.Binding!; + SetSelection (hit.Value.X, hit.Value.Y, mouseBinding.MouseEvent!.Flags.FastHasFlags (MouseFlags.Shift)); + + Update (); + } + + // Returns true when the binding represents a left-click on this TableView; in that case + // hit is the cell that was clicked, or null if the click was outside any cell (e.g. header, + // below the last row, or right of the last rendered column). Returns false for non-mouse or + // non-left-click bindings. + private bool TryGetMouseCellHit (ICommandContext? ctx, out Point? hit) + { + hit = null; + + if (ctx?.Binding is not MouseBinding mouseBinding || mouseBinding.MouseEvent is null) + { + return false; + } if (!mouseBinding.MouseEvent.Flags.FastHasFlags (MouseFlags.LeftButtonClicked)) { - return; + return false; } - Point? hit = ScreenToCell (boundsX, boundsY); - if (hit is null) + int clientX = mouseBinding.MouseEvent.Position!.Value.X; + int clientY = mouseBinding.MouseEvent.Position!.Value.Y; + + hit = ScreenToCell (clientX, clientY); + + // ScreenToCell maps far-right X positions onto the last column (its column lookup picks + // the largest X with X <= clientX, with no upper-bound check). When ExpandLastColumn is + // false there is visible whitespace to the right of the last rendered column; reject + // hits that fall there so clicks in that whitespace don't raise Activating. + if (hit is not null && !IsXWithinAnyRenderedColumn (clientX)) { - return; + hit = null; } - SetSelection (hit.Value.X, hit.Value.Y, mouseBinding.MouseEvent.Flags.FastHasFlags (MouseFlags.Shift)); - Update (); + return true; + } + + private bool IsXWithinAnyRenderedColumn (int clientX) + { + int viewportX = clientX + Viewport.X; + + foreach (ColumnToRender col in NonHiddenCellInfos ()) + { + if (viewportX >= col.X && viewportX < col.X + col.Width) + { + return true; + } + } + + return false; } /// diff --git a/Terminal.Gui/Views/TableView/TreeTableSource.cs b/Terminal.Gui/Views/TableView/TreeTableSource.cs index 6800b1d8b2..af52835c8b 100644 --- a/Terminal.Gui/Views/TableView/TreeTableSource.cs +++ b/Terminal.Gui/Views/TableView/TreeTableSource.cs @@ -123,12 +123,12 @@ private bool IsInTreeColumn (int column, bool isKeyboard) private void Table_KeyPress (object? sender, Key e) { - if (!IsInTreeColumn (_tableView.Value?.Cursor.X ?? 0, true)) + if (!IsInTreeColumn (_tableView.Value?.SelectedCell.X ?? 0, true)) { return; } - T? obj = _tree.GetObjectOnRow (_tableView.Value?.Cursor.Y ?? 0); + T? obj = _tree.GetObjectOnRow (_tableView.Value?.SelectedCell.Y ?? 0); if (obj is null) { diff --git a/Terminal.Gui/Views/TextInput/DateEditor.cs b/Terminal.Gui/Views/TextInput/DateEditor.cs index 5b428e66b1..7cac78f36e 100644 --- a/Terminal.Gui/Views/TextInput/DateEditor.cs +++ b/Terminal.Gui/Views/TextInput/DateEditor.cs @@ -141,6 +141,23 @@ public DateTimeFormatInfo Format object? IValue.GetValue () => Value; + /// + /// + /// Resolves the diamond between 's IValue<string> + /// and this view's IValue<DateTime> by parsing into . + /// + bool IValue.TrySetValueFromString (string input) + { + if (!IValueParser.TryParseValue (input, out DateTime parsed)) + { + return false; + } + + Value = parsed; + + return true; + } + /// /// Synchronizes the backing field when the base class /// property changes programmatically. diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Mouse.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Mouse.cs index e1fe5aa46a..a0666e31fe 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.Mouse.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Mouse.cs @@ -274,7 +274,7 @@ private void ProcessMouseClick (Mouse mouse, out List line) if (newInsertionIndex >= r.Count || (IsSelecting && p.X >= Math.Max (Viewport.Width - 1, 0))) { Viewport = Viewport with { X = Math.Max (0, colsWidth - Viewport.Width + 1) }; - CurrentColumn = Math.Max (r.Count - (ReadOnly ? 1 : 0), 0); + CurrentColumn = r.Count; } else if (Viewport.X + p.X == 0 || (IsSelecting && p.X == 0)) { diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs index 366ab7f7b8..4f995754df 100644 --- a/Terminal.Gui/Views/TextInput/TimeEditor.cs +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -157,6 +157,23 @@ public DateTimeFormatInfo Format object? IValue.GetValue () => Value; + /// + /// + /// Resolves the diamond between 's IValue<string> + /// and this view's IValue<TimeSpan> by parsing into . + /// + bool IValue.TrySetValueFromString (string input) + { + if (!IValueParser.TryParseValue (input, out TimeSpan parsed)) + { + return false; + } + + Value = parsed; + + return true; + } + /// /// Synchronizes the backing field when the base class /// property changes programmatically. diff --git a/Terminal.sln b/Terminal.sln index 16239ed1db..4c9bc79d78 100644 --- a/Terminal.sln +++ b/Terminal.sln @@ -12,9 +12,6 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example", "Examples\Example\Example.csproj", "{B0A602CD-E176-449D-8663-64238D54F857}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E143FB1F-0B88-48CB-9086-72CDCECFCD22}" - ProjectSection(SolutionItems) = preProject - docfx\docs\borders.md = docfx\docs\borders.md - EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkitExample", "Examples\CommunityToolkitExample\CommunityToolkitExample.csproj", "{58FDCA8F-08F7-4D80-9DA3-6A9AED01E163}" EndProject diff --git a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputBufferBenchmark.cs b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputBufferBenchmark.cs new file mode 100644 index 0000000000..cfe036f637 --- /dev/null +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputBufferBenchmark.cs @@ -0,0 +1,102 @@ +using System.Drawing; +using System.Text; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Drivers; +using Attribute = Terminal.Gui.Drawing.Attribute; +using Color = Terminal.Gui.Drawing.Color; + +namespace Terminal.Gui.Benchmarks.ConsoleDrivers.OutputBuffer; + +/// +/// Benchmarks for operations. +/// Measures the performance of AddStr, FillRect, ClearContents, and SetSize +/// to establish a baseline before and after the _contentsLock fix (#5130). +/// +[MemoryDiagnoser] +[BenchmarkCategory ("OutputBuffer")] +public class OutputBufferBenchmark +{ + private OutputBufferImpl _buffer = null!; + + [Params (80, 200)] + public int Cols { get; set; } + + [Params (25, 50)] + public int Rows { get; set; } + + [GlobalSetup] + public void Setup () + { + _buffer = new (); + _buffer.SetSize (Cols, Rows); + _buffer.CurrentAttribute = new Attribute (Color.White, Color.Black); + } + + /// + /// Baseline: Write a full row of text using AddStr. + /// + [Benchmark (Baseline = true)] + public void AddStr_FullRow () + { + _buffer.Move (0, 0); + _buffer.AddStr (new string ('A', Cols)); + } + + /// + /// Write text to every row in the buffer. + /// + [Benchmark] + public void AddStr_AllRows () + { + string line = new ('A', Cols); + + for (var row = 0; row < Rows; row++) + { + _buffer.Move (0, row); + _buffer.AddStr (line); + } + } + + /// + /// Fill the entire screen using FillRect(Rectangle, Rune). + /// + [Benchmark] + public void FillRect_FullScreen () + { + _buffer.FillRect (new Rectangle (0, 0, Cols, Rows), new Rune (' ')); + } + + /// + /// ClearContents resets the entire buffer. + /// + [Benchmark] + public void ClearContents () + { + _buffer.ClearContents (); + } + + /// + /// SetSize triggers ClearContents internally. + /// + [Benchmark] + public void SetSize () + { + _buffer.SetSize (Cols, Rows); + } + + /// + /// Simulates a typical draw cycle: clear, then fill every row. + /// + [Benchmark] + public void TypicalDrawCycle () + { + _buffer.ClearContents (); + string line = new ('X', Cols); + + for (var row = 0; row < Rows; row++) + { + _buffer.Move (0, row); + _buffer.AddStr (line); + } + } +} diff --git a/Tests/IntegrationTests/FluentTests/LinearRangeFluentTests.cs b/Tests/IntegrationTests/FluentTests/LinearRangeFluentTests.cs index 22cfe681e0..2a188ab63b 100644 --- a/Tests/IntegrationTests/FluentTests/LinearRangeFluentTests.cs +++ b/Tests/IntegrationTests/FluentTests/LinearRangeFluentTests.cs @@ -8,36 +8,34 @@ public class LinearRangeFluentTests (ITestOutputHelper outputHelper) : TestsAllD [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_CanCreateAndRender (string d) + public void LinearSelector_CanCreateAndRender (string d) { using AppTestHelper c = With.A (30, 10, d, _out) .Add ( - new LinearRange ([0, 10, 20, 30, 40, 50]) + new LinearSelector ([0, 10, 20, 30, 40, 50]) { X = 2, - Y = 2, - Type = LinearRangeType.Single + Y = 2 }) - .Focus> () + .Focus> () .WaitIteration () - .ScreenShot ("LinearRange initial render", _out) + .ScreenShot ("LinearSelector initial render", _out) .Stop (); } [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_CanNavigateWithArrowKeys (string d) + public void LinearSelector_CanNavigateWithArrowKeys (string d) { using AppTestHelper c = With.A (30, 10, d, _out) .Add ( - new LinearRange ([0, 10, 20, 30]) + new LinearSelector ([0, 10, 20, 30]) { X = 2, Y = 2, - Type = LinearRangeType.Single, AllowEmpty = false }) - .Focus> () + .Focus> () .WaitIteration () .ScreenShot ("Initial state", _out) .KeyDown (Key.CursorRight) @@ -51,50 +49,41 @@ public void LinearRange_CanNavigateWithArrowKeys (string d) [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_TypeChange_TriggersEvents (string d) + public void LinearRange_RangeKindChange_TriggersValueChange (string d) { LinearRange linearRange = new ([0, 10, 20, 30]) { X = 2, Y = 2, - Type = LinearRangeType.Single + RangeKind = LinearRangeSpanKind.Closed }; + linearRange.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 0, 30, 0, 3); - var changingEventRaised = false; - var changedEventRaised = false; + var changedRaised = false; - linearRange.TypeChanging += (_, args) => + linearRange.ValueChanged += (_, args) => { - changingEventRaised = true; - Assert.Equal (LinearRangeType.Single, args.CurrentValue); - Assert.Equal (LinearRangeType.Range, args.NewValue); + changedRaised = true; + Assert.Equal (LinearRangeSpanKind.LeftBounded, args.NewValue.Kind); }; - linearRange.TypeChanged += (_, args) => - { - changedEventRaised = true; - Assert.Equal (LinearRangeType.Single, args.OldValue); - Assert.Equal (LinearRangeType.Range, args.NewValue); - }; - - // Change the type before adding to window - linearRange.Type = LinearRangeType.Range; + // Migrate from Closed -> LeftBounded; the End is preserved. + linearRange.RangeKind = LinearRangeSpanKind.LeftBounded; using AppTestHelper c = With.A (30, 10, d, _out) .Add (linearRange) .Focus> () .WaitIteration () - .ScreenShot ("After type change to Range", _out) + .ScreenShot ("After RangeKind change to LeftBounded", _out) .Stop (); - Assert.True (changingEventRaised); - Assert.True (changedEventRaised); - Assert.Equal (LinearRangeType.Range, linearRange.Type); + Assert.True (changedRaised); + Assert.Equal (LinearRangeSpanKind.LeftBounded, linearRange.RangeKind); } [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_RangeType_CanSelectRange (string d) + public void LinearRange_Closed_CanSelectRange (string d) { using AppTestHelper c = With.A (30, 10, d, _out) .Add ( @@ -102,7 +91,7 @@ public void LinearRange_RangeType_CanSelectRange (string d) { X = 2, Y = 2, - Type = LinearRangeType.Range, + RangeKind = LinearRangeSpanKind.Closed, AllowEmpty = false }) .Focus> () @@ -121,18 +110,17 @@ public void LinearRange_RangeType_CanSelectRange (string d) [Theory] [MemberData (nameof (GetAllDriverNames))] - public void LinearRange_VerticalOrientation_Renders (string d) + public void LinearSelector_VerticalOrientation_Renders (string d) { using AppTestHelper c = With.A (10, 15, d, _out) .Add ( - new LinearRange ([0, 10, 20, 30]) + new LinearSelector ([0, 10, 20, 30]) { X = 2, Y = 2, - Orientation = Orientation.Vertical, - Type = LinearRangeType.Single + Orientation = Orientation.Vertical }) - .Focus> () + .Focus> () .WaitIteration () .ScreenShot ("Vertical orientation", _out) .KeyDown (Key.CursorDown) diff --git a/Tests/UnitTests.NonParallelizable/Application/ApplicationInvokeLifecycleTests.cs b/Tests/UnitTests.NonParallelizable/Application/ApplicationInvokeLifecycleTests.cs new file mode 100644 index 0000000000..cf77a49997 --- /dev/null +++ b/Tests/UnitTests.NonParallelizable/Application/ApplicationInvokeLifecycleTests.cs @@ -0,0 +1,77 @@ +// Claude - Opus 4.7 +#nullable enable + +namespace UnitTests.NonParallelizable.ApplicationTests; + +/// +/// Tests asserting and +/// contract relative to the +/// / lifecycle. +/// Regression coverage for issue #5163. +/// +public class ApplicationInvokeLifecycleTests +{ + [Fact] + public void Invoke_Action_BeforeInit_Throws_NotInitializedException () + { + IApplication app = Application.Create (); + + try + { + Assert.Throws (() => app.Invoke (() => { })); + } + finally + { + app.Dispose (); + } + } + + [Fact] + public void Invoke_ActionOfApplication_BeforeInit_Throws_NotInitializedException () + { + IApplication app = Application.Create (); + + try + { + Assert.Throws (() => app.Invoke (static _ => { })); + } + finally + { + app.Dispose (); + } + } + + [Fact] + public void Invoke_Action_AfterDispose_Throws_NotInitializedException () + { + IApplication app = Application.Create (); + + try + { + app.Init (DriverRegistry.Names.ANSI); + } + finally + { + app.Dispose (); + } + + Assert.Throws (() => app.Invoke (() => { })); + } + + [Fact] + public void Invoke_ActionOfApplication_AfterDispose_Throws_NotInitializedException () + { + IApplication app = Application.Create (); + + try + { + app.Init (DriverRegistry.Names.ANSI); + } + finally + { + app.Dispose (); + } + + Assert.Throws (() => app.Invoke (static _ => { })); + } +} diff --git a/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs b/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs index 8aa0e87d9b..0ec6189397 100644 --- a/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs +++ b/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs @@ -1,9 +1,10 @@ -using System.Collections.Frozen; +using System.Collections.Frozen; using System.Collections.Immutable; using System.Diagnostics; using System.Reflection; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using static Terminal.Gui.Configuration.ConfigurationManager; using File = System.IO.File; using SourcesManager = Terminal.Gui.Configuration.SourcesManager; @@ -14,6 +15,10 @@ namespace UnitTests.NonParallelizable.ConfigurationTests; public class ConfigurationMangerTests (ITestOutputHelper output) { + [ConfigurationProperty (Scope = typeof (AppSettingsScope))] + [JsonConverter (typeof (YesNoBooleanJsonConverter))] + public static bool? CustomConvertedBoolean { get; set; } + [Fact] public void ModuleInitializer_Was_Called () { @@ -49,6 +54,100 @@ public void HardCodedDefaultCache_Properties_Are_Copies () Disable (true); } + [Fact] + public void HardCodedDefaultCache_KeyBindingDictionaries_Are_Typed_Deep_Copies () + { + // Copilot + Assert.False (IsEnabled); + Application.ResetState (true); + + try + { + AssertKeyBindingDictionaryIsDeepCopy ("Application.DefaultKeyBindings", Application.DefaultKeyBindings!); + AssertKeyBindingDictionaryIsDeepCopy ("View.DefaultKeyBindings", View.DefaultKeyBindings!); + } + finally + { + Disable (true); + Application.ResetState (true); + } + + static void AssertKeyBindingDictionaryIsDeepCopy (string propertyName, Dictionary currentBindings) + { + FrozenDictionary cache = GetHardCodedConfigPropertyCache (); + Dictionary cachedBindings = Assert.IsType> (cache [propertyName].PropertyValue); + + Assert.NotSame (currentBindings, cachedBindings); + Assert.NotEmpty (cachedBindings); + + KeyValuePair cachedEntry = cachedBindings.First (kvp => HasAnyKeys (kvp.Value)); + PlatformKeyBinding currentBinding = currentBindings [cachedEntry.Key]; + + Assert.NotSame (currentBinding, cachedEntry.Value); + AssertKeyArraysAreDeepCopies (currentBinding.All, cachedEntry.Value.All); + AssertKeyArraysAreDeepCopies (currentBinding.Windows, cachedEntry.Value.Windows); + AssertKeyArraysAreDeepCopies (currentBinding.Linux, cachedEntry.Value.Linux); + AssertKeyArraysAreDeepCopies (currentBinding.Macos, cachedEntry.Value.Macos); + } + + static bool HasAnyKeys (PlatformKeyBinding binding) + { + return binding.All is { Length: > 0 } + || binding.Windows is { Length: > 0 } + || binding.Linux is { Length: > 0 } + || binding.Macos is { Length: > 0 }; + } + +#pragma warning disable CS8632 // Nullable annotations document the nullable PlatformKeyBinding properties in this non-nullable test file. + static void AssertKeyArraysAreDeepCopies (Key []? currentKeys, Key []? cachedKeys) +#pragma warning restore CS8632 + { + if (currentKeys is null) + { + Assert.Null (cachedKeys); + + return; + } + + Assert.NotNull (cachedKeys); + Assert.NotSame (currentKeys, cachedKeys); + Assert.Equal (currentKeys.Length, cachedKeys.Length); + + for (var i = 0; i < currentKeys.Length; i++) + { + Assert.NotSame (currentKeys [i], cachedKeys [i]); + Assert.Equal (currentKeys [i], cachedKeys [i]); + } + } + } + + [Fact] + public void HardCoded_Default_Theme_Uses_Fully_Populated_Cache_Values () + { + // Copilot + Assert.False (IsEnabled); + + try + { + Enable (ConfigLocations.HardCoded); + + ThemeScope defaultTheme = ThemeManager.Themes! [ThemeManager.DEFAULT_THEME_NAME]; + + Assert.Equal ( + MouseState.PressedOutside | MouseState.Pressed | MouseState.In, + (MouseState)defaultTheme ["CheckBox.DefaultMouseHighlightStates"].PropertyValue! + ); + Assert.Equal ( + CursorStyle.BlinkingBlock, + (CursorStyle)defaultTheme ["LinearRangeDefaults.DefaultCursorStyle"].PropertyValue! + ); + } + finally + { + Disable (true); + } + } + [Fact] public void GetHardCodedDefaultCache_Always_Returns_Same_Ref () { @@ -886,6 +985,69 @@ public void Save_Library_Defaults_To_config_json () } } + [Fact] + public void Save_HardCodedDefaults_ToJson_Omits_Invalid_Null_ValueType_Entries_And_RoundTrips () + { + Assert.False (IsEnabled); + + try + { + Enable (ConfigLocations.HardCoded); + ResetToHardCodedDefaults (); + + string json = ConfigurationManager.SourcesManager?.ToJson (Settings); + + Assert.NotNull (json); + Assert.DoesNotContain ("\"MessageBox.DefaultBorderStyle\": null", json); + Assert.DoesNotContain ("\"Button.DefaultShadow\": null", json); + + SettingsScope roundTripped = JsonSerializer.Deserialize (json, SerializerContext.SettingsScope); + + Assert.NotNull (roundTripped); + Assert.NotNull (roundTripped! ["Themes"].PropertyValue); + } + finally + { + Disable (true); + } + } + + [Fact] + public void Load_And_Save_AppSettings_With_Custom_Property_Converter_Works_In_Jit () + { + Assert.False (IsEnabled); + + try + { + CustomConvertedBoolean = false; + ThrowOnJsonErrors = true; + Enable (ConfigLocations.HardCoded); + + RuntimeConfig = """ + { + "AppSettings": { + "ConfigurationMangerTests.CustomConvertedBoolean": "yes" + } + } + """; + + Load (ConfigLocations.Runtime); + Apply (); + + Assert.True (CustomConvertedBoolean); + + string json = ConfigurationManager.SourcesManager?.ToJson (Settings); + + Assert.NotNull (json); + Assert.Contains ("\"ConfigurationMangerTests.CustomConvertedBoolean\": \"yes\"", json); + } + finally + { + Disable (true); + CustomConvertedBoolean = false; + } + } + [Fact] public void TestConfigProperties () { @@ -1657,4 +1819,24 @@ public void ConfigLocations_All_LoadsInCorrectOrder () Disable (true); } } + + private sealed class YesNoBooleanJsonConverter : JsonConverter + { + public override bool? Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string value = reader.GetString (); + + return value?.ToLowerInvariant () switch + { + "yes" => true, + "no" => false, + _ => throw new JsonException ($"Unexpected boolean token '{value}'.") + }; + } + + public override void Write (Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) + { + writer.WriteStringValue (value == true ? "yes" : "no"); + } + } } diff --git a/Tests/UnitTestsParallelizable/Application/Popover/Application.PopoverTests.cs b/Tests/UnitTestsParallelizable/Application/Popover/Application.PopoverTests.cs index dc4cd38a3c..c3a0d5de47 100644 --- a/Tests/UnitTestsParallelizable/Application/Popover/Application.PopoverTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Popover/Application.PopoverTests.cs @@ -250,12 +250,222 @@ public void Hide_NonActivePopover_DoesNotAffectActivePopover () Assert.Equal (initialVisibleState, popover2.Visible); } + // CoPilot - ChatGPT v4 + [Fact] + public void LayoutAndDraw_ScreenResize_LayoutsVisiblePopover () + { + // Arrange + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (80, 25); + + Runnable top = new () { App = app }; + SessionToken? token = app.Begin (top); + + PopoverTestClass popover = new () + { + App = app, + Width = 10, + Height = 5 + }; + app.Popovers!.Register (popover); + app.Popovers.Show (popover); + + app.LayoutAndDraw (); + popover.LayoutPassCount = 0; + + app.Driver.SetScreenSize (100, 30); + + // Act + app.LayoutAndDraw (); + + // Assert + Assert.True (popover.LayoutPassCount > 0); + + app.End (token!); + top.Dispose (); + } + + // CoPilot - ChatGPT v4 + [Fact] + public void LayoutAndDraw_VisibleIdlePopover_DoesNotRaiseLayoutAndDrawComplete () + { + // Arrange + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (80, 25); + + Runnable top = new () { App = app }; + SessionToken? token = app.Begin (top); + + PopoverTestClass popover = new () + { + App = app, + Width = 10, + Height = 5 + }; + app.Popovers!.Register (popover); + app.Popovers.Show (popover); + + // Clear the initial invalidation caused by showing the popover. + app.LayoutAndDraw (); + + int layoutAndDrawCompleteCount = 0; + app.LayoutAndDrawComplete += CountLayoutAndDrawComplete; + + // Act + app.LayoutAndDraw (); + + // Assert + Assert.Equal (0, layoutAndDrawCompleteCount); + + app.LayoutAndDrawComplete -= CountLayoutAndDrawComplete; + app.End (token!); + top.Dispose (); + + return; + + void CountLayoutAndDrawComplete (object? _, EventArgs __) => layoutAndDrawCompleteCount++; + } + + // CoPilot - ChatGPT v4 + [Fact] + public void LayoutAndDraw_VisiblePopoverNeedingDraw_RaisesLayoutAndDrawComplete () + { + // Arrange + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (80, 25); + + Runnable top = new () { App = app }; + SessionToken? token = app.Begin (top); + + PopoverTestClass popover = new () + { + App = app, + Width = 10, + Height = 5 + }; + app.Popovers!.Register (popover); + app.Popovers.Show (popover); + app.LayoutAndDraw (); + + int layoutAndDrawCompleteCount = 0; + app.LayoutAndDrawComplete += CountLayoutAndDrawComplete; + popover.SetNeedsDraw (); + + // Act + app.LayoutAndDraw (); + + // Assert + Assert.Equal (1, layoutAndDrawCompleteCount); + + app.LayoutAndDrawComplete -= CountLayoutAndDrawComplete; + app.End (token!); + top.Dispose (); + + return; + + void CountLayoutAndDrawComplete (object? _, EventArgs __) => layoutAndDrawCompleteCount++; + } + + // CoPilot - ChatGPT v4 + [Fact] + public void LayoutAndDraw_VisiblePopoverNeedingLayout_RaisesLayoutAndDrawComplete () + { + // Arrange + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (80, 25); + + Runnable top = new () { App = app }; + SessionToken? token = app.Begin (top); + + PopoverTestClass popover = new () + { + App = app, + Width = 10, + Height = 5 + }; + app.Popovers!.Register (popover); + app.Popovers.Show (popover); + app.LayoutAndDraw (); + + int layoutAndDrawCompleteCount = 0; + app.LayoutAndDrawComplete += CountLayoutAndDrawComplete; + popover.SetNeedsLayout (); + + // Act + app.LayoutAndDraw (); + + // Assert + Assert.Equal (1, layoutAndDrawCompleteCount); + + app.LayoutAndDrawComplete -= CountLayoutAndDrawComplete; + app.End (token!); + top.Dispose (); + + return; + + void CountLayoutAndDrawComplete (object? _, EventArgs __) => layoutAndDrawCompleteCount++; + } + + // CoPilot - ChatGPT v4 + [Fact] + public void LayoutAndDraw_UnderlyingViewNeedsDraw_RedrawsPopoverWithoutLayout () + { + // Arrange + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (80, 25); + + Runnable top = new () { App = app }; + + View content = new () + { + Width = 10, + Height = 5 + }; + top.Add (content); + + SessionToken? token = app.Begin (top); + + PopoverTestClass popover = new () + { + App = app, + Width = 10, + Height = 5 + }; + app.Popovers!.Register (popover); + app.Popovers.Show (popover); + + app.LayoutAndDraw (); + popover.DrawCompleteCount = 0; + popover.LayoutPassCount = 0; + + content.SetNeedsDraw (); + + // Act + app.LayoutAndDraw (); + + // Assert + Assert.Equal (1, popover.DrawCompleteCount); + Assert.Equal (0, popover.LayoutPassCount); + + app.End (token!); + top.Dispose (); + } + public class PopoverTestClass : View, IPopoverView { public List HandledKeys { get; } = []; public int NewCommandInvokeCount { get; private set; } + public int DrawCompleteCount { get; set; } + + public int LayoutPassCount { get; set; } + public bool HandleNewCommand { get; set; } /// @@ -342,6 +552,18 @@ protected override bool OnKeyDown (Key key) return false; } + protected override void OnDrawComplete (DrawContext? context) + { + DrawCompleteCount++; + base.OnDrawComplete (context); + } + + protected override void OnSubViewLayout (LayoutEventArgs args) + { + LayoutPassCount++; + base.OnSubViewLayout (args); + } + /// public IRunnable? Owner { get; set; } diff --git a/Tests/UnitTestsParallelizable/Application/RunAsyncTests.cs b/Tests/UnitTestsParallelizable/Application/RunAsyncTests.cs new file mode 100644 index 0000000000..74f2e6a84c --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/RunAsyncTests.cs @@ -0,0 +1,176 @@ +#nullable enable + +// Copilot + +namespace ApplicationTests; + +[Collection ("Application Tests")] +public class RunAsyncTests +{ + [Fact] + public async Task RunAsync_TokenCancelledBefore_ReturnsImmediately () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new (); + using CancellationTokenSource cts = new (); + cts.Cancel (); // Cancel before calling RunAsync + + // Act + object? result = await app.RunAsync (runnable, cts.Token); + + // Assert - should return null immediately without starting + Assert.Null (result); + Assert.False (runnable.IsRunning); + + runnable.Dispose (); + app.Dispose (); + } + + [Fact] + public async Task RunAsync_TokenCancelledMidRun_LoopExits () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new (); + using CancellationTokenSource cts = new (); + var iterationCount = 0; + + void OnIteration (object? s, EventArgs a) + { + iterationCount++; + + if (iterationCount >= 2) + { + cts.Cancel (); + } + } + + app.Iteration += OnIteration; + + // Act + object? result = await app.RunAsync (runnable, cts.Token); + + app.Iteration -= OnIteration; + + // Assert - loop should have exited cleanly + Assert.False (runnable.IsRunning); + Assert.True (iterationCount >= 2); + + runnable.Dispose (); + app.Dispose (); + } + + [Fact] + public async Task RunAsync_BothRequestStopAndToken_IdempotentShutdown () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new (); + using CancellationTokenSource cts = new (); + + void OnIteration (object? s, EventArgs a) + { + // Both cancel the token AND call RequestStop + cts.Cancel (); + app.RequestStop (runnable); + } + + app.Iteration += OnIteration; + + // Act - should not throw or deadlock + object? result = await app.RunAsync (runnable, cts.Token); + + app.Iteration -= OnIteration; + + // Assert - clean shutdown + Assert.False (runnable.IsRunning); + + runnable.Dispose (); + app.Dispose (); + } + + [Fact] + public async Task RunAsync_UnhandledException_FaultedTask () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new (); + using CancellationTokenSource cts = new (); + InvalidOperationException expectedException = new ("Test exception from run loop"); + + void OnIteration (object? s, EventArgs a) => throw expectedException; + + app.Iteration += OnIteration; + + // Act & Assert - the task should propagate the exception + InvalidOperationException ex = await Assert.ThrowsAsync ( + async () => await app.RunAsync (runnable, cts.Token)); + + Assert.Same (expectedException, ex); + + app.Iteration -= OnIteration; + runnable.Dispose (); + app.Dispose (); + } + + [Fact] + public async Task RunAsync_Generic_TokenCancelledBefore_ReturnsImmediately () + { + // Arrange + IApplication app = Application.Create (); + using CancellationTokenSource cts = new (); + cts.Cancel (); // Cancel before calling RunAsync + + app.StopAfterFirstIteration = true; + + // Act + IApplication result = await app.RunAsync (cts.Token, driverName: DriverRegistry.Names.ANSI); + + // Assert - should return immediately without starting + Assert.Same (app, result); + + app.Dispose (); + } + + [Fact] + public async Task RunAsync_Generic_TokenCancelledMidRun_LoopExits () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + using CancellationTokenSource cts = new (); + var iterationCount = 0; + + void OnIteration (object? s, EventArgs a) + { + iterationCount++; + + if (iterationCount >= 2) + { + cts.Cancel (); + } + } + + app.Iteration += OnIteration; + + // Act + IApplication result = await app.RunAsync (cts.Token); + + app.Iteration -= OnIteration; + + // Assert + Assert.Same (app, result); + Assert.True (iterationCount >= 2); + + app.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/Configuration/AttributeJsonConverterTests.cs b/Tests/UnitTestsParallelizable/Configuration/AttributeJsonConverterTests.cs index 47ff59c5a3..c9bdda0628 100644 --- a/Tests/UnitTestsParallelizable/Configuration/AttributeJsonConverterTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/AttributeJsonConverterTests.cs @@ -44,6 +44,27 @@ public void Deserialize_TextStyle () Assert.Equal (TextStyle.Bold, attribute.Style); } + // Copilot + [Fact] + public void Deserialize_TextStyle_WithNonStringToken_ThrowsJsonException () + { + string json = "{\"Foreground\":\"Blue\",\"Background\":\"Green\",\"Style\":1}"; + + JsonException exception = Assert.Throws (() => JsonSerializer.Deserialize (json, JsonOptions)); + Assert.Contains ("Style", exception.Message); + Assert.Contains ("Expected a string value", exception.Message); + } + + // Copilot + [Fact] + public void Deserialize_TextStyle_WithInvalidString_ThrowsJsonException () + { + string json = "{\"Foreground\":\"Blue\",\"Background\":\"Green\",\"Style\":\"Bogus\"}"; + + JsonException exception = Assert.Throws (() => JsonSerializer.Deserialize (json, JsonOptions)); + Assert.Contains ("Style", exception.Message); + Assert.Contains ("Expected a valid text style value", exception.Message); + } [Fact] public void TestSerialize () @@ -86,4 +107,4 @@ public void JsonRoundTrip_PreservesEquality () Assert.Equal (original, roundTripped); // ✅ This should pass if all fields are faithfully round-tripped } -} \ No newline at end of file +} diff --git a/Tests/UnitTestsParallelizable/Configuration/ConcurrentDictionaryJsonConverterTests.cs b/Tests/UnitTestsParallelizable/Configuration/ConcurrentDictionaryJsonConverterTests.cs new file mode 100644 index 0000000000..dc6ce21e45 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Configuration/ConcurrentDictionaryJsonConverterTests.cs @@ -0,0 +1,28 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Terminal.Gui.Configuration; + +namespace ConfigurationTests; + +public class ConcurrentDictionaryJsonConverterTests +{ + // Claude - Opus 4.7 + [Fact] + public void Read_RejectsDuplicateKeys () + { + const string json = """ + [ + { "alpha": "first" }, + { "alpha": "second" } + ] + """; + + JsonSerializerOptions options = new () + { + Converters = { new ConcurrentDictionaryJsonConverter () } + }; + + Assert.Throws ( + () => JsonSerializer.Deserialize> (json, options)); + } +} diff --git a/Tests/UnitTestsParallelizable/Configuration/DeepClonerTests.cs b/Tests/UnitTestsParallelizable/Configuration/DeepClonerTests.cs index 484a50178d..92e54d4638 100644 --- a/Tests/UnitTestsParallelizable/Configuration/DeepClonerTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/DeepClonerTests.cs @@ -208,6 +208,25 @@ public void Scheme_All_Set_ReturnsEqualValue () Assert.Equal (source, result); } + [Fact] + public void Dictionary_Of_Schemes_Preserves_Styled_Normal_Attribute () + { + // Copilot + Dictionary source = new () + { + ["Base"] = new Scheme + { + Normal = new Attribute (Color.White, Color.Black, TextStyle.Bold) + } + }; + + Dictionary? result = DeepCloner.DeepClone (source); + + Assert.NotNull (result); + Assert.Equal (TextStyle.Bold, result ["Base"]!.Normal.Style); + Assert.Equal (source, result); + } + // Simple Reference Types [Fact] diff --git a/Tests/UnitTestsParallelizable/Configuration/ScopeJsonConverterTests.cs b/Tests/UnitTestsParallelizable/Configuration/ScopeJsonConverterTests.cs index 52d50ecfab..36760eedfd 100644 --- a/Tests/UnitTestsParallelizable/Configuration/ScopeJsonConverterTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/ScopeJsonConverterTests.cs @@ -30,4 +30,20 @@ public void RoundTripConversion_Property_Positive (string configPropertyJson) // Assert Assert.Contains (configPropertyJson, json); } + + [Fact] + public void RoundTripConversion_JsonIncludeProperty_WithNullValue_WritesNull () + { + // Copilot + // Arrange + SettingsScope settingsScope = new () { Schema = null! }; + + // Act + string json = JsonSerializer.Serialize (settingsScope, ConfigurationManager.SerializerContext.SettingsScope); + JsonDocument document = JsonDocument.Parse (json); + + // Assert + Assert.True (document.RootElement.TryGetProperty ("$schema", out JsonElement schema)); + Assert.Equal (JsonValueKind.Null, schema.ValueKind); + } } diff --git a/Tests/UnitTestsParallelizable/Drawing/Region/DrawOuterBoundaryTests.cs b/Tests/UnitTestsParallelizable/Drawing/Region/DrawOuterBoundaryTests.cs deleted file mode 100644 index 4b45b514b2..0000000000 --- a/Tests/UnitTestsParallelizable/Drawing/Region/DrawOuterBoundaryTests.cs +++ /dev/null @@ -1,529 +0,0 @@ -using System.Collections.Concurrent; -namespace DrawingTests.RegionTests; - -/// -/// Tests for . -/// -public class DrawOuterBoundaryTests (ITestOutputHelper output) -{ - private readonly ITestOutputHelper _output = output; - - [Fact] - public void DrawOuterBoundary_AfterIntersect_DrawsIntersectedBoundary () - { - // Arrange - var region = new Region (new (0, 0, 10, 10)); - region.Intersect (new Rectangle (5, 5, 10, 10)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_AfterMinimalUnion_DrawsMinimalBoundary () - { - // Arrange - var region = new Region (new (0, 0, 3, 3)); - region.MinimalUnion (new Rectangle (3, 0, 3, 3)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_ComplexShape_HandlesMultipleRegions () - { - // Arrange - Create a complex shape with multiple rectangles - var region = new Region (new (0, 0, 3, 3)); - region.Union (new Rectangle (3, 3, 3, 3)); - region.Union (new Rectangle (6, 0, 3, 3)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_DiagonallyConnectedRectangles_DrawsOuterBoundary () - { - // Arrange - Test the specific case from BUGBUG comment: (0,0,3,3) and (3,3,3,3) - var region = new Region (new (0, 0, 3, 3)); - region.Union (new Rectangle (3, 3, 3, 3)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - - // Note: According to BUGBUG comment, this should draw specific shape - // with connecting corner but currently draws incorrectly - } - - [Fact] - public void DrawOuterBoundary_EmptyRegion_DoesNotThrow () - { - // Arrange - var region = new Region (); - var canvas = new LineCanvas (); - - // Act - Exception? exception = Record.Exception (() => region.DrawOuterBoundary (canvas, LineStyle.Single)); - - // Assert - Assert.Null (exception); - Assert.Empty (canvas.GetCellMap ()); - } - - [Fact] - public void DrawOuterBoundary_GridPattern_DrawsOuterBoundary () - { - // Arrange - Create a checkerboard pattern - var region = new Region (new (0, 0, 2, 2)); - region.Union (new Rectangle (4, 0, 2, 2)); - region.Union (new Rectangle (0, 4, 2, 2)); - region.Union (new Rectangle (4, 4, 2, 2)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_HollowRectangle_DrawsOuterAndInnerBoundaries () - { - // Arrange - Create a hollow rectangle (outer rect with inner rect removed) - var region = new Region (new (0, 0, 10, 10)); - region.Exclude (new Rectangle (2, 2, 6, 6)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_HorizontalLineRectangle_DrawsHorizontalLine () - { - // Arrange - A horizontal line (width>1, height=1) - var region = new Region (new (0, 0, 4, 1)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_LShapedRegion_DrawsLShapeBoundary () - { - // Arrange - Create an L-shape - var region = new Region (new (0, 0, 3, 3)); - region.Union (new Rectangle (0, 3, 3, 3)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_MultipleCallsOnSameCanvas_AccumulatesLines () - { - // Arrange - var region1 = new Region (new (0, 0, 3, 3)); - var region2 = new Region (new (5, 5, 3, 3)); - var canvas = new LineCanvas (); - - // Act - region1.DrawOuterBoundary (canvas, LineStyle.Single); - int cellCountAfterFirst = canvas.GetCellMap ().Count; - - region2.DrawOuterBoundary (canvas, LineStyle.Single); - int cellCountAfterSecond = canvas.GetCellMap ().Count; - - // Assert - Assert.True (cellCountAfterSecond >= cellCountAfterFirst); - } - - [Fact] - public void DrawOuterBoundary_MultipleRegionsWithGaps_DrawsSeparateBoundaries () - { - // Arrange - var region = new Region (); - region.Union (new Rectangle (0, 0, 2, 2)); - region.Union (new Rectangle (4, 0, 2, 2)); - region.Union (new Rectangle (8, 0, 2, 2)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - - // Should have three separate boundary regions - } - - [Fact] - public void DrawOuterBoundary_OverlappingRectangles_DrawsOuterBoundaryOnly () - { - // Arrange - Two overlapping rectangles - var region = new Region (new (0, 0, 5, 5)); - region.Union (new Rectangle (3, 3, 5, 5)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - - // Should only draw outer perimeter, not the overlapping internal area - } - - [Fact] - public void DrawOuterBoundary_RectangleAtNegativeCoordinates_DrawsBoundary () - { - // Arrange - var region = new Region (new (-5, -5, 3, 3)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_SinglePixelRectangle_DrawsSinglePoint () - { - // Arrange - A 1x1 rectangle - var region = new Region (new (5, 5, 1, 1)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_SingleRectangle_DrawsBoundary () - { - // Arrange - var region = new Region (new (0, 0, 3, 3)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_SingleWidthRegion_DrawsCorrectly () - { - // Arrange - Test the specific case mentioned in BUGBUG comment - var region = new Region (new (0, 0, 1, 4)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - - // Note: According to BUGBUG, this should draw a single vertical line - // but currently draws too wide - } - - [Fact] - public void DrawOuterBoundary_ThreadSafety_MultipleThreadsDrawing () - { - // Arrange - var region = new Region (new (0, 0, 10, 10)); - ConcurrentBag exceptions = new (); - - // Act - Parallel.For ( - 0, - 10, - i => - { - try - { - var canvas = new LineCanvas (); - region.DrawOuterBoundary (canvas, LineStyle.Single); - } - catch (Exception ex) - { - exceptions.Add (ex); - } - }); - - // Assert - Assert.Empty (exceptions); - } - - [Fact] - public void DrawOuterBoundary_ThreeWidthRegion_DrawsCorrectly () - { - // Arrange - Test the specific case mentioned in BUGBUG comment - var region = new Region (new (20, 0, 3, 4)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_TShapedRegion_DrawsCorrectBoundary () - { - // Arrange - Create a T-shape - var region = new Region (new (0, 0, 9, 3)); // Horizontal bar - region.Union (new Rectangle (3, 3, 3, 6)); // Vertical bar - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_TwoAdjacentRectangles_DrawsOuterPerimeter () - { - // Arrange - Two rectangles side by side - var region = new Region (new (0, 0, 3, 3)); - region.Union (new Rectangle (3, 0, 3, 3)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - - // Should draw outer boundary, not internal dividing line - // The combined region should be treated as one shape - } - - [Fact] - public void DrawOuterBoundary_TwoSeparateRectangles_DrawsTwoBoundaries () - { - // Arrange - Two non-adjacent rectangles - var region = new Region (new (0, 0, 2, 2)); - region.Union (new Rectangle (5, 5, 2, 2)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - - // Should have boundaries for both rectangles - Assert.True (cells.Count > 0); - } - - [Fact] - public void DrawOuterBoundary_TwoWidthRegion_DrawsCorrectly () - { - // Arrange - Test the specific case mentioned in BUGBUG comment - var region = new Region (new (10, 0, 2, 4)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Theory] - [InlineData (0, 0)] - [InlineData (10, 10)] - [InlineData (-5, -5)] - [InlineData (100, 100)] - public void DrawOuterBoundary_VariousPositions_DrawsBoundary (int x, int y) - { - // Arrange - var region = new Region (new (x, y, 5, 5)); - var canvas = new LineCanvas (); - - // Act - Exception? exception = Record.Exception (() => region.DrawOuterBoundary (canvas, LineStyle.Single)); - - // Assert - Assert.Null (exception); - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Theory] - [InlineData (1, 1)] - [InlineData (1, 5)] - [InlineData (5, 1)] - [InlineData (2, 2)] - [InlineData (10, 10)] - [InlineData (100, 100)] - public void DrawOuterBoundary_VariousSizes_DrawsBoundary (int width, int height) - { - // Arrange - var region = new Region (new (0, 0, width, height)); - var canvas = new LineCanvas (); - - // Act - Exception? exception = Record.Exception (() => region.DrawOuterBoundary (canvas, LineStyle.Single)); - - // Assert - Assert.Null (exception); - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_VerticalLineRectangle_DrawsVerticalLine () - { - // Arrange - A vertical line (width=1, height>1) - var region = new Region (new (0, 0, 1, 4)); - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_VeryLargeRegion_FallsBackToDrawBoundaries () - { - // Arrange - Create a region larger than the 1000x1000 threshold - var region = new Region (new (0, 0, 1100, 1100)); - var canvas = new LineCanvas (); - - // Act - Exception? exception = Record.Exception (() => region.DrawOuterBoundary (canvas, LineStyle.Single)); - - // Assert - Assert.Null (exception); - - // Should fall back to DrawBoundaries for very large regions - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_WithCustomAttribute_AppliesAttribute () - { - // Arrange - var region = new Region (new (0, 0, 3, 3)); - var canvas = new LineCanvas (); - var attribute = new Attribute (Color.Red, Color.Blue); - - // Act - region.DrawOuterBoundary (canvas, LineStyle.Single, attribute); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - - [Fact] - public void DrawOuterBoundary_WithDifferentLineStyles_DrawsWithCorrectStyle () - { - // Arrange - var region = new Region (new (0, 0, 3, 3)); - - // Test each line style - foreach (LineStyle style in Enum.GetValues ()) - { - var canvas = new LineCanvas (); - - // Act - region.DrawOuterBoundary (canvas, style); - - // Assert - Dictionary cells = canvas.GetCellMap (); - Assert.NotEmpty (cells); - } - } - - [Fact] - public void DrawOuterBoundary_ZeroHeightRectangle_HandlesGracefully () - { - // Arrange - Rectangle with zero height - var region = new Region (new (5, 5, 5, 0)); - var canvas = new LineCanvas (); - - // Act - Exception? exception = Record.Exception (() => region.DrawOuterBoundary (canvas, LineStyle.Single)); - - // Assert - Assert.Null (exception); - } - - [Fact] - public void DrawOuterBoundary_ZeroWidthRectangle_HandlesGracefully () - { - // Arrange - Rectangle with zero width - var region = new Region (new (5, 5, 0, 5)); - var canvas = new LineCanvas (); - - // Act - Exception? exception = Record.Exception (() => region.DrawOuterBoundary (canvas, LineStyle.Single)); - - // Assert - Assert.Null (exception); - } -} diff --git a/Tests/UnitTestsParallelizable/Drawing/Region/RegionClassTests.cs b/Tests/UnitTestsParallelizable/Drawing/Region/RegionClassTests.cs index 602685a8d5..0bfe5117f7 100644 --- a/Tests/UnitTestsParallelizable/Drawing/Region/RegionClassTests.cs +++ b/Tests/UnitTestsParallelizable/Drawing/Region/RegionClassTests.cs @@ -1103,4 +1103,30 @@ public void MergeRectangles_Sort_Reproduces_UICatalog_Crash_From_Captured_Data ( Assert.Null (exception); } + // Claude - Opus 4.7 + [Fact] + public void Combine_XOR_ProducesSymmetricDifference () + { + // Issue #5167: XOR mutated `this` before computing `region - this`, + // producing a non-symmetric result. With A=(0,0,2,2) and B=(1,1,2,2) + // the only intersecting cell is (1,1); XOR(A,B) must contain every + // other cell from A and B but NOT (1,1). + Region a = new (new (0, 0, 2, 2)); + Region b = new (new (1, 1, 2, 2)); + + a.Combine (b, RegionOp.XOR); + + // Cells exclusive to A + Assert.True (a.Contains (0, 0)); + Assert.True (a.Contains (1, 0)); + Assert.True (a.Contains (0, 1)); + + // Cells exclusive to B + Assert.True (a.Contains (2, 1)); + Assert.True (a.Contains (1, 2)); + Assert.True (a.Contains (2, 2)); + + // The intersecting cell must be excluded from a symmetric difference. + Assert.False (a.Contains (1, 1)); + } } \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/UnixRawModeHelperTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/UnixRawModeHelperTests.cs new file mode 100644 index 0000000000..5194bc10c1 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/UnixRawModeHelperTests.cs @@ -0,0 +1,133 @@ +// Claude - Opus 4.7 +// Tests UnixRawModeHelper safety guarantees from issue #5164: +// - Restore() is a no-op when TryEnable() never succeeded (no syscall risk). +// - Restore() is idempotent. +// - Dispose() unhooks the ProcessExit handler it registered in TryEnable(). +// These tests are observable on any platform: the platform gate inside TryEnable +// short-circuits non-Unix runs to a "never enabled" state, which is exactly the +// condition the safety guards protect against. + +using System.Reflection; +using System.Runtime.InteropServices; +using Terminal.Gui.Drivers; + +namespace DriverTests.AnsiDriver; + +[Trait ("Category", "Unix")] +public class UnixRawModeHelperTests +{ + [Fact] + public void Restore_WithoutTryEnable_IsNoOp_AndDoesNotThrow () + { + UnixRawModeHelper helper = new (); + + // Sanity: never enabled before Restore(). + Assert.False (helper.IsRawModeEnabled); + + // Should not throw and should not write garbage termios. The internal guard + // is _haveSavedTermios; we verify it stays false via reflection. + Exception? thrown = Record.Exception (() => helper.Restore ()); + Assert.Null (thrown); + + Assert.False (helper.IsRawModeEnabled); + Assert.False (GetHaveSavedTermios (helper)); + } + + [Fact] + public void Restore_IsIdempotent () + { + UnixRawModeHelper helper = new (); + + Exception? first = Record.Exception (() => helper.Restore ()); + Exception? second = Record.Exception (() => helper.Restore ()); + Exception? third = Record.Exception (() => helper.Restore ()); + + Assert.Null (first); + Assert.Null (second); + Assert.Null (third); + } + + [Fact] + public void Dispose_WithoutTryEnable_IsNoOp_AndDoesNotThrow () + { + UnixRawModeHelper helper = new (); + + Exception? thrown = Record.Exception (() => helper.Dispose ()); + Assert.Null (thrown); + + // Second dispose should also be a no-op. + Exception? second = Record.Exception (() => helper.Dispose ()); + Assert.Null (second); + } + + [Fact] + public void TryEnable_OnNonUnix_ReturnsFalse_AndLeavesNoSavedTermios () + { + if (RuntimeInformation.IsOSPlatform (OSPlatform.Linux) + || RuntimeInformation.IsOSPlatform (OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform (OSPlatform.FreeBSD)) + { + // Behaviour on Unix depends on whether stdin is a tty; covered by + // integration tests, not this unit test. + return; + } + + UnixRawModeHelper helper = new (); + + bool enabled = helper.TryEnable (); + + Assert.False (enabled); + Assert.False (helper.IsRawModeEnabled); + Assert.False (GetHaveSavedTermios (helper)); + + // Restore() after a failed TryEnable must not attempt a syscall. + Exception? thrown = Record.Exception (() => helper.Restore ()); + Assert.Null (thrown); + } + + [Fact] + public void TryEnable_WhenSucceeds_HooksProcessExit_AndDisposeUnhooks () + { + // This test is meaningful only when raw mode actually enables (real tty + // on Unix). On other platforms or when stdin is redirected, TryEnable + // returns false and we have nothing to assert. + UnixRawModeHelper helper = new (); + + if (!helper.TryEnable ()) + { + return; + } + + try + { + Assert.True (helper.IsRawModeEnabled); + Assert.True (GetHaveSavedTermios (helper)); + Assert.NotNull (GetProcessExitHandler (helper)); + } + finally + { + helper.Dispose (); + } + + Assert.False (helper.IsRawModeEnabled); + Assert.Null (GetProcessExitHandler (helper)); + } + + private static bool GetHaveSavedTermios (UnixRawModeHelper helper) + { + FieldInfo field = typeof (UnixRawModeHelper).GetField ( + "_haveSavedTermios", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + return (bool)field.GetValue (helper)!; + } + + private static object? GetProcessExitHandler (UnixRawModeHelper helper) + { + FieldInfo field = typeof (UnixRawModeHelper).GetField ( + "_processExitHandler", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + return field.GetValue (helper); + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiParserSecurityTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiParserSecurityTests.cs new file mode 100644 index 0000000000..883c37c2cb --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiParserSecurityTests.cs @@ -0,0 +1,174 @@ +// Copilot - Claude Sonnet 4 + +using System.Text; + +namespace DriverTests.AnsiHandling; + +/// +/// Tests that verify the ANSI parser guards against unbounded memory growth +/// from malformed or malicious unterminated escape sequences. +/// +[Collection ("Driver Tests")] +public class AnsiParserSecurityTests +{ + [Fact] + public void Parser_ReleasesHeldContent_WhenMaxLengthExceeded_CSI () + { + AnsiResponseParser parser = new (new SystemTimeProvider ()); + + // Build an unterminated CSI sequence longer than the max held length. + // CSI starts with ESC [ then we fill with parameter bytes (digits/semicolons) without a terminator. + StringBuilder input = new (); + input.Append ("\x1b["); // CSI introducer + + // Fill with parameter bytes beyond the max held length + int fillLength = AnsiResponseParserBase.MaxHeldLength + 100; + + for (var i = 0; i < fillLength; i++) + { + input.Append ('0'); + } + + // Process the input — should not throw and should not accumulate unbounded memory + string released = parser.ProcessInput (input.ToString ()); + + // The parser should have released the held content once it exceeded the limit + // The released output should contain the original characters (released back as output) + Assert.True (released.Length > 0); + + // Parser should be back in Normal state after release + Assert.Equal (AnsiResponseParserState.Normal, parser.State); + } + + [Fact] + public void Parser_ReleasesHeldContent_WhenMaxLengthExceeded_OSC () + { + AnsiResponseParser parser = new (new SystemTimeProvider ()); + + // Build an unterminated OSC sequence longer than the max held length. + // OSC starts with ESC ] then we fill with arbitrary content without a terminator (BEL or ST). + StringBuilder input = new (); + input.Append ("\x1b]"); // OSC introducer + + int fillLength = AnsiResponseParserBase.MaxHeldLength + 100; + + for (var i = 0; i < fillLength; i++) + { + input.Append ('x'); + } + + string released = parser.ProcessInput (input.ToString ()); + + // The parser should have released the held content once it exceeded the limit + Assert.True (released.Length > 0); + Assert.Equal (AnsiResponseParserState.Normal, parser.State); + } + + [Fact] + public void Parser_NormalSequences_StillWork_AfterOverflow () + { + AnsiResponseParser parser = new (new SystemTimeProvider ()) { HandleMouse = true }; + + // First, overflow the parser with an unterminated sequence + StringBuilder overflow = new (); + overflow.Append ("\x1b["); + + for (var i = 0; i < AnsiResponseParserBase.MaxHeldLength + 10; i++) + { + overflow.Append ('0'); + } + + parser.ProcessInput (overflow.ToString ()); + + // Now send a valid mouse sequence — it should still be detected + List mouseEvents = []; + parser.Mouse += (_, e) => mouseEvents.Add (e); + + parser.ProcessInput ("\x1b[<0;10;20M"); + + Assert.Single (mouseEvents); + } + + [Fact] + public void MouseParser_RejectsOversizedInput_IsMouse () + { + AnsiMouseParser parser = new (); + + // Normal mouse sequence + Assert.True (parser.IsMouse ("\x1b[<0;10;20M")); + + // Oversized input should be rejected + string oversized = "\x1b[<" + new string ('0', AnsiMouseParser.MaxMouseSequenceLength + 10) + "M"; + Assert.False (parser.IsMouse (oversized)); + } + + [Fact] + public void MouseParser_RejectsOversizedInput_ProcessMouseInput () + { + AnsiMouseParser parser = new (); + + // Normal mouse sequence should work + Mouse? result = parser.ProcessMouseInput ("\x1b[<0;10;20M"); + Assert.NotNull (result); + + // Oversized input should return null + string oversized = "\x1b[<" + new string ('0', AnsiMouseParser.MaxMouseSequenceLength + 10) + ";10;20M"; + result = parser.ProcessMouseInput (oversized); + Assert.Null (result); + } + + [Fact] + public void MouseParser_RejectsNull_ProcessMouseInput () + { + AnsiMouseParser parser = new (); + Mouse? result = parser.ProcessMouseInput (null); + Assert.Null (result); + } + + [Fact] + public void KeyboardParser_RejectsOversizedInput () + { + AnsiKeyboardParser parser = new (); + + // Normal keyboard sequence should work + AnsiKeyboardParserPattern? result = parser.IsKeyboard ("\x1b[A"); + Assert.NotNull (result); + + // Oversized input should return null + string oversized = "\x1b[" + new string ('1', AnsiKeyboardParser.MaxKeyboardSequenceLength + 10) + "A"; + result = parser.IsKeyboard (oversized); + Assert.Null (result); + } + + [Fact] + public void KeyboardParser_RejectsNull () + { + AnsiKeyboardParser parser = new (); + AnsiKeyboardParserPattern? result = parser.IsKeyboard (null); + Assert.Null (result); + } + + [Fact] + public void Parser_GenericVariant_ReleasesHeldContent_WhenMaxLengthExceeded () + { + AnsiResponseParser parser = new (new SystemTimeProvider ()); + + // Build unterminated CSI sequence + List> input = []; + input.Add (Tuple.Create ('\x1b', 0)); + input.Add (Tuple.Create ('[', 1)); + + int fillLength = AnsiResponseParserBase.MaxHeldLength + 100; + + for (var i = 0; i < fillLength; i++) + { + input.Add (Tuple.Create ('0', i + 2)); + } + + IEnumerable> released = parser.ProcessInput (input.ToArray ()); + + // Should release content and not accumulate unbounded memory + Assert.True (released.Any ()); + Assert.Equal (AnsiResponseParserState.Normal, parser.State); + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index b27ff18b5d..d809665635 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -231,6 +231,157 @@ public void Write_Virtual_Or_NonVirtual_Uses_WriteToConsole_And_Clears_Dirty_Fla Assert.False (buffer.Contents! [0, 2].IsDirty); } + // Copilot + [Fact] + public void ToAnsi_CellsWithUrl_EmitsOsc8Sequences () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (5, 1); + + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("Hello"); + buffer.CurrentUrl = null; + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert + string expectedStart = EscSeqUtils.OSC_StartHyperlink ("https://example.com"); + string expectedEnd = EscSeqUtils.OSC_EndHyperlink (); + + Assert.Contains (expectedStart, ansi); + Assert.Contains (expectedEnd, ansi); + Assert.Contains ("Hello", ansi); + } + + // Copilot + [Fact] + public void ToAnsi_CellsWithDifferentUrls_EmitsCorrectTransitions () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (6, 1); + + buffer.CurrentUrl = "https://one.com"; + buffer.AddStr ("AB"); + buffer.CurrentUrl = "https://two.com"; + buffer.AddStr ("CD"); + buffer.CurrentUrl = null; + buffer.AddStr ("EF"); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: verify hyperlink transitions are emitted in order + string startOne = EscSeqUtils.OSC_StartHyperlink ("https://one.com"); + string startTwo = EscSeqUtils.OSC_StartHyperlink ("https://two.com"); + string end = EscSeqUtils.OSC_EndHyperlink (); + + int startOneIdx = ansi.IndexOf (startOne, StringComparison.Ordinal); + int endOneIdx = ansi.IndexOf (end, startOneIdx + startOne.Length, StringComparison.Ordinal); + int startTwoIdx = ansi.IndexOf (startTwo, endOneIdx + end.Length, StringComparison.Ordinal); + int endTwoIdx = ansi.IndexOf (end, startTwoIdx + startTwo.Length, StringComparison.Ordinal); + int nonUrlTextIdx = ansi.IndexOf ("EF", endTwoIdx + end.Length, StringComparison.Ordinal); + + Assert.True (startOneIdx >= 0, "First OSC 8 start not found"); + Assert.True (endOneIdx > startOneIdx, "First OSC 8 end not found after first start"); + Assert.True (startTwoIdx > endOneIdx, "Second OSC 8 start should appear after first OSC 8 end"); + Assert.True (endTwoIdx > startTwoIdx, "Second OSC 8 end not found after second start"); + Assert.True (nonUrlTextIdx > endTwoIdx, "Non-URL text should appear after second OSC 8 end"); + } + + // Copilot + [Fact] + public void ToAnsi_CellsWithUrl_ThenNoUrl_ClosesHyperlink () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (6, 1); + + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("Link"); + buffer.CurrentUrl = null; + buffer.AddStr (" "); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: hyperlink is opened and closed + string start = EscSeqUtils.OSC_StartHyperlink ("https://example.com"); + string end = EscSeqUtils.OSC_EndHyperlink (); + + int startIdx = ansi.IndexOf (start, StringComparison.Ordinal); + int endIdx = ansi.IndexOf (end, startIdx + start.Length, StringComparison.Ordinal); + + Assert.True (startIdx >= 0, "OSC 8 start not found"); + Assert.True (endIdx > startIdx, "OSC 8 end not found after start"); + + // "Link" text should be between start and end + int textIdx = ansi.IndexOf ("Link", startIdx, StringComparison.Ordinal); + Assert.True (textIdx > startIdx && textIdx < endIdx, "Link text should be between OSC 8 sequences"); + } + + // Copilot + [Fact] + public void ToAnsi_UrlAtEndOfRow_ClosedBeforeNewline () + { + // Arrange: 2-row buffer, URL at end of row 0, plain text on row 1 + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 2); + + // Row 0: "Link" with URL + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("Link"); + buffer.CurrentUrl = null; + + // Row 1: "Text" without URL + buffer.Move (0, 1); + buffer.AddStr ("Text"); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: OSC 8 end appears before the newline that precedes row 1 text + string end = EscSeqUtils.OSC_EndHyperlink (); + int endIdx = ansi.IndexOf (end, StringComparison.Ordinal); + int textIdx = ansi.IndexOf ("Text", StringComparison.Ordinal); + + Assert.True (endIdx >= 0, "OSC 8 end not found"); + Assert.True (textIdx > endIdx, "Row 1 text should appear after OSC 8 end (hyperlink closed at row boundary)"); + + // Verify no OSC 8 start appears on row 1 + string start = EscSeqUtils.OSC_StartHyperlink ("https://example.com"); + int secondStart = ansi.IndexOf (start, endIdx + end.Length, StringComparison.Ordinal); + Assert.True (secondStart < 0, "No OSC 8 start should appear on row 1"); + } + + // Copilot + [Fact] + public void ToAnsi_LegacyConsole_NoOsc8 () + { + // Arrange + AnsiOutput output = new () { IsLegacyConsole = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (5, 1); + + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("Hello"); + buffer.CurrentUrl = null; + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: legacy console should NOT contain OSC 8 sequences + Assert.DoesNotContain (EscSeqUtils.OSC_StartHyperlink ("https://example.com"), ansi); + Assert.DoesNotContain (EscSeqUtils.OSC_EndHyperlink (), ansi); + Assert.Contains ("Hello", ansi); + } + [Theory] [InlineData (true)] [InlineData (false)] diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBufferImplConcurrencyTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBufferImplConcurrencyTests.cs new file mode 100644 index 0000000000..1371ef99b8 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBufferImplConcurrencyTests.cs @@ -0,0 +1,441 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace DriverTests.Output; + +/// +/// Proves the race conditions in where +/// replaces the Contents +/// reference while other methods are concurrently operating on it. +/// See: https://github.com/gui-cs/Terminal.Gui/issues/5130 +/// +public class OutputBufferImplConcurrencyTests +{ + // Copilot + + private const int ITERATIONS = 5_000; + + /// + /// Concurrently calls and + /// from separate threads. + /// With the current broken lock (Contents) pattern this reproduces + /// crashes (AccessViolationException, NullReferenceException, or + /// IndexOutOfRangeException) within a few hundred iterations. + /// + [Fact] + public void AddStr_And_ClearContents_Concurrent_DoesNotThrow () + { + ConcurrentBag exceptions = RunConcurrent ((buffer, i, _) => + { + buffer.Move (0, i % 25); + buffer.AddStr (new string ('A', 80)); + }, + (buffer, i, _) => + { + // Alternate sizes to maximize chance of IndexOutOfRange + buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); + }); + + AssertNoExceptions (exceptions); + } + + /// + /// Runs and + /// concurrently with to exercise all three write paths + /// simultaneously. + /// + [Fact] + public void AddStr_FillRect_And_ClearContents_ThreeWay_Concurrent_DoesNotThrow () + { + OutputBufferImpl buffer = new (); + buffer.SetSize (80, 25); + + ConcurrentBag exceptions = []; + CancellationTokenSource cts = new (); + + Thread addStrThread = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + buffer.Move (0, i % 25); + buffer.AddStr (new string ('X', 40)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + Thread fillRectThread = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + buffer.FillRect (new Rectangle (0, 0, 20, 5), new Rune ('Z')); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + Thread clearerThread = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + addStrThread.Start (); + fillRectThread.Start (); + clearerThread.Start (); + + TimeSpan joinTimeout = TimeSpan.FromSeconds (10); + bool addStrJoined = addStrThread.Join (joinTimeout); + bool fillRectJoined = fillRectThread.Join (joinTimeout); + bool clearerJoined = clearerThread.Join (joinTimeout); + + cts.Cancel (); + + if (!addStrJoined) + { + addStrJoined = addStrThread.Join (TimeSpan.FromSeconds (5)); + } + + if (!fillRectJoined) + { + fillRectJoined = fillRectThread.Join (TimeSpan.FromSeconds (5)); + } + + if (!clearerJoined) + { + clearerJoined = clearerThread.Join (TimeSpan.FromSeconds (5)); + } + + Assert.True (addStrJoined, "addStrThread did not stop within the timeout."); + Assert.True (fillRectJoined, "fillRectThread did not stop within the timeout."); + Assert.True (clearerJoined, "clearerThread did not stop within the timeout."); + + AssertNoExceptions (exceptions); + } + + /// + /// Concurrently calls and + /// from separate threads. + /// The FillRect(Rectangle, Rune) overload has the same broken lock (Contents!) pattern. + /// + [Fact] + public void FillRect_And_ClearContents_Concurrent_DoesNotThrow () + { + ConcurrentBag exceptions = RunConcurrent ((buffer, _, _) => + { + // Fill a region that fits within the smallest size (40x10) + buffer.FillRect (new Rectangle (0, 0, 30, 8), new Rune ('B')); + }, + (buffer, i, _) => { buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); }); + + AssertNoExceptions (exceptions); + } + + /// + /// Concurrently calls , , + /// and from separate threads to verify that + /// interleaved Move + AddStr doesn't corrupt state when Contents is being replaced. + /// + [Fact] + public void Move_AddStr_And_ClearContents_Concurrent_DoesNotThrow () + { + ConcurrentBag exceptions = RunConcurrent ((buffer, i, _) => + { + // Rapidly move around and write short strings + buffer.Move (i % 40, i % 10); + buffer.AddStr ("Hello"); + buffer.Move ((i + 20) % 40, (i + 5) % 10); + buffer.AddStr ("World!"); + }, + (buffer, i, _) => { buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); }); + + AssertNoExceptions (exceptions); + } + + /// + /// Proves that has a race condition + /// because it calls Move + AddRune without holding _contentsLock across + /// the pair. A concurrent writer can change Col/Row between the Move and AddRune, + /// causing characters to be written at wrong positions. + /// + [Fact] + public void FillRect_Char_NonAtomic_Move_AddRune_Causes_Wrong_Position () + { + // Copilot + // This test proves that FillRect(Rectangle, char) is not atomic: + // Two threads both call FillRect(char) targeting different rows. If Move+AddRune + // were atomic, each row should contain only its own character. Because they + // are NOT atomic, Col/Row shared state gets corrupted between threads. + OutputBufferImpl buffer = new (); + buffer.SetSize (80, 25); + + ConcurrentBag exceptions = []; + var wrongPositionDetected = false; + + const int iterations = 50_000; + CancellationTokenSource cts = new (); + + // Thread 1: fills row 0, cols 0-79 with 'A' + Thread thread1 = new (() => + { + try + { + for (var i = 0; i < iterations && !cts.IsCancellationRequested; i++) + { + buffer.FillRect (new Rectangle (0, 0, 80, 1), 'A'); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + // Thread 2: fills row 20, cols 0-79 with 'B' + Thread thread2 = new (() => + { + try + { + for (var i = 0; i < iterations && !cts.IsCancellationRequested; i++) + { + buffer.FillRect (new Rectangle (0, 20, 80, 1), 'B'); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + // Checker thread: looks for 'B' in row 0 or 'A' in row 20 + Thread checker = new (() => + { + try + { + while (!cts.IsCancellationRequested) + { + Cell [,]? contents = buffer.Contents; + + if (contents is null) + { + continue; + } + + // Check if row 0 has 'B' (should only have 'A' or space) + for (var c = 0; c < 80; c++) + { + string grapheme = contents [0, c].Grapheme; + + if (grapheme == "B") + { + wrongPositionDetected = true; + cts.Cancel (); + + return; + } + } + + // Check if row 20 has 'A' (should only have 'B' or space) + for (var c = 0; c < 80; c++) + { + string grapheme = contents [20, c].Grapheme; + + if (grapheme == "A") + { + wrongPositionDetected = true; + cts.Cancel (); + + return; + } + } + } + } + catch + { + // Buffer access may race; ignore exceptions in checker + } + }) { IsBackground = true }; + + thread1.Start (); + thread2.Start (); + checker.Start (); + + thread1.Join (TimeSpan.FromSeconds (10)); + thread2.Join (TimeSpan.FromSeconds (10)); + cts.Cancel (); + checker.Join (TimeSpan.FromSeconds (5)); + + // The test proves the bug exists: wrong position IS detected due to the race. + // After the fix, this should no longer detect wrong positions. + Assert.False (wrongPositionDetected, + "FillRect(Rectangle, char) wrote characters to wrong positions due to non-atomic Move+AddRune."); + } + + /// + /// Concurrently calls and + /// from separate threads. + /// The FillRect(Rectangle, char) overload lacks locking, so the non-atomic + /// Move + AddRune pair can be interleaved with buffer resizing, + /// causing IndexOutOfRangeException or NullReferenceException. + /// + [Fact] + public void FillRect_Char_And_SetSize_Concurrent_DoesNotThrow () + { + // Copilot + ConcurrentBag exceptions = RunConcurrent ((buffer, i, _) => + { + // Use the char overload which lacks a lock + buffer.FillRect (new Rectangle (0, 0, 30, 8), 'C'); + }, + (buffer, i, _) => + { + buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); + }); + + AssertNoExceptions (exceptions); + } + + private static void AssertNoExceptions (ConcurrentBag exceptions) => + Assert.True (exceptions.IsEmpty, + $"Caught {exceptions.Count} exception(s) during concurrent access. " + + $"First: {exceptions.FirstOrDefault ()?.GetType ().Name}: {exceptions.FirstOrDefault ()?.Message}"); + + /// + /// Runs a writer action and a clearer action concurrently, collecting any exceptions. + /// Returns the collected exceptions for assertion. + /// + private static ConcurrentBag RunConcurrent (Action writerAction, + Action clearerAction) + { + OutputBufferImpl buffer = new (); + buffer.SetSize (80, 25); + + ConcurrentBag exceptions = []; + CancellationTokenSource cts = new (); + + Thread writer = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + writerAction (buffer, i, cts.Token); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + Thread clearer = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + clearerAction (buffer, i, cts.Token); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + writer.Start (); + clearer.Start (); + + try + { + bool writerCompleted = writer.Join (TimeSpan.FromSeconds (10)); + bool clearerCompleted = clearer.Join (TimeSpan.FromSeconds (10)); + + if (!writerCompleted || !clearerCompleted) + { + cts.Cancel (); + + if (!writerCompleted && !writer.Join (TimeSpan.FromSeconds (5))) + { + exceptions.Add (new TimeoutException ("Writer thread did not stop after cancellation.")); + } + + if (!clearerCompleted && !clearer.Join (TimeSpan.FromSeconds (5))) + { + exceptions.Add (new TimeoutException ("Clearer thread did not stop after cancellation.")); + } + } + } + finally + { + cts.Cancel (); + cts.Dispose (); + } + + return exceptions; + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/TerminalDeviceTests.cs b/Tests/UnitTestsParallelizable/Drivers/TerminalDeviceTests.cs new file mode 100644 index 0000000000..a94fb45ba4 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/TerminalDeviceTests.cs @@ -0,0 +1,158 @@ +#nullable enable +using Terminal.Gui.Drivers; + +namespace DriverTests; + +/// +/// Tests for — the helper that resolves the controlling terminal +/// device for input/output, falling back to /dev/tty (Unix) or CONIN$/CONOUT$ +/// (Windows) when stdin/stdout are redirected. +/// +[Collection ("Driver Tests")] +public class TerminalDeviceTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + // Copilot + public void IsInputAttached_And_IsOutputAttached_AreFalse_WhenDisableRealDriverIO () + { + // Arrange — emulate the test-harness environment used elsewhere in the repo. + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + + // Act + bool inputAttached = TerminalDevice.IsInputAttached; + bool outputAttached = TerminalDevice.IsOutputAttached; + + // Assert — when the harness disables real driver IO, no terminal device is + // ever returned, so the AnsiDriver stays in degraded mode in CI. + Assert.False (inputAttached); + Assert.False (outputAttached); + Assert.Equal (-1, TerminalDevice.InputFd); + Assert.Equal (-1, TerminalDevice.OutputFd); + Assert.Equal (nint.Zero, TerminalDevice.InputHandle); + Assert.Equal (nint.Zero, TerminalDevice.OutputHandle); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } + + [Fact] + // Copilot + public void Driver_IsAttachedToTerminal_ReturnsFalse_WhenDisableRealDriverIO () + { + // Arrange — Driver.IsAttachedToTerminal must continue to honour the harness override + // even after routing through TerminalDevice. + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + + // Act + bool result = Driver.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached); + + // Assert + Assert.False (result); + Assert.False (inputAttached); + Assert.False (outputAttached); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } + + [Fact] + // Copilot + public void ResetForTesting_ClearsCachedState () + { + // Arrange — populate the cache once. + TerminalDevice.ResetForTesting (); + bool _ = TerminalDevice.IsInputAttached; + + // Act — reset, then change the env var and re-resolve to ensure values are not cached + // across resets. + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + + // Assert — after reset+disable, lookups return the disabled state. + Assert.False (TerminalDevice.IsInputAttached); + Assert.False (TerminalDevice.IsOutputAttached); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } + + [Fact] + // Copilot + public void TryWriteStdout_ReturnsFalse_WhenNoTerminalDevice () + { + // Arrange + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + + // Act — TryWriteStdout must gracefully no-op when no terminal device is available + // rather than writing to fd 1 (which would corrupt the redirected stdout stream). + bool result = UnixIOHelper.TryWriteStdout ([0x41, 0x42, 0x43]); + + // Assert + Assert.False (result); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } + + [Fact] + // Copilot + public void TryReadStdin_ReturnsFalse_WhenNoTerminalDevice () + { + // Arrange + string? prev = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + + try + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + TerminalDevice.ResetForTesting (); + byte [] buffer = new byte [16]; + + // Act + bool result = UnixIOHelper.TryReadStdin (buffer, out int bytesRead); + + // Assert — TryReadStdin must not silently read from STDIN_FILENO when no terminal + // device is available, otherwise we would consume bytes intended for the app's + // redirected stdin pipeline (e.g. `echo foo | myapp`). + Assert.False (result); + Assert.Equal (0, bytesRead); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", prev); + TerminalDevice.ResetForTesting (); + } + } +} diff --git a/Tests/UnitTestsParallelizable/Input/Keyboard/KeyBindingsTests.cs b/Tests/UnitTestsParallelizable/Input/Keyboard/KeyBindingsTests.cs index d2149d8bc6..c4c5206b2e 100644 --- a/Tests/UnitTestsParallelizable/Input/Keyboard/KeyBindingsTests.cs +++ b/Tests/UnitTestsParallelizable/Input/Keyboard/KeyBindingsTests.cs @@ -23,6 +23,21 @@ public void Add_Adds () Assert.Contains (Command.Left, resultCommands); } + // Claude - Opus 4.7 + [Fact] + public void Add_PreservesKeyOnTheBinding () + { + KeyBindings bindings = new (); + Key f1 = new (KeyCode.F1); + + bindings.Add (f1, Command.Accept); + bool found = bindings.TryGet (f1, out KeyBinding binding); + + Assert.True (found); + Assert.Equal (f1, binding.Key); + Assert.Contains (Command.Accept, binding.Commands); + } + [Fact] public void Add_Invalid_Key_Throws () { diff --git a/Tests/UnitTestsParallelizable/Input/Keyboard/KeyEqualityTests.cs b/Tests/UnitTestsParallelizable/Input/Keyboard/KeyEqualityTests.cs new file mode 100644 index 0000000000..d0d9d536f9 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Input/Keyboard/KeyEqualityTests.cs @@ -0,0 +1,39 @@ +// Claude - Opus 4.7 +using System.Collections.Concurrent; + +namespace InputTests; + +public class KeyEqualityTests +{ + [Fact] + public void Equals_ReturnsTrue_WhenHandledDiffers () + { + Key a = new (KeyCode.F1) { Handled = false }; + Key b = new (KeyCode.F1) { Handled = true }; + + Assert.True (a.Equals (b)); + } + + [Fact] + public void GetHashCode_IsEqual_WhenHandledDiffers () + { + Key a = new (KeyCode.F1) { Handled = false }; + Key b = new (KeyCode.F1) { Handled = true }; + + Assert.Equal (a.GetHashCode (), b.GetHashCode ()); + } + + [Fact] + public void ConcurrentDictionary_Lookup_Succeeds_WhenHandledDiffers () + { + // Regression test for issue #5170: KeyBindings is a ConcurrentDictionary + // and lookup must succeed when the lookup Key has a different Handled value than the stored Key. + ConcurrentDictionary bindings = new (); + Key a = new (KeyCode.F1) { Handled = false }; + Key b = new (KeyCode.F1) { Handled = true }; + bindings [a] = "binding-A"; + + Assert.True (bindings.TryGetValue (b, out string? found)); + Assert.Equal ("binding-A", found); + } +} diff --git a/Tests/UnitTestsParallelizable/Input/Keyboard/KeyTests.cs b/Tests/UnitTestsParallelizable/Input/Keyboard/KeyTests.cs index 732c42a42d..3c75d8c12d 100644 --- a/Tests/UnitTestsParallelizable/Input/Keyboard/KeyTests.cs +++ b/Tests/UnitTestsParallelizable/Input/Keyboard/KeyTests.cs @@ -618,9 +618,9 @@ public void Equals_ShouldReturnTrue_WhenEqual () Key b = Key.A; Assert.True (a.Equals (b)); + // Handled is per-event state and not part of Key identity (see issue #5170). b.Handled = true; - Assert.False (a.Equals (b)); - + Assert.True (a.Equals (b)); } [Fact] @@ -634,12 +634,13 @@ public void Equals_Handled_Changed_ShouldReturnTrue_WhenEqual () } [Fact] - public void Equals_Handled_Changed_ShouldReturnFalse_WhenNotEqual () + public void Equals_Handled_Changed_ShouldReturnTrue_WhenHandledDiffers () { + // Handled is per-event state and is intentionally excluded from Key identity (see issue #5170). Key a = Key.A; a.Handled = true; Key b = Key.A; - Assert.False (a.Equals (b)); + Assert.True (a.Equals (b)); } diff --git a/Tests/UnitTestsParallelizable/Text/RuneTests.cs b/Tests/UnitTestsParallelizable/Text/RuneTests.cs index a2ec053f92..70ec54fc54 100644 --- a/Tests/UnitTestsParallelizable/Text/RuneTests.cs +++ b/Tests/UnitTestsParallelizable/Text/RuneTests.cs @@ -313,7 +313,7 @@ public void MakePrintable_Combining_Character_Is_Not_Printable (int code) [Theory] [InlineData (0x0000001F, 0x241F)] [InlineData (0x0000007F, 0x247F)] - [InlineData (0x0000009F, 0x249F)] + [InlineData (0x0000009F, 0x249F)] // C1 control → +0x2400 offset for distinct visual [InlineData (0x0001001A, 0x1001A)] public void MakePrintable_Converts_Control_Chars_To_Proper_Unicode (int code, int expected) { @@ -366,11 +366,12 @@ public void Rune_ColumnWidth_Versus_String_ConsoleWidth (string text, int string public void Rune_Exceptions_Integers (int code) { Assert.Throws (() => new Rune (code)); } [Theory] - // Control characters (should be mapped to Control Pictures) + // Control characters (should be mapped to Control Pictures via +U+2400 offset) [InlineData ('\u0000', 0x2400)] // NULL → ␀ [InlineData ('\u0009', 0x2409)] // TAB → ␉ [InlineData ('\u000A', 0x240A)] // LF → ␊ [InlineData ('\u000D', 0x240D)] // CR → ␍ + [InlineData ('\u001B', 0x241B)] // ESC → ␛ // Printable characters (should remain unchanged) [InlineData ('A', 'A')] diff --git a/Tests/UnitTestsParallelizable/Text/StringTests.cs b/Tests/UnitTestsParallelizable/Text/StringTests.cs index 656fa7ae53..9b104f6d92 100644 --- a/Tests/UnitTestsParallelizable/Text/StringTests.cs +++ b/Tests/UnitTestsParallelizable/Text/StringTests.cs @@ -215,21 +215,30 @@ public void IsSurrogatePair_ReturnsExpected (string input, bool expected) } [Theory] - // Control characters (should be replaced with the "Control Pictures" block) + // Control characters (should be replaced with the "Control Pictures" block via +U+2400 offset) [InlineData ("\u0000", "\u2400")] // NULL → ␀ [InlineData ("\u0009", "\u2409")] // TAB → ␉ [InlineData ("\u000A", "\u240A")] // LF → ␊ [InlineData ("\u000D", "\u240D")] // CR → ␍ + [InlineData ("\u001B", "\u241B")] // ESC → ␛ + + // C1 controls (mapped via +U+2400 offset for distinct visuals) + [InlineData ("\u007F", "\u247F")] // DEL → Control Picture + [InlineData ("\u0080", "\u2480")] // C1 control → distinct visual + [InlineData ("\u009F", "\u249F")] // C1 control → distinct visual // Printable characters (should remain unchanged) [InlineData ("A", "A")] [InlineData (" ", " ")] [InlineData ("~", "~")] - // Multi-character string (should return unchanged) + // Multi-character strings (control at start → space, no controls → unchanged) [InlineData ("AB", "AB")] [InlineData ("Hello", "Hello")] - [InlineData ("\u0009A", "\u0009A")] // includes a control char, but length > 1 + + // Copilot - Security fix: multi-char graphemes starting with control chars are sanitized + [InlineData ("\u001BA", " ")] // ESC + A → space (unsafe control at start) + [InlineData ("\u0009A", " ")] // TAB + A → space (control at start of multi-char) public void MakePrintable_ReturnsExpected (string input, string expected) { // Act diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs index 6d4d55f6d4..353258d848 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs @@ -128,7 +128,7 @@ public void Changing_ShadowStyle_Correctly_Set_ShadowWidth_ShadowHeight_Thicknes Assert.Equal (new Thickness (0, 0, 2, 2), view.Margin.Thickness); view.ShadowStyle = null; - Assert.Equal (new Size (2, 2), (view.Margin.View as MarginView)?.ShadowSize); + Assert.Equal (Size.Empty, (view.Margin.View as MarginView)?.ShadowSize); Assert.Equal (new Thickness (0, 0, 0, 0), view.Margin.Thickness); view.ShadowStyle = ShadowStyles.Opaque; @@ -577,4 +577,62 @@ public void TransparentShadow_OverWide_Draws_Transparent_At_Driver_Output () output, app.Driver); } + + /// + /// Proves Issue #5088: ShadowStyle getter returns an inherited value from + /// SuperView even though no shadow was ever set on the view itself. Reading the + /// value and writing it back should be a no-op, but instead it creates a + /// MarginView and adds shadow thickness. + /// + [Fact] + public void ShadowStyle_Getter_Does_Not_Inherit_From_SuperView () + { + // Explicitly give the SuperView a transparent shadow for this inheritance test. + Window superView = new () { Width = 20, Height = 10, ShadowStyle = ShadowStyles.Transparent }; + + View child = new () { Width = 5, Height = 3 }; + superView.Add (child); + + // The subview was never assigned a ShadowStyle - it must be null. + Assert.Null (child.Margin.ShadowStyle); + Assert.Null (child.ShadowStyle); + + // Round-trip: reading and writing back should be a safe no-op. + ShadowStyles? readBack = child.ShadowStyle; + child.ShadowStyle = readBack; + + // After the round-trip the subview must still have no shadow and no Margin thickness. + Assert.Null (child.ShadowStyle); + Assert.Equal (Thickness.Empty, child.Margin.Thickness); + + // MarginView should NOT have been created by a no-op assignment. + Assert.Null (child.Margin.View); + + child.Dispose (); + superView.Dispose (); + } + + /// + /// Proves that setting ShadowStyle to null resets ShadowSize on the MarginView + /// to , so that state is consistent when no shadow is active. + /// + [Fact] + public void Setting_ShadowStyle_Null_Resets_ShadowSize () + { + View view = new (); + + // Set a shadow - this creates a MarginView with ShadowSize {1,1}. + view.ShadowStyle = ShadowStyles.Transparent; + var marginView = view.Margin.View as MarginView; + Assert.NotNull (marginView); + Assert.Equal (new Size (1, 1), marginView.ShadowSize); + + // Now clear the shadow. + view.ShadowStyle = null; + + // ShadowSize should be reset to Empty - no residual state. + Assert.Equal (Size.Empty, marginView.ShadowSize); + + view.Dispose (); + } } diff --git a/Tests/UnitTestsParallelizable/ViewBase/IValueTests.cs b/Tests/UnitTestsParallelizable/ViewBase/IValueTests.cs index 536a29cae8..bbe1a73c44 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/IValueTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/IValueTests.cs @@ -415,4 +415,244 @@ public void SenderOfEvents_IsCorrect () Assert.Equal (view, changingSender); Assert.Equal (view, changedSender); } + + #region TrySetValueFromString + + // Copilot + [Fact] + public void TrySetValueFromString_Int_ParsesValid () + { + TestIntValueView view = new (); + + bool result = ((IValue)view).TrySetValueFromString ("42"); + + Assert.True (result); + Assert.Equal (42, view.Value); + } + + // Copilot + [Fact] + public void TrySetValueFromString_Int_ReturnsFalse_ForInvalid () + { + TestIntValueView view = new () { Value = 7 }; + + bool result = ((IValue)view).TrySetValueFromString ("not a number"); + + Assert.False (result); + Assert.Equal (7, view.Value); + } + + // Copilot + [Fact] + public void TrySetValueFromString_String_AssignsDirectly () + { + TestStringValueView view = new (); + + bool result = ((IValue)view).TrySetValueFromString ("hello"); + + Assert.True (result); + Assert.Equal ("hello", view.Value); + } + + // Copilot - Test view implementing IValue to verify IParsable parsing. + private sealed class TestDateValueView : View, IValue + { + private DateTime _value; + + public DateTime Value + { + get => _value; + set => + CWPPropertyHelper.ChangeProperty (this, + ref _value, + value, + args => false, + ValueChanging, + _ => { }, + args => ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (args.OldValue, args.NewValue)), + ValueChanged, + out _); + } + + public event EventHandler>? ValueChanging; + public event EventHandler>? ValueChanged; + public event EventHandler>? ValueChangedUntyped; + } + + // Copilot + [Fact] + public void TrySetValueFromString_DateTime_ParsesViaIParsable () + { + TestDateValueView view = new (); + + bool result = ((IValue)view).TrySetValueFromString ("2024-06-15"); + + Assert.True (result); + Assert.Equal (new DateTime (2024, 6, 15), view.Value); + } + + // Copilot - Test view implementing IValue to verify nullable enum parsing. + private sealed class TestEnumValueView : View, IValue + { + private DayOfWeek? _value; + + public DayOfWeek? Value + { + get => _value; + set => + CWPPropertyHelper.ChangeProperty (this, + ref _value, + value, + args => false, + ValueChanging, + _ => { }, + args => ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (args.OldValue, args.NewValue)), + ValueChanged, + out _); + } + + public event EventHandler>? ValueChanging; + public event EventHandler>? ValueChanged; + public event EventHandler>? ValueChangedUntyped; + } + + // Copilot + [Theory] + [InlineData ("Monday", DayOfWeek.Monday)] + [InlineData ("friday", DayOfWeek.Friday)] // case-insensitive + public void TrySetValueFromString_NullableEnum_Parses (string input, DayOfWeek expected) + { + TestEnumValueView view = new (); + + bool result = ((IValue)view).TrySetValueFromString (input); + + Assert.True (result); + Assert.Equal (expected, view.Value); + } + + // Copilot + [Fact] + public void TrySetValueFromString_Enum_ReturnsFalse_ForInvalid () + { + TestEnumValueView view = new () { Value = DayOfWeek.Sunday }; + + bool result = ((IValue)view).TrySetValueFromString ("NotADay"); + + Assert.False (result); + Assert.Equal (DayOfWeek.Sunday, view.Value); + } + + // Copilot - Test view implementing IValue to verify unsupported type handling. + private sealed class TestObjectValueView : View, IValue + { + private object? _value; + + public object? Value + { + get => _value; + set => + CWPPropertyHelper.ChangeProperty (this, + ref _value, + value, + args => false, + ValueChanging, + _ => { }, + args => ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (args.OldValue, args.NewValue)), + ValueChanged, + out _); + } + + public event EventHandler>? ValueChanging; + public event EventHandler>? ValueChanged; + public event EventHandler>? ValueChangedUntyped; + } + + // Copilot + [Fact] + public void TrySetValueFromString_UnsupportedType_ReturnsFalse () + { + TestObjectValueView view = new (); + + bool result = ((IValue)view).TrySetValueFromString ("anything"); + + Assert.False (result); + Assert.Null (view.Value); + } + + // Copilot + [Fact] + public void TrySetValueFromString_RaisesValueChanged () + { + TestIntValueView view = new (); + var raised = 0; + view.ValueChanged += (_, _) => raised++; + + bool result = ((IValue)view).TrySetValueFromString ("99"); + + Assert.True (result); + Assert.Equal (1, raised); + Assert.Equal (99, view.Value); + } + + // Copilot - Verifies TrySetValueFromString integration with real Views. + [Fact] + public void TrySetValueFromString_NumericUpDown_Int_Parses () + { + NumericUpDown upDown = new (); + + bool result = ((IValue)upDown).TrySetValueFromString ("123"); + + Assert.True (result); + Assert.Equal (123, upDown.Value); + } + + // Copilot + [Fact] + public void TrySetValueFromString_ColorPicker_ParsesHexColor () + { + ColorPicker picker = new (); + + bool result = ((IValue)picker).TrySetValueFromString ("#FF0000"); + + Assert.True (result); + Assert.Equal (Color.Red, picker.SelectedColor); + } + + // Copilot + [Fact] + public void TrySetValueFromString_ColorPicker_ReturnsFalse_ForInvalid () + { + ColorPicker picker = new (); + + bool result = ((IValue)picker).TrySetValueFromString ("not-a-color"); + + Assert.False (result); + } + + // Copilot + [Fact] + public void TrySetValueFromString_DatePicker_ParsesIsoDate () + { + DatePicker picker = new (); + + bool result = ((IValue)picker).TrySetValueFromString ("2024-12-25"); + + Assert.True (result); + Assert.Equal (new DateTime (2024, 12, 25), picker.Value); + } + + // Copilot + [Fact] + public void TrySetValueFromString_MenuItem_SetsTitle () + { + MenuItem item = new () { Title = "Old" }; + + bool result = ((IValue)item).TrySetValueFromString ("New"); + + Assert.True (result); + Assert.Equal ("New", item.Title); + Assert.Equal ("New", item.GetValue ()); + } + + #endregion TrySetValueFromString } diff --git a/Tests/UnitTestsParallelizable/Views/ColorPickerTests.cs b/Tests/UnitTestsParallelizable/Views/ColorPickerTests.cs index b4964b9793..4378ae9648 100644 --- a/Tests/UnitTestsParallelizable/Views/ColorPickerTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ColorPickerTests.cs @@ -494,13 +494,16 @@ public void EnterHexFor_ColorName () Assert.Same (hex, cp.Focused); hex.Text = ""; - name.Text = ""; Assert.Empty (hex.Text); - Assert.Empty (name.Text); + + // Name field shows "Black" initially (from SelectedColor = Color.Black) + Assert.Equal ("Black", name.Text); cp.App!.Keyboard.RaiseKeyDownEvent ('#'); - Assert.Empty (name.Text); + + // Name stays at "Black" while typing hex (no update until leave/accept) + Assert.Equal ("Black", name.Text); //7FFFD4 @@ -510,7 +513,7 @@ public void EnterHexFor_ColorName () cp.App!.Keyboard.RaiseKeyDownEvent ('F'); cp.App!.Keyboard.RaiseKeyDownEvent ('F'); cp.App!.Keyboard.RaiseKeyDownEvent ('D'); - Assert.Empty (name.Text); + Assert.Equal ("Black", name.Text); cp.App!.Keyboard.RaiseKeyDownEvent ('4'); @@ -548,13 +551,16 @@ public void EnterHexFor_ColorName_AcceptVariation () Assert.Same (hex, cp.Focused); hex.Text = ""; - name.Text = ""; Assert.Empty (hex.Text); - Assert.Empty (name.Text); + + // Name field shows "Black" initially (from SelectedColor = Color.Black) + Assert.Equal ("Black", name.Text); cp.App!.Keyboard.RaiseKeyDownEvent ('#'); - Assert.Empty (name.Text); + + // Name stays at "Black" while typing hex (no update until leave/accept) + Assert.Equal ("Black", name.Text); //7FFFD4 @@ -564,7 +570,7 @@ public void EnterHexFor_ColorName_AcceptVariation () cp.App!.Keyboard.RaiseKeyDownEvent ('F'); cp.App!.Keyboard.RaiseKeyDownEvent ('F'); cp.App!.Keyboard.RaiseKeyDownEvent ('D'); - Assert.Empty (name.Text); + Assert.Equal ("Black", name.Text); cp.App!.Keyboard.RaiseKeyDownEvent ('4'); @@ -868,8 +874,9 @@ public void SyncBetweenTextFieldAndBars () } [Fact] - public void TabCompleteColorName () + public void DropDownListColorName_SelectsColor () { + // Copilot ColorPicker cp = GetColorPicker (ColorModel.RGB, true, true); cp.Draw (); // Draw is needed to update TrianglePosition @@ -880,38 +887,16 @@ public void TabCompleteColorName () TextField name = GetTextField (cp, ColorPickerPart.ColorName); TextField hex = GetTextField (cp, ColorPickerPart.Hex); - name.SetFocus (); - - Assert.True (name.HasFocus); - Assert.Same (name, cp.Focused); - - name.Text = ""; - Assert.Empty (name.Text); - - cp.App!.Keyboard.RaiseKeyDownEvent (Key.A); - cp.App!.Keyboard.RaiseKeyDownEvent (Key.Q); - - Assert.Equal ("aq", name.Text); + // Verify the name field is a DropDownList + Assert.IsType (name); - // Auto complete the color name - cp.App!.Keyboard.RaiseKeyDownEvent (Key.Tab); - - // Match cyan alternative name - Assert.Equal ("Aqua", name.Text); - - Assert.True (name.HasFocus); - - cp.App!.Keyboard.RaiseKeyDownEvent (Key.Tab); - - // Resolves to cyan color - Assert.Equal ("Aqua", name.Text); - - // Tab out of the text field - cp.App!.Keyboard.RaiseKeyDownEvent (Key.Tab); + // Initial color is Black + Assert.Equal ("Black", name.Text); - Assert.False (name.HasFocus); - Assert.NotSame (name, cp.Focused); + // Directly set the text on the DropDownList to simulate selecting "Aqua" + name.Text = "Aqua"; + // The color should now be Aqua (#00FFFF) Assert.Equal ("#00FFFF", hex.Text); cp.App?.Dispose (); diff --git a/Tests/UnitTestsParallelizable/Views/DefaultFileOperationsSecurityTests.cs b/Tests/UnitTestsParallelizable/Views/DefaultFileOperationsSecurityTests.cs new file mode 100644 index 0000000000..ab2ea4441f --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/DefaultFileOperationsSecurityTests.cs @@ -0,0 +1,85 @@ +// Copilot + +namespace UnitTests.Views; + +/// +/// Tests for path-traversal and invalid-name validation in . +/// +public class DefaultFileOperationsSecurityTests +{ + [Theory] + [InlineData ("/home/user/docs", "/home/user/docs/file.txt", true)] + [InlineData ("/home/user/docs", "/home/user/docs/sub/file.txt", true)] + [InlineData ("/home/user/docs", "/home/user/file.txt", false)] + [InlineData ("/home/user/docs", "/home/user/docs/../file.txt", false)] + [InlineData ("/home/user/docs", "/home/file.txt", false)] + public void IsContainedIn_DetectsPathTraversal_Unix (string root, string candidate, bool expected) + { + if (!OperatingSystem.IsLinux () && !OperatingSystem.IsMacOS ()) + { + return; + } + + bool result = DefaultFileOperations.IsContainedIn (root, candidate); + Assert.Equal (expected, result); + } + + [Theory] + [InlineData ("C:\\Users\\docs", "C:\\Users\\docs\\file.txt", true)] + [InlineData ("C:\\Users\\docs", "C:\\Users\\docs\\sub\\file.txt", true)] + [InlineData ("C:\\Users\\docs", "C:\\Users\\file.txt", false)] + [InlineData ("C:\\Users\\docs", "C:\\Users\\docs\\..\\file.txt", false)] + public void IsContainedIn_DetectsPathTraversal_Windows (string root, string candidate, bool expected) + { + if (!OperatingSystem.IsWindows ()) + { + return; + } + + bool result = DefaultFileOperations.IsContainedIn (root, candidate); + Assert.Equal (expected, result); + } + + [Theory] + [InlineData ("validname", false)] + [InlineData ("my-file.txt", false)] + [InlineData ("../escape", true)] + [InlineData ("sub/dir", true)] + [InlineData ("", true)] + [InlineData (" ", true)] + [InlineData ("file\0name", true)] + public void ContainsInvalidNameCharacters_DetectsInvalidNames (string name, bool expected) + { + bool result = DefaultFileOperations.ContainsInvalidNameCharacters (name); + Assert.Equal (expected, result); + } + + [Fact] + public void IsContainedIn_RootIsContainedInItself_WhenSubPath () + { + // A path that is exactly the root + separator + name should be contained + if (OperatingSystem.IsWindows ()) + { + Assert.True (DefaultFileOperations.IsContainedIn ("C:\\root", "C:\\root\\child")); + } + else + { + Assert.True (DefaultFileOperations.IsContainedIn ("/root", "/root/child")); + } + } + + [Fact] + public void IsContainedIn_RootItself_IsNotContained () + { + // The root directory path itself (without trailing separator) is NOT considered "contained" + // because it's not a child path + if (OperatingSystem.IsWindows ()) + { + Assert.False (DefaultFileOperations.IsContainedIn ("C:\\root", "C:\\root")); + } + else + { + Assert.False (DefaultFileOperations.IsContainedIn ("/root", "/root")); + } + } +} diff --git a/Tests/UnitTestsParallelizable/Views/DropDownListTests.cs b/Tests/UnitTestsParallelizable/Views/DropDownListTests.cs index 45012aea99..413a7b08cb 100644 --- a/Tests/UnitTestsParallelizable/Views/DropDownListTests.cs +++ b/Tests/UnitTestsParallelizable/Views/DropDownListTests.cs @@ -900,6 +900,285 @@ public void Scrolling_TallDropdown_TopItemsDraw () app.End (token!); } + // CoPilot - ChatGPT v4 + [Fact] + public void VisiblePopover_Repositions_WhenTerminalIsResized () + { + // Arrange + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (40, 15); + + using Runnable top = new (); + SessionToken? token = app.Begin (top); + + ObservableCollection items = ["Alpha", "Beta", "Gamma"]; + + DropDownList dropdown = new () + { + X = Pos.Center (), + Y = Pos.Center (), + Width = 12, + Source = new ListWrapper (items), + ReadOnly = true + }; + + top.Add (dropdown); + app.LayoutAndDraw (); + + dropdown.SetFocus (); + app.InjectKey (Key.F4); + app.LayoutAndDraw (); + + Popover? popover = FindDropDownPopover (app) as Popover; + Assert.NotNull (popover); + Assert.True (popover.Visible); + + Rectangle initialListFrame = popover.ContentView!.FrameToScreen (); + Rectangle initialDropDownFrame = dropdown.FrameToScreen (); + + // Act + app.Driver.SetScreenSize (60, 25); + app.LayoutAndDraw (); + + // Assert + Rectangle resizedListFrame = popover.ContentView.FrameToScreen (); + Rectangle resizedDropDownFrame = dropdown.FrameToScreen (); + + Assert.NotEqual (initialDropDownFrame.Location, resizedDropDownFrame.Location); + Assert.Equal (resizedDropDownFrame.X, resizedListFrame.X); + Assert.Equal (resizedDropDownFrame.Bottom, resizedListFrame.Y); + Assert.NotEqual (initialListFrame.Location, resizedListFrame.Location); + + app.End (token!); + } + + // CoPilot - ChatGPT v4 + [Fact] + public void VisiblePopover_LayoutsHeight_WhenTerminalIsResized () + { + // Arrange + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (30, 10); + + using Runnable top = new (); + SessionToken? token = app.Begin (top); + + ObservableCollection items = + [ + "Item_00", + "Item_01", + "Item_02", + "Item_03", + "Item_04", + "Item_05", + "Item_06", + "Item_07", + "Item_08", + "Item_09" + ]; + + DropDownList dropdown = new () + { + X = 0, + Y = 0, + Width = 12, + Source = new ListWrapper (items), + ReadOnly = true + }; + + top.Add (dropdown); + app.LayoutAndDraw (); + + dropdown.SetFocus (); + app.InjectKey (Key.F4); + app.LayoutAndDraw (); + + Popover? popover = FindDropDownPopover (app) as Popover; + Assert.NotNull (popover); + Assert.True (popover.Visible); + + int initialHeight = popover.ContentView!.Frame.Height; + Assert.Equal (9, initialHeight); + + // Act + app.Driver.SetScreenSize (30, 5); + app.LayoutAndDraw (); + + // Assert + int resizedHeight = popover.ContentView.Frame.Height; + Assert.Equal (4, resizedHeight); + Assert.NotEqual (initialHeight, resizedHeight); + + app.End (token!); + } + + [Fact] + public void Space_OpensDropdown_WhenReadOnly () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () { Source = new ListWrapper (new ObservableCollection (["Item1", "Item2"])), ReadOnly = true }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.Space); + + IPopoverView? popover = FindDropDownPopover (app); + Assert.NotNull (popover); + Assert.True (popover.Visible); + + dropdown.Dispose (); + } + + [Fact] + public void Down_SelectsNextItem_WhenClosed () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Apple" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorDown); + + Assert.Equal ("Banana", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void Up_SelectsPreviousItem_WhenClosed () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Banana" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorUp); + + Assert.Equal ("Apple", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void Down_DoesNothing_WhenAtEnd () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Cherry" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorDown); + + Assert.Equal ("Cherry", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void Up_DoesNothing_WhenAtStart () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Apple" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorUp); + + Assert.Equal ("Apple", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void CollectionNavigation_SelectsMatchingItem_WhenClosed () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Apple" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + // Type 'c' to navigate to "Cherry" + dropdown.NewKeyDownEvent (Key.C); + + Assert.Equal ("Cherry", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void Down_SelectsFirstItem_WhenNoSelection () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorDown); + + Assert.Equal ("Apple", dropdown.Text); + + dropdown.Dispose (); + } + // Helper to find the DropDownList popover (excludes the context menu popover) private static IPopoverView? FindDropDownPopover (IApplication app) => app.Popovers?.Popovers.OfType> ().FirstOrDefault (); } diff --git a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs new file mode 100644 index 0000000000..69befbdf15 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs @@ -0,0 +1,190 @@ +// Copilot + +using System.Reflection; +using System.IO.Abstractions.TestingHelpers; + +namespace UnitTests.Views; + +/// +/// Tests for refactored to inherit from +/// where TResult is IReadOnlyList<string>?. +/// +public class FileDialogResultTests +{ + [Fact] + public void FileDialog_Result_IsNull_WhenNotAccepted () + { + // Arrange + MockFileSystem fs = new (); + fs.AddDirectory ("/testdir"); + using FileDialog fd = new TestableFileDialog (fs); + + // Assert - Result should be null before any acceptance + Assert.Null (fd.Result); + Assert.True (fd.Canceled); + } + + [Fact] + public void FileDialog_Result_IsNull_BeforeAcceptance () + { + // Arrange + MockFileSystem fs = new (); + fs.AddFile ("/testdir/file1.txt", new MockFileData ("hello")); + using SaveDialog sd = new TestableSaveDialog (fs); + sd.Path = "/testdir/file1.txt"; + + // Assert - Result should be null before any acceptance + Assert.True (sd.Canceled); + Assert.Null (sd.Result); + } + + [Fact] + public void FileDialog_Canceled_IsTrue_WhenResultIsNull () + { + // Arrange + MockFileSystem fs = new (); + fs.AddDirectory ("/testdir"); + using FileDialog fd = new TestableFileDialog (fs); + + // Assert + Assert.True (fd.Canceled); + } + + [Fact] + public void FileDialog_Canceled_IsFalse_WhenAccepted_ThroughCommandPipeline () + { + // Arrange + MockFileSystem fs = new (); + fs.AddFile ("/testdir/file1.txt", new MockFileData ("hello")); + using SaveDialog sd = new TestableSaveDialog (fs); + sd.Path = "/testdir/file1.txt"; + + // Act + bool? accepted = sd.InvokeCommand (Command.Accept); + + // Assert + Assert.True (accepted is true); + Assert.False (sd.Canceled); + Assert.NotNull (sd.Result); + Assert.Single (sd.Result); + Assert.Equal ("/testdir/file1.txt", sd.Result [0]); + } + + [Fact] + public void FileDialog_InheritsFromDialogOfReadOnlyListString () + { + // Arrange + MockFileSystem fs = new (); + fs.AddDirectory ("/testdir"); + using FileDialog fd = new TestableFileDialog (fs); + + // Assert - verify the new base type + Assert.IsAssignableFrom?>> (fd); + } + + [Fact] + public void OpenDialog_FilePaths_IsEmpty_WhenCanceled () + { + // Arrange + using OpenDialog od = new TestableOpenDialog (); + + // Assert - Result is null → Canceled → FilePaths empty + Assert.True (od.Canceled); + Assert.Empty (od.FilePaths); + } + + [Fact] + public void SaveDialog_FileName_IsNull_WhenCanceled () + { + // Arrange + MockFileSystem fs = new (); + fs.AddDirectory ("/testdir"); + using SaveDialog sd = new TestableSaveDialog (fs); + + // Assert + Assert.True (sd.Canceled); + Assert.Null (sd.FileName); + } + + [Fact] + public void FileDialog_Result_MultiSelection_IsPopulated () + { + // Arrange + MockFileSystem fs = new (); + fs.AddFile ("/testdir/file1.txt", new MockFileData ("a")); + fs.AddFile ("/testdir/file2.txt", new MockFileData ("b")); + using FileDialog fd = new TestableFileDialog (fs); + + // Act - directly set Result as would happen after multi-select acceptance + List paths = ["/testdir/file1.txt", "/testdir/file2.txt"]; + fd.Result = paths.AsReadOnly (); + + // Assert + Assert.False (fd.Canceled); + Assert.Equal (2, fd.Result.Count); + Assert.Contains ("/testdir/file1.txt", fd.Result); + Assert.Contains ("/testdir/file2.txt", fd.Result); + } + + [Fact] + public void FileDialog_Cancel_ClearsResult_WhenPreviouslyAccepted () + { + // Copilot + // This test validates that canceling a dialog after a previous acceptance + // correctly clears Result to null (so Canceled == true). + // Without the explicit `Result = null` in the Cancel path, this test would fail + // because Result would retain the previously accepted value. + + // Arrange + MockFileSystem fs = new (); + fs.AddFile ("/testdir/file1.txt", new MockFileData ("hello")); + using SaveDialog sd = new TestableSaveDialog (fs); + sd.Path = "/testdir/file1.txt"; + + // Act - first accept the dialog to set Result + sd.InvokeCommand (Command.Accept); + Assert.False (sd.Canceled); // Result is set — not canceled + + // Act - now simulate pressing Cancel button + Button cancelBtn = sd.Buttons [sd.CancelButtonIndex]; + cancelBtn.InvokeCommand (Command.Accept); + + // Assert - Result should be cleared, Canceled should be true + Assert.Null (sd.Result); + Assert.True (sd.Canceled); + } + + [Fact] + public void OpenDialog_UsesInnerTableSeparatorsWithoutOuterBorders () + { + using OpenDialog od = new TestableOpenDialog (); + + FieldInfo? tableViewField = typeof (FileDialog).GetField ("_tableView", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull (tableViewField); + + TableView tableView = Assert.IsType (tableViewField!.GetValue (od)); + + Assert.True (tableView.Style.ShowVerticalCellLines); + Assert.True (tableView.Style.ShowVerticalHeaderLines); + Assert.False (tableView.Style.ShowVerticalCellLineForFirstColumn); + Assert.False (tableView.Style.ShowVerticalCellLineForLastColumn); + } + + /// Testable subclass that exposes the internal file-system constructor. + private sealed class TestableFileDialog : FileDialog + { + public TestableFileDialog (MockFileSystem fs) : base (fs) { } + } + + /// Testable subclass for OpenDialog. + private sealed class TestableOpenDialog : OpenDialog + { + public TestableOpenDialog () { } + } + + /// Testable subclass for SaveDialog. + private sealed class TestableSaveDialog : SaveDialog + { + public TestableSaveDialog (MockFileSystem fs) : base (fs) { } + } +} diff --git a/Tests/UnitTestsParallelizable/Views/FlagSelectorTests.cs b/Tests/UnitTestsParallelizable/Views/FlagSelectorTests.cs index 4b05d78796..25c2627d4a 100644 --- a/Tests/UnitTestsParallelizable/Views/FlagSelectorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/FlagSelectorTests.cs @@ -151,6 +151,36 @@ public void FlagSelector_Command_HotKey_WhenFocused_Does_Not_Change_Value () Assert.Equal (valueBefore, flagSelector.Value); } + // Copilot - Sonnet 4 + // Per issue: Enter should just accept, not toggle, in FlagSelector + [Fact] + public void FlagSelector_Enter_Does_Not_Toggle_Value () + { + using FlagSelector flagSelector = new (); + + CheckBox firstCheckBox = flagSelector.SubViews.OfType ().ElementAt (0); + flagSelector.SetFocus (); + Assert.True (flagSelector.HasFocus); + + SelectorStyles? valueBefore = flagSelector.Value; + + int selectorValueChanged = 0; + flagSelector.ValueChanged += (_, _) => selectorValueChanged++; + + int acceptingFired = 0; + flagSelector.Accepting += (_, _) => acceptingFired++; + + // Press Enter on the focused checkbox + firstCheckBox.NewKeyDownEvent (Key.Enter); + + // Value should NOT change (Enter does not toggle in FlagSelector) + Assert.Equal (0, selectorValueChanged); + Assert.Equal (valueBefore, flagSelector.Value); + + // But Accepting should fire + Assert.Equal (1, acceptingFired); + } + // Tests for FlagSelector [Fact] public void GenericInitialization_ShouldSetDefaults () diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorTests.cs new file mode 100644 index 0000000000..919d993a42 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorTests.cs @@ -0,0 +1,175 @@ +using UnitTests; + +namespace ViewsTests; + +public class LinearMultiSelectorTests : TestDriverBase +{ + [Fact] + public void Constructor_Default () + { + LinearMultiSelector ms = new (); + + Assert.NotNull (ms); + Assert.NotNull (ms.Value); + Assert.Empty (ms.Value!); + } + + // Copilot + [Fact] + public void Value_Setter_Selects_Matching_Options () + { + LinearMultiSelector ms = new (["A", "B", "C"]); + + ms.Value = ["A", "C"]; + + Assert.Equal (2, ms.Value!.Count); + Assert.Equal (2, ms.SelectedIndices.Count); + Assert.Contains (0, ms.SelectedIndices); + Assert.Contains (2, ms.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Null_Treated_As_Empty () + { + LinearMultiSelector ms = new (["A", "B"]) { Value = ["A"] }; + + ms.Value = null; + + Assert.NotNull (ms.Value); + Assert.Empty (ms.Value!); + Assert.Empty (ms.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_SequenceEqual_Does_Not_Raise_Events () + { + LinearMultiSelector ms = new (["A", "B"]) { Value = ["A", "B"] }; + var changedCount = 0; + ms.ValueChanged += (_, _) => changedCount++; + + // Different list instance, same elements/order. + ms.Value = ["A", "B"]; + + Assert.Equal (0, changedCount); + } + + // Copilot + [Fact] + public void Value_Setter_Defensive_Copy_Of_Input () + { + LinearMultiSelector ms = new (["A", "B", "C"]); + List mutable = ["A"]; + + ms.Value = mutable; + + // Mutate caller's list after assignment. + mutable.Add ("B"); + + Assert.Single (ms.Value!); + Assert.Equal ("A", ms.Value! [0]); + } + + // Copilot + [Fact] + public void Value_Getter_Never_Returns_Null () + { + LinearMultiSelector ms = new (["A"]); + + Assert.NotNull (ms.Value); + } + + // Copilot + [Fact] + public void Value_Setter_ValueChanging_Cancellation_Reverts () + { + LinearMultiSelector ms = new (["A", "B"]); + ms.ValueChanging += (_, args) => args.Handled = true; + + ms.Value = ["A"]; + + Assert.Empty (ms.Value!); + Assert.Empty (ms.SelectedIndices); + } + + // Copilot + [Fact] + public void Internal_Multi_Selection_Builds_Sorted_Value () + { + LinearMultiSelector ms = new (["A", "B", "C"]) { AllowEmpty = true }; + + // Activate index 2 then index 0. + ms.FocusedOption = 2; + ms.InvokeCommand (Command.Activate); + + ms.FocusedOption = 0; + ms.InvokeCommand (Command.Activate); + + // Value is built in option-order, not selection-order. + Assert.Equal (2, ms.Value!.Count); + Assert.Equal ("A", ms.Value! [0]); + Assert.Equal ("C", ms.Value! [1]); + } + + // Copilot + [Fact] + public void IValue_GetValue_Returns_Boxed_List () + { + LinearMultiSelector ms = new (["A", "B"]) { Value = ["A"] }; + IValue ivalue = ms; + + object? boxed = ivalue.GetValue (); + + IReadOnlyList? list = boxed as IReadOnlyList; + Assert.NotNull (list); + Assert.Single (list!); + Assert.Equal ("A", list! [0]); + } + + [Fact] + public void EnableForDesign_String_Populates_Days_With_Weekdays_Selected () + { + // Copilot + LinearMultiSelector ms = new (); + + bool ok = ms.EnableForDesign (); + + Assert.True (ok); + Assert.Equal (7, ms.Options.Count); + Assert.Equal (["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], ms.Options.Select (o => o.Legend)); + Assert.NotNull (ms.Value); + Assert.Equal (["Mon", "Tue", "Wed", "Thu", "Fri"], ms.Value!); + } + + [Fact] + public void EnableForDesign_NonString_Returns_False_And_Leaves_Options_Empty () + { + // Copilot + LinearMultiSelector ms = new (); + + bool ok = ms.EnableForDesign (); + + Assert.False (ok); + Assert.Empty (ms.Options); + } + + // Copilot + [Fact] + public void NonGeneric_LinearMultiSelector_Activator_CreateInstance_And_EnableForDesign_Populates () + { + Type type = typeof (LinearMultiSelector); + Assert.False (type.ContainsGenericParameters); + + View view = (View)Activator.CreateInstance (type)!; + Assert.IsType (view); + + var demoText = "demo"; + bool ok = ((IDesignable)view).EnableForDesign (ref demoText); + + Assert.True (ok); + LinearMultiSelector ms = (LinearMultiSelector)view; + Assert.Equal (7, ms.Options.Count); + Assert.Equal (["Mon", "Tue", "Wed", "Thu", "Fri"], ms.Value!); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorVisualTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorVisualTests.cs new file mode 100644 index 0000000000..d24724059a --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearMultiSelectorVisualTests.cs @@ -0,0 +1,90 @@ +using UnitTests; + +namespace ViewsTests; + +/// +/// Visual + input-driven tests for . +/// +public class LinearMultiSelectorVisualTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + // Copilot + [Fact] + public void Renders_Multiple_Set_Options () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearMultiSelector ms = new (["A", "B", "C"]) { Value = ["A", "C"], AllowEmpty = true }; + (runnable as View)?.Add (ms); + app.Begin (runnable); + + app.LayoutAndDraw (); + + // Indexes 0 and 2 set; index 1 not set. + DriverAssert.AssertDriverContentsWithFrameAre ( + """ + █─●─█ + A B C + """, + _output, + app.Driver); + } + + // Copilot + [Fact] + public void Keyboard_Space_Toggles_Selection () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearMultiSelector ms = new (["A", "B", "C"]) { AllowEmpty = true }; + (runnable as View)?.Add (ms); + app.Begin (runnable); + ms.SetFocus (); + + // Toggle index 0 on, then move and toggle index 2 on. + app.InjectKey (new Key (KeyCode.Space)); + Assert.Equal (["A"], ms.Value); + + app.InjectKey (new Key (KeyCode.CursorRight)); + app.InjectKey (new Key (KeyCode.CursorRight)); + app.InjectKey (new Key (KeyCode.Space)); + Assert.Equal (["A", "C"], ms.Value); + + // Move back and toggle off. + app.InjectKey (new Key (KeyCode.CursorLeft)); + app.InjectKey (new Key (KeyCode.CursorLeft)); + app.InjectKey (new Key (KeyCode.Space)); + Assert.Equal (["C"], ms.Value); + } + + // Copilot + [Fact] + public void Mouse_Click_Toggles_Selection () + { + VirtualTimeProvider time = new (); + using IApplication app = Application.Create (time); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (12, 3); + + IRunnable runnable = new Runnable (); + LinearMultiSelector ms = new (["A", "B", "C"]) { AllowEmpty = true }; + (runnable as View)?.Add (ms); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (ms.TryGetPositionByOption (1, out (int x, int y) p1)); + Point screen = ms.ViewportToScreen (new Point (p1.x, p1.y)); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = screen }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = screen }); + + Assert.Equal (["B"], ms.Value); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeCWPTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeCWPTests.cs new file mode 100644 index 0000000000..1f9576e7a4 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeCWPTests.cs @@ -0,0 +1,205 @@ +using System.Text; +using UnitTests; + +namespace ViewsTests; + +/// +/// Tests for the Cancellable Workflow Pattern (CWP) properties on . +/// +public class LinearRangeCWPTests : TestDriverBase +{ + [Fact] + public void LegendsOrientation_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = Orientation.Horizontal; + var newValue = Orientation.Vertical; + + sel.LegendsOrientationChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.LegendsOrientationChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.LegendsOrientation = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.LegendsOrientation); + } + + [Fact] + public void MinimumInnerSpacing_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = 1; + var newValue = 5; + + sel.MinimumInnerSpacingChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.MinimumInnerSpacingChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.MinimumInnerSpacing = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.MinimumInnerSpacing); + } + + [Fact] + public void ShowEndSpacing_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = false; + var newValue = true; + + sel.ShowEndSpacingChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.ShowEndSpacingChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.ShowEndSpacing = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.ShowEndSpacing); + } + + [Fact] + public void ShowLegends_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = true; + var newValue = false; + + sel.ShowLegendsChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.ShowLegendsChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.ShowLegends = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.ShowLegends); + } + + [Fact] + public void UseMinimumSize_PropertyChange_RaisesChangingAndChangedEvents () + { + LinearSelector sel = new (); + var changingRaised = false; + var changedRaised = false; + var oldValue = false; + var newValue = true; + + sel.UseMinimumSizeChanging += (sender, args) => + { + changingRaised = true; + Assert.Equal (oldValue, args.CurrentValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.UseMinimumSizeChanged += (sender, args) => + { + changedRaised = true; + Assert.Equal (oldValue, args.OldValue); + Assert.Equal (newValue, args.NewValue); + }; + + sel.UseMinimumSize = newValue; + + Assert.True (changingRaised); + Assert.True (changedRaised); + Assert.Equal (newValue, sel.UseMinimumSize); + } + + // Copilot + [Fact] + public void Command_Activate_Calls_SetFocusedOption () + { + LinearSelector sel = new (); + + sel.Options = + [ + new LinearRangeOption ("A", new Rune ('a'), 1), + new LinearRangeOption ("B", new Rune ('b'), 2), + new LinearRangeOption ("C", new Rune ('c'), 3) + ]; + + sel.FocusedOption = 1; + + bool? result = sel.InvokeCommand (Command.Activate); + + Assert.False (result); + Assert.Contains (1, sel.SelectedIndices); + + sel.Dispose (); + } + + // Copilot + [Fact] + public void Command_Accept_Calls_SetFocusedOption () + { + LinearSelector sel = new (); + + sel.Options = + [ + new LinearRangeOption ("A", new Rune ('a'), 1), + new LinearRangeOption ("B", new Rune ('b'), 2), + new LinearRangeOption ("C", new Rune ('c'), 3) + ]; + + sel.FocusedOption = 2; + + sel.InvokeCommand (Command.Accept); + + Assert.Contains (2, sel.SelectedIndices); + + sel.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRangeDefaultKeyBindingsTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeDefaultKeyBindingsTests.cs similarity index 64% rename from Tests/UnitTestsParallelizable/Views/LinearRangeDefaultKeyBindingsTests.cs rename to Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeDefaultKeyBindingsTests.cs index 47e330473d..fe195f98ed 100644 --- a/Tests/UnitTestsParallelizable/Views/LinearRangeDefaultKeyBindingsTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeDefaultKeyBindingsTests.cs @@ -5,17 +5,17 @@ namespace ViewsTests; /// -/// Tests for static property. +/// Tests for static property. /// public class LinearRangeDefaultKeyBindingsTests { [Fact] - public void LinearRange_DefaultKeyBindings_IsNotNull () => Assert.NotNull (LinearRange.DefaultKeyBindings); + public void LinearRange_DefaultKeyBindings_IsNotNull () => Assert.NotNull (LinearSelector.DefaultKeyBindings); [Fact] public void LinearRange_DefaultKeyBindings_AllKeyStringsParseable () { - foreach ((Command command, PlatformKeyBinding platformBinding) in LinearRange.DefaultKeyBindings!) + foreach ((Command command, PlatformKeyBinding platformBinding) in LinearSelector.DefaultKeyBindings!) { Key [] [] allKeyArrays = [platformBinding.All ?? [], platformBinding.Windows ?? [], platformBinding.Linux ?? [], platformBinding.Macos ?? []]; @@ -32,7 +32,7 @@ public void LinearRange_DefaultKeyBindings_AllKeyStringsParseable () [Fact] public void LinearRange_DefaultKeyBindings_AllCommandNamesParseable () { - foreach (Command command in LinearRange.DefaultKeyBindings!.Keys) + foreach (Command command in LinearSelector.DefaultKeyBindings!.Keys) { Assert.True (Enum.IsDefined (command), $"Command name '{command}' should parse to a Command enum value."); } @@ -41,8 +41,11 @@ public void LinearRange_DefaultKeyBindings_AllCommandNamesParseable () [Fact] public void LinearRange_DefaultKeyBindings_DoesNotHaveConfigurationPropertyAttribute () { + // DefaultKeyBindings is declared on the abstract base LinearRangeViewBase. PropertyInfo? property = - typeof (LinearRange).GetProperty (nameof (LinearRange.DefaultKeyBindings), BindingFlags.Public | BindingFlags.Static); + typeof (LinearRangeViewBase).GetProperty ( + nameof (LinearRangeViewBase.DefaultKeyBindings), + BindingFlags.Public | BindingFlags.Static); Assert.NotNull (property); @@ -55,5 +58,5 @@ public void LinearRange_DefaultKeyBindings_DoesNotHaveConfigurationPropertyAttri [InlineData (Command.Accept)] [InlineData (Command.Activate)] public void LinearRange_DefaultKeyBindings_ContainsUniqueCommands (Command command) => - Assert.True (LinearRange.DefaultKeyBindings!.ContainsKey (command)); + Assert.True (LinearSelector.DefaultKeyBindings!.ContainsKey (command)); } diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeOptionTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeOptionTests.cs new file mode 100644 index 0000000000..0845343c08 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeOptionTests.cs @@ -0,0 +1,119 @@ +using System.Text; +using UnitTests; + +namespace ViewsTests; + +public class LinearRangeOptionTests : TestDriverBase +{ + [Fact] + public void LinearRange_Option_Default_Constructor () + { + LinearRangeOption o = new (); + Assert.Null (o.Legend); + Assert.Equal (default (Rune), o.LegendAbbr); + Assert.Equal (0, o.Data); + } + + [Fact] + public void LinearRange_Option_Values_Constructor () + { + LinearRangeOption o = new ("1 thousand", new Rune ('y'), 1000); + Assert.Equal ("1 thousand", o.Legend); + Assert.Equal (new Rune ('y'), o.LegendAbbr); + Assert.Equal (1000, o.Data); + } + + [Fact] + public void LinearRangeOption_ToString_WhenEmpty () + { + LinearRangeOption sliderOption = new (); + Assert.Equal ("{Legend=, LegendAbbr=\0, Data=}", sliderOption.ToString ()); + } + + [Fact] + public void LinearRangeOption_ToString_WhenPopulated_WithInt () + { + LinearRangeOption sliderOption = new () { Legend = "Lord flibble", LegendAbbr = new Rune ('l'), Data = 1 }; + + Assert.Equal ("{Legend=Lord flibble, LegendAbbr=l, Data=1}", sliderOption.ToString ()); + } + + [Fact] + public void LinearRangeOption_ToString_WhenPopulated_WithSizeF () + { + LinearRangeOption sliderOption = new () { Legend = "Lord flibble", LegendAbbr = new Rune ('l'), Data = new SizeF (32, 11) }; + + Assert.Equal ("{Legend=Lord flibble, LegendAbbr=l, Data={Width=32, Height=11}}", sliderOption.ToString ()); + } + + [Fact] + public void OnChanged_Should_Raise_ChangedEvent () + { + LinearRangeOption sliderOption = new (); + var eventRaised = false; + sliderOption.Changed += (sender, args) => eventRaised = true; + + sliderOption.OnChanged (true); + + Assert.True (eventRaised); + } + + [Fact] + public void OnSet_Should_Raise_SetEvent () + { + LinearRangeOption sliderOption = new (); + var eventRaised = false; + sliderOption.Set += (sender, args) => eventRaised = true; + + sliderOption.OnSet (); + + Assert.True (eventRaised); + } + + [Fact] + public void OnUnSet_Should_Raise_UnSetEvent () + { + LinearRangeOption sliderOption = new (); + var eventRaised = false; + sliderOption.UnSet += (sender, args) => eventRaised = true; + + sliderOption.OnUnSet (); + + Assert.True (eventRaised); + } +} + +public class LinearRangeEventArgsTests : TestDriverBase +{ + [Fact] + public void Constructor_Sets_Cancel_Default_To_False () + { + Dictionary> options = new (); + var focused = 42; + + LinearRangeEventArgs sliderEventArgs = new (options, focused); + + Assert.False (sliderEventArgs.Cancel); + } + + [Fact] + public void Constructor_Sets_Focused () + { + Dictionary> options = new (); + var focused = 42; + + LinearRangeEventArgs sliderEventArgs = new (options, focused); + + Assert.Equal (focused, sliderEventArgs.Focused); + } + + [Fact] + public void Constructor_Sets_Options () + { + Dictionary> options = new (); + + LinearRangeEventArgs sliderEventArgs = new (options); + + Assert.Equal (options, sliderEventArgs.Options); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeRangeTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeRangeTests.cs new file mode 100644 index 0000000000..79f767880e --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeRangeTests.cs @@ -0,0 +1,271 @@ +using UnitTests; + +namespace ViewsTests; + +public class LinearRangeRangeTests : TestDriverBase +{ + [Fact] + public void Constructor_Default () + { + LinearRange r = new (); + + Assert.NotNull (r); + Assert.Equal (LinearRangeSpanKind.Closed, r.RangeKind); + Assert.Equal (LinearRangeSpanKind.None, r.Value.Kind); + } + + // Copilot + [Fact] + public void Value_Setter_Closed_Selects_Both_Bounds () + { + LinearRange r = new ([10, 20, 30, 40]); + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 20, 40, 1, 3); + + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (20, r.Value.Start); + Assert.Equal (40, r.Value.End); + Assert.Contains (1, r.SelectedIndices); + Assert.Contains (3, r.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Resolves_Indices_From_Data_When_NotProvided () + { + LinearRange r = new ([10, 20, 30, 40]); + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 20, 40, -1, -1); + + // The setter should resolve indices via IndexOfData. + Assert.Contains (1, r.SelectedIndices); + Assert.Contains (3, r.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_LeftBounded_Selects_End_Only () + { + LinearRange r = new ([10, 20, 30]) { RangeKind = LinearRangeSpanKind.LeftBounded }; + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, default, 20, -1, 1); + + Assert.Single (r.SelectedIndices); + Assert.Equal (1, r.SelectedIndices [0]); + Assert.Equal (20, r.Value.End); + } + + // Copilot + [Fact] + public void Value_Setter_RightBounded_Selects_Start_Only () + { + LinearRange r = new ([10, 20, 30]) { RangeKind = LinearRangeSpanKind.RightBounded }; + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.RightBounded, 20, default, 1, -1); + + Assert.Single (r.SelectedIndices); + Assert.Equal (1, r.SelectedIndices [0]); + Assert.Equal (20, r.Value.Start); + } + + // Copilot + [Fact] + public void Value_Setter_None_Clears () + { + LinearRange r = new ([10, 20, 30]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + + r.Value = LinearRangeSpan.Empty; + + Assert.Equal (LinearRangeSpanKind.None, r.Value.Kind); + Assert.Empty (r.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Same_Value_Does_Not_Raise_Events () + { + LinearRange r = new ([10, 20, 30]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + var changedCount = 0; + r.ValueChanged += (_, _) => changedCount++; + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + + Assert.Equal (0, changedCount); + } + + // Copilot + [Fact] + public void Value_Setter_ValueChanging_Cancellation_Reverts () + { + LinearRange r = new ([10, 20, 30]); + r.ValueChanging += (_, args) => args.Handled = true; + + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + + Assert.Equal (LinearRangeSpanKind.None, r.Value.Kind); + Assert.Empty (r.SelectedIndices); + } + + // Copilot + [Fact] + public void RangeKind_Closed_To_LeftBounded_Drops_Start () + { + LinearRange r = new ([10, 20, 30, 40]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 20, 40, 1, 3); + + r.RangeKind = LinearRangeSpanKind.LeftBounded; + + Assert.Equal (LinearRangeSpanKind.LeftBounded, r.Value.Kind); + Assert.Equal (40, r.Value.End); + Assert.Equal (3, r.Value.EndIndex); + Assert.Equal (default, r.Value.Start); + Assert.Equal (-1, r.Value.StartIndex); + } + + // Copilot + [Fact] + public void RangeKind_Closed_To_RightBounded_Drops_End () + { + LinearRange r = new ([10, 20, 30, 40]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 20, 40, 1, 3); + + r.RangeKind = LinearRangeSpanKind.RightBounded; + + Assert.Equal (LinearRangeSpanKind.RightBounded, r.Value.Kind); + Assert.Equal (20, r.Value.Start); + Assert.Equal (1, r.Value.StartIndex); + Assert.Equal (default, r.Value.End); + Assert.Equal (-1, r.Value.EndIndex); + } + + // Copilot + [Fact] + public void RangeKind_LeftBounded_To_RightBounded_Promotes_End_To_Start () + { + LinearRange r = new ([10, 20, 30]) { RangeKind = LinearRangeSpanKind.LeftBounded }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, default, 20, -1, 1); + + r.RangeKind = LinearRangeSpanKind.RightBounded; + + Assert.Equal (LinearRangeSpanKind.RightBounded, r.Value.Kind); + Assert.Equal (20, r.Value.Start); + Assert.Equal (1, r.Value.StartIndex); + } + + // Copilot + [Fact] + public void RangeKind_LeftBounded_To_Closed_Collapses_End_To_Both () + { + LinearRange r = new ([10, 20, 30]) { RangeKind = LinearRangeSpanKind.LeftBounded }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, default, 30, -1, 2); + + r.RangeKind = LinearRangeSpanKind.Closed; + + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (30, r.Value.Start); + Assert.Equal (30, r.Value.End); + } + + // Copilot + [Fact] + public void RangeKind_To_None_Clears () + { + LinearRange r = new ([10, 20]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 20, 0, 1); + + r.RangeKind = LinearRangeSpanKind.None; + + Assert.Equal (LinearRangeSpanKind.None, r.Value.Kind); + Assert.Empty (r.SelectedIndices); + } + + // Copilot + [Fact] + public void LinearRangeSpan_Equality_Is_ByValue () + { + LinearRangeSpan a = new (LinearRangeSpanKind.Closed, 10, 20, 0, 1); + LinearRangeSpan b = new (LinearRangeSpanKind.Closed, 10, 20, 0, 1); + + Assert.Equal (a, b); + Assert.True (a == b); + Assert.Equal (a.GetHashCode (), b.GetHashCode ()); + } + + // Copilot + [Fact] + public void LinearRangeSpan_Empty_Has_None_Kind () + { + LinearRangeSpan empty = LinearRangeSpan.Empty; + + Assert.Equal (LinearRangeSpanKind.None, empty.Kind); + Assert.Equal (-1, empty.StartIndex); + Assert.Equal (-1, empty.EndIndex); + } + + // Copilot + [Fact] + public void IValue_GetValue_Returns_Boxed_Span () + { + LinearRange r = new ([10, 20, 30]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 10, 30, 0, 2); + IValue ivalue = r; + + object? boxed = ivalue.GetValue (); + + Assert.IsType> (boxed); + Assert.Equal (r.Value, (LinearRangeSpan)boxed!); + } + + [Fact] + public void EnableForDesign_String_Populates_WorkHours_Closed_Range () + { + // Copilot + LinearRange r = new (); + + bool ok = r.EnableForDesign (); + + Assert.True (ok); + Assert.Equal (11, r.Options.Count); + Assert.Equal (LinearRangeSpanKind.Closed, r.RangeKind); + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal ("9 AM", r.Value.Start); + Assert.Equal ("5 PM", r.Value.End); + Assert.Equal (1, r.Value.StartIndex); + Assert.Equal (9, r.Value.EndIndex); + } + + [Fact] + public void EnableForDesign_NonString_Returns_False_And_Leaves_Options_Empty () + { + // Copilot + LinearRange r = new (); + + bool ok = r.EnableForDesign (); + + Assert.False (ok); + Assert.Empty (r.Options); + } + + // Copilot + [Fact] + public void NonGeneric_LinearRange_Activator_CreateInstance_And_EnableForDesign_Populates () + { + Type type = typeof (LinearRange); + Assert.False (type.ContainsGenericParameters); + + View view = (View)Activator.CreateInstance (type)!; + Assert.IsType (view); + + var demoText = "demo"; + bool ok = ((IDesignable)view).EnableForDesign (ref demoText); + + Assert.True (ok); + LinearRange r = (LinearRange)view; + Assert.Equal (11, r.Options.Count); + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal ("9 AM", r.Value.Start); + Assert.Equal ("5 PM", r.Value.End); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeViewBaseTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeViewBaseTests.cs new file mode 100644 index 0000000000..c6b872a23c --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeViewBaseTests.cs @@ -0,0 +1,279 @@ +using UnitTests; + +namespace ViewsTests; + +/// +/// Shared base behavior tests, exercised through +/// (the simplest concrete subclass). +/// +public class LinearRangeViewBaseTests : TestDriverBase +{ + [Fact] + public void MovePlus_Should_MoveFocusRight_When_OptionIsAvailable () + { + LinearSelector sel = new ([1, 2, 3, 4]); + + bool result = sel.MovePlus (); + + Assert.True (result); + Assert.Equal (1, sel.FocusedOption); + } + + [Fact] + public void MovePlus_Should_NotMoveFocusRight_When_AtEnd () + { + LinearSelector sel = new ([1, 2, 3, 4]); + + sel.FocusedOption = 3; + + bool result = sel.MovePlus (); + + Assert.False (result); + Assert.Equal (3, sel.FocusedOption); + } + + [Fact] + public void OnOptionFocused_Event_Cancelled () + { + LinearSelector sel = new ([1, 2, 3]); + var eventRaised = false; + sel.OptionFocused += (sender, args) => eventRaised = true; + var newFocusedOption = 1; + + LinearRangeEventArgs args = new (new Dictionary> (), newFocusedOption) { Cancel = false }; + Assert.Equal (0, sel.FocusedOption); + + sel.OnOptionFocused (newFocusedOption, args); + + Assert.True (eventRaised); + Assert.Equal (newFocusedOption, sel.FocusedOption); + + args = new LinearRangeEventArgs (new Dictionary> (), newFocusedOption) { Cancel = true }; + + sel.OnOptionFocused (2, args); + + Assert.True (eventRaised); + Assert.Equal (newFocusedOption, sel.FocusedOption); + } + + [Fact] + public void OnOptionFocused_Event_Raised () + { + LinearSelector sel = new ([1, 2, 3]); + var eventRaised = false; + sel.OptionFocused += (sender, args) => eventRaised = true; + var newFocusedOption = 1; + LinearRangeEventArgs args = new (new Dictionary> (), newFocusedOption); + + sel.OnOptionFocused (newFocusedOption, args); + + Assert.True (eventRaised); + } + + [Fact] + public void Set_Should_Not_Clear_When_EmptyNotAllowed () + { + LinearSelector sel = new ([1, 2, 3, 4]) { AllowEmpty = false }; + + Assert.NotEmpty (sel.SelectedIndices); + + // Re-activating the same focused option must not clear it when AllowEmpty=false. + sel.InvokeCommand (Command.Activate); + + Assert.NotEmpty (sel.SelectedIndices); + } + + [Fact] + public void Set_Should_SetFocusedOption () + { + LinearSelector sel = new ([1, 2, 3, 4]); + + sel.FocusedOption = 2; + bool result = sel.InvokeCommand (Command.Activate) ?? false; + + Assert.Equal (2, sel.FocusedOption); + Assert.Single (sel.SelectedIndices); + } + + [Fact] + public void TryGetOptionByPosition_InvalidPosition_Failure () + { + LinearSelector sel = new ([1, 2, 3]); + var x = 10; + var y = 10; + var threshold = 2; + int expectedOption = -1; + + bool result = sel.TryGetOptionByPosition (x, y, threshold, out int option); + + Assert.False (result); + Assert.Equal (expectedOption, option); + } + + [Theory] + [InlineData (0, 0, 0, 1)] + [InlineData (3, 0, 0, 2)] + [InlineData (9, 0, 0, 4)] + [InlineData (0, 0, 1, 1)] + [InlineData (3, 0, 1, 2)] + [InlineData (9, 0, 1, 4)] + public void TryGetOptionByPosition_ValidPositionHorizontal_Success (int x, int y, int threshold, int expectedData) + { + LinearSelector sel = new ([1, 2, 3, 4]); + + sel.MinimumInnerSpacing = 2; + + bool result = sel.TryGetOptionByPosition (x, y, threshold, out int option); + + Assert.True (result); + Assert.Equal (expectedData, sel.Options [option].Data); + } + + [Theory] + [InlineData (0, 0, 0, 1)] + [InlineData (0, 3, 0, 2)] + [InlineData (0, 9, 0, 4)] + [InlineData (0, 0, 1, 1)] + [InlineData (0, 3, 1, 2)] + [InlineData (0, 9, 1, 4)] + public void TryGetOptionByPosition_ValidPositionVertical_Success (int x, int y, int threshold, int expectedData) + { + LinearSelector sel = new ([1, 2, 3, 4]); + sel.Orientation = Orientation.Vertical; + sel.MinimumInnerSpacing = 2; + + bool result = sel.TryGetOptionByPosition (x, y, threshold, out int option); + + Assert.True (result); + Assert.Equal (expectedData, sel.Options [option].Data); + } + + [Fact] + public void TryGetPositionByOption_InvalidOption_Failure () + { + LinearSelector sel = new ([1, 2, 3]); + int option = -1; + (int, int) expectedPosition = (-1, -1); + + bool result = sel.TryGetPositionByOption (option, out (int x, int y) position); + + Assert.False (result); + Assert.Equal (expectedPosition, position); + } + + [Theory] + [InlineData (0, 0, 0)] + [InlineData (1, 3, 0)] + [InlineData (3, 9, 0)] + public void TryGetPositionByOption_ValidOptionHorizontal_Success (int option, int expectedX, int expectedY) + { + LinearSelector sel = new ([1, 2, 3, 4]); + sel.MinimumInnerSpacing = 2; + + bool result = sel.TryGetPositionByOption (option, out (int x, int y) position); + + Assert.True (result); + Assert.Equal (expectedX, position.x); + Assert.Equal (expectedY, position.y); + } + + [Theory] + [InlineData (0, 0, 0)] + [InlineData (1, 0, 3)] + [InlineData (3, 0, 9)] + public void TryGetPositionByOption_ValidOptionVertical_Success (int option, int expectedX, int expectedY) + { + LinearSelector sel = new ([1, 2, 3, 4]); + sel.Orientation = Orientation.Vertical; + sel.MinimumInnerSpacing = 2; + + bool result = sel.TryGetPositionByOption (option, out (int x, int y) position); + + Assert.True (result); + Assert.Equal (expectedX, position.x); + Assert.Equal (expectedY, position.y); + } + + [Fact] + private void DimAuto_Both_Respects_SuperView_ContentSize () + { + View view = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + + List options = ["01234", "01234"]; + + LinearMultiSelector ms = new (options) { Orientation = Orientation.Vertical }; + view.Add (ms); + view.BeginInit (); + view.EndInit (); + + Size expectedSize = ms.Frame.Size; + + Assert.Equal (new Size (6, 3), expectedSize); + + view.SetContentSize (new Size (1, 1)); + + view.LayoutSubViews (); + ms.SetRelativeLayout (view.Viewport.Size); + + Assert.Equal (expectedSize, ms.Frame.Size); + } + + [Fact] + private void DimAuto_Height_Respects_SuperView_ContentSize () + { + View view = new () { Width = 10, Height = Dim.Fill () }; + + List options = ["01234", "01234"]; + + LinearMultiSelector ms = new (options) { Orientation = Orientation.Vertical, Width = 10 }; + view.Add (ms); + view.BeginInit (); + view.EndInit (); + + Size expectedSize = ms.Frame.Size; + + Assert.Equal (new Size (10, 3), expectedSize); + + view.SetContentSize (new Size (1, 1)); + + view.LayoutSubViews (); + ms.SetRelativeLayout (view.Viewport.Size); + + Assert.Equal (expectedSize, ms.Frame.Size); + } + + [Fact] + private void DimAuto_Width_Respects_SuperView_ContentSize () + { + View view = new () { Width = Dim.Fill (), Height = 10 }; + + List options = ["01234", "01234"]; + + LinearMultiSelector ms = new (options) { Orientation = Orientation.Vertical, Height = 10 }; + view.Add (ms); + view.BeginInit (); + view.EndInit (); + + Size expectedSize = ms.Frame.Size; + + Assert.Equal (new Size (6, 10), expectedSize); + + view.SetContentSize (new Size (1, 1)); + + view.LayoutSubViews (); + ms.SetRelativeLayout (view.Viewport.Size); + + Assert.Equal (expectedSize, ms.Frame.Size); + } + + // https://github.com/gui-cs/Terminal.Gui/issues/3099 + [Fact] + private void One_Option_Does_Not_Throw () + { + LinearSelector sel = new (); + sel.BeginInit (); + sel.EndInit (); + + sel.Options = [new LinearRangeOption ()]; + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeVisualTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeVisualTests.cs new file mode 100644 index 0000000000..00c9cdd03a --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearRangeVisualTests.cs @@ -0,0 +1,241 @@ +using UnitTests; + +namespace ViewsTests; + +/// +/// Visual + input-driven tests for (range-only). +/// +public class LinearRangeVisualTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + // Copilot + [Fact] + public void Renders_Closed_Range () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (12, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4]); + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 2, 3, 1, 2); + (runnable as View)?.Add (r); + app.Begin (runnable); + + app.LayoutAndDraw (); + + // Indexes 1..2 selected: '●' (option), '─' (space), '█' (set), '░' (range). + DriverAssert.AssertDriverContentsWithFrameAre ( + """ + ●─█░█─● + 1 2 3 4 + """, + _output, + app.Driver); + } + + // Copilot + [Fact] + public void Mouse_Drag_Adjusts_End_Of_Range () + { + // This is the regression test for the user-reported bug: + // "Mouse dragging of the end of a range is not adjusting the values." + VirtualTimeProvider time = new (); + using IApplication app = Application.Create (time); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (15, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true }; + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (r.TryGetPositionByOption (1, out (int x, int y) p1)); + Assert.True (r.TryGetPositionByOption (4, out (int x, int y) p4)); + Point sStart = r.ViewportToScreen (new Point (p1.x, p1.y)); + Point sEnd = r.ViewportToScreen (new Point (p4.x, p4.y)); + + // Press at index 1 (the start of the range). + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = sStart }); + + // Drag to index 4 — the end of the range. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = sEnd }); + + // Release. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = sEnd }); + + // Range should span [2..5] = data values at indexes 1..4. + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (2, r.Value.Start); + Assert.Equal (5, r.Value.End); + } + + // Copilot + [Fact] + public void Keyboard_Extends_Range () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (15, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true }; + (runnable as View)?.Add (r); + app.Begin (runnable); + r.SetFocus (); + + // Activate at index 0 (start of range). + app.InjectKey (new Key (KeyCode.Space)); + Assert.Equal (1, r.Value.Start); + + // Ctrl+Right extends the range. + app.InjectKey (new Key (KeyCode.CursorRight | KeyCode.CtrlMask)); + app.InjectKey (new Key (KeyCode.CursorRight | KeyCode.CtrlMask)); + + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (1, r.Value.Start); + Assert.Equal (3, r.Value.End); + } + + // Copilot + [Fact] + public void Mouse_Press_On_Left_End_Of_Closed_Range_Preserves_Right_End () + { + // Bug: pressing on the left end of an existing Closed range causes the right end + // to reset/collapse depending on the previous _lastFocusedOption. + // + // Setup: Closed range covering all five options [0..4] (data 1..5). + // Click at the right end first to set _lastFocusedOption to the right. + // Then press at the left end. The range should still terminate at option 4. + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true, MinimumInnerSpacing = 3 }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 1, 5, 0, 4); + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + // First, force focus on the right end so _lastFocusedOption=4 when we press the left. + r.OnOptionFocused (4, new LinearRangeEventArgs (new (), 4)); + + Assert.True (r.TryGetPositionByOption (0, out (int x, int y) pLeft)); + Point sLeft = r.ViewportToScreen (new Point (pLeft.x, pLeft.y)); + + // Press at left end (option 0). + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = sLeft }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = sLeft }); + + // The range should still cover [option 0 .. option 4] = data 1..5. The right end must + // not have collapsed. + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.Equal (1, r.Value.Start); + Assert.Equal (5, r.Value.End); + } + + // Copilot + [Fact] + public void Mouse_Press_Inside_Closed_Range_Does_Not_Collapse_Range () + { + // Bug: pressing inside an existing Closed range causes one end to reset (collapse to a point). + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true, MinimumInnerSpacing = 3 }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.Closed, 2, 5, 1, 4); + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + // Force the right end (option 4) to be the most recently focused. + r.OnOptionFocused (4, new LinearRangeEventArgs (new (), 4)); + + // Press at option 2 (inside the [1..4] range). + Assert.True (r.TryGetPositionByOption (2, out (int x, int y) pMid)); + Point sMid = r.ViewportToScreen (new Point (pMid.x, pMid.y)); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = sMid }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = sMid }); + + // Range should remain closed and bracket option 2 — both ends should still exist. + Assert.Equal (LinearRangeSpanKind.Closed, r.Value.Kind); + Assert.NotEqual (r.Value.StartIndex, r.Value.EndIndex); + } + + // Copilot + [Fact] + public void Mouse_Drag_Adjusts_End_Of_LeftBounded_Range () + { + // Bug: for LeftBounded (RangeKind=LeftBounded), dragging the single end should follow the mouse. + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true, MinimumInnerSpacing = 3, RangeKind = LinearRangeSpanKind.LeftBounded }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.LeftBounded, default, 3, -1, 2); + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (r.TryGetPositionByOption (0, out (int x, int y) p0)); + Assert.True (r.TryGetPositionByOption (4, out (int x, int y) p4)); + Point s0 = r.ViewportToScreen (new Point (p0.x, p0.y)); + Point s4 = r.ViewportToScreen (new Point (p4.x, p4.y)); + + // Press at option 0 and drag to option 4. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s0 }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s4 }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = s4 }); + + // The single bounded end should now be option 4 (data 5). + Assert.Equal (LinearRangeSpanKind.LeftBounded, r.Value.Kind); + Assert.Equal (5, r.Value.End); + Assert.Equal (4, r.Value.EndIndex); + } + + // Copilot + [Fact] + public void Mouse_Drag_Adjusts_Start_Of_RightBounded_Range () + { + // Bug: for RightBounded (RangeKind=RightBounded), dragging the single start should follow the mouse, + // including through positions that snap via threshold. + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 3); + + IRunnable runnable = new Runnable (); + LinearRange r = new ([1, 2, 3, 4, 5]) { AllowEmpty = true, MinimumInnerSpacing = 3, RangeKind = LinearRangeSpanKind.RightBounded }; + r.Value = new LinearRangeSpan (LinearRangeSpanKind.RightBounded, 2, default, 1, -1); + (runnable as View)?.Add (r); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (r.TryGetPositionByOption (1, out (int x, int y) p1)); + Assert.True (r.TryGetPositionByOption (3, out (int x, int y) p3)); + Point s1 = r.ViewportToScreen (new Point (p1.x, p1.y)); + Point s3 = r.ViewportToScreen (new Point (p3.x, p3.y)); + + // Press at option 1, drag through intermediate positions to option 3. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s1 }); + + // Step through every viewport-x between s1.X and s3.X to simulate a continuous mouse drag. + for (int x = s1.X + 1; x <= s3.X; x++) + { + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = new Point (x, s1.Y) }); + } + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = s3 }); + + // Start should now be at option 3 (data 4); right end stays unbounded. + Assert.Equal (LinearRangeSpanKind.RightBounded, r.Value.Kind); + Assert.Equal (4, r.Value.Start); + Assert.Equal (3, r.Value.StartIndex); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorTests.cs new file mode 100644 index 0000000000..08b6a2dc85 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorTests.cs @@ -0,0 +1,365 @@ +using UnitTests; + +namespace ViewsTests; + +public class LinearSelectorTests : TestDriverBase +{ + [Fact] + public void Constructor_Default () + { + LinearSelector sel = new (); + + Assert.NotNull (sel); + Assert.NotNull (sel.Options); + Assert.Empty (sel.Options); + Assert.Equal (Orientation.Horizontal, sel.Orientation); + Assert.False (sel.AllowEmpty); + Assert.True (sel.ShowLegends); + Assert.False (sel.ShowEndSpacing); + Assert.Equal (1, sel.MinimumInnerSpacing); + Assert.True (sel.Width is DimAuto); + Assert.True (sel.Height is DimAuto); + Assert.Equal (0, sel.FocusedOption); + Assert.Equal (default, sel.Value); + } + + [Fact] + public void Constructor_With_Options () + { + List options = [1, 2, 3]; + + LinearSelector sel = new (options); + sel.SetRelativeLayout (new Size (100, 100)); + + // 1 2 3 + Assert.Equal (1, sel.MinimumInnerSpacing); + Assert.Equal (new Size (5, 2), sel.GetContentSize ()); + Assert.Equal (new Size (5, 2), sel.Frame.Size); + Assert.Equal (options.Count, sel.Options.Count); + } + + // Copilot + [Fact] + public void Value_Setter_Selects_Matching_Option () + { + LinearSelector sel = new ([10, 20, 30]); + + sel.Value = 20; + + Assert.Equal (20, sel.Value); + Assert.Single (sel.SelectedIndices); + Assert.Equal (1, sel.SelectedIndices [0]); + } + + // Copilot + [Fact] + public void Value_Setter_Null_Clears_Selection () + { + LinearSelector sel = new ([10, 20, 30]); + sel.Value = 20; + + sel.Value = default; + + Assert.Equal (default, sel.Value); + Assert.Empty (sel.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Unmatched_Clears_Indices () + { + LinearSelector sel = new ([10, 20, 30]); + + sel.Value = 99; + + Assert.Equal (99, sel.Value); + Assert.Empty (sel.SelectedIndices); + } + + // Copilot + [Fact] + public void Value_Setter_Same_Value_Does_Not_Raise_Events () + { + LinearSelector sel = new ([10, 20, 30]) { Value = 20 }; + var changedCount = 0; + sel.ValueChanged += (_, _) => changedCount++; + + sel.Value = 20; + + Assert.Equal (0, changedCount); + } + + // Copilot + [Fact] + public void Value_Setter_Raises_ValueChanging_And_ValueChanged () + { + LinearSelector sel = new ([10, 20, 30]); + var changingRaised = false; + var changedRaised = false; + + sel.ValueChanging += (_, args) => + { + changingRaised = true; + Assert.Equal (default, args.CurrentValue); + Assert.Equal (20, args.NewValue); + }; + + sel.ValueChanged += (_, args) => + { + changedRaised = true; + Assert.Equal (default, args.OldValue); + Assert.Equal (20, args.NewValue); + }; + + sel.Value = 20; + + Assert.True (changingRaised); + Assert.True (changedRaised); + } + + // Copilot + [Fact] + public void Value_Setter_ValueChanging_Cancellation_Reverts () + { + LinearSelector sel = new ([10, 20, 30]); + + sel.ValueChanging += (_, args) => args.Handled = true; + + sel.Value = 20; + + Assert.Equal (default, sel.Value); + Assert.Empty (sel.SelectedIndices); + } + + // Copilot + [Fact] + public void Internal_Selection_Syncs_Value () + { + LinearSelector sel = new ([10, 20, 30]); + sel.FocusedOption = 1; + + sel.InvokeCommand (Command.Activate); + + Assert.Equal (20, sel.Value); + Assert.Single (sel.SelectedIndices); + } + + // Copilot + [Fact] + public void Internal_Selection_Raises_ValueChanged_And_ValueChangedUntyped () + { + LinearSelector sel = new ([10, 20, 30]); + var typedRaised = false; + var untypedRaised = false; + + sel.ValueChanged += (_, args) => + { + typedRaised = true; + Assert.Equal (default, args.OldValue); + Assert.Equal (20, args.NewValue); + }; + + IValue ivalue = sel; + ivalue.ValueChangedUntyped += (_, args) => + { + untypedRaised = true; + Assert.Equal (20, args.NewValue); + }; + + sel.FocusedOption = 1; + sel.InvokeCommand (Command.Activate); + + Assert.True (typedRaised); + Assert.True (untypedRaised); + } + + // Copilot + [Fact] + public void IValue_GetValue_Returns_Boxed_Value () + { + LinearSelector sel = new ([10, 20, 30]); + sel.Value = 30; + + IValue ivalue = sel; + + Assert.Equal (30, ivalue.GetValue ()); + } + + // Copilot + [Fact] + public void Options_Replacement_Drops_Stale_Indices () + { + LinearSelector sel = new ([10, 20, 30]) { Value = 30 }; + + Assert.Single (sel.SelectedIndices); + + sel.Options = [new LinearRangeOption { Data = 1 }]; + + // Index 2 from previous selection no longer exists. + Assert.Empty (sel.SelectedIndices); + } + + [Fact] + public void EnableForDesign_String_Populates_TShirt_Sizes_With_M_Selected () + { + // Copilot + LinearSelector sel = new (); + + bool ok = sel.EnableForDesign (); + + Assert.True (ok); + Assert.Equal (6, sel.Options.Count); + Assert.Equal (["XS", "S", "M", "L", "XL", "XXL"], sel.Options.Select (o => o.Legend)); + Assert.Equal ("M", sel.Value); + } + + [Fact] + public void EnableForDesign_NonString_Returns_False_And_Leaves_Options_Empty () + { + // Copilot + LinearSelector sel = new (); + + bool ok = sel.EnableForDesign (); + + Assert.False (ok); + Assert.Empty (sel.Options); + } + + // Copilot + [Fact] + public void NonGeneric_LinearSelector_Activator_CreateInstance_And_EnableForDesign_Populates () + { + // AllViewsTester reflects over public, non-abstract, non-generic View subclasses and + // instantiates them via Activator.CreateInstance, then calls IDesignable.EnableForDesign. + // The non-generic LinearSelector exists so this discovery path works. + Type type = typeof (LinearSelector); + Assert.False (type.ContainsGenericParameters); + + View view = (View)Activator.CreateInstance (type)!; + Assert.IsType (view); + + var demoText = "demo"; + bool ok = ((IDesignable)view).EnableForDesign (ref demoText); + + Assert.True (ok); + LinearSelector sel = (LinearSelector)view; + Assert.Equal (6, sel.Options.Count); + Assert.Equal ("M", sel.Value); + } + + // Copilot - Sonnet 4.5 - SelectedIndex coverage for "no selection" representation across + // value-type and reference-type T (addresses bot review comment 3196703096). + + [Fact] + public void SelectedIndex_Is_Null_When_No_Selection () + { + LinearSelector sel = new ([10, 20, 30]); + + // Constructor does not auto-select; SelectedIndex is null until something is selected. + Assert.Null (sel.SelectedIndex); + } + + [Fact] + public void SelectedIndex_Is_Null_When_No_Selection_For_ValueType_T () + { + LinearSelector sel = new ([0, 1, 2]) { AllowEmpty = true, Value = 1 }; + Assert.Equal (1, sel.SelectedIndex); + + sel.SelectedIndex = null; + + // Value collapses to default(int)=0, which is also a legitimate option: + // SelectedIndex is the only unambiguous "no selection" surface. + Assert.Null (sel.SelectedIndex); + Assert.Equal (0, sel.Value); + } + + [Fact] + public void SelectedIndex_Is_Null_When_No_Selection_For_ReferenceType_T () + { + LinearSelector sel = new (["a", "b", "c"]) { AllowEmpty = true }; + sel.Value = null; + + Assert.Null (sel.SelectedIndex); + Assert.Null (sel.Value); + } + + [Fact] + public void SelectedIndex_Setter_Selects_Option_By_Index () + { + LinearSelector sel = new ([10, 20, 30]); + sel.SelectedIndex = 2; + + Assert.Equal (2, sel.SelectedIndex); + Assert.Equal (30, sel.Value); + } + + [Fact] + public void SelectedIndex_Setter_Null_Clears_Selection_When_AllowEmpty () + { + LinearSelector sel = new ([10, 20, 30]) { AllowEmpty = true, Value = 20 }; + Assert.Equal (1, sel.SelectedIndex); + + sel.SelectedIndex = null; + + Assert.Null (sel.SelectedIndex); + } + + [Fact] + public void SelectedIndex_Setter_Null_Ignored_When_AllowEmpty_False () + { + LinearSelector sel = new ([10, 20, 30]) { Value = 20 }; + Assert.False (sel.AllowEmpty); + + sel.SelectedIndex = null; + + // Selection unchanged because AllowEmpty=false. + Assert.Equal (1, sel.SelectedIndex); + Assert.Equal (20, sel.Value); + } + + [Fact] + public void SelectedIndex_Setter_OutOfRange_Throws () + { + LinearSelector sel = new ([10, 20, 30]); + + Assert.Throws (() => sel.SelectedIndex = -1); + Assert.Throws (() => sel.SelectedIndex = 3); + } + + [Fact] + public void SelectedIndex_Disambiguates_Default_Value_From_Empty_Selection () + { + // Tests the core motivation for SelectedIndex: for value types, Value=default(T) + // can mean either "option with default value selected" or "no selection". + // Note: assigning Value=0 to a fresh selector is a no-op because the equality guard + // sees the field already at default(int)=0. SelectedIndex is the only way to + // unambiguously select option 0 (or distinguish it from "nothing selected"). + LinearSelector sel = new ([0, 1, 2]) { AllowEmpty = true }; + + // Initial state: nothing selected. + Assert.Null (sel.SelectedIndex); + Assert.Equal (0, sel.Value); // default(int) + + // Explicitly select option 0 via SelectedIndex: + sel.SelectedIndex = 0; + Assert.Equal (0, sel.SelectedIndex); // not null — option 0 is selected + Assert.Equal (0, sel.Value); + + // Now clear: + sel.SelectedIndex = null; + Assert.Equal (0, sel.Value); // still default(int) + Assert.Null (sel.SelectedIndex); // unambiguously empty + } + + [Fact] + public void SelectedIndex_Tracks_Value_Setter_Roundtrip () + { + LinearSelector sel = new ([10, 20, 30]); + + sel.Value = 30; + Assert.Equal (2, sel.SelectedIndex); + + sel.Value = 10; + Assert.Equal (0, sel.SelectedIndex); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorVisualTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorVisualTests.cs new file mode 100644 index 0000000000..b567873330 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinearRange/LinearSelectorVisualTests.cs @@ -0,0 +1,204 @@ +using UnitTests; + +namespace ViewsTests; + +/// +/// Visual + input-driven tests for . Uses +/// together with +/// InjectKey / InjectMouse through the application pipeline. +/// +public class LinearSelectorVisualTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + // Copilot + [Fact] + public void Renders_Initial_Selection_Horizontal () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { Value = 2 }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + + app.LayoutAndDraw (); + + // Glyphs: '●' (option), '─' (space), '█' (set). Index 1 is selected. + DriverAssert.AssertDriverContentsWithFrameAre ( + """ + ●─█─● + 1 2 3 + """, + _output, + app.Driver); + } + + // Copilot + [Fact] + public void Renders_Legends_Without_Highlighting_Set_Option () + { + // Verifies the fix for "Label texts are showing underlined for focused value" — + // legend rows must use a uniform attribute regardless of which option is set. + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { Value = 2 }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + + app.LayoutAndDraw (); + + // All three legend cells should share the same Attribute (no HotNormal / underline for the set option). + Cell [,] contents = app.Driver!.Contents!; + Attribute a0 = contents [1, 0].Attribute!.Value; + Attribute a1 = contents [1, 2].Attribute!.Value; + Attribute a2 = contents [1, 4].Attribute!.Value; + + Assert.Equal (a0, a1); + Assert.Equal (a1, a2); + } + + // Copilot + [Fact] + public void Renders_Vertical () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 6); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { Orientation = Orientation.Vertical, Value = 2 }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsWithFrameAre ( + "●1\n│ \n█2\n│ \n●3", + _output, + app.Driver); + } + + // Copilot + [Fact] + public void Keyboard_Right_Moves_Focus_Without_Changing_Value_When_AllowEmpty () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { Value = 1, AllowEmpty = true }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + sel.SetFocus (); + + Assert.Equal (0, sel.FocusedOption); + Assert.Equal (1, sel.Value); + + app.InjectKey (new Key (KeyCode.CursorRight)); + + Assert.Equal (1, sel.FocusedOption); + + // With AllowEmpty=true, focus moves but value does not change until activation. + Assert.Equal (1, sel.Value); + } + + // Copilot + [Fact] + public void Keyboard_Space_Activates_FocusedOption () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (10, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([1, 2, 3]) { AllowEmpty = true }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + sel.SetFocus (); + + var changedCount = 0; + sel.ValueChanged += (_, _) => changedCount++; + + app.InjectKey (new Key (KeyCode.CursorRight)); + app.InjectKey (new Key (KeyCode.Space)); + + Assert.Equal (2, sel.Value); + Assert.Equal (1, changedCount); + } + + // Copilot + [Fact] + public void Mouse_Click_Selects_Option_Under_Cursor () + { + VirtualTimeProvider time = new (); + using IApplication app = Application.Create (time); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (12, 3); + + IRunnable runnable = new Runnable (); + LinearSelector sel = new ([10, 20, 30]) { AllowEmpty = true }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + app.LayoutAndDraw (); + + // Resolve the screen position of option index 2 from the view itself. + Assert.True (sel.TryGetPositionByOption (2, out (int x, int y) pos)); + Point screenPos = sel.ViewportToScreen (new Point (pos.x, pos.y)); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = screenPos }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = screenPos }); + + Assert.Equal (30, sel.Value); + } + + // Copilot + [Fact] + public void Mouse_Drag_Updates_Selection_When_AllowEmpty () + { + VirtualTimeProvider time = new (); + using IApplication app = Application.Create (time); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (15, 3); + + IRunnable runnable = new Runnable (); + + // AllowEmpty=true was the case where drag-update did NOT work before the fix. + LinearSelector sel = new ([10, 20, 30, 40, 50]) { AllowEmpty = true }; + (runnable as View)?.Add (sel); + app.Begin (runnable); + app.LayoutAndDraw (); + + // Resolve real screen positions from the view. + Assert.True (sel.TryGetPositionByOption (0, out (int x, int y) p0)); + Assert.True (sel.TryGetPositionByOption (2, out (int x, int y) p2)); + Assert.True (sel.TryGetPositionByOption (4, out (int x, int y) p4)); + Point s0 = sel.ViewportToScreen (new Point (p0.x, p0.y)); + Point s2 = sel.ViewportToScreen (new Point (p2.x, p2.y)); + Point s4 = sel.ViewportToScreen (new Point (p4.x, p4.y)); + + // Press at index 0. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s0 }); + + // Drag to index 2 — the bug was that focus did NOT advance here when AllowEmpty=true. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s2 }); + Assert.Equal (2, sel.FocusedOption); + Assert.Equal (30, sel.Value); + + // Drag further to index 4. + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, ScreenPosition = s4 }); + Assert.Equal (4, sel.FocusedOption); + Assert.Equal (50, sel.Value); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = s4 }); + + // After release the value must remain at the dragged target. + Assert.Equal (50, sel.Value); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/LinearRangeTests.cs b/Tests/UnitTestsParallelizable/Views/LinearRangeTests.cs deleted file mode 100644 index c610bb256e..0000000000 --- a/Tests/UnitTestsParallelizable/Views/LinearRangeTests.cs +++ /dev/null @@ -1,845 +0,0 @@ -using System.Text; -using UnitTests; - -namespace ViewsTests; - -public class LinearRangeOptionTests : TestDriverBase -{ - [Fact] - public void LinearRange_Option_Default_Constructor () - { - LinearRangeOption o = new (); - Assert.Null (o.Legend); - Assert.Equal (default (Rune), o.LegendAbbr); - Assert.Equal (0, o.Data); - } - - [Fact] - public void LinearRange_Option_Values_Constructor () - { - LinearRangeOption o = new ("1 thousand", new Rune ('y'), 1000); - Assert.Equal ("1 thousand", o.Legend); - Assert.Equal (new Rune ('y'), o.LegendAbbr); - Assert.Equal (1000, o.Data); - } - - [Fact] - public void LinearRangeOption_ToString_WhenEmpty () - { - LinearRangeOption sliderOption = new (); - Assert.Equal ("{Legend=, LegendAbbr=\0, Data=}", sliderOption.ToString ()); - } - - [Fact] - public void LinearRangeOption_ToString_WhenPopulated_WithInt () - { - LinearRangeOption sliderOption = new () { Legend = "Lord flibble", LegendAbbr = new Rune ('l'), Data = 1 }; - - Assert.Equal ("{Legend=Lord flibble, LegendAbbr=l, Data=1}", sliderOption.ToString ()); - } - - [Fact] - public void LinearRangeOption_ToString_WhenPopulated_WithSizeF () - { - LinearRangeOption sliderOption = new () { Legend = "Lord flibble", LegendAbbr = new Rune ('l'), Data = new SizeF (32, 11) }; - - Assert.Equal ("{Legend=Lord flibble, LegendAbbr=l, Data={Width=32, Height=11}}", sliderOption.ToString ()); - } - - [Fact] - public void OnChanged_Should_Raise_ChangedEvent () - { - // Arrange - LinearRangeOption sliderOption = new (); - var eventRaised = false; - sliderOption.Changed += (sender, args) => eventRaised = true; - - // Act - sliderOption.OnChanged (true); - - // Assert - Assert.True (eventRaised); - } - - [Fact] - public void OnSet_Should_Raise_SetEvent () - { - // Arrange - LinearRangeOption sliderOption = new (); - var eventRaised = false; - sliderOption.Set += (sender, args) => eventRaised = true; - - // Act - sliderOption.OnSet (); - - // Assert - Assert.True (eventRaised); - } - - [Fact] - public void OnUnSet_Should_Raise_UnSetEvent () - { - // Arrange - LinearRangeOption sliderOption = new (); - var eventRaised = false; - sliderOption.UnSet += (sender, args) => eventRaised = true; - - // Act - sliderOption.OnUnSet (); - - // Assert - Assert.True (eventRaised); - } -} - -public class LinearRangeEventArgsTests : TestDriverBase -{ - [Fact] - public void Constructor_Sets_Cancel_Default_To_False () - { - // Arrange - Dictionary> options = new (); - var focused = 42; - - // Act - LinearRangeEventArgs sliderEventArgs = new (options, focused); - - // Assert - Assert.False (sliderEventArgs.Cancel); - } - - [Fact] - public void Constructor_Sets_Focused () - { - // Arrange - Dictionary> options = new (); - var focused = 42; - - // Act - LinearRangeEventArgs sliderEventArgs = new (options, focused); - - // Assert - Assert.Equal (focused, sliderEventArgs.Focused); - } - - [Fact] - public void Constructor_Sets_Options () - { - // Arrange - Dictionary> options = new (); - - // Act - LinearRangeEventArgs sliderEventArgs = new (options); - - // Assert - Assert.Equal (options, sliderEventArgs.Options); - } -} - -public class LinearRangeTests : TestDriverBase -{ - [Fact] - public void Constructor_Default () - { - // Arrange & Act - LinearRange slider = new (); - - // Assert - Assert.NotNull (slider); - Assert.NotNull (slider.Options); - Assert.Empty (slider.Options); - Assert.Equal (Orientation.Horizontal, slider.Orientation); - Assert.False (slider.AllowEmpty); - Assert.True (slider.ShowLegends); - Assert.False (slider.ShowEndSpacing); - Assert.Equal (LinearRangeType.Single, slider.Type); - Assert.Equal (1, slider.MinimumInnerSpacing); - Assert.True (slider.Width is DimAuto); - Assert.True (slider.Height is DimAuto); - Assert.Equal (0, slider.FocusedOption); - } - - [Fact] - public void Constructor_With_Options () - { - // Arrange - List options = new () { 1, 2, 3 }; - - // Act - LinearRange slider = new (options); - slider.SetRelativeLayout (new Size (100, 100)); - - // Assert - // 0123456789 - // 1 2 3 - Assert.Equal (1, slider.MinimumInnerSpacing); - Assert.Equal (new Size (5, 2), slider.GetContentSize ()); - Assert.Equal (new Size (5, 2), slider.Frame.Size); - Assert.NotNull (slider); - Assert.NotNull (slider.Options); - Assert.Equal (options.Count, slider.Options.Count); - } - - [Fact] - public void MovePlus_Should_MoveFocusRight_When_OptionIsAvailable () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - // Act - bool result = slider.MovePlus (); - - // Assert - Assert.True (result); - Assert.Equal (1, slider.FocusedOption); - } - - [Fact] - public void MovePlus_Should_NotMoveFocusRight_When_AtEnd () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - slider.FocusedOption = 3; - - // Act - bool result = slider.MovePlus (); - - // Assert - Assert.False (result); - Assert.Equal (3, slider.FocusedOption); - } - - [Fact] - public void OnOptionFocused_Event_Cancelled () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3 }); - var eventRaised = false; - var cancel = false; - slider.OptionFocused += (sender, args) => eventRaised = true; - var newFocusedOption = 1; - - // Create args with cancel set to false - cancel = false; - - LinearRangeEventArgs args = new (new Dictionary> (), newFocusedOption) { Cancel = cancel }; - Assert.Equal (0, slider.FocusedOption); - - // Act - slider.OnOptionFocused (newFocusedOption, args); - - // Assert - Assert.True (eventRaised); // Event should be raised - Assert.Equal (newFocusedOption, slider.FocusedOption); // Focused option should change - - // Create args with cancel set to true - cancel = true; - - args = new LinearRangeEventArgs (new Dictionary> (), newFocusedOption) { Cancel = cancel }; - - // Act - slider.OnOptionFocused (2, args); - - // Assert - Assert.True (eventRaised); // Event should be raised - Assert.Equal (newFocusedOption, slider.FocusedOption); // Focused option should not change - } - - [Fact] - public void OnOptionFocused_Event_Raised () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3 }); - var eventRaised = false; - slider.OptionFocused += (sender, args) => eventRaised = true; - var newFocusedOption = 1; - LinearRangeEventArgs args = new (new Dictionary> (), newFocusedOption); - - // Act - slider.OnOptionFocused (newFocusedOption, args); - - // Assert - Assert.True (eventRaised); - } - - [Fact] - public void OnOptionsChanged_Event_Raised () - { - // Arrange - LinearRange slider = new (); - var eventRaised = false; - slider.OptionsChanged += (sender, args) => eventRaised = true; - - // Act - slider.OnOptionsChanged (); - - // Assert - Assert.True (eventRaised); - } - - [Fact] - public void Set_Should_Not_UnSetFocusedOption_When_EmptyNotAllowed () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }) { AllowEmpty = false }; - - Assert.NotEmpty (slider.GetSetOptions ()); - - // Act - bool result = slider.UnSetOption (slider.FocusedOption); - - // Assert - Assert.False (result); - Assert.NotEmpty (slider.GetSetOptions ()); - } - - // Add similar tests for other methods like MoveMinus, MoveStart, MoveEnd, Set, etc. - - [Fact] - public void Set_Should_SetFocusedOption () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - // Act - slider.FocusedOption = 2; - bool result = slider.Select (); - - // Assert - Assert.True (result); - Assert.Equal (2, slider.FocusedOption); - Assert.Single (slider.GetSetOptions ()); - } - - [Fact] - public void TryGetOptionByPosition_InvalidPosition_Failure () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3 }); - var x = 10; - var y = 10; - var threshold = 2; - int expectedOption = -1; - - // Act - bool result = slider.TryGetOptionByPosition (x, y, threshold, out int option); - - // Assert - Assert.False (result); - Assert.Equal (expectedOption, option); - } - - [Theory] - [InlineData (0, 0, 0, 1)] - [InlineData (3, 0, 0, 2)] - [InlineData (9, 0, 0, 4)] - [InlineData (0, 0, 1, 1)] - [InlineData (3, 0, 1, 2)] - [InlineData (9, 0, 1, 4)] - public void TryGetOptionByPosition_ValidPositionHorizontal_Success (int x, int y, int threshold, int expectedData) - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - // 0123456789 - // 1234 - - slider.MinimumInnerSpacing = 2; - - // 0123456789 - // 1--2--3--4 - - // Arrange - - // Act - bool result = slider.TryGetOptionByPosition (x, y, threshold, out int option); - - // Assert - Assert.True (result); - Assert.Equal (expectedData, slider.Options [option].Data); - } - - [Theory] - [InlineData (0, 0, 0, 1)] - [InlineData (0, 3, 0, 2)] - [InlineData (0, 9, 0, 4)] - [InlineData (0, 0, 1, 1)] - [InlineData (0, 3, 1, 2)] - [InlineData (0, 9, 1, 4)] - public void TryGetOptionByPosition_ValidPositionVertical_Success (int x, int y, int threshold, int expectedData) - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - slider.Orientation = Orientation.Vertical; - - // Set auto size to true to enable testing - slider.MinimumInnerSpacing = 2; - - // 0 1 - // 1 | - // 2 | - // 3 2 - // 4 | - // 5 | - // 6 3 - // 7 | - // 8 | - // 9 4 - - // Act - bool result = slider.TryGetOptionByPosition (x, y, threshold, out int option); - - // Assert - Assert.True (result); - Assert.Equal (expectedData, slider.Options [option].Data); - } - - [Fact] - public void TryGetPositionByOption_InvalidOption_Failure () - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3 }); - int option = -1; - (int, int) expectedPosition = (-1, -1); - - // Act - bool result = slider.TryGetPositionByOption (option, out (int x, int y) position); - - // Assert - Assert.False (result); - Assert.Equal (expectedPosition, position); - } - - [Theory] - [InlineData (0, 0, 0)] - [InlineData (1, 3, 0)] - [InlineData (3, 9, 0)] - public void TryGetPositionByOption_ValidOptionHorizontal_Success (int option, int expectedX, int expectedY) - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - - // Set auto size to true to enable testing - slider.MinimumInnerSpacing = 2; - - // 0123456789 - // 1--2--3--4 - - // Act - bool result = slider.TryGetPositionByOption (option, out (int x, int y) position); - - // Assert - Assert.True (result); - Assert.Equal (expectedX, position.x); - Assert.Equal (expectedY, position.y); - } - - [Theory] - [InlineData (0, 0, 0)] - [InlineData (1, 0, 3)] - [InlineData (3, 0, 9)] - public void TryGetPositionByOption_ValidOptionVertical_Success (int option, int expectedX, int expectedY) - { - // Arrange - LinearRange slider = new (new List { 1, 2, 3, 4 }); - slider.Orientation = Orientation.Vertical; - - // Set auto size to true to enable testing - slider.MinimumInnerSpacing = 2; - - // Act - bool result = slider.TryGetPositionByOption (option, out (int x, int y) position); - - // Assert - Assert.True (result); - Assert.Equal (expectedX, position.x); - Assert.Equal (expectedY, position.y); - } - - [Fact] - private void DimAuto_Both_Respects_SuperView_ContentSize () - { - View view = new () { Width = Dim.Fill (), Height = Dim.Fill () }; - - List options = ["01234", "01234"]; - - LinearRange slider = new (options) { Orientation = Orientation.Vertical, Type = LinearRangeType.Multiple }; - view.Add (slider); - view.BeginInit (); - view.EndInit (); - - Size expectedSize = slider.Frame.Size; - - Assert.Equal (new Size (6, 3), expectedSize); - - view.SetContentSize (new Size (1, 1)); - - view.LayoutSubViews (); - slider.SetRelativeLayout (view.Viewport.Size); - - Assert.Equal (expectedSize, slider.Frame.Size); - } - - [Fact] - private void DimAuto_Height_Respects_SuperView_ContentSize () - { - View view = new () { Width = 10, Height = Dim.Fill () }; - - List options = new () { "01234", "01234" }; - - LinearRange slider = new (options) { Orientation = Orientation.Vertical, Type = LinearRangeType.Multiple, Width = 10 }; - view.Add (slider); - view.BeginInit (); - view.EndInit (); - - Size expectedSize = slider.Frame.Size; - - Assert.Equal (new Size (10, 3), expectedSize); - - view.SetContentSize (new Size (1, 1)); - - view.LayoutSubViews (); - slider.SetRelativeLayout (view.Viewport.Size); - - Assert.Equal (expectedSize, slider.Frame.Size); - } - - [Fact] - private void DimAuto_Width_Respects_SuperView_ContentSize () - { - View view = new () { Width = Dim.Fill (), Height = 10 }; - - List options = new () { "01234", "01234" }; - - LinearRange slider = new (options) { Orientation = Orientation.Vertical, Type = LinearRangeType.Multiple, Height = 10 }; - view.Add (slider); - view.BeginInit (); - view.EndInit (); - - Size expectedSize = slider.Frame.Size; - - Assert.Equal (new Size (6, 10), expectedSize); - - view.SetContentSize (new Size (1, 1)); - - view.LayoutSubViews (); - slider.SetRelativeLayout (view.Viewport.Size); - - Assert.Equal (expectedSize, slider.Frame.Size); - } - - // https://github.com/gui-cs/Terminal.Gui/issues/3099 - [Fact] - private void One_Option_Does_Not_Throw () - { - // Arrange - LinearRange slider = new (); - slider.BeginInit (); - slider.EndInit (); - - // Act/Assert - slider.Options = [new LinearRangeOption ()]; - } - - // Add more tests for different scenarios and edge cases. -} - -public class LinearRangeCWPTests : TestDriverBase -{ - [Fact] - public void LegendsOrientation_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = Orientation.Horizontal; - var newValue = Orientation.Vertical; - - linearRange.LegendsOrientationChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.LegendsOrientationChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.LegendsOrientation = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.LegendsOrientation); - } - - [Fact] - public void MinimumInnerSpacing_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = 1; - var newValue = 5; - - linearRange.MinimumInnerSpacingChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.MinimumInnerSpacingChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.MinimumInnerSpacing = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.MinimumInnerSpacing); - } - - [Fact] - public void ShowEndSpacing_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = false; - var newValue = true; - - linearRange.ShowEndSpacingChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.ShowEndSpacingChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.ShowEndSpacing = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.ShowEndSpacing); - } - - [Fact] - public void ShowLegends_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = true; - var newValue = false; - - linearRange.ShowLegendsChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.ShowLegendsChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.ShowLegends = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.ShowLegends); - } - - [Fact] - public void Type_PropertyChange_CanBeCancelled () - { - // Arrange - LinearRange linearRange = new (); - LinearRangeType oldValue = linearRange.Type; - - linearRange.TypeChanging += (sender, args) => { args.Handled = true; }; - - // Act - linearRange.Type = LinearRangeType.Range; - - // Assert - Assert.Equal (oldValue, linearRange.Type); - } - - [Fact] - public void Type_PropertyChange_ChangingEventCanModifyNewValue () - { - // Arrange - LinearRange linearRange = new (); - var modifiedValue = LinearRangeType.Multiple; - - linearRange.TypeChanging += (sender, args) => { args.NewValue = modifiedValue; }; - - // Act - linearRange.Type = LinearRangeType.Range; - - // Assert - Assert.Equal (modifiedValue, linearRange.Type); - } - - [Fact] - public void Type_PropertyChange_NoEventsWhenValueUnchanged () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - - linearRange.TypeChanging += (sender, args) => changingRaised = true; - linearRange.TypeChanged += (sender, args) => changedRaised = true; - - // Act - linearRange.Type = linearRange.Type; - - // Assert - Assert.False (changingRaised); - Assert.False (changedRaised); - } - - [Fact] - public void Type_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = LinearRangeType.Single; - var newValue = LinearRangeType.Range; - - linearRange.TypeChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.TypeChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.Type = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.Type); - } - - [Fact] - public void UseMinimumSize_PropertyChange_RaisesChangingAndChangedEvents () - { - // Arrange - LinearRange linearRange = new (); - var changingRaised = false; - var changedRaised = false; - var oldValue = false; - var newValue = true; - - linearRange.UseMinimumSizeChanging += (sender, args) => - { - changingRaised = true; - Assert.Equal (oldValue, args.CurrentValue); - Assert.Equal (newValue, args.NewValue); - }; - - linearRange.UseMinimumSizeChanged += (sender, args) => - { - changedRaised = true; - Assert.Equal (oldValue, args.OldValue); - Assert.Equal (newValue, args.NewValue); - }; - - // Act - linearRange.UseMinimumSize = newValue; - - // Assert - Assert.True (changingRaised); - Assert.True (changedRaised); - Assert.Equal (newValue, linearRange.UseMinimumSize); - } - - // Copilot - [Fact] - public void Command_Activate_Calls_SetFocusedOption () - { - LinearRange linearRange = new (); - - linearRange.Options = - [ - new LinearRangeOption ("A", new Rune ('a'), 1), - new LinearRangeOption ("B", new Rune ('b'), 2), - new LinearRangeOption ("C", new Rune ('c'), 3) - ]; - - linearRange.FocusedOption = 1; - - bool? result = linearRange.InvokeCommand (Command.Activate); - - // DefaultActivateHandler returns false for views without dispatch targets or bubble config, - // but the side effect (SetFocusedOption via OnActivated) still occurs. - Assert.False (result); - Assert.Contains (1, linearRange.GetSetOptions ()); - - linearRange.Dispose (); - } - - // Copilot - [Fact] - public void Command_Accept_Calls_SetFocusedOption () - { - LinearRange linearRange = new (); - - linearRange.Options = - [ - new LinearRangeOption ("A", new Rune ('a'), 1), - new LinearRangeOption ("B", new Rune ('b'), 2), - new LinearRangeOption ("C", new Rune ('c'), 3) - ]; - - linearRange.FocusedOption = 2; - - bool? result = linearRange.InvokeCommand (Command.Accept); - - Assert.Contains (2, linearRange.GetSetOptions ()); - - linearRange.Dispose (); - } -} diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index 7a1210eb8b..79b1b91c90 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -465,6 +465,70 @@ public void Link_With_Focus_Draws_With_Focus_Colors () window.Dispose (); } + // Copilot + [Theory] + [InlineData ("file:///C:/Windows/System32/calc.exe")] + [InlineData ("file:///etc/passwd")] + [InlineData ("ms-msdt:id PCWDiagnostic")] + [InlineData ("ms-officecmd:")] + [InlineData ("search-ms:query=test")] + [InlineData ("ldap://example.com")] + [InlineData ("vbscript:alert('test')")] + [InlineData ("ftp://example.com")] + [InlineData ("ftps://example.com")] + [InlineData ("javascript:alert(1)")] + [InlineData ("data:text/html,")] + public void OpenUrl_DisallowedScheme_DoesNotThrow_AndDoesNotLaunch (string url) + { + // OpenUrl should silently return for disallowed schemes without throwing or launching a process. + // DisableRealDriverIO is not set here; the scheme check is the guard. + Exception? ex = Record.Exception (() => Link.OpenUrl (url)); + Assert.Null (ex); + } + + // Copilot + [Theory] + [InlineData ("http://example.com")] + [InlineData ("https://example.com")] + [InlineData ("mailto:user@example.com")] + public void OpenUrl_AllowedScheme_IsInSafeSchemes (string url) + { + bool parsed = Uri.TryCreate (url, UriKind.Absolute, out Uri? uri); + Assert.True (parsed); + Assert.NotNull (uri); + Assert.Contains (uri!.Scheme, Link.SafeSchemes, StringComparer.OrdinalIgnoreCase); + } + + // Copilot + [Theory] + [InlineData ("file:///C:/Windows/System32/calc.exe")] + [InlineData ("ms-msdt:id PCWDiagnostic")] + [InlineData ("ftp://example.com")] + [InlineData ("ldap://example.com")] + public void OpenUrl_DisallowedScheme_IsNotInSafeSchemes (string url) + { + bool parsed = Uri.TryCreate (url, UriKind.Absolute, out Uri? uri); + Assert.True (parsed); + Assert.NotNull (uri); + Assert.DoesNotContain (uri!.Scheme, Link.SafeSchemes, StringComparer.OrdinalIgnoreCase); + } + + // Copilot + [Fact] + public void OpenUrl_MalformedUrl_DoesNotThrow () + { + Exception? ex = Record.Exception (() => Link.OpenUrl ("not a valid url")); + Assert.Null (ex); + } + + // Copilot + [Fact] + public void OpenUrl_EmptyUrl_DoesNotThrow () + { + Exception? ex = Record.Exception (() => Link.OpenUrl (string.Empty)); + Assert.Null (ex); + } + [Fact] public void IDesignable_EnableForDesign_Sets_Title_And_Url () { diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs new file mode 100644 index 0000000000..156bba53c3 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -0,0 +1,780 @@ +using JetBrains.Annotations; + +namespace ViewsTests.Markdown; + +// Copilot +[TestSubject (typeof (Terminal.Gui.Views.Markdown))] +public class MarkdownViewSelectionTests +{ + /// Helper: builds and lays out a Markdown view at the given width/height. + private static (IApplication App, Runnable Window, Terminal.Gui.Views.Markdown Mv) CreateMv (string text, int width = 40, int height = 10) + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (width, height); + app.Clipboard = new FakeClipboard (); + + Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + Terminal.Gui.Views.Markdown mv = new () { Text = text, Width = Dim.Fill (), Height = Dim.Fill () }; + window.Add (mv); + app.Begin (window); + app.LayoutAndDraw (); + + return (app, window, mv); + } + + [Fact] + public void SelectAll_Sets_IsSelecting_And_Returns_True () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello World", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + bool result = mv.SelectAll (); + + Assert.True (result); + Assert.True (mv.IsInSelection (0, 0)); + + host.Dispose (); + } + + [Fact] + public void SelectAll_Empty_Content_Returns_True () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + bool result = mv.SelectAll (); + + Assert.True (result); + + host.Dispose (); + } + + [Fact] + public void ClearSelection_Clears_IsSelecting () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + mv.SelectAll (); + Assert.True (mv.IsInSelection (0, 0)); + + mv.ClearSelection (); + Assert.False (mv.IsInSelection (0, 0)); + + host.Dispose (); + } + + [Fact] + public void IsInSelection_False_When_Not_Selecting () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + // No SelectAll called — should return false + Assert.False (mv.IsInSelection (0, 0)); + Assert.False (mv.IsInSelection (0, 5)); + + host.Dispose (); + } + + [Fact] + public void Copy_Without_Selection_Copies_Markdown_Source () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("# Hello\n\nWorld"); + + bool result = mv.Copy (); + + Assert.True (result); + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Equal ("# Hello\n\nWorld", clipboard); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void Copy_With_SelectAll_Copies_Selected_Text () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + mv.SelectAll (); + bool result = mv.Copy (); + + Assert.True (result); + app.Clipboard!.TryGetClipboardData (out string clipboard); + + // The rendered text for "Hello" should contain "Hello" + Assert.Contains ("Hello", clipboard); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void Copy_Command_Copies_Markdown_When_No_Selection () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("# Test"); + + // Invoke Command.Copy directly (no selection active) + mv.InvokeCommand (Command.Copy); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Equal ("# Test", clipboard); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void SelectAll_Command_Then_Copy_Command_Copies_All_Text () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello World"); + + mv.InvokeCommand (Command.SelectAll); + mv.InvokeCommand (Command.Copy); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Contains ("Hello World", clipboard); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void Text_Change_Clears_Selection () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + mv.SelectAll (); + Assert.True (mv.IsInSelection (0, 0)); + + // Changing text should clear selection + mv.Text = "World"; + + Assert.False (mv.IsInSelection (0, 0)); + + host.Dispose (); + } + + [Fact] + public void ContextMenu_Is_Null_Before_Init () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello" }; + + Assert.Null (mv.ContextMenu); + + mv.Dispose (); + } + + [Fact] + public void ContextMenu_Is_Created_On_Focus () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + mv.SetFocus (); + + Assert.NotNull (mv.ContextMenu); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void ContextMenu_Is_Disposed_On_Losing_Focus () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + // Add another focusable view + Button btn = new () { Text = "OK", X = 0, Y = 5 }; + window.Add (btn); + app.LayoutAndDraw (); + + mv.SetFocus (); + Assert.NotNull (mv.ContextMenu); + + // Move focus away + btn.SetFocus (); + Assert.Null (mv.ContextMenu); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - verifies that LeftButtonPressed is bound to Command.Activate + [Fact] + public void MouseBindings_LeftButtonPressed_IsBoundTo_Activate () + { + Terminal.Gui.Views.Markdown mv = new (); + + bool found = mv.MouseBindings.TryGet (MouseFlags.LeftButtonPressed, out MouseBinding binding); + + Assert.True (found); + Assert.Contains (Command.Activate, binding.Commands); + + mv.Dispose (); + } + + // Copilot - verifies that LeftButtonPressed|PositionReport is bound to Command.Activate + [Fact] + public void MouseBindings_LeftButtonPressedPositionReport_IsBoundTo_Activate () + { + Terminal.Gui.Views.Markdown mv = new (); + + bool found = mv.MouseBindings.TryGet (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, out MouseBinding binding); + + Assert.True (found); + Assert.Contains (Command.Activate, binding.Commands); + + mv.Dispose (); + } + + // Copilot - verifies that a drag (press + position-report) activates the selection + [Fact] + public void Drag_Mouse_Creates_Selection () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello World"); + + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (5, 0), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + // After a drag the selection should span columns 0-4 + Assert.True (mv.IsInSelection (0, 0)); + Assert.True (mv.IsInSelection (0, 3)); + Assert.False (mv.IsInSelection (0, 6)); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - verifies that mouse release does not clear an active selection + [Fact] + public void Selection_Persists_After_LeftButtonReleased () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello World"); + + // Simulate a drag: press, drag, release + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (5, 0), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + mv.NewMouseEvent (new Mouse { Position = new Point (5, 0), Flags = MouseFlags.LeftButtonReleased }); + + // Selection should survive the release + Assert.True (mv.IsInSelection (0, 0)); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - verifies that a plain click (no drag) clears the selection + [Fact] + public void Plain_Click_Clears_Selection () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello World"); + + mv.SelectAll (); + Assert.True (mv.IsInSelection (0, 0)); + + // Simulate a plain click (no PositionReport drag events) + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonReleased }); + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonClicked }); + + // Selection should be cleared by the click + Assert.False (mv.IsInSelection (0, 0)); + + window.Dispose (); + app.Dispose (); + } + + // --- Copy fidelity tests --- + // These use content from Markdown.DefaultMarkdownSample where possible, which contains + // Unicode characters (emoji), task-list markers, and fenced code blocks with language tags. + + // Copilot + [Fact] + public void SelectedText_Is_Null_When_No_Selection () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + Assert.Null (mv.SelectedText); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - task-list items from DefaultMarkdownSample (includes emoji ✅ 🔧 🎉) + [Fact] + public void SelectAll_TaskList_SelectedText_Preserves_Markdown_List_Markers () + { + // Source task-list lines taken from Markdown.DefaultMarkdownSample § Checklist + string md = "- [x] Bold & italic ✅\n- [x] Code blocks 🔧\n- [ ] Emojis 🎉"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("- [x] Bold & italic ✅", selected); + Assert.Contains ("- [x] Code blocks 🔧", selected); + Assert.Contains ("- [ ] Emojis 🎉", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - plain bullet list (no task markers) + [Fact] + public void SelectAll_BulletList_SelectedText_Preserves_Markdown_List_Markers () + { + string md = "- foo\n- bar\n- baz"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 40, height: 10); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.DoesNotContain ("•", selected); + Assert.Contains ("- foo", selected); + Assert.Contains ("- bar", selected); + Assert.Contains ("- baz", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - fenced code block from DefaultMarkdownSample (csharp, contains 🌍 emoji) + [Fact] + public void SelectAll_FencedCodeBlock_With_Language_SelectedText_Preserves_Fences () + { + // Source taken from Markdown.DefaultMarkdownSample § Code Block (csharp) + 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); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("```csharp", selected); + Assert.Contains ("Console.WriteLine", selected); + Assert.Contains ("🌍", selected); + Assert.Contains ("var x = 42;", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - fenced code block without language specifier + [Fact] + public void SelectAll_FencedCodeBlock_Without_Language_SelectedText_Preserves_Fences () + { + string md = "```\nhello world\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 40, height: 10); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("```", selected); + Assert.Contains ("hello world", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Copy() after SelectAll with task list (includes emoji) + [Fact] + public void Copy_After_SelectAll_TaskList_Clipboard_Contains_Markdown_Markers () + { + string md = "- [x] Bold & italic ✅\n- [x] Code blocks 🔧\n- [ ] Emojis 🎉"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + mv.SelectAll (); + mv.Copy (); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Contains ("- [x] Bold & italic ✅", clipboard); + Assert.Contains ("- [x] Code blocks 🔧", clipboard); + Assert.Contains ("- [ ] Emojis 🎉", clipboard); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Copy() after SelectAll with csharp code block (includes 🌍) + [Fact] + public void Copy_After_SelectAll_FencedCodeBlock_Clipboard_Contains_Fences () + { + 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); + + mv.SelectAll (); + mv.Copy (); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Contains ("```csharp", clipboard); + Assert.Contains ("🌍", clipboard); + Assert.Contains ("var x = 42;", clipboard); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - partial drag selection spanning task-list items with emoji + [Fact] + public void PartialSelection_TaskList_SelectedText_Preserves_Markdown_Markers () + { + string md = "- [x] Bold & italic ✅\n- [ ] Emojis 🎉"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Press at start of line 0, drag to column 10 of line 1 + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (10, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("- [x] Bold & italic ✅", selected); + Assert.Contains ("- [ ]", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - partial drag selection spanning lines inside a csharp code block (with 🌍) + [Fact] + public void PartialSelection_FencedCodeBlock_SelectedText_Preserves_Fence_Context () + { + 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) + 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 }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("```csharp", selected); + Assert.Contains ("Console.WriteLine", selected); + Assert.Contains ("🌍", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: a table is rendered as a single placeholder RenderedLine (IsTable=true) + // with no text content. When the selection is partial (start.X > 0, so IsFullDocumentSelected + // short-circuit does not fire) and covers the table rows, the pipe-row syntax is completely + // absent from GetSelectedText(). + [Fact] + public void PartialSelection_IncludingTable_SelectedText_Preserves_Table_Markdown () + { + // Content taken from DefaultMarkdownSample § Table (uses ✅ emoji). + // "After." is appended so the last rendered line is text, not the table placeholder, + // which makes the drag a genuine partial selection (end.Y < lastLine is NOT required — + // starting at col 1 is enough to skip the IsFullDocumentSelected shortcut). + string md = "## Table\n\n| Feature | Status |\n|---|---|\n| A | ✅ |\n\nAfter."; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Start at column 1 — IsFullDocumentSelected() requires start==(0,0), so this forces + // the partial-selection (display-text) code path. Drag far right/down to cover the table. + mv.NewMouseEvent (new Mouse { Position = new Point (1, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (100, 100), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("| Feature | Status |", selected); + Assert.Contains ("| A | ✅ |", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: inline formatting (**bold**, *italic*, `code`, ~~strike~~), heading + // markers (#), tables, thematic breaks, and link syntax ([text](url)) are all lost in + // the display representation — the selected text cannot equal the original markdown source. + [Fact] + public void SelectAll_DefaultMarkdownSample_SelectedText_RoundTrips () + { + string md = Terminal.Gui.Views.Markdown.DefaultMarkdownSample; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 120, height: 60); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Equal (md, selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - right-clicking an unfocused view should focus it and open the context menu + [Fact] + public void RightClick_On_Unfocused_View_Creates_And_Shows_ContextMenu () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + // Add another focusable view and move focus there so the Markdown view is unfocused + Button btn = new () { Text = "OK", X = 0, Y = 5 }; + window.Add (btn); + app.LayoutAndDraw (); + btn.SetFocus (); + + Assert.False (mv.HasFocus); + Assert.Null (mv.ContextMenu); + + // Simulate a right-click while the view is not focused + mv.NewMouseEvent (new Mouse + { + Position = new Point (0, 0), + ScreenPosition = new Point (0, 0), + Flags = MouseFlags.RightButtonClicked + }); + + // The view should now be focused and the context menu should be created + Assert.True (mv.HasFocus); + Assert.NotNull (mv.ContextMenu); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - right-clicking an already-focused view should still open the context menu + [Fact] + public void RightClick_On_Focused_View_Shows_ContextMenu () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + mv.SetFocus (); + Assert.NotNull (mv.ContextMenu); + + // Second right-click should not crash and context menu should remain + mv.NewMouseEvent (new Mouse + { + Position = new Point (0, 0), + ScreenPosition = new Point (0, 0), + Flags = MouseFlags.RightButtonClicked + }); + + Assert.NotNull (mv.ContextMenu); + + window.Dispose (); + app.Dispose (); + } + + // --- Drag clamping + auto-scroll tests (comment #3183635182) --- + + // Copilot - dragging above the viewport (negative Y) must not produce a negative + // contentY that causes an IndexOutOfRangeException in GetSelectedText(). + [Fact] + public void Drag_AboveViewport_ClampsToFirstLine () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello\nWorld"); + + // Anchor at line 0 and then drag ABOVE the top of the view (pos.Y = -5). + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (3, -5), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + // Copy should not throw even though the drag went above the viewport. + // Before the fix, contentY could be negative → IndexOutOfRangeException in GetSelectedText(). + Exception? ex = Record.Exception (() => mv.Copy ()); + Assert.Null (ex); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - dragging below the viewport must not produce a contentY beyond the last line. + [Fact] + public void Drag_BelowViewport_ClampsToLastLine () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("line0\nline1\nline2", height: 2); + + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + + // Drag to row 999 — well beyond the content + mv.NewMouseEvent (new Mouse { Position = new Point (0, 999), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + // Copy must not throw + Exception? ex = Record.Exception (() => mv.Copy ()); + Assert.Null (ex); + + window.Dispose (); + app.Dispose (); + } + + // --- Table-duplication fix tests (comment #3183635237) --- + + // Copilot - a table with 2 body rows generates multiple placeholder RenderedLines; + // GetSelectedText() must output the reconstructed table exactly once, not once per + // placeholder row. + [Fact] + public void PartialSelection_TableWithMultipleRows_TableAppearsExactlyOnce () + { + // 2 body rows → at least 2 (usually more) table placeholder lines + string md = "| H1 | H2 |\n|---|---|\n| A | B |\n| C | D |"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 15); + + // Start at col 1 so IsFullDocumentSelected() returns false (forces the display-text path) + mv.NewMouseEvent (new Mouse { Position = new Point (1, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (100, 100), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // Header row should appear exactly once, not once per placeholder row. + int count = CountOccurrences (selected, "| H1 | H2 |"); + Assert.Equal (1, count); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - two adjacent tables with the same structure should each appear exactly once. + [Fact] + public void PartialSelection_TwoAdjacentTables_EachTableAppearsOnce () + { + // Two separate TableData instances → two independent tables + const string TABLE = "| H |\n|---|\n| R |"; + string md = TABLE + "\n\n" + TABLE; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 20); + + mv.NewMouseEvent (new Mouse { Position = new Point (1, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (100, 100), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // The header appears in each table → should occur exactly twice in the output + int count = CountOccurrences (selected, "| H |"); + Assert.Equal (2, count); + + window.Dispose (); + app.Dispose (); + } + + // --- Selection drawing over SubView rows (comment #3183635267) --- + + // Copilot - after SelectAll() on a document that contains a code block, the ANSI output + // should contain the Focus-attribute escape codes (white-on-black) in the code-block rows, + // proving the selection overlay is drawn on top of the MarkdownCodeBlock SubView. + [Fact] + public void SelectAll_WithCodeBlock_DrawingContainsFocusAttributeOnCodeRows () + { + const int WIDTH = 20; + + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (WIDTH, 5); + 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 = "```\nAB\n```", Width = Dim.Fill (), Height = Dim.Fill () }; + mv.SchemeName = null; + mv.SetScheme (scheme); + + window.Add (mv); + app.Begin (window); + app.LayoutAndDraw (); + + mv.SelectAll (); + app.LayoutAndDraw (); + app.Driver.Refresh (); + + string output = app.Driver.GetOutput ().GetLastOutput (); + + // Focus attribute (Black-on-White scheme, Focus = swap → White fg = 97, Black bg = 40) + // must appear somewhere in the rendered output now that the overlay is drawn. + Assert.Contains ("\x1b[97m", output); + Assert.Contains ("\x1b[40m", output); + + window.Dispose (); + app.Dispose (); + } + + // --- IsFullDocumentSelected with zero-width last line (comment #3183635296) --- + + // Copilot - SelectAll() on a document ending with a table (zero-width last rendered line) + // should correctly return the full source markdown via Copy(). + [Fact] + public void SelectAll_DocEndingWithTable_CopyReturnsFullMarkdown () + { + string md = "intro\n| H | B |\n|---|---|\n| A | C |"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 15); + + mv.SelectAll (); + mv.Copy (); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Equal (md, clipboard); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - a partial selection on a document ending with a table (starting from col 1) + // must NOT be treated as a full-document selection, even when the drag reaches the last row. + [Fact] + public void PartialSelection_DocEndingWithTable_NotTreatedAsFullDocument () + { + string md = "intro\n| H | B |\n|---|---|\n| A | C |"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 15); + + // Start at col 1 (not col 0) → IsFullDocumentSelected() must return false + mv.NewMouseEvent (new Mouse { Position = new Point (1, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (100, 100), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // Should NOT equal the full source markdown because the selection started at col 1 + Assert.NotEqual (md, selected); + + window.Dispose (); + app.Dispose (); + } + + private static int CountOccurrences (string text, string pattern) + { + int count = 0; + int idx = 0; + + while ((idx = text.IndexOf (pattern, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += pattern.Length; + } + + return count; + } +} diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewTests.cs index 0bc438b415..9bde5debf3 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewTests.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Reflection; +using System.Text; using JetBrains.Annotations; using UnitTests; @@ -201,6 +202,326 @@ public void Mouse_Click_On_Link_Raises_LinkClicked () window.Dispose (); } + // Copilot + [Fact] + public void Mouse_Click_On_Link_In_Table_Cell_Raises_LinkClicked () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable window = new () { Width = 80, Height = 20, BorderStyle = LineStyle.None }; + + string markdown = """ + | Name | Description | + |------|-------------| + | [select](https://example.com) | Pick one item | + """; + + Terminal.Gui.Views.Markdown markdownView = new () { Text = markdown, Width = 60, Height = 15 }; + window.Add (markdownView); + + var clickedUrl = ""; + + markdownView.LinkClicked += (_, e) => + { + clickedUrl = e.Url; + e.Handled = true; + }; + + app.Begin (window); + app.LayoutAndDraw (); + + // The table is a SubView of the Markdown view. Find it. + MarkdownTable? tableView = null; + + foreach (View sub in markdownView.SubViews) + { + if (sub is MarkdownTable t) + { + tableView = t; + + break; + } + } + + Assert.NotNull (tableView); + + // Click on the link text in the first body row, first column. + // Table layout: row 0 = top border, row 1 = header, row 2 = separator, row 3 = body row. + // Column layout: col 0 starts after left border (x=1), with 1 char padding = x=2. + tableView.NewMouseEvent (new Mouse { Position = new Point (2, 3), Flags = MouseFlags.LeftButtonClicked }); + + Assert.Equal ("https://example.com", clickedUrl); + + window.Dispose (); + } + + // Copilot + [Fact] + public void Tab_Navigation_Cycles_Through_Table_Links () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable window = new () { Width = 80, Height = 20, BorderStyle = LineStyle.None }; + + string markdown = """ + | Name | Description | + |------|-------------| + | [alpha](https://a.com) | [beta](https://b.com) | + """; + + Terminal.Gui.Views.Markdown markdownView = new () { Text = markdown, Width = 60, Height = 15 }; + List activatedUrls = new (); + + markdownView.LinkClicked += (_, e) => + { + activatedUrls.Add (e.Url); + e.Handled = true; + }; + + window.Add (markdownView); + + app.Begin (window); + app.LayoutAndDraw (); + + // Find the table SubView + MarkdownTable? tableView = markdownView.SubViews.OfType ().FirstOrDefault (); + Assert.NotNull (tableView); + Assert.True (tableView.CanFocus); + + // Give focus to the table + tableView.SetFocus (); + + // Activate to check first focused link + tableView.InvokeCommand (Command.Accept); + + // Advance focus forward + markdownView.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop); + tableView.InvokeCommand (Command.Accept); + + // Should have activated two distinct links + Assert.Equal (2, activatedUrls.Count); + Assert.Contains ("https://a.com", activatedUrls); + Assert.Contains ("https://b.com", activatedUrls); + + window.Dispose (); + } + + // Copilot + [Fact] + public void Enter_Activates_Focused_Table_Link () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable window = new () { Width = 80, Height = 20, BorderStyle = LineStyle.None }; + + string markdown = """ + | Name | Description | + |------|-------------| + | [alpha](https://a.com) | info | + """; + + Terminal.Gui.Views.Markdown markdownView = new () { Text = markdown, Width = 60, Height = 15 }; + var clickedUrl = ""; + + markdownView.LinkClicked += (_, e) => + { + clickedUrl = e.Url; + e.Handled = true; + }; + + window.Add (markdownView); + + app.Begin (window); + app.LayoutAndDraw (); + + MarkdownTable? tableView = markdownView.SubViews.OfType ().FirstOrDefault (); + Assert.NotNull (tableView); + + // Focus the table — the first link becomes active + tableView.SetFocus (); + + // Simulate Enter key via Command.Accept + tableView.InvokeCommand (Command.Accept); + + Assert.Equal ("https://a.com", clickedUrl); + + window.Dispose (); + } + + // Copilot + [Fact] + public void ShiftTab_Navigation_Cycles_Backwards_Through_Table_Links () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable window = new () { Width = 80, Height = 20, BorderStyle = LineStyle.None }; + + string markdown = """ + | Name | Description | + |------|-------------| + | [alpha](https://a.com) | [beta](https://b.com) | + """; + + Terminal.Gui.Views.Markdown markdownView = new () { Text = markdown, Width = 60, Height = 15 }; + List activatedUrls = new (); + + markdownView.LinkClicked += (_, e) => + { + activatedUrls.Add (e.Url); + e.Handled = true; + }; + + window.Add (markdownView); + + app.Begin (window); + app.LayoutAndDraw (); + + MarkdownTable? tableView = markdownView.SubViews.OfType ().FirstOrDefault (); + Assert.NotNull (tableView); + + // Focus the table — first link (alpha) becomes active on focus gain + tableView.SetFocus (); + + // Advance forward to second link (beta) + markdownView.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop); + tableView.InvokeCommand (Command.Accept); + Assert.Single (activatedUrls); + Assert.Equal ("https://b.com", activatedUrls [0]); + + // Navigate backward — should go back to first link (alpha) + markdownView.AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabStop); + tableView.InvokeCommand (Command.Accept); + Assert.Equal (2, activatedUrls.Count); + Assert.Equal ("https://a.com", activatedUrls [1]); + + window.Dispose (); + } + + // Copilot - Opus 4.6 - verify Issue 1: anchor links in tables should scroll to heading + [Fact] + public void Anchor_Link_In_Table_Scrolls_To_Heading () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable window = new () { Width = 80, Height = 20, BorderStyle = LineStyle.None }; + + // Create markdown with a heading and a table containing an anchor link to it. + // The heading must be far enough below the table that scrolling is needed. + string markdown = """ + | Name | Link | + |------|------| + | jump | [go](#target) | + + Line 1 + + Line 2 + + Line 3 + + Line 4 + + Line 5 + + Line 6 + + Line 7 + + Line 8 + + Line 9 + + ## Target + """; + + Terminal.Gui.Views.Markdown markdownView = new () { Text = markdown, Width = 60, Height = 10 }; + var clickedUrl = ""; + + markdownView.LinkClicked += (_, e) => + { + clickedUrl = e.Url; + e.Handled = true; + }; + + window.Add (markdownView); + + app.Begin (window); + app.LayoutAndDraw (); + + // Viewport should start at top + Assert.Equal (0, markdownView.Viewport.Y); + + // Find the table and activate the anchor link + MarkdownTable? tableView = markdownView.SubViews.OfType ().FirstOrDefault (); + Assert.NotNull (tableView); + + tableView.SetFocus (); + tableView.InvokeCommand (Command.Accept); + + // The anchor link should have been reported + Assert.Equal ("#target", clickedUrl); + + // The viewport should have scrolled down to the heading + Assert.True (markdownView.Viewport.Y > 0, "Viewport should have scrolled to the anchor target"); + + window.Dispose (); + } + + // Copilot - Opus 4.6 - verify Issue 2: duplicate URLs in different cells create separate navigable entries + [Fact] + public void Duplicate_Urls_In_Different_Cells_Are_Separately_Navigable () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable window = new () { Width = 80, Height = 20, BorderStyle = LineStyle.None }; + + // Two cells in different rows with the same URL — should be independently navigable + string markdown = """ + | Name | Description | + |------|-------------| + | [link](https://same.com) | info | + | [link](https://same.com) | info | + """; + + Terminal.Gui.Views.Markdown markdownView = new () { Text = markdown, Width = 60, Height = 15 }; + List activatedUrls = []; + + markdownView.LinkClicked += (_, e) => + { + activatedUrls.Add (e.Url); + e.Handled = true; + }; + + window.Add (markdownView); + + app.Begin (window); + app.LayoutAndDraw (); + + MarkdownTable? tableView = markdownView.SubViews.OfType ().FirstOrDefault (); + Assert.NotNull (tableView); + Assert.True (tableView.CanFocus); + + // Focus the table — first link becomes active + tableView.SetFocus (); + tableView.InvokeCommand (Command.Accept); + + // Advance to second link (same URL but different cell) + markdownView.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop); + tableView.InvokeCommand (Command.Accept); + + // Both activations should have fired — proving they are independently navigable + Assert.Equal (2, activatedUrls.Count); + Assert.Equal ("https://same.com", activatedUrls [0]); + Assert.Equal ("https://same.com", activatedUrls [1]); + + window.Dispose (); + } + [Fact] public void Image_Fallback_Text_Renders () { @@ -382,11 +703,11 @@ public void Style_ListMarker_Bold_Text_Normal () } [Fact] - public void Style_TaskDone_Renders_Strikethrough () + public void Style_TaskDone_Renders_Checked_Glyph_With_Strikethrough () { (IApplication app, Runnable window) = SetupStyleTest ("- [x] D"); - DriverAssert.AssertDriverOutputIs (@"\x1b[30m\x1b[107m\x1b[1m" + "• [x] " + @"\x1b[30m\x1b[107m\x1b[22;9mD\x1b[30m\x1b[107m\x1b[29m", + DriverAssert.AssertDriverOutputIs (@"\x1b[30m\x1b[107m\x1b[1m" + $"• {Glyphs.CheckStateChecked} " + @"\x1b[30m\x1b[107m\x1b[22;9mD\x1b[30m\x1b[107m\x1b[29m", output, app.Driver); @@ -395,11 +716,11 @@ public void Style_TaskDone_Renders_Strikethrough () } [Fact] - public void Style_TaskTodo_Renders_Bold () + public void Style_TaskTodo_Renders_Unchecked_Glyph_With_Bold () { (IApplication app, Runnable window) = SetupStyleTest ("- [ ] T"); - DriverAssert.AssertDriverOutputIs (@"\x1b[30m\x1b[107m\x1b[1m" + "• [ ] T" + @"\x1b[30m\x1b[107m\x1b[22m", output, app.Driver); + DriverAssert.AssertDriverOutputIs (@"\x1b[30m\x1b[107m\x1b[1m" + $"• {Glyphs.CheckStateUnChecked} T" + @"\x1b[30m\x1b[107m\x1b[22m", output, app.Driver); window.Dispose (); app.Dispose (); @@ -1765,4 +2086,282 @@ public void Setting_UseThemeBackground_After_Text_Updates_Without_ReSettingText } #endregion + + // Copilot - regression test: GetContentHeight should not overestimate when content includes tables + [Fact] + public void GetContentHeight_Does_Not_Overestimate_With_Tables () + { + // Wide cells that WILL wrap at 80 columns but fit single-line at 120 + string md = """ + # Before + + | Column A | Column B with a long header name | Column C also has a longish header | Column D | + |----------|----------------------------------|-------------------------------------|----------| + | Row 1 A | This cell has content that is definitely long enough to wrap when columns are narrow at 80 | Another cell with enough text to cause wrapping at narrow widths | Value D1 | + | Row 2 A | More long content in column B that should wrap around when the available width is restricted | Cell C2 also has substantial text that would need multiple lines | Value D2 | + | Row 3 A | Third row with plenty of descriptive text to demonstrate the wrapping | Third row C column with lots of text content | Value D3 | + | Row 4 A | Fourth row B column content that is quite verbose and will cause word wrap | Fourth row C with more text | Value D4 | + | Row 5 A | Fifth row B demonstrating word wrap behavior at different terminal widths | Fifth row C also long enough to wrap | Value D5 | + + # After + """; + + // At wide width (120), table cells don't wrap → fewer placeholder lines needed + Terminal.Gui.Views.Markdown mv120 = new () { Width = 120, Height = 50, Text = md }; + + View host120 = new () { Width = 120, Height = 50 }; + host120.Add (mv120); + host120.BeginInit (); + host120.EndInit (); + host120.Layout (); + + int contentHeight120 = mv120.GetContentSize ().Height; + + // At narrow width (80), table cells wrap → more placeholder lines needed + Terminal.Gui.Views.Markdown mv80 = new () { Width = 80, Height = 50, Text = md }; + + View host80 = new () { Width = 80, Height = 50 }; + host80.Add (mv80); + host80.BeginInit (); + host80.EndInit (); + host80.Layout (); + + int contentHeight80 = mv80.GetContentSize ().Height; + + // Content height at 120 cols should be LESS than at 80 cols because table rows + // don't wrap at wider widths. Before the fix, Frame.Height was stale from the + // initial Recalculate(80) in the TableData setter, causing overestimation. + Assert.True ( + contentHeight120 < contentHeight80, + $"Content height at 120 cols ({contentHeight120}) should be less than at 80 cols ({contentHeight80}) since table rows don't wrap at wider widths"); + + host120.Dispose (); + host80.Dispose (); + } + + // Copilot - regression test: table height accounts for content width expansion from long code lines + [Fact] + public void GetContentHeight_Table_With_Wide_Code_Block_Does_Not_Overestimate () + { + // When a long unwrapped code line makes contentWidth > viewportWidth, the table + // (which uses Dim.Fill) renders at the wider content width and may need fewer + // wrapped rows. The content height must reflect the table at its actual render width. + string longCodeLine = new ('x', 150); + + string mdWithCode = $""" + # Title + + | Column A | Column B with a fairly long header | Column C header | + |----------|-------------------------------------|-----------------| + | Row 1 A | This cell has content that is long enough to wrap when rendered at 80 columns width | Cell C1 value | + | Row 2 A | More long content in column B that should wrap around when available width is 80 cols | Cell C2 value | + | Row 3 A | Third row with plenty of descriptive text to demonstrate the wrapping at narrow width | Cell C3 value | + | Row 4 A | Fourth row B column content that is quite verbose and will cause word wrapping to occur | Cell C4 value | + | Row 5 A | Fifth row B demonstrating word wrap behavior at narrow terminal widths but not at wide | Cell C5 value | + + ``` + {longCodeLine} + ``` + + # End + """; + + string mdNoCode = """ + # Title + + | Column A | Column B with a fairly long header | Column C header | + |----------|-------------------------------------|-----------------| + | Row 1 A | This cell has content that is long enough to wrap when rendered at 80 columns width | Cell C1 value | + | Row 2 A | More long content in column B that should wrap around when available width is 80 cols | Cell C2 value | + | Row 3 A | Third row with plenty of descriptive text to demonstrate the wrapping at narrow width | Cell C3 value | + | Row 4 A | Fourth row B column content that is quite verbose and will cause word wrapping to occur | Cell C4 value | + | Row 5 A | Fifth row B demonstrating word wrap behavior at narrow terminal widths but not at wide | Cell C5 value | + + # End + """; + + // Both at 80 col viewport + Terminal.Gui.Views.Markdown mvCode = new () { Width = 80, Height = 50, Text = mdWithCode }; + View hostCode = new () { Width = 80, Height = 50 }; + hostCode.Add (mvCode); + hostCode.BeginInit (); + hostCode.EndInit (); + hostCode.Layout (); + + Terminal.Gui.Views.Markdown mvNoCode = new () { Width = 80, Height = 50, Text = mdNoCode }; + View hostNoCode = new () { Width = 80, Height = 50 }; + hostNoCode.Add (mvNoCode); + hostNoCode.BeginInit (); + hostNoCode.EndInit (); + hostNoCode.Layout (); + + int heightWithCode = mvCode.GetContentSize ().Height; + int heightNoCode = mvNoCode.GetContentSize ().Height; + int contentWidthWithCode = mvCode.GetContentSize ().Width; + + // Precondition: the code line expands content width beyond viewport + Assert.True (contentWidthWithCode > 80, "Code line should expand content width beyond viewport"); + + // The version with the code block has the table rendered at the wider content width + // (less wrapping needed), so the table is shorter. The code block adds a few lines, + // but the table savings should partially or fully offset that. + // Key assertion: heightWithCode must be LESS than heightNoCode + code_block_lines. + // If the bug exists (table placeholder uses viewport width), heightWithCode = + // heightNoCode + code_block_lines (table not re-sized, just code added). + // If the fix works, heightWithCode < heightNoCode + code_block_lines + // (table shrinks because it renders wider). + + // The code block adds 1 rendered line of content. Count actual code block lines. + int codeBlockRenderedLines = mvCode.SubViews + .OfType () + .Sum (v => v.Frame.Height); + + // With the fix: table is shorter at content width, so total is less than naive sum + Assert.True ( + heightWithCode < heightNoCode + codeBlockRenderedLines, + $"Content height with code ({heightWithCode}) should be less than no-code height ({heightNoCode}) + code lines ({codeBlockRenderedLines}) = {heightNoCode + codeBlockRenderedLines}, because the wider content width makes the table shorter"); + + hostCode.Dispose (); + hostNoCode.Dispose (); + } + + // Copilot + [Fact] + public void CanFocus_False_Text_HotKeySpecifier_SetsFocus_Next () + { + using IApplication app = Application.Create (); + Runnable runnable = new (); + View otherView = new () { CanFocus = true }; + Terminal.Gui.Views.Markdown markdown = new () + { + CanFocus = false, + Width = 20, + Height = 1, + Text = "_Markdown" + }; + View nextView = new () { CanFocus = true }; + + markdown.HotKeySpecifier = (Rune)'_'; + + app.Begin (runnable); + runnable.Add (otherView, markdown, nextView); + otherView.SetFocus (); + + Assert.Equal (Key.M, markdown.HotKey); + Assert.True (otherView.HasFocus); + Assert.False (markdown.HasFocus); + Assert.False (nextView.HasFocus); + + app.Keyboard.RaiseKeyDownEvent (markdown.HotKey); + + Assert.False (otherView.HasFocus); + Assert.False (markdown.HasFocus); + Assert.True (nextView.HasFocus); + } + + // Copilot + [Fact] + public void CanFocus_False_LeftButtonClicked_SetsFocus_Next () + { + using IApplication app = Application.Create (); + Runnable runnable = new (); + View otherView = new () + { + X = 0, + Y = 0, + Width = 1, + Height = 1, + CanFocus = true + }; + Terminal.Gui.Views.Markdown markdown = new () + { + X = 0, + Y = 1, + Width = 20, + Height = 1, + CanFocus = false, + Text = "_Markdown" + }; + View nextView = new () + { + X = Pos.Right (markdown), + Y = Pos.Top (markdown), + Width = 1, + Height = 1, + CanFocus = true + }; + + markdown.HotKeySpecifier = (Rune)'_'; + + app.Begin (runnable); + runnable.Add (otherView, markdown, nextView); + otherView.SetFocus (); + + Assert.Equal (Key.M, markdown.HotKey); + Assert.True (otherView.HasFocus); + Assert.False (markdown.HasFocus); + Assert.False (nextView.HasFocus); + + app.Mouse.RaiseMouseEvent (new Mouse { ScreenPosition = markdown.Frame.Location, Flags = MouseFlags.LeftButtonClicked }); + + Assert.False (markdown.HasFocus); + Assert.True (nextView.HasFocus); + } + + // Copilot + [Fact] + public void CanFocus_True_Text_HotKeySpecifier_SetsFocus_OnMarkdown () + { + using IApplication app = Application.Create (); + Runnable runnable = new (); + View otherView = new () { CanFocus = true }; + Terminal.Gui.Views.Markdown markdown = new () + { + CanFocus = true, + Width = 20, + Height = 1, + Text = "_Markdown" + }; + View nextView = new () { CanFocus = true }; + + markdown.HotKeySpecifier = (Rune)'_'; + + app.Begin (runnable); + runnable.Add (otherView, markdown, nextView); + otherView.SetFocus (); + + Assert.Equal (Key.M, markdown.HotKey); + + app.Keyboard.RaiseKeyDownEvent (markdown.HotKey); + + Assert.False (otherView.HasFocus); + Assert.True (markdown.HasFocus); + Assert.False (nextView.HasFocus); + } + + // Copilot + [Fact] + public void CanFocus_False_Text_HotKeySpecifier_NoNextPeer_DoesNotFocusMarkdown () + { + using IApplication app = Application.Create (); + Runnable runnable = new (); + Terminal.Gui.Views.Markdown markdown = new () + { + CanFocus = false, + Width = 20, + Height = 1, + Text = "_Markdown" + }; + + markdown.HotKeySpecifier = (Rune)'_'; + + app.Begin (runnable); + runnable.Add (markdown); + + Assert.Equal (Key.M, markdown.HotKey); + + app.Keyboard.RaiseKeyDownEvent (markdown.HotKey); + + Assert.False (markdown.HasFocus); + } } diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/TableHeightInvestigation.cs b/Tests/UnitTestsParallelizable/Views/Markdown/TableHeightInvestigation.cs new file mode 100644 index 0000000000..b9e93c16f7 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/Markdown/TableHeightInvestigation.cs @@ -0,0 +1,116 @@ +using Terminal.Gui; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Xunit; + +namespace ViewsTests.Markdown; + +public class TableHeightInvestigation +{ + // Copilot + + [Theory] + [InlineData (80)] + [InlineData (100)] + [InlineData (120)] + [InlineData (150)] + public void Table_RenderedHeight_Matches_Frame_Height_After_Layout (int screenWidth) + { + // Copilot + // Regression test: RenderedHeight must match Frame.Height after layout. + // Previously, Add(tableView) triggered EndInit → Layout → Recalculate at stale width, + // corrupting RenderedHeight and causing extra blank lines after the table. + string markdown = @"## Available Clets + +| Alias | Description | Options | +|-------|-------------|---------| +| select | Presents a list of options and returns the text of the selected item. | --options, args ... | +| text | Prompts for free-form text input and returns the entered string. | | +| multiline-text, mt | Prompts for multi-line text input and returns the entered string. | | +| int | Prompts for an integer value using a numeric spinner. | --step | +| decimal | Prompts for a decimal value using a numeric spinner. | --step | +| confirm | Prompts for a yes/no confirmation and returns a boolean | --prompt | +| date | Prompts for a date and returns an ISO-8601 date string (YYYY-MM-DD). | | +| time | Prompts for a time and returns an ISO-8601 time string (HH:MM:SS). | | +| duration | Prompts for a duration and returns an ISO-8601 duration string (e.g. PT1H30M). | | +| color | Prompts for a color and returns a hex string (@rrggbb). | | +| multi-select | Presents a list of options with checkboxes and returns the selected texts. | --options, args ... | +| attribute-picker, attribute | Prompts for text attributes (Foreground, Background, style) and returns a JSON object. | | +| pick-file, file | Opens a file picker dialog and returns the selected file path(s). | --multi, --root, --filter | +| pick-directory, dir | Opens a directory picker dialog and returns the selected directory path. | --root | +| linear-range, range | Presents a LinearRange (single, multi, or bounded range) over a list of labelled options and returns the selection. | --mode, --options, --orientation, --range-kind, --allow-empty, --hide-legends, args ... | +| md, markdown | Browse and render Markdown files with link navigation and syntax highlighting. | --theme, --cat, --no-browse, args | +| help | Shows help for clet commands. | args ... | + +Click for details: select, text"; + + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (screenWidth, 50); + + Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + + Terminal.Gui.Views.Markdown mv = new () { Text = markdown, Width = Dim.Fill (), Height = Dim.Fill () }; + window.Add (mv); + + app.Begin (window); + app.LayoutAndDraw (); + + List tableViews = mv.SubViews.OfType ().ToList (); + Assert.Single (tableViews); + + MarkdownTable table = tableViews [0]; + + // The table's RenderedHeight must equal its Frame.Height after layout + Assert.Equal (table.RenderedHeight, table.Frame.Height); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void Table_LineCount_Consistent_After_Resize () + { + // Copilot + // Regression test: resizing should not produce extra blank lines after a table. + string markdown = @"## Test + +| Alias | Description | Options | +|-------|-------------|---------| +| select | Presents a list of options and returns the text of the selected item. | --options, args ... | +| multiline-text, mt | Prompts for multi-line text input and returns the entered string. | | +| linear-range, range | Presents a LinearRange (single, multi, or bounded range) over a list of labelled options and returns the selection. | --mode, --options, --orientation, --range-kind, --allow-empty, --hide-legends, args ... | + +End"; + + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (100, 30); + + Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + + Terminal.Gui.Views.Markdown mv = new () { Text = markdown, Width = Dim.Fill (), Height = Dim.Fill () }; + window.Add (mv); + + app.Begin (window); + app.LayoutAndDraw (); + + int initialLineCount = mv.LineCount; + + // Resize by 1 column - should not dramatically change line count + app.Driver.SetScreenSize (101, 30); + window.SetNeedsLayout (); + app.LayoutAndDraw (); + + int resizedLineCount = mv.LineCount; + + // The line counts should be equal or differ by at most 1 (due to wrapping boundary) + // Previously the initial count could be many lines too large. + Assert.InRange (Math.Abs (initialLineCount - resizedLineCount), 0, 1); + + window.Dispose (); + app.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/ScrollSliderTests.cs b/Tests/UnitTestsParallelizable/Views/ScrollSliderTests.cs index be93f43740..e92113d000 100644 --- a/Tests/UnitTestsParallelizable/Views/ScrollSliderTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ScrollSliderTests.cs @@ -1010,4 +1010,150 @@ public void Draws_Correctly (int superViewportWidth, int superViewportHeight, in _ = DriverAssert.AssertDriverContentsWithFrameAre (expected, output, driver); } + + // Copilot - IValue tests + + [Fact] + public void Value_Gets_And_Sets_Position () + { + ScrollSlider slider = new () { VisibleContentSize = 100, Size = 10 }; + Assert.Equal (0, slider.Value); + + slider.Value = 5; + Assert.Equal (5, slider.Value); + Assert.Equal (5, slider.Position); + + slider.Position = 10; + Assert.Equal (10, slider.Value); + Assert.Equal (10, slider.Position); + + slider.Dispose (); + } + + [Fact] + public void ValueChanging_Event_Is_Raised () + { + ScrollSlider slider = new () { VisibleContentSize = 100, Size = 10 }; + var eventRaised = false; + int oldValue = -1; + int newValue = -1; + + slider.ValueChanging += (_, e) => + { + eventRaised = true; + oldValue = e.CurrentValue; + newValue = e.NewValue; + }; + + slider.Value = 5; + + Assert.True (eventRaised); + Assert.Equal (0, oldValue); + Assert.Equal (5, newValue); + + slider.Dispose (); + } + + [Fact] + public void ValueChanging_Can_Cancel_Change () + { + ScrollSlider slider = new () { VisibleContentSize = 100, Size = 10 }; + + slider.ValueChanging += (_, e) => e.Handled = true; + + slider.Value = 5; + + Assert.Equal (0, slider.Value); + + slider.Dispose (); + } + + [Fact] + public void ValueChanged_Event_Is_Raised () + { + ScrollSlider slider = new () { VisibleContentSize = 100, Size = 10 }; + var eventRaised = false; + int oldValue = -1; + int newValue = -1; + + slider.ValueChanged += (_, e) => + { + eventRaised = true; + oldValue = e.OldValue; + newValue = e.NewValue; + }; + + slider.Value = 5; + + Assert.True (eventRaised); + Assert.Equal (0, oldValue); + Assert.Equal (5, newValue); + + slider.Dispose (); + } + + [Fact] + public void ValueChangedUntyped_Event_Is_Raised () + { + ScrollSlider slider = new () { VisibleContentSize = 100, Size = 10 }; + var eventRaised = false; + object? oldValue = null; + object? newValue = null; + + slider.ValueChangedUntyped += (_, e) => + { + eventRaised = true; + oldValue = e.OldValue; + newValue = e.NewValue; + }; + + slider.Value = 5; + + Assert.True (eventRaised); + Assert.Equal (0, oldValue); + Assert.Equal (5, newValue); + + slider.Dispose (); + } + + [Fact] + public void GetValue_Returns_Boxed_Position () + { + ScrollSlider slider = new () { VisibleContentSize = 100, Size = 10 }; + slider.Value = 7; + + IValue ivalue = slider; + Assert.Equal (7, ivalue.GetValue ()); + + slider.Dispose (); + } + + [Fact] + public void TrySetValueFromString_Sets_Position () + { + ScrollSlider slider = new () { VisibleContentSize = 100, Size = 10 }; + + IValue ivalue = slider; + bool result = ivalue.TrySetValueFromString ("5"); + + Assert.True (result); + Assert.Equal (5, slider.Position); + Assert.Equal (5, slider.Value); + + slider.Dispose (); + } + + [Fact] + public void TrySetValueFromString_Returns_False_For_Invalid_Input () + { + ScrollSlider slider = new () { VisibleContentSize = 100, Size = 10 }; + + IValue ivalue = slider; + bool result = ivalue.TrySetValueFromString ("not-a-number"); + + Assert.False (result); + Assert.Equal (0, slider.Value); + + slider.Dispose (); + } } diff --git a/Tests/UnitTestsParallelizable/Views/TabView/TabsTests.cs b/Tests/UnitTestsParallelizable/Views/TabView/TabsTests.cs index faa766fd38..e15b7e7231 100644 --- a/Tests/UnitTestsParallelizable/Views/TabView/TabsTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TabView/TabsTests.cs @@ -217,13 +217,13 @@ public void App_EnableForDesign_DrawsCorrectly () │││H:▲ 0 │☐ Bold │ ││ │││S:▲ 0 │☐ Faint │ ││ │││V: ▲100 │☐ Italic │ ││ - │││Name: White │☐ Underline │ ││ + │││Name: White ▼ │☐ Underline │ ││ │││Hex:#FFFFFF ■ │☐ Blink │ ││ ││├┼Background┼─────────────────────────────────────────────┤☐ Reverse │ ││ │││H:▲ 0 │☐ Strikethrough│ ││ │││S:▲ 0 │ │ ││ │││V:▲ 0 │ │ ││ - │││Name: Black │ │ ││ + │││Name: Black ▼ │ │ ││ │││Hex:#000000 ■ │ │ ││ ││└─────────────────────────────────────────────────────────┴───────────────┘ ││ ││ Sample Text ││ diff --git a/Tests/UnitTestsParallelizable/Views/TableViewBaselineTests.cs b/Tests/UnitTestsParallelizable/Views/TableViewBaselineTests.cs index 74828bbc04..1f7312c247 100644 --- a/Tests/UnitTestsParallelizable/Views/TableViewBaselineTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TableViewBaselineTests.cs @@ -62,12 +62,12 @@ public void ArrowRight_MovesCursorRight () TableView tv = CreateTableView (5, 10); // Table setter puts us at (0,0) - Assert.Equal (0, tv.Value!.Cursor.X); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.X); + Assert.Equal (0, tv.Value!.SelectedCell.Y); tv.NewKeyDownEvent (Key.CursorRight); - Assert.Equal (1, tv.Value!.Cursor.X); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (1, tv.Value!.SelectedCell.X); + Assert.Equal (0, tv.Value!.SelectedCell.Y); } [Fact] @@ -75,49 +75,49 @@ public void ArrowDown_MovesCursorDown () { TableView tv = CreateTableView (5, 10); tv.NewKeyDownEvent (Key.CursorDown); - Assert.Equal (0, tv.Value!.Cursor.X); - Assert.Equal (1, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.X); + Assert.Equal (1, tv.Value!.SelectedCell.Y); } [Fact] public void ArrowLeft_AtColumn0_DoesNotGoNegative () { TableView tv = CreateTableView (5, 10); - Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.SelectedCell.X); // Left at col 0 — should not go negative // HACK: Without Application/focus context, the command returns false // and doesn't transfer focus. The key assertion is column stays at 0. tv.NewKeyDownEvent (Key.CursorLeft); - Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.SelectedCell.X); } [Fact] public void ArrowUp_AtRow0_DoesNotGoNegative () { TableView tv = CreateTableView (5, 10); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.Y); tv.NewKeyDownEvent (Key.CursorUp); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.Y); } [Fact] public void ArrowRight_AtLastColumn_ClampsToLastColumn () { TableView tv = CreateTableView (3, 5); - tv.SetSelection (2, tv.Value?.Cursor.Y ?? 0, false); // last column (0-indexed) + tv.SetSelection (2, tv.Value?.SelectedCell.Y ?? 0, false); // last column (0-indexed) tv.NewKeyDownEvent (Key.CursorRight); - Assert.Equal (2, tv.Value!.Cursor.X); + Assert.Equal (2, tv.Value!.SelectedCell.X); } [Fact] public void ArrowDown_AtLastRow_ClampsToLastRow () { TableView tv = CreateTableView (3, 5); - tv.SetSelection (tv.Value?.Cursor.X ?? 0, 4, false); // last row (0-indexed) + tv.SetSelection (tv.Value?.SelectedCell.X ?? 0, 4, false); // last row (0-indexed) tv.NewKeyDownEvent (Key.CursorDown); - Assert.Equal (4, tv.Value!.Cursor.Y); + Assert.Equal (4, tv.Value!.SelectedCell.Y); } [Fact] @@ -132,8 +132,8 @@ public void ArrowKeys_MultipleSteps_TraversesGrid () tv.NewKeyDownEvent (Key.CursorDown); tv.NewKeyDownEvent (Key.CursorDown); - Assert.Equal (2, tv.Value!.Cursor.X); - Assert.Equal (3, tv.Value!.Cursor.Y); + Assert.Equal (2, tv.Value!.SelectedCell.X); + Assert.Equal (3, tv.Value!.SelectedCell.Y); } #endregion @@ -144,62 +144,62 @@ public void ArrowKeys_MultipleSteps_TraversesGrid () public void PageDown_MovesByViewportHeight () { TableView tv = CreateTableView (3, 50, viewportHeight: 10); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.Y); tv.PageDown (false, null); - Assert.Equal (10, tv.Value!.Cursor.Y); + Assert.Equal (10, tv.Value!.SelectedCell.Y); } [Fact] public void PageUp_MovesByViewportHeight () { TableView tv = CreateTableView (3, 50, viewportHeight: 10); - tv.SetSelection (tv.Value?.Cursor.X ?? 0, 20, false); + tv.SetSelection (tv.Value?.SelectedCell.X ?? 0, 20, false); tv.PageUp (false, null); - Assert.Equal (10, tv.Value!.Cursor.Y); + Assert.Equal (10, tv.Value!.SelectedCell.Y); } [Fact] public void PageDown_ClampsAtLastRow () { TableView tv = CreateTableView (3, 5, viewportHeight: 10); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.Y); tv.PageDown (false, null); - Assert.Equal (4, tv.Value!.Cursor.Y); // last row is 4 (0-indexed, 5 rows) + Assert.Equal (4, tv.Value!.SelectedCell.Y); // last row is 4 (0-indexed, 5 rows) } [Fact] public void PageUp_ClampsAtRow0 () { TableView tv = CreateTableView (3, 50, viewportHeight: 10); - tv.SetSelection (tv.Value?.Cursor.X ?? 0, 3, false); + tv.SetSelection (tv.Value?.SelectedCell.X ?? 0, 3, false); tv.PageUp (false, null); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.Y); } [Fact] public void Home_Key_MovesToStartOfRow () { TableView tv = CreateTableView (5, 10); - tv.SetSelection (3, tv.Value?.Cursor.Y ?? 0, false); + tv.SetSelection (3, tv.Value?.SelectedCell.Y ?? 0, false); tv.NewKeyDownEvent (Key.Home); - Assert.Equal (0, tv.Value!.Cursor.X); - Assert.Equal (0, tv.Value!.Cursor.Y); // row unchanged + Assert.Equal (0, tv.Value!.SelectedCell.X); + Assert.Equal (0, tv.Value!.SelectedCell.Y); // row unchanged } [Fact] public void End_Key_MovesToEndOfRow () { TableView tv = CreateTableView (5, 10); - tv.SetSelection (1, tv.Value?.Cursor.Y ?? 0, false); + tv.SetSelection (1, tv.Value?.SelectedCell.Y ?? 0, false); tv.NewKeyDownEvent (Key.End); - Assert.Equal (4, tv.Value!.Cursor.X); // last column (0-indexed, 5 cols) - Assert.Equal (0, tv.Value!.Cursor.Y); // row unchanged + Assert.Equal (4, tv.Value!.SelectedCell.X); // last column (0-indexed, 5 cols) + Assert.Equal (0, tv.Value!.SelectedCell.Y); // row unchanged } [Fact] @@ -209,8 +209,8 @@ public void MoveCursorToStartOfTable_MovesToOrigin () tv.SetSelection (3, 7, false); tv.MoveCursorToStartOfTable (false, null); - Assert.Equal (0, tv.Value!.Cursor.X); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.X); + Assert.Equal (0, tv.Value!.SelectedCell.Y); } [Fact] @@ -218,8 +218,8 @@ public void MoveCursorToEndOfTable_MovesToLastCell () { TableView tv = CreateTableView (5, 10); tv.MoveCursorToEndOfTable (false, null); - Assert.Equal (4, tv.Value!.Cursor.X); - Assert.Equal (9, tv.Value!.Cursor.Y); + Assert.Equal (4, tv.Value!.SelectedCell.X); + Assert.Equal (9, tv.Value!.SelectedCell.Y); } [Fact] @@ -227,11 +227,11 @@ public void MoveCursorToEndOfTable_FullRowSelect_KeepsColumn () { TableView tv = CreateTableView (5, 10); tv.FullRowSelect = true; - tv.SetSelection (2, tv.Value?.Cursor.Y ?? 0, false); + tv.SetSelection (2, tv.Value?.SelectedCell.Y ?? 0, false); tv.MoveCursorToEndOfTable (false, null); - Assert.Equal (2, tv.Value!.Cursor.X); // column preserved with FullRowSelect - Assert.Equal (9, tv.Value!.Cursor.Y); + Assert.Equal (2, tv.Value!.SelectedCell.X); // column preserved with FullRowSelect + Assert.Equal (9, tv.Value!.SelectedCell.Y); } [Fact] @@ -241,8 +241,8 @@ public void MoveCursorToStartOfRow_API () tv.SetSelection (3, 5, false); tv.MoveCursorToStartOfRow (false, null); - Assert.Equal (0, tv.Value!.Cursor.X); - Assert.Equal (5, tv.Value!.Cursor.Y); // row unchanged + Assert.Equal (0, tv.Value!.SelectedCell.X); + Assert.Equal (5, tv.Value!.SelectedCell.Y); // row unchanged } [Fact] @@ -252,8 +252,8 @@ public void MoveCursorToEndOfRow_API () tv.SetSelection (1, 5, false); tv.MoveCursorToEndOfRow (false, null); - Assert.Equal (4, tv.Value!.Cursor.X); - Assert.Equal (5, tv.Value!.Cursor.Y); + Assert.Equal (4, tv.Value!.SelectedCell.X); + Assert.Equal (5, tv.Value!.SelectedCell.Y); } #endregion @@ -271,8 +271,8 @@ public void ArrowDown_FiresValueChanged () tv.ValueChanged += (_, e) => { fired = true; - oldCursor = e.OldValue?.Cursor; - newCursor = e.NewValue?.Cursor; + oldCursor = e.OldValue?.SelectedCell; + newCursor = e.NewValue?.SelectedCell; }; tv.NewKeyDownEvent (Key.CursorDown); @@ -417,7 +417,7 @@ public void FullRowSelect_IsSelected_ReturnsTrueForEntireRow () { TableView tv = CreateTableView (5, 10); tv.FullRowSelect = true; - tv.SetSelection (tv.Value?.Cursor.X ?? 0, 3, false); + tv.SetSelection (tv.Value?.SelectedCell.X ?? 0, 3, false); for (var col = 0; col < 5; col++) { @@ -437,7 +437,7 @@ public void ExtendSelection_ShiftRight_CreatesRegion () Assert.True (tv.IsSelected (1, 1), "Origin cell should be selected"); Assert.True (tv.IsSelected (2, 1), "Extended cell should be selected"); - Assert.Equal (2, tv.Value!.Cursor.X); + Assert.Equal (2, tv.Value!.SelectedCell.X); } [Fact] @@ -451,7 +451,7 @@ public void ExtendSelection_ShiftDown_CreatesRegion () Assert.True (tv.IsSelected (0, 0)); Assert.True (tv.IsSelected (0, 1)); Assert.True (tv.IsSelected (0, 2)); - Assert.Equal (2, tv.Value!.Cursor.Y); + Assert.Equal (2, tv.Value!.SelectedCell.Y); } #endregion @@ -566,47 +566,47 @@ public void EmptyTable_NoRows_NavigationDoesNotThrow () public void SingleCell_Table_BoundaryNavigation () { TableView tv = CreateTableView (1, 1); - Assert.Equal (0, tv.Value!.Cursor.X); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.X); + Assert.Equal (0, tv.Value!.SelectedCell.Y); // Can't move anywhere tv.NewKeyDownEvent (Key.CursorRight); - Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.SelectedCell.X); tv.NewKeyDownEvent (Key.CursorDown); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.Y); } [Fact] public void SelectedColumn_SetBeyondBounds_Clamped () { TableView tv = CreateTableView (5, 10); - tv.SetSelection (100, tv.Value?.Cursor.Y ?? 0, false); - Assert.Equal (4, tv.Value!.Cursor.X); // clamped to last column + tv.SetSelection (100, tv.Value?.SelectedCell.Y ?? 0, false); + Assert.Equal (4, tv.Value!.SelectedCell.X); // clamped to last column } [Fact] public void SelectedRow_SetBeyondBounds_Clamped () { TableView tv = CreateTableView (5, 10); - tv.SetSelection (tv.Value?.Cursor.X ?? 0, 100, false); - Assert.Equal (9, tv.Value!.Cursor.Y); // clamped to last row + tv.SetSelection (tv.Value?.SelectedCell.X ?? 0, 100, false); + Assert.Equal (9, tv.Value!.SelectedCell.Y); // clamped to last row } [Fact] public void SelectedColumn_SetNegative_ClampedToZero () { TableView tv = CreateTableView (5, 10); - tv.SetSelection (-5, tv.Value?.Cursor.Y ?? 0, false); - Assert.Equal (0, tv.Value!.Cursor.X); + tv.SetSelection (-5, tv.Value?.SelectedCell.Y ?? 0, false); + Assert.Equal (0, tv.Value!.SelectedCell.X); } [Fact] public void SelectedRow_SetNegative_ClampedToZero () { TableView tv = CreateTableView (5, 10); - tv.SetSelection (tv.Value?.Cursor.X ?? 0, -5, false); - Assert.Equal (0, tv.Value!.Cursor.Y); + tv.SetSelection (tv.Value?.SelectedCell.X ?? 0, -5, false); + Assert.Equal (0, tv.Value!.SelectedCell.Y); } [Fact] @@ -618,8 +618,8 @@ public void SetTable_SetsSelectionToOrigin () tv.Table = BuildTable (5, 10); // Table setter calls SetSelection(0, 0, false) - Assert.Equal (0, tv.Value!.Cursor.X); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (0, tv.Value!.SelectedCell.X); + Assert.Equal (0, tv.Value!.SelectedCell.Y); } [Fact] @@ -659,13 +659,13 @@ public void Value_ReflectsCursorPosition () { TableView tv = CreateTableView (5, 10); Assert.NotNull (tv.Value); - Assert.Equal (new Point (0, 0), tv.Value!.Cursor); + Assert.Equal (new Point (0, 0), tv.Value!.SelectedCell); - tv.SetSelection (2, tv.Value?.Cursor.Y ?? 0, false); - Assert.Equal (new Point (2, 0), tv.Value!.Cursor); + tv.SetSelection (2, tv.Value?.SelectedCell.Y ?? 0, false); + Assert.Equal (new Point (2, 0), tv.Value!.SelectedCell); - tv.SetSelection (tv.Value?.Cursor.X ?? 0, 3, false); - Assert.Equal (new Point (2, 3), tv.Value!.Cursor); + tv.SetSelection (tv.Value?.SelectedCell.X ?? 0, 3, false); + Assert.Equal (new Point (2, 3), tv.Value!.SelectedCell); } [Fact] @@ -675,7 +675,7 @@ public void Value_UpdatedByNavigation () tv.NewKeyDownEvent (Key.CursorRight); tv.NewKeyDownEvent (Key.CursorDown); Assert.NotNull (tv.Value); - Assert.Equal (new Point (1, 1), tv.Value!.Cursor); + Assert.Equal (new Point (1, 1), tv.Value!.SelectedCell); } [Fact] @@ -688,7 +688,7 @@ public void Value_SetByTableSetter () tv.Table = BuildTable (5, 10); Assert.NotNull (tv.Value); - Assert.Equal (new Point (0, 0), tv.Value!.Cursor); + Assert.Equal (new Point (0, 0), tv.Value!.SelectedCell); } [Fact] @@ -709,9 +709,9 @@ public void ValueChanged_FiresOnNavigation () tv.NewKeyDownEvent (Key.CursorDown); Assert.True (fired); Assert.NotNull (oldVal); - Assert.Equal (new Point (0, 0), oldVal!.Cursor); + Assert.Equal (new Point (0, 0), oldVal!.SelectedCell); Assert.NotNull (newVal); - Assert.Equal (new Point (0, 1), newVal!.Cursor); + Assert.Equal (new Point (0, 1), newVal!.SelectedCell); } #endregion @@ -759,7 +759,7 @@ public void EnsureCursorIsVisible_ScrollsRowIntoView () TableView tv = CreateTableView (3, 50, viewportHeight: 5); // Move to a row that is beyond viewport - tv.SetSelection (tv.Value?.Cursor.X ?? 0, 20, false); + tv.SetSelection (tv.Value?.SelectedCell.X ?? 0, 20, false); tv.EnsureCursorIsVisible (); // After ensuring visibility, Viewport.Y should have adjusted @@ -781,17 +781,17 @@ public void MoveCursorByOffset_Positive_MovesRight () { TableView tv = CreateTableView (5, 10); tv.MoveCursorByOffset (2, 0, false, null); - Assert.Equal (2, tv.Value!.Cursor.X); - Assert.Equal (0, tv.Value!.Cursor.Y); + Assert.Equal (2, tv.Value!.SelectedCell.X); + Assert.Equal (0, tv.Value!.SelectedCell.Y); } [Fact] public void MoveCursorByOffset_Negative_MovesLeft () { TableView tv = CreateTableView (5, 10); - tv.SetSelection (3, tv.Value?.Cursor.Y ?? 0, false); + tv.SetSelection (3, tv.Value?.SelectedCell.Y ?? 0, false); tv.MoveCursorByOffset (-2, 0, false, null); - Assert.Equal (1, tv.Value!.Cursor.X); + Assert.Equal (1, tv.Value!.SelectedCell.X); } [Fact] @@ -802,8 +802,8 @@ public void MoveCursorByOffset_Extend_CreatesMultiSelectRegion () tv.MoveCursorByOffset (2, 2, true, null); - Assert.Equal (2, tv.Value!.Cursor.X); - Assert.Equal (2, tv.Value!.Cursor.Y); + Assert.Equal (2, tv.Value!.SelectedCell.X); + Assert.Equal (2, tv.Value!.SelectedCell.Y); Assert.True (tv.IsSelected (0, 0), "Origin should still be selected"); Assert.True (tv.IsSelected (2, 2), "New position should be selected"); Assert.True (tv.IsSelected (1, 1), "Cell in between should be selected"); @@ -816,8 +816,8 @@ public void MoveCursorByOffset_ClampsAtBounds () tv.SetSelection (2, 4, false); tv.MoveCursorByOffset (5, 5, false, null); - Assert.Equal (2, tv.Value!.Cursor.X); // clamped - Assert.Equal (4, tv.Value!.Cursor.Y); // clamped + Assert.Equal (2, tv.Value!.SelectedCell.X); // clamped + Assert.Equal (4, tv.Value!.SelectedCell.Y); // clamped } #endregion @@ -830,8 +830,8 @@ public void SetSelection_MovesToSpecifiedCell () TableView tv = CreateTableView (5, 10); tv.SetSelection (3, 7, false); - Assert.Equal (3, tv.Value!.Cursor.X); - Assert.Equal (7, tv.Value!.Cursor.Y); + Assert.Equal (3, tv.Value!.SelectedCell.X); + Assert.Equal (7, tv.Value!.SelectedCell.Y); } [Fact] @@ -841,8 +841,8 @@ public void SetSelection_Extend_KeepsRegion () tv.SetSelection (1, 1, false); tv.SetSelection (3, 3, true); - Assert.Equal (3, tv.Value!.Cursor.X); - Assert.Equal (3, tv.Value!.Cursor.Y); + Assert.Equal (3, tv.Value!.SelectedCell.X); + Assert.Equal (3, tv.Value!.SelectedCell.Y); Assert.True (tv.IsSelected (1, 1), "Origin of extend should be selected"); Assert.True (tv.IsSelected (2, 2), "Interior cell should be selected"); Assert.True (tv.IsSelected (3, 3), "End of extend should be selected"); diff --git a/Tests/UnitTestsParallelizable/Views/TableViewLegacyTests.cs b/Tests/UnitTestsParallelizable/Views/TableViewLegacyTests.cs index 7f1436feeb..5fd5d8f3a9 100644 --- a/Tests/UnitTestsParallelizable/Views/TableViewLegacyTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TableViewLegacyTests.cs @@ -171,8 +171,8 @@ public void ValueChanged_CursorIndexesCorrect () tableView.ValueChanged += (_, e) => { called = true; - Assert.Equal (0, e.OldValue!.Cursor.X); - Assert.Equal (10, e.NewValue!.Cursor.X); + Assert.Equal (0, e.OldValue!.SelectedCell.X); + Assert.Equal (10, e.NewValue!.SelectedCell.X); }; tableView.SetSelection (10, 0, false); @@ -210,9 +210,9 @@ public void TableCollectionNavigator_FullRowSelect_True_False (bool fullRowSelec dt.Rows.Add (1, 2); dt.Rows.Add (3, 4); tableView.Table = new DataTableSource (dt); - tableView.SetSelection (selectedCol, tableView.Value?.Cursor.Y ?? 0, false); + tableView.SetSelection (selectedCol, tableView.Value?.SelectedCell.Y ?? 0, false); - Assert.Equal (expectedRow, tableView.CollectionNavigator.GetNextMatchingItem (0, "3".ToCharArray () [0])); + Assert.Equal (expectedRow, tableView.CollectionNavigator!.GetNextMatchingItem (0, "3".ToCharArray () [0])); } [Fact] diff --git a/Tests/UnitTestsParallelizable/Views/TableViewTests.cs b/Tests/UnitTestsParallelizable/Views/TableViewTests.cs index 7d1fe8e004..ef23713055 100644 --- a/Tests/UnitTestsParallelizable/Views/TableViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TableViewTests.cs @@ -14,7 +14,7 @@ public void CanTabOutOfTableViewUsingCursor_Left () GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField _); // Make the selected cell one in - tableView.SetSelection (1, tableView.Value?.Cursor.Y ?? 0, false); + tableView.SetSelection (1, tableView.Value?.SelectedCell.Y ?? 0, false); // Pressing left should move us to the first column without changing focus tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorLeft); @@ -37,7 +37,7 @@ public void CanTabOutOfTableViewUsingCursor_Up () GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField _); // Make the selected cell one in - tableView.SetSelection (tableView.Value?.Cursor.X ?? 0, 1, false); + tableView.SetSelection (tableView.Value?.SelectedCell.X ?? 0, 1, false); // First press should move us up tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorUp); @@ -60,7 +60,7 @@ public void CanTabOutOfTableViewUsingCursor_Right () GetTableViewWithSiblings (out TextField _, out TableView tableView, out TextField tf2); // Make the selected cell one in from the rightmost column - tableView.SetSelection (tableView.Table!.Columns - 2, tableView.Value?.Cursor.Y ?? 0, false); + tableView.SetSelection (tableView.Table!.Columns - 2, tableView.Value?.SelectedCell.Y ?? 0, false); // First press should move us to the rightmost column without changing focus tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorRight); @@ -83,7 +83,7 @@ public void CanTabOutOfTableViewUsingCursor_Down () GetTableViewWithSiblings (out TextField _, out TableView tableView, out TextField tf2); // Make the selected cell one in from the bottommost row - tableView.SetSelection (tableView.Value?.Cursor.X ?? 0, tableView.Table!.Rows - 2, false); + tableView.SetSelection (tableView.Value?.SelectedCell.X ?? 0, tableView.Table!.Rows - 2, false); // First press should move us to the bottommost row without changing focus tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorDown); @@ -106,7 +106,7 @@ public void CanTabOutOfTableViewUsingCursor_Left_ClearsSelectionFirst () GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField _); // Make the selected cell one in - tableView.SetSelection (1, tableView.Value?.Cursor.Y ?? 0, false); + tableView.SetSelection (1, tableView.Value?.SelectedCell.Y ?? 0, false); // Pressing shift-left should give us a multi selection tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorLeft.WithShift); @@ -215,18 +215,18 @@ public void TableView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator () tableView.HasFocus = true; tableView.KeyBindings.Add (Key.B, Command.Down); - Assert.Equal (0, tableView.Value!.Cursor.Y); + Assert.Equal (0, tableView.Value!.SelectedCell.Y); // Keys should be consumed to move down the navigation i.e. to apricot Assert.True (tableView.NewKeyDownEvent (Key.B)); - Assert.Equal (1, tableView.Value!.Cursor.Y); + Assert.Equal (1, tableView.Value!.SelectedCell.Y); Assert.True (tableView.NewKeyDownEvent (Key.B)); - Assert.Equal (2, tableView.Value!.Cursor.Y); + Assert.Equal (2, tableView.Value!.SelectedCell.Y); // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle Assert.True (tableView.NewKeyDownEvent (Key.C)); - Assert.Equal (5, tableView.Value!.Cursor.Y); + Assert.Equal (5, tableView.Value!.SelectedCell.Y); } [Fact] @@ -247,10 +247,58 @@ public void TableView_CollectionNavigatorMatcher_HotKey_Finds_Item () tableView.Table = new DataTableSource (dt); tableView.HasFocus = true; - Assert.Equal (0, tableView.Value!.Cursor.Y); + Assert.Equal (0, tableView.Value!.SelectedCell.Y); Assert.True (tableView.NewKeyDownEvent (Key.B)); - Assert.Equal (2, tableView.Value!.Cursor.Y); + Assert.Equal (2, tableView.Value!.SelectedCell.Y); + } + + // Claude - Opus 4.7 + // Regression: setting CollectionNavigator to null disables type-to-search and lets printable keys bubble + [Fact] + public void CollectionNavigator_Null_DisablesTypeToSearch_KeyNotConsumed () + { + var dt = new DataTable (); + dt.Columns.Add ("blah"); + dt.Rows.Add ("apricot"); + dt.Rows.Add ("bat"); + dt.Rows.Add ("candle"); + + TableView tableView = new () { Table = new DataTableSource (dt), CollectionNavigator = null }; + tableView.HasFocus = true; + + Assert.Equal (0, tableView.Value!.SelectedCell.Y); + + // Key.B would normally jump to "bat" (row 1) — with navigator disabled, selection must not move + // and the key event must not be consumed (returns false so it can bubble to the SuperView). + bool consumed = tableView.NewKeyDownEvent (Key.B); + + Assert.False (consumed); + Assert.Equal (0, tableView.Value!.SelectedCell.Y); + } + + // Claude - Opus 4.7 + // Regression: with CollectionNavigator = null, HotKey path must not throw + [Fact] + public void CollectionNavigator_Null_HotKey_DoesNotThrow () + { + var dt = new DataTable (); + dt.Columns.Add ("blah"); + dt.Rows.Add ("apricot"); + dt.Rows.Add ("bat"); + + TableView tableView = new () + { + Table = new DataTableSource (dt), + CollectionNavigator = null, + HotKey = Key.B + }; + tableView.HasFocus = true; + + Exception? ex = Record.Exception (() => tableView.NewKeyDownEvent (Key.B)); + + Assert.Null (ex); + Assert.Equal (0, tableView.Value!.SelectedCell.Y); } // Copilot @@ -363,7 +411,7 @@ public void TableCollectionNavigator_NullCellValue_DoesNotThrow () Assert.Null (ex); // Should land on "apple" (row 1), skipping the null-cell row gracefully - Assert.Equal (1, tableView.Value!.Cursor.Y); + Assert.Equal (1, tableView.Value!.SelectedCell.Y); tableView.Dispose (); } @@ -384,7 +432,7 @@ public void TableCollectionNavigator_DBNullCellValue_DoesNotThrow () Exception? ex = Record.Exception (() => tableView.NewKeyDownEvent (Key.B)); Assert.Null (ex); - Assert.Equal (1, tableView.Value!.Cursor.Y); + Assert.Equal (1, tableView.Value!.SelectedCell.Y); tableView.Dispose (); } @@ -508,6 +556,270 @@ public void TruncateOrPad_SurrogatePairs_DoesNotThrowOrCorrupt () Assert.True (result.GetColumns () <= 4, $"Truncated result '{result}' exceeds available space"); } + // Claude - Opus 4.7 + [Fact] + public void TruncationIndicator_DefaultIsHorizontalEllipsis () + { + ColumnStyle style = new (); + + Assert.Equal (Glyphs.HorizontalEllipsis.ToString (), style.TruncationIndicator); + } + + // Claude - Opus 4.7 + // Verifies fix for #5068: TableView appends a truncation indicator (default "…") + // when cell content exceeds the available column width. + [Fact] + public void TruncateOrPad_DefaultIndicator_AppendsEllipsisWhenTruncated () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + ColumnStyle style = new (); + + // "Hello" is 5 cols wide, available space is 5 → truncation branch (5 >= 5). + // Visible budget is availableHorizontalSpace - 1 = 4 (1 cell reserved for boundary). + // With 1-col indicator, content budget is 3 → "Hel" + "…" = "Hel…" (4 cols). + var result = (string)method.Invoke (null, ["Hello", "Hello", 5, style])!; + + Assert.Equal ("Hel…", result); + Assert.Equal (4, result.GetColumns ()); + } + + // Claude - Opus 4.7 + [Fact] + public void TruncateOrPad_NullIndicator_SilentlyClips () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + ColumnStyle style = new () { TruncationIndicator = null }; + + var result = (string)method.Invoke (null, ["Hello", "Hello", 5, style])!; + + Assert.Equal ("Hell", result); + } + + // Claude - Opus 4.7 + [Fact] + public void TruncateOrPad_EmptyIndicator_SilentlyClips () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + ColumnStyle style = new () { TruncationIndicator = string.Empty }; + + var result = (string)method.Invoke (null, ["Hello", "Hello", 5, style])!; + + Assert.Equal ("Hell", result); + } + + // Claude - Opus 4.7 + // When colStyle is null no indicator is configured so existing silent-clip behavior is preserved. + [Fact] + public void TruncateOrPad_NullColumnStyle_SilentlyClips () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + + var result = (string)method.Invoke (null, ["Hello", "Hello", 5, null])!; + + Assert.Equal ("Hell", result); + } + + // Claude - Opus 4.7 + [Fact] + public void TruncateOrPad_CustomMultiColumnIndicator_Appended () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + ColumnStyle style = new () { TruncationIndicator = "..." }; + + // availableHorizontalSpace=8, "Hello World" is 11 cols → truncation branch. + // Visible budget is 7 (8-1). Reserve 3 for "...". Content budget = 4 → "Hell" + "..." = "Hell..." (7 cols). + var result = (string)method.Invoke (null, ["Hello World", "Hello World", 8, style])!; + + Assert.Equal ("Hell...", result); + Assert.Equal (7, result.GetColumns ()); + } + + // Claude - Opus 4.7 + // When the indicator is wider than the visible column budget, fall back to silent clipping + // rather than producing an oversize result. + [Fact] + public void TruncateOrPad_IndicatorTooWide_SilentlyClips () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + ColumnStyle style = new () { TruncationIndicator = "..." }; + + // availableHorizontalSpace=3 → visible budget is 2 → 3-col indicator does not fit. + var result = (string)method.Invoke (null, ["Hello", "Hello", 3, style])!; + + Assert.Equal ("He", result); + Assert.True (result.GetColumns () <= 2); + } + + // Claude - Opus 4.7 + [Fact] + public void TruncateOrPad_IndicatorEqualsBudget_SilentlyClips () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + ColumnStyle style = new () { TruncationIndicator = "…" }; + + // availableHorizontalSpace=2 → visible budget is 1 → indicator (1 col) would consume the entire + // budget and leave nothing for content. Falls back to silent clip. + var result = (string)method.Invoke (null, ["Hello", "Hello", 2, style])!; + + Assert.Equal ("H", result); + } + + // Claude - Opus 4.7 + // No truncation occurs when content fits, so the indicator should not appear and the value + // should be padded according to the column style alignment. + [Fact] + public void TruncateOrPad_ContentFits_NoIndicatorAdded () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + ColumnStyle style = new (); + + // "Hi" is 2 cols, availableHorizontalSpace=10 → no truncation, padded to 9 cols (10-1 boundary). + var result = (string)method.Invoke (null, ["Hi", "Hi", 10, style])!; + + Assert.DoesNotContain ('…', result); + Assert.StartsWith ("Hi", result); + } + + // Claude - Opus 4.7 + // Indicator must be appended after a complete grapheme cluster and must not split surrogate pairs + // or combining sequences. + [Fact] + public void TruncateOrPad_GraphemeContent_IndicatorAppendedCleanly () + { + MethodInfo method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static)!; + ColumnStyle style = new (); + + // "🎉Hello" is 7 cols (emoji=2 + Hello=5). availableHorizontalSpace=5 → truncation. + // Visible budget = 4. Reserve 1 for indicator → content budget = 3. + // Add 🎉 (w=2, remaining content budget=1), add 'H' (w=1, content budget=0), break. + // Result = "🎉H" + "…" = "🎉H…" (4 cols). + var result = (string)method.Invoke (null, ["\U0001F389Hello", "\U0001F389Hello", 5, style])!; + + Assert.Equal ("\U0001F389H…", result); + Assert.Equal (4, result.GetColumns ()); + + // No isolated surrogates + for (var i = 0; i < result.Length; i++) + { + if (char.IsHighSurrogate (result [i])) + { + Assert.True (i + 1 < result.Length && char.IsLowSurrogate (result [i + 1])); + i++; + } + else + { + Assert.False (char.IsLowSurrogate (result [i])); + } + } + } + + // Claude - Opus 4.5 — regression for issue #5075 + // When ShowVerticalCellLines is false AND a custom ColorGetter is used, the position between cells + // (the separator column, current.X - 1) was being overdrawn with a space using the row's normal + // scheme. This punched a 1-cell hole in the cell's custom color. The fix skips the separator draw + // when the symbol would be the default space and lines are off — the cell padding has already + // filled that position with the cell's color. + [Fact] + public void ShowVerticalCellLines_False_WithCustomColorGetter_PreservesCellColorAtSeparator () + { + IDriver driver = CreateTestDriver (40, 5); + + TableView tableView = new () { Driver = driver }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Base); + tableView.Viewport = new Rectangle (0, 0, 40, 5); + + tableView.Style.ShowHeaders = true; + tableView.Style.ShowHorizontalHeaderUnderline = false; + tableView.Style.ShowHorizontalHeaderOverline = false; + tableView.Style.AlwaysShowHeaders = true; + tableView.Style.ShowVerticalCellLines = false; + tableView.Style.ShowVerticalHeaderLines = false; + tableView.Style.ExpandLastColumn = false; + tableView.FullRowSelect = false; + + // A custom scheme that is visibly distinct from the row scheme. + Scheme customScheme = new () + { + Normal = new Attribute (Color.White, Color.Red), + Focus = new Attribute (Color.Red, Color.White), + HotNormal = new Attribute (Color.White, Color.Red), + HotFocus = new Attribute (Color.Red, Color.White), + Disabled = new Attribute (Color.White, Color.Red), + Active = new Attribute (Color.White, Color.Red) + }; + + tableView.Style.GetOrCreateColumnStyle (0).ColorGetter = _ => customScheme; + tableView.Style.GetOrCreateColumnStyle (1).ColorGetter = _ => customScheme; + + DataTable dt = new (); + dt.Columns.Add ("A"); + dt.Columns.Add ("B"); + dt.Rows.Add ("aa", "bb"); + tableView.Table = new DataTableSource (dt); + + tableView.Layout (); + tableView.SetClipToScreen (); + tableView.Draw (); + + // Find the row that contains the data ("aa" then "bb"). + Cell [,] contents = driver.Contents!; + var dataRow = -1; + + for (var r = 0; r < 5; r++) + { + var rowText = string.Empty; + + for (var c = 0; c < 10; c++) + { + rowText += contents [r, c].Grapheme; + } + + if (rowText.Contains ("aa") && rowText.Contains ("bb")) + { + dataRow = r; + + break; + } + } + + Assert.True (dataRow >= 0, "Expected a rendered data row containing 'aa' and 'bb'"); + + // Locate the columns for "aa" and "bb". + var aaCol = -1; + var bbCol = -1; + + for (var c = 0; c < 39; c++) + { + if (aaCol < 0 && contents [dataRow, c].Grapheme == "a" && contents [dataRow, c + 1].Grapheme == "a") + { + aaCol = c; + } + + if (bbCol < 0 && contents [dataRow, c].Grapheme == "b" && contents [dataRow, c + 1].Grapheme == "b") + { + bbCol = c; + } + } + + Assert.True (aaCol >= 0 && bbCol > aaCol + 1, $"Expected 'aa' before 'bb'. aaCol={aaCol}, bbCol={bbCol}"); + + // The cells "aa" and "bb" must use the custom red background. + Assert.Equal (customScheme.Normal, contents [dataRow, aaCol].Attribute); + Assert.Equal (customScheme.Normal, contents [dataRow, bbCol].Attribute); + + // The separator position is the gap between the end of "aa" and the start of "bb". + // Before the fix, this position was overdrawn with the row scheme attribute. + // After the fix, the cell padding's custom red attribute remains. + for (int c = aaCol + 2; c < bbCol; c++) + { + Assert.Equal (customScheme.Normal, contents [dataRow, c].Attribute); + } + + tableView.Dispose (); + } + [Fact] public void Test_CalculateMaxCellWidth_UsesGraphemeWidth () { @@ -549,4 +861,600 @@ public void Test_CalculateMaxCellWidth_UsesGraphemeWidth () headerRow }'"); } + + // Copilot + // Verifies fix for #5072: a column with very wide content must not consume all viewport space + // and push later columns off-screen. Each subsequent visible column should be reserved at least + // its header width. + [Fact] + public void Calculate_WideColumn_DoesNotStarveLaterColumns () + { + DataTable dt = new (); + dt.Columns.Add ("Description"); + dt.Columns.Add ("Status"); + dt.Columns.Add ("Owner"); + dt.Rows.Add (new string ('x', 200), "ok", "me"); + + using TableView tableView = new () + { + Table = new DataTableSource (dt), + Viewport = new Rectangle (0, 0, 40, 5) + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.RefreshContentSize (); + + TableView.ColumnToRender [] columns = GetColumnsToRender (tableView); + + Assert.Equal (3, columns.Length); + + // Description must be clamped so that Status and Owner fit + TableView.ColumnToRender description = columns [0]; + TableView.ColumnToRender status = columns [1]; + TableView.ColumnToRender owner = columns [2]; + + Assert.True (description.X >= 0); + Assert.True (status.X > description.X); + Assert.True (owner.X > status.X); + + // Every column's right edge must lie within the viewport + Assert.True (description.X + description.Width - 1 < tableView.Viewport.Width, + $"Description right edge {description.X + description.Width - 1} exceeds viewport {tableView.Viewport.Width}"); + Assert.True (status.X + status.Width - 1 < tableView.Viewport.Width, + $"Status right edge {status.X + status.Width - 1} exceeds viewport {tableView.Viewport.Width}"); + Assert.True (owner.X + owner.Width - 1 < tableView.Viewport.Width, + $"Owner right edge {owner.X + owner.Width - 1} exceeds viewport {tableView.Viewport.Width}"); + + // Status and Owner each must have at least header-width room (excluding separator) + Assert.True (status.Width - 1 >= "Status".Length, $"Status got width {status.Width - 1}"); + Assert.True (owner.Width - 1 >= "Owner".Length, $"Owner got width {owner.Width - 1}"); + } + + // Copilot + // When the viewport is too small to fit even minimum widths for every column, layout falls back + // to the prior left-to-right packing (columns may extend past the viewport, accessible via + // horizontal scrolling). + [Fact] + public void Calculate_NarrowViewport_StillProducesLayout () + { + DataTable dt = new (); + dt.Columns.Add ("Description"); + dt.Columns.Add ("Status"); + dt.Columns.Add ("Owner"); + dt.Rows.Add (new string ('x', 50), "ok", "me"); + + using TableView tableView = new () + { + Table = new DataTableSource (dt), + Viewport = new Rectangle (0, 0, 10, 5) + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.RefreshContentSize (); + + TableView.ColumnToRender [] columns = GetColumnsToRender (tableView); + + Assert.Equal (3, columns.Length); + + // Each column should have a positive width + Assert.All (columns, c => Assert.True (c.Width > 0, $"Column {c.Column} got non-positive width {c.Width}")); + } + + // Copilot + // Single-column tables should still expand to fill the viewport when ExpandLastColumn is true. + [Fact] + public void Calculate_SingleColumn_StillExpandsLastColumn () + { + DataTable dt = new (); + dt.Columns.Add ("Only"); + dt.Rows.Add ("hi"); + + using TableView tableView = new () + { + Table = new DataTableSource (dt), + Viewport = new Rectangle (0, 0, 30, 5) + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.RefreshContentSize (); + + TableView.ColumnToRender [] columns = GetColumnsToRender (tableView); + + Assert.Single (columns); + Assert.True (columns [0].Width >= tableView.Viewport.Width - 2, + $"Single column width {columns [0].Width} should fill viewport {tableView.Viewport.Width}"); + } + + private static TableView.ColumnToRender [] GetColumnsToRender (TableView tableView) + { + FieldInfo? field = typeof (TableView).GetField ("_columnsToRenderCache", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull (field); + + return (TableView.ColumnToRender []?)field!.GetValue (tableView) ?? []; + } + + // Copilot + [Fact] + public void HeaderColorGetter_AppliesCustomSchemeToColumnHeader () + { + IDriver driver = CreateTestDriver (40, 5); + + TableView tableView = new () { Driver = driver }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Base); + tableView.Viewport = new Rectangle (0, 0, 40, 5); + + tableView.Style.ShowHeaders = true; + tableView.Style.ShowHorizontalHeaderUnderline = false; + tableView.Style.ShowHorizontalHeaderOverline = false; + tableView.Style.AlwaysShowHeaders = true; + tableView.Style.ShowVerticalCellLines = true; + tableView.Style.ShowVerticalHeaderLines = true; + + Scheme headerScheme = new () + { + Normal = new Attribute (Color.BrightYellow, Color.DarkGray), + Focus = new Attribute (Color.BrightYellow, Color.DarkGray) + }; + + // Only column 0 gets custom header color + tableView.Style.GetOrCreateColumnStyle (0).HeaderColorGetter = _ => headerScheme; + + DataTable dt = new (); + dt.Columns.Add ("Name"); + dt.Columns.Add ("Value"); + dt.Rows.Add ("test", "123"); + tableView.Table = new DataTableSource (dt); + + tableView.Layout (); + tableView.SetClipToScreen (); + tableView.Draw (); + + Cell [,] contents = driver.Contents!; + + // Find row 0 which has headers (since overline is off, headers are on line 0) + // Look for 'N' from "Name" header + var nameCol = -1; + var valueCol = -1; + + for (var c = 0; c < 40; c++) + { + if (nameCol < 0 && contents [0, c].Grapheme == "N") + { + nameCol = c; + } + + if (valueCol < 0 && contents [0, c].Grapheme == "V") + { + valueCol = c; + } + } + + Assert.True (nameCol >= 0, "Expected to find 'N' from 'Name' header"); + Assert.True (valueCol >= 0, "Expected to find 'V' from 'Value' header"); + + // Column 0 header should use the custom scheme + Assert.Equal (headerScheme.Normal, contents [0, nameCol].Attribute); + + // Column 1 header should NOT use the custom scheme (no HeaderColorGetter set) + Assert.NotEqual (headerScheme.Normal, contents [0, valueCol].Attribute); + + tableView.Dispose (); + } + + // Copilot + [Fact] + public void TableStyle_HeaderScheme_AppliesBaseSchemeToAllHeaders () + { + IDriver driver = CreateTestDriver (40, 5); + + TableView tableView = new () { Driver = driver }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Base); + tableView.Viewport = new Rectangle (0, 0, 40, 5); + + tableView.Style.ShowHeaders = true; + tableView.Style.ShowHorizontalHeaderUnderline = false; + tableView.Style.ShowHorizontalHeaderOverline = false; + tableView.Style.AlwaysShowHeaders = true; + tableView.Style.ShowVerticalCellLines = true; + tableView.Style.ShowVerticalHeaderLines = true; + + Scheme globalHeaderScheme = new () + { + Normal = new Attribute (Color.Green, Color.Black), + Focus = new Attribute (Color.Green, Color.Black) + }; + + tableView.Style.HeaderScheme = globalHeaderScheme; + + DataTable dt = new (); + dt.Columns.Add ("Name"); + dt.Columns.Add ("Value"); + dt.Rows.Add ("test", "123"); + tableView.Table = new DataTableSource (dt); + + tableView.Layout (); + tableView.SetClipToScreen (); + tableView.Draw (); + + Cell [,] contents = driver.Contents!; + + // Find header characters + var nameCol = -1; + var valueCol = -1; + + for (var c = 0; c < 40; c++) + { + if (nameCol < 0 && contents [0, c].Grapheme == "N") + { + nameCol = c; + } + + if (valueCol < 0 && contents [0, c].Grapheme == "V") + { + valueCol = c; + } + } + + Assert.True (nameCol >= 0, "Expected to find 'N' from 'Name' header"); + Assert.True (valueCol >= 0, "Expected to find 'V' from 'Value' header"); + + // Both headers should use the global header scheme + Assert.Equal (globalHeaderScheme.Normal, contents [0, nameCol].Attribute); + Assert.Equal (globalHeaderScheme.Normal, contents [0, valueCol].Attribute); + + tableView.Dispose (); + } + + // Copilot + [Fact] + public void HeaderSeparatorLines_DoNotUseFocusAttribute_WhenTableHasFocus () + { + IDriver driver = CreateTestDriver (40, 5); + + TableView tableView = new () { Driver = driver }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Base); + tableView.Viewport = new Rectangle (0, 0, 40, 5); + + tableView.Style.ShowHeaders = true; + tableView.Style.ShowHorizontalHeaderUnderline = false; + tableView.Style.ShowHorizontalHeaderOverline = false; + tableView.Style.AlwaysShowHeaders = true; + tableView.Style.ShowVerticalCellLines = true; + tableView.Style.ShowVerticalHeaderLines = true; + + DataTable dt = new (); + dt.Columns.Add ("Name"); + dt.Columns.Add ("Value"); + dt.Rows.Add ("test", "123"); + tableView.Table = new DataTableSource (dt); + + // Simulate focus so headers render with Focus attribute + tableView.HasFocus = true; + + tableView.Layout (); + tableView.SetClipToScreen (); + tableView.Draw (); + + Cell [,] contents = driver.Contents!; + + // Find the separator column (│) between the two headers on row 0 + var separatorCol = -1; + + for (var c = 1; c < 39; c++) + { + if (contents [0, c].Grapheme == Glyphs.VLine.ToString ()) + { + separatorCol = c; + + break; + } + } + + Assert.True (separatorCol >= 0, "Expected to find a vertical separator '│' between headers"); + + // The separator should use Normal attribute, not Focus + Scheme viewScheme = tableView.GetScheme (); + Attribute normalAttr = viewScheme.Normal; + Attribute focusAttr = viewScheme.Focus; + + Attribute? separatorAttribute = contents [0, separatorCol].Attribute; + + // The separator must NOT use focus colors + Assert.NotEqual (focusAttr, separatorAttribute); + + // The separator should use normal attribute + Assert.Equal (normalAttr, separatorAttribute); + + // Also verify that actual header text DOES use focus attribute + var nameCol = -1; + + for (var c = 0; c < 40; c++) + { + if (contents [0, c].Grapheme == "N") + { + nameCol = c; + + break; + } + } + + Assert.True (nameCol >= 0, "Expected to find 'N' from 'Name' header"); + Assert.Equal (focusAttr, contents [0, nameCol].Attribute); + + tableView.Dispose (); + } + + // Copilot + [Fact] + public void ShowVerticalCellLines_CanHideOuterBorders_AndPreserveInnerSeparators () + { + IDriver driver = CreateTestDriver (20, 5); + + TableView tableView = new () { Driver = driver }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Base); + tableView.Viewport = new Rectangle (0, 0, 20, 5); + + tableView.Style.ShowHeaders = true; + tableView.Style.ShowHorizontalHeaderUnderline = false; + tableView.Style.ShowHorizontalHeaderOverline = false; + tableView.Style.AlwaysShowHeaders = true; + tableView.Style.ShowVerticalCellLines = true; + tableView.Style.ShowVerticalHeaderLines = true; + tableView.Style.ExpandLastColumn = false; + tableView.Style.ShowVerticalCellLineForFirstColumn = false; + tableView.Style.ShowVerticalCellLineForLastColumn = false; + + DataTable dt = new (); + dt.Columns.Add ("Name"); + dt.Columns.Add ("Value"); + dt.Rows.Add ("A", "B"); + tableView.Table = new DataTableSource (dt); + + tableView.Layout (); + tableView.SetClipToScreen (); + tableView.Draw (); + + Cell [,] contents = driver.Contents!; + TableView.ColumnToRender [] columns = GetColumnsToRender (tableView); + + Assert.Equal (2, columns.Length); + + int leftBorderCol = 0; + int innerSeparatorCol = columns [1].X - 1; + int rightBorderCol = columns [1].X + columns [1].Width - 1; + + Assert.NotEqual (Glyphs.VLine.ToString (), contents [0, leftBorderCol].Grapheme); + Assert.Equal (Glyphs.VLine.ToString (), contents [0, innerSeparatorCol].Grapheme); + Assert.NotEqual (Glyphs.VLine.ToString (), contents [0, rightBorderCol].Grapheme); + + Assert.NotEqual (Glyphs.VLine.ToString (), contents [1, leftBorderCol].Grapheme); + Assert.Equal (Glyphs.VLine.ToString (), contents [1, innerSeparatorCol].Grapheme); + Assert.NotEqual (Glyphs.VLine.ToString (), contents [1, rightBorderCol].Grapheme); + + tableView.Dispose (); + } + + // Claude - Opus 4.7 + // Regression test for https://github.com/gui-cs/Terminal.Gui/issues/5126 + // Clicking outside the row area of a TableView (e.g. below the last row) must + // not raise the Activating event. + [Fact] + public void Click_OutsideRows_DoesNotRaise_Activating () + { + TableView tableView = new () { Viewport = new Rectangle (0, 0, 25, 10) }; + tableView.Table = BuildTable (2, 2); + + var activatingFired = 0; + tableView.Activating += (_, _) => activatingFired++; + + // Construct a left-click well below the last data row. With a 2-row table, + // y=9 is past the rows so ScreenToCell returns null. + Mouse mouseEvent = new () + { + Position = new Point (1, 9), + Flags = MouseFlags.LeftButtonClicked + }; + + MouseBinding binding = new ([Command.Activate], mouseEvent); + + tableView.InvokeCommand (Command.Activate, binding); + + Assert.Equal (0, activatingFired); + + tableView.Dispose (); + } + + // Copilot + [Fact] + public void CalculateContentSize_HidingOuterVerticalCellLines_ReclaimsBothOuterColumns () + { + DataTable dt = new (); + dt.Columns.Add ("Name"); + dt.Columns.Add ("Value"); + dt.Rows.Add ("A", "B"); + + using TableView tableViewWithOuterBorders = new () + { + Table = new DataTableSource (dt), + Viewport = new Rectangle (0, 0, 20, 5) + }; + tableViewWithOuterBorders.BeginInit (); + tableViewWithOuterBorders.EndInit (); + tableViewWithOuterBorders.Style.ShowHeaders = true; + tableViewWithOuterBorders.Style.ShowVerticalCellLines = true; + tableViewWithOuterBorders.Style.ShowVerticalHeaderLines = true; + tableViewWithOuterBorders.Style.ExpandLastColumn = false; + tableViewWithOuterBorders.RefreshContentSize (); + + using TableView tableViewWithoutOuterBorders = new () + { + Table = new DataTableSource (dt), + Viewport = new Rectangle (0, 0, 20, 5) + }; + tableViewWithoutOuterBorders.BeginInit (); + tableViewWithoutOuterBorders.EndInit (); + tableViewWithoutOuterBorders.Style.ShowHeaders = true; + tableViewWithoutOuterBorders.Style.ShowVerticalCellLines = true; + tableViewWithoutOuterBorders.Style.ShowVerticalHeaderLines = true; + tableViewWithoutOuterBorders.Style.ExpandLastColumn = false; + tableViewWithoutOuterBorders.Style.ShowVerticalCellLineForFirstColumn = false; + tableViewWithoutOuterBorders.Style.ShowVerticalCellLineForLastColumn = false; + tableViewWithoutOuterBorders.RefreshContentSize (); + + Assert.Equal (tableViewWithOuterBorders.GetContentSize ().Width - 2, tableViewWithoutOuterBorders.GetContentSize ().Width); + } + + // Claude - Opus 4.7 + // Companion to Click_OutsideRows_DoesNotRaise_Activating: clicking on a real + // cell still raises Activating as before. + [Fact] + public void Click_OnRow_Raises_Activating () + { + TableView tableView = new () { Viewport = new Rectangle (0, 0, 25, 10) }; + tableView.Table = BuildTable (2, 2); + + var activatingFired = 0; + tableView.Activating += (_, _) => activatingFired++; + + // y=3 lands on the first data row. The default header style consumes 3 lines + // (overline + header text + underline), so y=0..2 is header and y=3 is the + // first data row. + Mouse mouseEvent = new () + { + Position = new Point (1, 3), + Flags = MouseFlags.LeftButtonClicked + }; + + MouseBinding binding = new ([Command.Activate], mouseEvent); + + tableView.InvokeCommand (Command.Activate, binding); + + Assert.Equal (1, activatingFired); + + tableView.Dispose (); + } + + // Claude - Opus 4.7 + // Click in horizontal whitespace (right of the last rendered column) must not + // raise Activating. Disable ExpandLastColumn so the last column doesn't fill + // the viewport and there is real whitespace to the right. + [Fact] + public void Click_RightOfLastColumn_DoesNotRaise_Activating () + { + TableView tableView = new () { Viewport = new Rectangle (0, 0, 40, 10) }; + tableView.Style.ExpandLastColumn = false; + tableView.Table = BuildTable (2, 2); + + var activatingFired = 0; + tableView.Activating += (_, _) => activatingFired++; + + // BuildTable column "Col0" / "Col1" rendered with values like "R0C0" gives + // narrow columns. With ExpandLastColumn=false and viewport width 40, x=35 is + // well past the right edge of the last rendered column. + Mouse mouseEvent = new () + { + Position = new Point (35, 3), + Flags = MouseFlags.LeftButtonClicked + }; + + MouseBinding binding = new ([Command.Activate], mouseEvent); + + tableView.InvokeCommand (Command.Activate, binding); + + Assert.Equal (0, activatingFired); + + tableView.Dispose (); + } + + // Claude - Opus 4.7 + // Click on the column header area must not raise Activating — the click + // doesn't correspond to a data cell. + [Fact] + public void Click_OnHeader_DoesNotRaise_Activating () + { + TableView tableView = new () { Viewport = new Rectangle (0, 0, 25, 10) }; + tableView.Table = BuildTable (2, 2); + + var activatingFired = 0; + tableView.Activating += (_, _) => activatingFired++; + + // The header occupies y=0..2 (overline + header text + underline). y=1 is + // the header text line. + Mouse mouseEvent = new () + { + Position = new Point (1, 1), + Flags = MouseFlags.LeftButtonClicked + }; + + MouseBinding binding = new ([Command.Activate], mouseEvent); + + tableView.InvokeCommand (Command.Activate, binding); + + Assert.Equal (0, activatingFired); + + tableView.Dispose (); + } + + // Claude - Opus 4.7 + // Click on a TableView with no Table set must not raise Activating. + [Fact] + public void Click_EmptyTable_DoesNotRaise_Activating () + { + TableView tableView = new () { Viewport = new Rectangle (0, 0, 25, 10) }; + + // Intentionally no Table assigned. + var activatingFired = 0; + tableView.Activating += (_, _) => activatingFired++; + + Mouse mouseEvent = new () + { + Position = new Point (5, 5), + Flags = MouseFlags.LeftButtonClicked + }; + + MouseBinding binding = new ([Command.Activate], mouseEvent); + + tableView.InvokeCommand (Command.Activate, binding); + + Assert.Equal (0, activatingFired); + + tableView.Dispose (); + } + + // Claude - Opus 4.7 + // Click on a Table that has zero rows (header rendered but no data) must not + // raise Activating regardless of where in the data area the user clicks. + [Fact] + public void Click_TableWithZeroRows_DoesNotRaise_Activating () + { + TableView tableView = new () { Viewport = new Rectangle (0, 0, 25, 10) }; + tableView.Table = BuildTable (2, 0); + + var activatingFired = 0; + tableView.Activating += (_, _) => activatingFired++; + + // y=3 is just past the header, in what would be the first data row if any + // existed. With zero rows, ScreenToCell must return null. + Mouse mouseEvent = new () + { + Position = new Point (1, 3), + Flags = MouseFlags.LeftButtonClicked + }; + + MouseBinding binding = new ([Command.Activate], mouseEvent); + + tableView.InvokeCommand (Command.Activate, binding); + + Assert.Equal (0, activatingFired); + + tableView.Dispose (); + } } diff --git a/Tests/UnitTestsParallelizable/Views/TextView.SelectionTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.SelectionTests.cs index 7d6b22794d..6aa0b528c4 100644 --- a/Tests/UnitTestsParallelizable/Views/TextView.SelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextView.SelectionTests.cs @@ -302,4 +302,45 @@ public void CtrlSpace_Toggles_Selection_Mode () Assert.Equal (28, tv.SelectionStartColumn); Assert.Equal (2, tv.SelectionStartRow); } + + [Fact] + public void Mouse_Selection_ToTheEndOfLine_With_ReadOnly_FalseOrTrue_Works_Correctly () + { + // // Test that mouse selection to the end of line works correctly with ReadOnly false or true + TextView tv = new () + { + Width = 10, + Height = 2, + Text = "This is the first line.\nThis is the second line.\nThis is the third line.first" + }; + + tv.BeginInit (); + tv.EndInit (); + + Assert.False (tv.ReadOnly); + + // Simulate mouse selection from (0,0) to end of first line + tv.NewMouseEvent (new Mouse () { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + tv.NewMouseEvent (new Mouse () { Position = new Point (23, 0), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + tv.NewMouseEvent (new Mouse () { Position = new Point (23, 0), Flags = MouseFlags.LeftButtonReleased }); + + Assert.Equal (new Point (23, 0), tv.InsertionPoint); + Assert.Equal (23, tv.SelectedLength); + Assert.Equal ("This is the first line.", tv.SelectedText); + Assert.True (tv.IsSelecting); + + // Now set ReadOnly to true, reset position and repeat + tv.ReadOnly = true; + tv.InsertionPoint = Point.Empty; + + // Simulate mouse selection from (0,0) to end of first line again + tv.NewMouseEvent (new Mouse () { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + tv.NewMouseEvent (new Mouse () { Position = new Point (23, 0), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + tv.NewMouseEvent (new Mouse () { Position = new Point (23, 0), Flags = MouseFlags.LeftButtonReleased }); + + Assert.Equal (new Point (23, 0), tv.InsertionPoint); + Assert.Equal (23, tv.SelectedLength); + Assert.Equal ("This is the first line.", tv.SelectedText); + Assert.True (tv.IsSelecting); + } } diff --git a/docfx/docs/config.md b/docfx/docs/config.md index 96abc414cf..60f1e33455 100644 --- a/docfx/docs/config.md +++ b/docfx/docs/config.md @@ -227,12 +227,9 @@ A **Theme** is a named collection of visual settings bundled together. Terminal. // Get current theme ThemeScope currentTheme = ThemeManager.GetCurrentTheme(); -// Get all available themes (null if ConfigurationManager not yet initialized) -ConcurrentDictionary? themes = ThemeManager.Themes; -if (themes is null) -{ - return; // ConfigurationManager not yet initialized -} +// Get all available themes. Before ConfigurationManager is initialized, +// this falls back to the hard-coded Default theme. +ConcurrentDictionary themes = ThemeManager.Themes!; // Get theme names ImmutableList themeNames = ThemeManager.GetThemeNames(); @@ -507,6 +504,13 @@ This: 2. Loads configuration from all locations 3. Applies settings to the application +### Configuration Gotchas + +- `ConfigurationManager.Initialize()` is internal and runs automatically when the module loads. Do not call it directly. +- Initialization discovers configuration properties and caches hard-coded defaults, but it does **not** enable resource-backed configuration. +- To load `Resources/config.json`, home/current-directory config files, `TUI_CONFIG`, or `RuntimeConfig`, call `ConfigurationManager.Enable(...)`. +- `ThemeManager.Themes` falls back to the hard-coded `Default` theme before configuration is initialized. After initialization, it expects a `Themes` entry in settings and can throw if that entry is unavailable. + ### Granular Control For more control, use 's `Load` and `Apply()` separately: diff --git a/docfx/docs/layout.md b/docfx/docs/layout.md index b187a011eb..1e3e7243cd 100644 --- a/docfx/docs/layout.md +++ b/docfx/docs/layout.md @@ -158,7 +158,7 @@ Label absoluteLabel = new () { X = 1, Y = 2, Width = 12, Height = 1, Text = "Abs Label responsiveLabel = new () { Text = "Responsive", - X = Pos.Right (absoluteLabel), + X = Pos.Right (otherView), Y = Pos.Center (), Width = Dim.Fill (), Height = Dim.Percent (50) @@ -169,13 +169,13 @@ Label responsiveLabel = new () is the type of `View.X` and `View.Y`. To make a view's position respond to available space or other views instead of using a fixed coordinate, use it. -* Absolute position, by passing an integer - `Pos.Absolute (10)` or simply `X = 10` -* Percentage of the `SuperView` size - `Pos.Percent (50)` -* Anchored from the end of the dimension - `Pos.AnchorEnd (10)` +* Absolute position, by passing an integer - `Pos.Absolute ()` +* Percentage of the `SuperView` size - `Pos.Percent ()` +* Anchored from the end of the dimension - `Pos.AnchorEnd ()` * Centered - `Pos.Center ()` -* Tracking another view - `Pos.Left (otherView)`, `Pos.Right (otherView)`, `Pos.Top (otherView)`, `Pos.Bottom (otherView)` -* Aligning as a group - `Pos.Align (...)` -* Computing from a function - `Pos.Func (...)` +* Tracking another view - `Pos.Left ()`, `Pos.Right ()`, `Pos.Top ()`, `Pos.Bottom ()` +* Aligning as a group - `Pos.Align ()` +* Computing from a function - `Pos.Func ()` All coordinates are relative to the SuperView's content area. @@ -197,12 +197,12 @@ myView.Y = Pos.Bottom (anotherView) + 5; is the type of `View.Width` and `View.Height`. To make size respond to content, terminal size, or sibling views instead of using a fixed number of cells, use it. * Automatic size based on the view's content - `Dim.Auto ()` - See [Dim.Auto Deep Dive](dimauto.md) -* Absolute size, by passing an integer - `Dim.Absolute (10)` -* Percentage of the `SuperView` content area - `Dim.Percent (50)` +* Absolute size, by passing an integer - `Dim.Absolute ()` +* Percentage of the `SuperView` content area - `Dim.Percent ()` * Fill the remaining space - `Dim.Fill ()` * Fill up to another view - `Dim.Fill (to: otherView)` -* Track another view's size - `Dim.Width (otherView)`, `Dim.Height (otherView)` -* Compute from a function - `Dim.Func (() => 10)` +* Track another view's size - `Dim.Width ()`, `Dim.Height ()` +* Compute from a function - `Dim.Func ()` `Dim.Fill ()` is especially useful for responsive forms and panes. **Note:** `Dim.Fill` does not contribute to a `SuperView`'s `Dim.Auto ()` sizing unless `minimumContentDim` is specified. See [Dim.Auto Deep Dive](dimauto.md) for details. diff --git a/docfx/docs/tableview.md b/docfx/docs/tableview.md index 625ccc6ce7..b0fc21ce40 100644 --- a/docfx/docs/tableview.md +++ b/docfx/docs/tableview.md @@ -99,13 +99,13 @@ TableView implements `IValue` to expose the complete selection | Type | Description | |------|-------------| -| `TableSelection` | Immutable snapshot: `Cursor` (a `Point`) + `Regions` (an `IReadOnlyList`) | +| `TableSelection` | Immutable snapshot: `SelectedCell` (a `Point`) + `Regions` (an `IReadOnlyList`) | | `TableSelectionRegion` | A contiguous rectangular selection. Has `Origin`, `Rectangle`, and `IsExtended` | | `Value` property | The current `TableSelection?`. `null` means no table is set or selection was cleared | -### Cursor +### SelectedCell -The cursor is the active cell — the anchor for navigation. Access it via `Value.Cursor` (`Point` where `X` = column index, `Y` = row index). +The selected cell is the active cell — the anchor for navigation. Access it via `Value.SelectedCell` (`Point` where `X` = column index, `Y` = row index). Move the cursor programmatically with `SetSelection (col, row, extend)`. @@ -124,11 +124,25 @@ Extended regions (`IsExtended = true`) persist through keyboard navigation. Non- When `FullRowSelect` is `true`, entire rows are selected instead of individual cells. All cells in the cursor's row are reported as selected by `GetAllSelectedCells ()` and `IsSelected ()`. +> **Tip — Home/End with FullRowSelect:** By default, `Home` and `End` navigate to the +> start/end of the current *row* (i.e. first/last column). To make `Home`/`End` jump to +> the first/last *row* instead (which is often more useful in full-row mode), rebind them +> to `Command.Start` and `Command.End`: +> +> ```csharp +> tableView.KeyBindings.Remove (Key.Home); +> tableView.KeyBindings.Add (Key.Home, Command.Start); +> tableView.KeyBindings.Remove (Key.End); +> tableView.KeyBindings.Add (Key.End, Command.End); +> ``` +> +> This is the pattern used by `UICatalogRunnable` for its scenario list. + ### Reading the Selection ```csharp -// Cursor position -Point cursor = tv.Value!.Cursor; // (col, row) +// Selected cell position +Point selectedCell = tv.Value!.SelectedCell; // (col, row) // All selected cell coordinates IEnumerable cells = tv.GetAllSelectedCells (); @@ -304,18 +318,18 @@ TableView uses the standard `IValue` and `View` event patterns: | Event | When | |-------|------| | `ValueChanging` | Before `Value` changes. Set `Handled = true` to cancel. | -| `ValueChanged` | After `Value` changed. Use this to react to cursor/selection changes. | +| `ValueChanged` | After `Value` changed. Use this to react to selection changes. | | `Accepted` | User double-clicks or presses the Accept key on a cell. | | `Activating` | User clicks a cell (`Command.Activate`). | -### Example: Reacting to Cursor Movement +### Example: Reacting to Selection Changes ```csharp tv.ValueChanged += (sender, e) => { if (e.NewValue is { } sel) { - statusBar.Text = $"Row {sel.Cursor.Y}, Col {sel.Cursor.X}"; + statusBar.Text = $"Row {sel.SelectedCell.Y}, Col {sel.SelectedCell.X}"; } }; ``` @@ -325,8 +339,8 @@ tv.ValueChanged += (sender, e) => ```csharp tv.Accepted += (sender, e) => { - Point cursor = tv.Value!.Cursor; - object cellValue = tv.Table! [cursor.Y, cursor.X]; + Point selectedCell = tv.Value!.SelectedCell; + object cellValue = tv.Table! [selectedCell.Y, selectedCell.X]; MessageBox.Query ("Cell", $"Value: {cellValue}", "OK"); }; ``` diff --git a/docfx/images/hero.gif b/docfx/images/hero.gif index 8b484a29de..a0f14b73d1 100644 Binary files a/docfx/images/hero.gif and b/docfx/images/hero.gif differ diff --git a/plans/linear-range-ivalue-and-clet.md b/plans/linear-range-ivalue-and-clet.md new file mode 100644 index 0000000000..1acc4e0476 --- /dev/null +++ b/plans/linear-range-ivalue-and-clet.md @@ -0,0 +1,263 @@ +# LinearRange `IValue` Refactor and `linear-range` Clet + +Tracking: [Terminal.Gui#5202](https://github.com/gui-cs/Terminal.Gui/issues/5202) +Downstream consumer: [gui-cs/clet](https://github.com/gui-cs/clet) + +## 1. Problem + +`LinearRange` does not implement `IValue`. The Terminal.Gui v2 convention +(`SelectorBase`, `CheckBox`, `DatePicker`, `ScrollBar`, `Tabs`, `ListView`, +`NumericUpDown`, …) is for any view whose primary purpose is editing a value +to expose that value through `IValue` (`Value` getter/setter, +`ValueChanging` / `ValueChanged` / `ValueChangedUntyped` events). + +This matters now because the [clet](https://github.com/gui-cs/clet) project +wraps Terminal.Gui views as command-line tools. Today, clet ships a +hand-rolled `RangeView` + `RangeClet` that is **not** built on `LinearRange`; +that is duplicated work and leaves the real, feature-rich `LinearRange` (with +typed options, legends, abbreviation, range modes, drag, CWP events) inaccessible +from the CLI. Wiring `LinearRange` into clet requires a single canonical +typed `Value` surface — i.e. `IValue`. + +### The design tension + +`LinearRange`'s "value" is heterogeneous and depends on +`LinearRangeType`: + +| Type | What the user picked | Natural shape | +|----------------|-------------------------------------------------------|---------------------| +| `Single` | 0 or 1 option | `T?` | +| `Multiple` | 0..N options | `IReadOnlyList` | +| `LeftRange` | 1 cut point: "everything ≤ X" | `T` plus a kind tag | +| `RightRange` | 1 cut point: "everything ≥ X" | `T` plus a kind tag | +| `Range` | 2 cut points: closed interval | `(T start, T end)` | + +Today this is exposed as `_setOptions: List` (indices) plus an +`OptionsChanged` event carrying `Dictionary>`. +There is no `Value` and no clean way to bind one. Forcing a single +`IValue` over all five types either over-boxes (`IValue` — +useless for type-safety) or under-fits (`IValue` — drops Multiple/Range). + +## 2. Recommendation: split the family, then implement `IValue` + +Mirror what was done with `SelectorBase` → `OptionSelector` / +`FlagSelector`. Make value semantics part of the **type** +the consumer picks, so each subclass has a single, honest +`IValue`. + +### 2.1 New type family (in `Terminal.Gui/Views/LinearRange/`) + +``` +LinearRangeViewBase abstract base : View, IOrientation, IValue + │ + ├── LinearSelector : LinearRangeViewBase // was Type.Single + │ + ├── LinearMultiSelector : LinearRangeViewBase> // was Type.Multiple + │ + └── LinearRange : LinearRangeViewBase> // was Range / LeftRange / RightRange +``` + +* `LinearRangeViewBase` owns: + the option list (`IReadOnlyList> Options`), + `Orientation`, `LegendsOrientation`, `ShowLegends`, `ShowEndSpacing`, + `MinimumInnerSpacing`, `UseMinimumSize`, `Style`, `AllowEmpty`, + drawing, hit-testing, CWP events for those properties, key/mouse handling, + and the protected `SetSelectedIndices(IReadOnlyList)` plumbing + shared by subclasses. + +* Each subclass exposes its own `Value` via `CWPPropertyHelper.ChangeProperty` + and translates `Value ↔ indices` internally. + +* `LinearRangeType` enum is **deleted** as public surface. + (Internally `LinearRangeViewBase` keeps a `RenderMode` analogue used + only by drawing/hit-testing — `Single`, `Multiple`, `LeftSpan`, `RightSpan`, + `Span` — set by each subclass in its constructor.) + +* The non-generic `LinearRange : LinearRange` shortcut goes away. + Callers that wanted "any options" now pick `LinearSelector`, + `LinearMultiSelector`, or `LinearRange`. + +### 2.2 `LinearRangeSpan` + +```csharp +public readonly record struct LinearRangeSpan +{ + public LinearRangeSpan (LinearRangeSpanKind kind, T? start, T? end, int startIndex, int endIndex) + { … } + + public LinearRangeSpanKind Kind { get; } // None | LeftBounded | RightBounded | Closed + public T? Start { get; } + public T? End { get; } + public int StartIndex { get; } // -1 when not set + public int EndIndex { get; } // -1 when not set + + public static LinearRangeSpan Empty { get; } = new (LinearRangeSpanKind.None, default, default, -1, -1); +} + +public enum LinearRangeSpanKind { None, LeftBounded, RightBounded, Closed } +``` + +This is one struct that can describe all three "range" sub-modes — kind +selects which fields are meaningful. Equality / `record struct` +gives free `EqualityComparer<>` for the CWP guard. + +`LinearRange` exposes `RangeKind { get; set; }` — `LeftBounded`, +`RightBounded`, `Closed` — defaulting to `Closed`. Setting it migrates +the current `Value` (e.g. dropping `End` when switching `Closed → +LeftBounded`). + +### 2.3 Why this works for `IValue` + +| New view | `IValue` satisfies #5202 because… | +|--------------------------|------------------------------------------------------------------------------| +| `LinearSelector` | Drop-in for any "pick one of N typed things" — same shape as `Tabs`, `OptionSelector`. | +| `LinearMultiSelector` | First-class multi-pick view; `Value` is an immutable list, easy to data-bind. | +| `LinearRange` | The honest "range" case; `Value` is a struct that already carries kind. | + +Each is a single concrete `IValue` — no `T?` ambiguity, no +heterogeneous boxing. + +## 3. Migration / breaking changes + +`LinearRange` is alpha and #5202 explicitly trades breakage for a clean +shape. Concretely: + +| Old | New | +|----------------------------------------------|-------------------------------------------------------| +| `LinearRange` (Type=Single) | `LinearSelector` | +| `LinearRange` (Type=Multiple) | `LinearMultiSelector` | +| `LinearRange` (Type=Range / Left / Right) | `LinearRange` with `RangeKind` | +| `LinearRange : LinearRange` | removed; pick the typed subclass | +| `Type` property | removed | +| `SetOption`, `UnSetOption`, `GetSetOptions` | removed; use `Value` setter | +| `OptionsChanged` | replaced by `ValueChanging` / `ValueChanged` | +| `OptionFocused` | retained on the base (focus is independent of value) | + +UICatalog `LinearRanges.cs` and the existing tests +(`LinearRangeTests`, `LinearRangeFluentTests`, +`LinearRangeDefaultKeyBindingsTests`) get migrated as part of the same +PR; net coverage must not drop. + +## 4. Downstream: the `linear-range` clet + +clet currently has `RangeClet` + a custom `RangeView` for numeric +`low..high` input. That stays — it's the *numeric* range tool. The new +clet wraps the new Terminal.Gui views. + +### 4.1 One clet, three modes + +`clet linear-range` covers all three subclasses via `--mode`. This keeps +discovery simple (`clet list` shows one entry) and lets agents pick the +shape they want from a single command. + +``` +clet linear-range \ + --title \ + --mode single | multi | range \ + --options \ + [--initial ] \ + [--orientation horizontal|vertical] \ + [--show-legends] [--no-end-spacing] [--allow-empty] \ + [--range-kind closed|left|right] # only with --mode range + [--json] [--timeout 30s] +``` + +#### Options spec + +`--options` accepts two forms, picked by parsing: + +1. **Labelled enumeration** — `"Free,Pro,Team,Enterprise"`. + Each label becomes a `LinearRangeOption` whose `Data == Legend`. +2. **Numeric range** — `"0..1000:50"` (start..end[:step]). Expands to + `LinearRangeOption` (or `` if all components parse as + integers) with `Legend = value.ToString()`. + +`--initial` matches the same forms: + +| Mode | Initial syntax | +|---------|-----------------------------------------| +| single | `"Pro"` or `"500"` | +| multi | `"Pro,Team"` or `"100,300,500"` | +| range | `"100..500"`, `"..500"` (left), `"100.."` (right) | + +### 4.2 JSON output + +Schema version stays 1, status / cancelled / error envelopes stay as in +the existing clet contract. + +```jsonc +// --mode single +{ "schemaVersion":1, "status":"ok", "mode":"single", + "value":"Pro", "index":1 } + +// --mode multi +{ "schemaVersion":1, "status":"ok", "mode":"multi", + "values":["Pro","Team"], "indices":[1,2] } + +// --mode range (range-kind closed) +{ "schemaVersion":1, "status":"ok", "mode":"range", "kind":"closed", + "start":100, "end":500, "startIndex":2, "endIndex":10 } + +// --mode range (range-kind left) +{ "schemaVersion":1, "status":"ok", "mode":"range", "kind":"left", + "end":500, "endIndex":10 } +``` + +Cancellation / errors use the existing envelopes; exit codes unchanged +(0/1/2/130). + +### 4.3 Mapping from Terminal.Gui to clet + +``` +RangeCletV2 (file: Clets/Input/LinearRangeClet.cs) + --mode single → LinearSelector → result.Value + --mode multi → LinearMultiSelector→ result.Values + --mode range → LinearRange → result.Value : LinearRangeSpan +``` + +All three are `IValue`, so the clet wraps each in +`RunnableWrapper`, awaits Accept/Cancel, and serialises `view.Value` with +a per-mode JSON shape. No special-cases beyond the mode switch. + +## 5. Implementation order + +1. **Add `LinearRangeSpan` + `LinearRangeSpanKind`** under `LinearRange/`. +2. **Extract `LinearRangeViewBase`** from today's + `LinearRange.cs` — keep drawing, layout, options, key/mouse, + focus, CWP for non-value properties; abstract out `SetSelectedIndices` + and value translation. +3. **Add `LinearSelector`** with `IValue`. Migrate single-select + tests. +4. **Add `LinearMultiSelector`** with `IValue>`. + Use a defensive immutable copy in the setter; equality via + `SequenceEqual` in the CWP guard. +5. **Replace `LinearRange`** body with the range-only subclass using + `IValue>`; expose `RangeKind`. +6. **Delete** `LinearRangeType`, the non-generic `LinearRange`, and the + index-centric public methods (`SetOption`, `UnSetOption`, + `GetSetOptions`, `OptionsChanged`, `ChangeOption`). +7. **Migrate** `Examples/UICatalog/Scenarios/LinearRanges.cs` — three + demo views, one per subclass. +8. **Update tests** — port to value-based assertions; new tests for + `Value`/`ValueChanging`/`ValueChanged` on each subclass. +9. **(clet repo)** Add `LinearRangeClet : IClet` next to + `RangeClet.cs`. Don't touch `RangeClet`. + +## 6. Open questions for review + +* **Naming of `LinearSelector` / `LinearMultiSelector`.** + Alternatives: `LinearPicker`, `LinearChoice`, + `RangeSelector` (Multi). `LinearSelector` reads cleanly next to + `OptionSelector` / `FlagSelector` and stays in the slider family. +* **Should the multi value be `ImmutableArray` instead of + `IReadOnlyList`?** Better defensiveness, slightly heavier API. + Lean toward `IReadOnlyList` to match `IValue` precedent + (lightweight contract). +* **`LinearRangeOption` keeps its `Set`/`UnSet`/`Changed` events?** + These are useful per-option signals (e.g. "this tier was just + selected"). Keep them on the base; they fire alongside the new + `ValueChanged` event. +* **Single clet vs three clets in the CLI.** Single `linear-range` + with `--mode` keeps `clet list` lean and matches the structural + symmetry; the alternative would be `linear-select`, `linear-multi`, + `linear-range`. Recommend single. diff --git a/plans/osc8-toansi-hyperlinks.md b/plans/osc8-toansi-hyperlinks.md new file mode 100644 index 0000000000..fb2420a4b9 --- /dev/null +++ b/plans/osc8-toansi-hyperlinks.md @@ -0,0 +1,57 @@ +# Plan: Emit OSC 8 Hyperlink Sequences in `ToAnsi()` + +## Problem Statement + +When `Driver.ToAnsi()` is called (e.g., for print mode in `clet --help` or `mdv --print`), the resulting ANSI string includes SGR styling (underline, color) for links but does NOT include [OSC 8 hyperlink sequences](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda). This means links are visually styled but not clickable in terminals that support OSC 8. + +## Existing Infrastructure + +The codebase already has all the pieces: + +1. **Cell-level URL storage**: `IOutputBuffer.GetCellUrl(col, row)` returns the URL associated with a cell. `OutputBufferImpl` stores these in a `_urlMap` dictionary keyed by `Point(col, row)`. + +2. **URL assignment during draw**: Both `Link` view and `Markdown` view set `Driver.CurrentUrl` before drawing cells. The `OutputBufferImpl.AddStr`/`AddRune` methods call `SetCellUrl` when `CurrentUrl` is non-null. + +3. **OSC 8 utilities**: `EscSeqUtils.OSC_StartHyperlink(url)` and `EscSeqUtils.OSC_EndHyperlink()` already exist. + +4. **Real-time rendering already works**: `OutputBase.Write(IOutputBuffer)` already queries `buffer.GetCellUrl(col, row)` and emits OSC 8 sequences during live terminal output. + +5. **`ToAnsi()` does NOT emit OSC 8**: `BuildAnsiForRegion` (called by `ToAnsi`) only handles SGR attribute changes and graphemes — it has no URL tracking. + +## Design + +### Approach + +Modify `BuildAnsiForRegion` in `OutputBase.cs` to track the current URL state and emit OSC 8 open/close sequences when the URL changes between cells. + +### Implementation + +In `BuildAnsiForRegion`, add a `string? lastUrl = null` tracker. For each cell: +1. Query `buffer.GetCellUrl(col, row)` to get the cell's URL +2. If the URL differs from `lastUrl`: + - If `lastUrl` was non-null, emit `EscSeqUtils.OSC_EndHyperlink()` to close the previous link + - If the new URL is non-null, emit `EscSeqUtils.OSC_StartHyperlink(url)` to open the new link + - Update `lastUrl` +3. After the loop completes (or at end of each row), if `lastUrl` is non-null, emit `EscSeqUtils.OSC_EndHyperlink()` + +### Files Changed + +- `Terminal.Gui/Drivers/Output/OutputBase.cs` — `BuildAnsiForRegion` method + +## Unit Tests + +Tests in `Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs`: + +1. **`ToAnsi_CellsWithUrl_EmitsOsc8Sequences`**: Create a buffer, set `CurrentUrl`, write text, verify `ToAnsi()` output contains OSC 8 start/end sequences. + +2. **`ToAnsi_CellsWithDifferentUrls_EmitsCorrectTransitions`**: Verify that transitioning between different URLs properly closes the first and opens the second. + +3. **`ToAnsi_CellsWithUrl_ThenNoUrl_ClosesHyperlink`**: Verify that when URL cells are followed by non-URL cells, the hyperlink is properly closed. + +4. **`ToAnsi_LegacyConsole_NoOsc8`**: Verify that legacy console mode does not emit OSC 8. + +## Verification + +- Existing `LinkTests.Link_Renders_With_OSC8_Hyperlink` test already verifies OSC 8 in live rendering +- New tests verify OSC 8 in `ToAnsi()` output specifically +- Run `MarkdownViewTests` to ensure no regressions