diff --git a/.claude/POST-GENERATION-VALIDATION.md b/.claude/POST-GENERATION-VALIDATION.md new file mode 100644 index 0000000000..01d92eaa4c --- /dev/null +++ b/.claude/POST-GENERATION-VALIDATION.md @@ -0,0 +1,245 @@ +# Post-Generation Validation Checklist + +**USE THIS CHECKLIST AFTER GENERATING OR MODIFYING ANY CODE.** + +This is a mandatory validation step. AI agents frequently make formatting and style errors that violate Terminal.Gui conventions. Scan every line of generated code before considering the task complete. + +## Part 1: Formatting Violations (MOST CRITICAL) + +These are the **most commonly violated rules**. Check EVERY line: + +### Space Before Parentheses ⚠️ #1 MISTAKE +```csharp +// CORRECT ✓ +void MyMethod () +int result = Calculate (x, y); +var items = GetItems (); +if (condition) +using (var obj = Create ()) + +// WRONG ✗ - SCAN FOR THESE +void MyMethod() // Missing space before () +int result = Calculate(x, y); +var items = GetItems(); +if(condition) +using(var obj = Create()) +``` + +**Scan pattern:** Look for `\w(` (word character followed immediately by `(`) + +### Space Before Brackets ⚠️ #2 MISTAKE +```csharp +// CORRECT ✓ +var value = array [index]; +var item = list [0]; +MyArray [i] = value; + +// WRONG ✗ - SCAN FOR THESE +var value = array[index]; // Missing space before [ +var item = list[0]; +MyArray[i] = value; +``` + +**Scan pattern:** Look for `\w[` (word character followed immediately by `[`) + +### Braces on Next Line ⚠️ #3 MISTAKE +```csharp +// CORRECT ✓ +void MyMethod () +{ + if (condition) + { + DoWork (); + } +} + +// WRONG ✗ - SCAN FOR THESE +void MyMethod() { // Brace on same line +void MyMethod () { // Brace on same line + if (condition) { // Brace on same line + DoWork(); + } +} +``` + +**Scan pattern:** Look for `) {` or `= {` (brace on same line) + +### Blank Lines ⚠️ #4 MISTAKE +```csharp +// CORRECT ✓ - blank line BEFORE control transfer +DoWork (); + +return result; // Blank line above + +// CORRECT ✓ - blank line AFTER control block +if (condition) +{ + DoWork (); +} + +DoNext (); // Blank line above + +// WRONG ✗ - SCAN FOR THESE +DoWork (); +return result; // No blank line above return + +if (condition) { DoWork (); } +DoNext (); // No blank line after control block +``` + +**Control transfer statements:** `return`, `break`, `continue`, `throw` +**Control blocks:** `if`, `for`, `while`, `foreach`, `using`, `try`/`catch` + +### Indentation +```csharp +// CORRECT ✓ - 4 spaces per level +public class MyClass +{ + public void MyMethod () + { + if (condition) + { + DoWork (); + } + } +} + +// WRONG ✗ - tabs or wrong spacing +public class MyClass +{ + public void MyMethod () // Tab instead of spaces + { + if (condition) // 2 spaces instead of 4 + { +``` + +**Rule:** 4 spaces per indentation level. NO tabs. + +## Part 2: Code Style Violations + +### No `var` for Non-Built-In Types +```csharp +// CORRECT ✓ +Label label = new () { Text = "Hello" }; +List views = []; +Window window = new (); +var count = 0; // OK - int is built-in +var text = "hello"; // OK - string is built-in + +// WRONG ✗ +var label = new Label { Text = "Hello" }; +var views = new List(); +var window = new Window(); +``` + +**Built-in types where var is OK:** `int`, `string`, `bool`, `double`, `float`, `decimal`, `char`, `byte` + +### Target-Typed New +```csharp +// CORRECT ✓ +Label label = new () { Text = "Hello" }; +Window window = new (); + +// WRONG ✗ +Label label = new Label() { Text = "Hello" }; +Window window = new Window(); +``` + +### Collection Expressions +```csharp +// CORRECT ✓ +List items = ["one", "two", "three"]; +AllSuggestions = ["word1", "word2", "word3"]; +return []; + +// WRONG ✗ +List items = new () { "one", "two", "three" }; +AllSuggestions = new () { "word1", "word2", "word3" }; +return new List(); +``` + +### Lambda Parameter Discards +```csharp +// CORRECT ✓ +textField.TextChanged += (_, _) => { /* ... */ }; +button.Accepting += (_, args) => ProcessArgs (args); + +// WRONG ✗ - unused parameters +textField.TextChanged += (sender, e) => { /* ... */ }; +textField.TextChanged += (s, prev) => { /* ... */ }; +``` + +### Terminology +```csharp +// CORRECT ✓ - containment relationship +View superView = new (); +View subView = new (); +superView.Add (subView); // subView.SuperView == superView + +// WRONG ✗ - for containment +View parent = new (); // Should be: superView +View child = new (); // Should be: subView +parent.Add (child); + +// Comments: +// Add the button as a SubView of the window +// The dialog's SuperView is the application + +// WRONG ✗ - in comments +// Add the button as a child of the window +// The dialog's parent is the application +``` + +**Use SuperView/SubView for containment.** Parent/Child only for non-containment references (rare). + +## Part 3: Quick Scan Commands + +Use these bash commands to catch common violations: + +```bash +# Missing space before ( - most common error +grep -nE '\w\(' YourFile.cs | grep -v '//' + +# Missing space before [ +grep -nE '\w\[' YourFile.cs | grep -v '//' + +# Brace on same line +grep -n ') {' YourFile.cs +grep -n '= {' YourFile.cs + +# Var usage (manually verify each is built-in type) +grep -n '\bvar\b' YourFile.cs + +# Trailing whitespace +grep -n ' $' YourFile.cs +``` + +## Part 4: Manual Review + +After automated checks, manually review: + +1. **Every method call/declaration** - space before `()`? +2. **Every array access** - space before `[]`? +3. **Every opening brace** - on next line? +4. **Every return statement** - blank line before it? +5. **Every control block** - blank line after it? + +## Validation Frequency + +Use this checklist: +- ✅ After generating any new class or file +- ✅ After modifying existing methods (scan modified lines) +- ✅ After completing any task that involves code generation +- ✅ Before creating a commit or pull request +- ✅ When the code "looks wrong" visually + +## Why This Matters + +Formatting violations: +- Create noise in code reviews +- Violate project CI/CD checks +- Make code inconsistent with the rest of Terminal.Gui +- Are the #1 complaint about AI-generated code +- Are easily preventable with this checklist + +**The space-before-parentheses style is unusual compared to most C# projects, making it the most commonly violated rule.** diff --git a/.claude/REFRESH.md b/.claude/REFRESH.md index 8b293f4ab7..a4f5f9a710 100644 --- a/.claude/REFRESH.md +++ b/.claude/REFRESH.md @@ -11,7 +11,9 @@ 5. **Unused lambda params** - use `_` discard: `(_, _) => { }` 6. **Local functions** - use camelCase: `void myLocalFunc ()` 7. **Backing fields** - place immediately before their property (ReSharper bug, must do manually) -8. **ReShaper Formatting** - run ReSharper code cleanup with "Full Cleanup" profile (not the built-in one). +8. **SPACE BEFORE PARENTHESES** - `Method ()` not `Method()`, `array [i]` not `array[i]` (see `formatting.md`) +9. **Braces on next line** - ALL opening braces on next line (Allman style) +10. **Blank lines** - before `return`/`break`/`continue`/`throw`, after control blocks ## Before Each File Edit @@ -21,10 +23,14 @@ Ask yourself: - [ ] Am I using collection expressions []? - [ ] Are my lambda parameters discards if unused? - [ ] Am I using correct terminology (SubView, not child)? +- [ ] **Did I add space BEFORE parentheses and brackets?** +- [ ] **Are ALL braces on the next line?** +- [ ] **Did I add blank lines before returns and after control blocks?** ## If Unsure Re-read the relevant rule file in `.claude/rules/`: +- `formatting.md` - **SPACING, BRACES, BLANK LINES** (most commonly violated!) - `type-declarations.md` - var vs explicit types - `target-typed-new.md` - new() syntax - `terminology.md` - SubView/SuperView terms diff --git a/.claude/rules/formatting.md b/.claude/rules/formatting.md new file mode 100644 index 0000000000..2838b70b6a --- /dev/null +++ b/.claude/rules/formatting.md @@ -0,0 +1,157 @@ +# Code Formatting Rules + +**CRITICAL: AI agents frequently violate these formatting rules. Check EVERY line of code you write.** + +## Most Commonly Violated Rules + +### 1. Spacing Before Parentheses (CRITICAL!) + +```csharp +// CORRECT - space BEFORE parentheses +void MyMethod () +int result = Calculate (x, y); +var items = GetItems (); +var value = array [index]; + +// WRONG - no space before parentheses +void MyMethod() +int result = Calculate(x, y); +var items = GetItems(); +var value = array[index]; +``` + +**Rule:** Space BEFORE: +- Method declaration parentheses +- Method call parentheses +- Array/indexer brackets +- `if`, `while`, `for`, `foreach`, `switch`, `using`, `catch` parentheses + +### 2. Brace Placement + +```csharp +// CORRECT - braces on next line +void MyMethod () +{ + if (condition) + { + DoSomething (); + } +} + +// WRONG - braces on same line +void MyMethod() { + if (condition) { + DoSomething(); + } +} +``` + +**Rule:** ALL opening braces on the NEXT line (Allman style). + +### 3. Blank Lines + +```csharp +// CORRECT +DoWork (); + +return result; // Blank line BEFORE return + +// CORRECT +if (condition) +{ + DoWork (); +} + +DoSomethingElse (); // Blank line AFTER control block + +// WRONG - no blank line before return +DoWork (); +return result; +``` + +**Rules:** +- 1 blank line BEFORE control transfer statements (`return`, `break`, `continue`, `throw`) +- 1 blank line AFTER control blocks (`if`, `for`, `while`, `foreach`) +- 1 blank line BEFORE single-line comments +- 0 blank lines inside type declarations at start/end + +### 4. Indentation + +```csharp +// CORRECT - 4 spaces per level +public class MyClass +{ + public void MyMethod () + { + if (condition) + { + DoWork (); + } + } +} +``` + +**Rule:** 4 spaces per indentation level. NEVER tabs. + +### 5. Expression Bodies + +```csharp +// CORRECT - use expression bodies when appropriate +public int Count => _items.Count; +public string Name => _name; +public void Clear () => _items.Clear (); + +// CORRECT - use block bodies for complex logic +public int Calculate () +{ + int result = DoComplexWork (); + + return result * 2; +} +``` + +**Rule:** Prefer expression bodies (`=>`) for simple single-expression members. + +## Additional Spacing Rules + +```csharp +// After comma, colon, semicolon +Method (a, b, c); +base : MyBase +for (int i = 0; i < 10; i++) + +// Around binary operators +int sum = a + b; +bool result = x == y && z != 0; + +// NO space after cast, dot, or inside parentheses +var x = (int)value; +object.Property.Method (); +Calculate (a, b); // NO space inside parens +``` + +## Quick Checklist + +Before submitting code, verify: + +- [ ] Space BEFORE all method call/declaration parentheses +- [ ] Space BEFORE array brackets +- [ ] ALL braces on next line +- [ ] Blank line BEFORE `return`/`break`/`continue`/`throw` +- [ ] Blank line AFTER control blocks +- [ ] 4-space indentation (not tabs) +- [ ] Expression bodies for simple properties/methods +- [ ] Space after commas, around binary operators + +## When Modifying Existing Code + +1. **Match the surrounding style** if it differs from these rules +2. **Only reformat code you're actively changing** - don't reformat entire files +3. **Run ReSharper's "Cleanup Code"** if available (Ctrl+E, C) + +## Pro Tips + +- The spacing before parentheses is UNUSUAL compared to most C# codebases +- This is the #1 mistake AI agents make +- When in doubt about formatting, read a similar file in the codebase +- `.editorconfig` and `Terminal.sln.DotSettings` contain the complete rules diff --git a/.claude/tasks/clean-code-review.md b/.claude/tasks/clean-code-review.md new file mode 100644 index 0000000000..0c814ca006 --- /dev/null +++ b/.claude/tasks/clean-code-review.md @@ -0,0 +1,76 @@ +# Clean Git Commit History Workflow + +Reimplement the current branch on a new branch with a clean, narrative-quality git commit history suitable for reviewer comprehension. + +## Steps + +1. **Validate the source branch** + - Ensure the current branch has no merge conflicts, uncommitted changes, or other issues. + - Confirm it is up to date with `v2_develop`. + +2. **Analyze the diff** + - Study all changes between the current branch and `v2_develop`. + - Form a clear understanding of the final intended state. + +3. **Create the clean branch** + - Create a new branch named `{branch_name}-clean` from the current branch. + +4. **Plan the commit storyline** + - Break the implementation down into a sequence of self-contained steps. + - Each step should reflect a logical stage of development—as if writing a tutorial. + +5. **Reimplement the work** + - Recreate the changes in the clean branch, committing step by step according to your plan. + - Each commit must: + - Introduce a single coherent idea. + - Include a clear commit message and description. + - Add comments or inline code comments when needed to explain intent. + - Follow Terminal.Gui commit message conventions (see CONTRIBUTING.md). + - **Pass formatting validation** (see POST-GENERATION-VALIDATION.md). + +6. **Verify correctness** + - Confirm that the final state of `{branch_name}-clean` exactly matches the final state of the original branch. + - Use `--no-verify` only when necessary (e.g., to bypass known issues). Individual commits do not need to pass tests, but this should be rare. + +7. **Open a pull request** + - Create a PR from the clean branch to `v2_develop`. + - Follow Terminal.Gui PR guidelines (see CONTRIBUTING.md). + - Include a link to the original branch in the PR description. + +## Important Notes + +- **Each commit must run all tests**: Integration tests, unit tests, and parallelizable unit tests must be executed for every commit. While individual commits do not strictly need to *pass* all tests (this should be exceptional), the tests must be *run* to verify the commit's impact. Use `--no-verify` only when necessary to bypass known issues. +- It is essential that the end state of your new branch be identical to the end state of the source branch. +- Follow all Terminal.Gui coding conventions (see `.claude/rules/`). +- Ensure XML documentation is updated for any API changes (see `.claude/rules/api-documentation.md`). + +## Test Commands + +Run these for each commit: + +```bash +dotnet build --no-restore +dotnet test Tests/IntegrationTests --no-build +dotnet test Tests/UnitTests --no-build +dotnet test Tests/UnitTestsParallelizable --no-build +``` + +## Terminal.Gui Specific Requirements + +When creating commits for Terminal.Gui: + +1. **Code style** + - No `var` except for built-in types (see `.claude/rules/type-declarations.md`) + - Use `new ()` for target-typed new (see `.claude/rules/target-typed-new.md`) + - Use `[...]` for collections (see `.claude/rules/collection-expressions.md`) + - Use SubView/SuperView terminology (see `.claude/rules/terminology.md`) + +2. **Commit messages** + - Follow the project's commit message style + - Include Co-Authored-By line per CONTRIBUTING.md guidelines + - Keep messages clear and descriptive + +3. **Testing** + - Add tests to `UnitTestsParallelizable` when possible + - Never decrease code coverage + - Follow patterns in `.claude/rules/testing-patterns.md` diff --git a/.gitignore b/.gitignore index 308d4abebf..11b549b733 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,9 @@ _ReSharper.** *.[Rr]e[Ss]harper *.DotSettings.user -.vscode/ +.vscode/* +!.vscode/launch.json +!.vscode/tasks.json .vs/ # Visual Studio cache files diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..1c80a01442 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (Console - External Terminal)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/Examples/UICatalog/bin/Debug/net10.0/UICatalog.dll", + "args": [], + "cwd": "${workspaceFolder}/Examples/UICatalog", + "console": "externalTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Launch (Console - Integrated Terminal)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/Examples/UICatalog/bin/Debug/net10.0/UICatalog.dll", + "args": [], + "cwd": "${workspaceFolder}/Examples/UICatalog", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..ad0939d414 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Examples/UICatalog/UICatalog.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index fa56a6b7b2..7083c95ca9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,16 @@ > This file provides quick-reference conventions for AI agents. > See also: [llms.txt](llms.txt) for machine-readable context. +## Codex Compatibility Import + +When using Codex in this repository, import and follow `.claude` guidance as mandatory: + +1. Read `.claude/REFRESH.md` before editing any file. +2. Follow `.claude/rules/formatting.md` and all files in `.claude/rules/` while coding. +3. Run `.claude/POST-GENERATION-VALIDATION.md` after generating or modifying code. + +If this file and `.claude` conflict on coding conventions, `.claude` guidance takes precedence. + ## Are You Building an App or Contributing? | Task | Start Here | @@ -52,6 +62,14 @@ dotnet run **Test:** `dotnet test --no-build` **Details:** [Build & Test Workflow](.claude/workflows/build-test-workflow.md) +## Before Every File Edit + +**READ `.claude/REFRESH.md` first.** It contains the pre-edit checklist that prevents the most common violations. + +## After Writing/Modifying Code + +**USE `.claude/POST-GENERATION-VALIDATION.md` to validate all code changes.** + ## Quick Rules **⚠️ READ THIS BEFORE MODIFYING ANY FILE - These are Terminal.Gui-specific conventions:** @@ -63,32 +81,45 @@ dotnet run 5. **Unused lambda params** - Use `_` discard: `(_, _) => { }` 6. **Local functions** - Use camelCase: `void myLocalFunc ()` 7. **Backing fields** - Place immediately before their property +8. **Space before parentheses/brackets** - `Method ()`, `array [i]` (not `Method()`, `array[i]`) +9. **Braces on next line** - Use Allman style for all opening braces +10. **Blank lines** - Before `return`/`break`/`continue`/`throw`, and after control blocks ## Detailed Coding Rules Consult these files in `.claude/rules/` before editing code: -- [Type Declarations](/.claude/rules/type-declarations.md) - `var` vs explicit types -- [Target-Typed New](/.claude/rules/target-typed-new.md) - `new()` syntax -- [Collection Expressions](/.claude/rules/collection-expressions.md) - `[...]` syntax -- [Terminology](/.claude/rules/terminology.md) - SubView/SuperView terms -- [Event Patterns](/.claude/rules/event-patterns.md) - Lambdas, handlers, closures -- [CWP Pattern](/.claude/rules/cwp-pattern.md) - Cancellable Workflow Pattern -- [Code Layout](/.claude/rules/code-layout.md) - Member ordering, backing fields -- [Testing Patterns](/.claude/rules/testing-patterns.md) - Test writing conventions -- [API Documentation](/.claude/rules/api-documentation.md) - XML doc requirements +- [Formatting](.claude/rules/formatting.md) - Spacing, braces, blank lines +- [Type Declarations](.claude/rules/type-declarations.md) - `var` vs explicit types +- [Target-Typed New](.claude/rules/target-typed-new.md) - `new()` syntax +- [Collection Expressions](.claude/rules/collection-expressions.md) - `[...]` syntax +- [Terminology](.claude/rules/terminology.md) - SubView/SuperView terms +- [Event Patterns](.claude/rules/event-patterns.md) - Lambdas, handlers, closures +- [CWP Pattern](.claude/rules/cwp-pattern.md) - Cancellable Workflow Pattern +- [Code Layout](.claude/rules/code-layout.md) - Member ordering, backing fields +- [Testing Patterns](.claude/rules/testing-patterns.md) - Test writing conventions +- [API Documentation](.claude/rules/api-documentation.md) - XML doc requirements ## Workflows Process guides in `.claude/workflows/`: -- [Build & Test Workflow](/.claude/workflows/build-test-workflow.md) - Build, test, and troubleshooting -- [PR Workflow](/.claude/workflows/pr-workflow.md) - Submitting pull requests +- [Build & Test Workflow](.claude/workflows/build-test-workflow.md) - Build, test, and troubleshooting +- [PR Workflow](.claude/workflows/pr-workflow.md) - Submitting pull requests + +## Planning Mode + +When creating implementation plans: +- **Create plan files in `./plans/`** (relative to repository root) +- Use markdown format with clear sections +- Include: problem statement, implementation steps, file changes, verification steps +- Reference existing patterns and reuse opportunities from exploration ## Task-Specific Guides See `.claude/tasks/` for specialized checklists: - [build-app.md](.claude/tasks/build-app.md) - Building apps with Terminal.Gui +- [clean-code-review.md](.claude/tasks/clean-code-review.md) - Creating clean commit histories See `.claude/cookbook/` for common UI patterns: - [common-patterns.md](.claude/cookbook/common-patterns.md) - Forms, lists, menus, dialogs, etc. @@ -199,7 +230,7 @@ See `.claude/cookbook/` for common UI patterns: |Drivers/UnixDriver:{IUnixInput.cs,SuspendHelper.cs,UnixClipboard.cs,UnixComponentFactory.cs,UnixInput.cs,UnixInputProcessor.cs,UnixIOHelper.cs,UnixOutput.cs,UnixRawModeHelper.cs} |Drivers/WindowsDriver:{ClipboardImpl.cs,CursorVisibility.cs,IWindowsInput.cs,WindowsComponentFactory.cs,WindowsConsole.cs,WindowsInput.cs,WindowsInputProcessor.cs,WindowsKeyboardLayout.cs,WindowsKeyConverter.cs,WindowsKeyHelper.cs,WindowsOutput.cs,WindowsVTInputHelper.cs,WindowsVTOutputHelper.cs} |FileServices:{DefaultSearchMatcher.cs,FileSystemColorProvider.cs,FileSystemIconProvider.cs,FileSystemInfoStats.cs,FileSystemTreeBuilder.cs,IFileOperations.cs,ISearchMatcher.cs} -|Input:{Command.cs,CommandContext.cs,CommandEventArgs.cs,ICommandContext.cs,IInputBinding.cs,InputBinding.cs,InputBindings.cs} +|Input:{Command.cs,CommandContext.cs,CommandEventArgs.cs,ICommandContext.cs,ICommandBinding.cs,CommandBinding.cs,CommandBindings.cs} |Input/Keyboard:{Key.cs,KeyBinding.cs,KeyBindings.cs,KeyChangedEventArgs.cs,KeyEqualityComparer.cs,KeystrokeNavigatorEventArgs.cs} |Input/Mouse:{GrabMouseEventArgs.cs,Mouse.cs,MouseBinding.cs,MouseBindings.cs,MouseFlags.cs,MouseFlagsChangedEventArgs.cs} |Resources:{GlobalResources.cs,ResourceManagerWrapper.cs,Strings.Designer.cs} @@ -404,4 +435,3 @@ See `.claude/cookbook/` for common UI patterns: |FileSystemTreeBuilder|Class|Build file trees ``` - diff --git a/CLAUDE.md b/CLAUDE.md index c502d96a2e..5973460009 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,21 +33,39 @@ See [.claude/cookbook/common-patterns.md](.claude/cookbook/common-patterns.md) f **READ `.claude/REFRESH.md` first.** It contains a quick checklist to prevent common mistakes. +## After Writing/Modifying Code + +**USE `.claude/POST-GENERATION-VALIDATION.md` to validate ALL code.** This catches the most common formatting violations AI agents make. + ## Detailed Rules See `.claude/rules/` for detailed guidance: +- `formatting.md` - **SPACING, BRACES, BLANK LINES** (most commonly violated!) - `type-declarations.md` - **No var** except built-in types - `target-typed-new.md` - Use `new ()` not `new TypeName()` - `terminology.md` - **SubView/SuperView**, never "child/parent" - `event-patterns.md` - Lambdas, closures, handlers - `collection-expressions.md` - Use `[...]` syntax - `cwp-pattern.md` - Cancellable Workflow Pattern +- `code-layout.md` - Backing fields, member ordering +- `api-documentation.md` - XML documentation requirements +- `testing-patterns.md` - Test patterns and requirements ## Task-Specific Guides See `.claude/tasks/` for task checklists: +- `scenario-modernization.md` - Upgrading UICatalog scenarios +- `clean-code-review.md` - Creating clean git commit histories - `build-app.md` - Building applications with Terminal.Gui +## Planning Mode + +When in planning mode: +- **Create plan files in `./plans/`** (relative to the repository root) +- Plan files should be markdown format +- Include detailed implementation steps, file changes, and verification steps +- Reference existing code patterns and reuse opportunities + --- ## Project Overview @@ -79,11 +97,14 @@ dotnet test Tests/UnitTests --no-build ## Critical Rules (Summary) -1. **No `var`** except: `int`, `string`, `bool`, `double`, `float`, `decimal`, `char`, `byte` -2. **Use `new ()`** not `new TypeName()` -3. **Use `[...]`** not `new () { ... }` for collections -4. **SubView/SuperView** for containment (Parent/Child only for non-containment refs) -5. **Unused lambda params** - use `_`: `(_, _) => { }` +1. **Space BEFORE `()` and `[]`** - `Method ()` not `Method()`, `array [i]` not `array[i]` (MOST VIOLATED!) +2. **Braces on NEXT line** - ALL opening braces use Allman style +3. **Blank lines** - before `return`/`break`/`continue`, after control blocks +4. **No `var`** except: `int`, `string`, `bool`, `double`, `float`, `decimal`, `char`, `byte` +5. **Use `new ()`** not `new TypeName()` +6. **Use `[...]`** not `new () { ... }` for collections +7. **SubView/SuperView** for containment (Parent/Child only for non-containment refs) +8. **Unused lambda params** - use `_`: `(_, _) => { }` ## Testing @@ -104,8 +125,12 @@ dotnet test Tests/UnitTests --no-build ## What NOT to Do +- Don't forget space before `()` and `[]` - this is the #1 mistake! +- Don't put braces on same line (use Allman style) +- Don't skip blank lines before returns or after control blocks - Don't use `var` for non-built-in types - Don't use redundant type names with `new` - Don't say "child/parent" for containment (use SubView/SuperView) - Don't modify unrelated code - Don't introduce new warnings +- Don't skip POST-GENERATION-VALIDATION.md after writing code diff --git a/Examples/ShortcutTest/ShortcutTest.cs b/Examples/ShortcutTest/ShortcutTest.cs new file mode 100644 index 0000000000..de1c781d2b --- /dev/null +++ b/Examples/ShortcutTest/ShortcutTest.cs @@ -0,0 +1,158 @@ +// Test app for Command Propagation through Shortcut hierarchy +// Tests: CheckBox (CommandView) -> Shortcut -> Window + +using System.Collections.ObjectModel; +using Terminal.Gui.App; +using Terminal.Gui.Configuration; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +ConfigurationManager.Enable (ConfigLocations.All); + +using IApplication app = Application.Create ().Init (); +app.Run (); + +public sealed class ShortcutTestWindow : Window +{ + private readonly ObservableCollection _eventLog = []; + private readonly ListView _eventLogView; + + public ShortcutTestWindow () + { + Title = $"Shortcut Command Propagation Test ({Application.QuitKey} to quit)"; + + AssignHotKeys = true; + + // Event log on the right + _eventLogView = new ListView + { + X = Pos.AnchorEnd (), + Y = 0, + Width = Dim.Percent (50), + Height = Dim.Fill (), + Source = new ListWrapper (_eventLog), + BorderStyle = LineStyle.Double, + Title = "Event Log" + }; + Add (_eventLogView); + + // Test Shortcut 1: CheckBox CommandView + CheckBox cb1 = new () { Id = "cb1", Text = "Option 1", CanFocus = false }; + + var shortcut1 = new Shortcut + { + Id = "shortcut1", + HelpText = "Option1", + X = 0, + Y = 0, + Width = Dim.Fill () - Dim.Width (_eventLogView), + CommandView = cb1, + Key = Key.F5 + }; + Add (shortcut1); + + // Test Shortcut 2: CheckBox CommandView (CanFocus = true) + var shortcut2 = new Shortcut + { + Id = "shortcut2", + HelpText = "Option2", + X = 0, + Y = Pos.Bottom (shortcut1) + 1, + Width = Dim.Fill () - Dim.Width (_eventLogView), + CommandView = new CheckBox { Id = "cb2", Text = "Option 2 (CanFocus)", CanFocus = true }, + Key = Key.F6 + }; + Add (shortcut2); + + // Test Shortcut 3: Button CommandView + var shortcut3 = new Shortcut + { + Id = "shortcut3", + HelpText = "Button", + X = 0, + Y = Pos.Bottom (shortcut2) + 1, + Width = Dim.Fill () - Dim.Width (_eventLogView), + CommandView = new Button { Id = "btn1", Text = "_Action Button" }, + Key = Key.F7 + }; + Add (shortcut3); + + // Instructions + var instructions = new Label + { + X = 0, + Y = Pos.Bottom (shortcut3) + 2, + Width = Dim.Fill () - Dim.Width (_eventLogView), + Text = "Press F5, F6, or F7 to trigger shortcuts.\nClick checkboxes with mouse.\nWatch event log to see command propagation." + }; + Add (instructions); + + // Window level handlers + Activating += (_, args) => LogEvent ("Window.Activating", args); + + Accepting += (_, args) => + { + LogEvent ("Window.Accepting", args); + args.Handled = true; + }; + + foreach (Shortcut shortcut in SubViews.OfType ()) + { + shortcut.Activating += (s, args) => + { + if (args.Handled) + { + return; + } + + LogEvent ($"{(s as View)?.Id}", args); + }; + shortcut.Accepting += (s, args) => { LogEvent ($"{(s as View)?.Id}", args); }; + + shortcut.CommandView.Activating += (s, args) => + { + if (args.Handled) + { + return; + } + LogEvent ($"{(s as View)?.Id}", args); + }; + + shortcut.CommandView.Accepting += (s, args) => + { + if (args.Handled) + { + return; + } + LogEvent ($"{(s as View)?.Id}", args); + }; + + if (shortcut.CommandView is CheckBox cb) + { + cb.ValueChanged += (s, args) => { LogEvent ($"{(s as View)?.Id} {args.OldValue} -> {args.NewValue}", null); }; + } + } + } + + private void LogEvent (string source, CommandEventArgs? args) + { + string entry; + + if (args is null) + { + entry = source; + } + else + { + View? sourceView = null; + args.Context?.Source?.TryGetTarget (out sourceView); + string bindingType = args.Context?.Binding?.GetType ().Name ?? "null"; + entry = $"{source}: Cmd={args.Context?.Command}, Binding={bindingType}, Src={sourceView?.Id ?? "null"}, Handled={args.Handled}"; + } + + _eventLog.Add (entry); + _eventLogView.MoveDown (); + } +} diff --git a/Examples/ShortcutTest/ShortcutTest.csproj b/Examples/ShortcutTest/ShortcutTest.csproj new file mode 100644 index 0000000000..9813cc977c --- /dev/null +++ b/Examples/ShortcutTest/ShortcutTest.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + latest + enable + + + + + + + diff --git a/Examples/UICatalog/Scenarios/AllViewsTester.cs b/Examples/UICatalog/Scenarios/AllViewsTester.cs index 867ce4d766..58e8c12360 100644 --- a/Examples/UICatalog/Scenarios/AllViewsTester.cs +++ b/Examples/UICatalog/Scenarios/AllViewsTester.cs @@ -156,9 +156,10 @@ public override void Main () { X = Pos.AnchorEnd () - 1, Y = 0, - Width = 30, + Width = Dim.Percent(20), Height = Dim.Fill (), - SuperViewRendersLineCanvas = true + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.LeftResizable }; _eventLog.Border!.Thickness = new Thickness (1); @@ -216,28 +217,28 @@ private void CreateCurrentView (Type type) // If we are to create a generic Type case true: - { - // For each of the arguments - List typeArguments = []; - - // use or the original type if applicable - foreach (Type arg in type.GetGenericArguments ()) { - if (arg.IsValueType && Nullable.GetUnderlyingType (arg) == null) - { - typeArguments.Add (arg); - } - else + // For each of the arguments + List typeArguments = []; + + // use or the original type if applicable + foreach (Type arg in type.GetGenericArguments ()) { - typeArguments.Add (typeof (object)); + if (arg.IsValueType && Nullable.GetUnderlyingType (arg) == null) + { + typeArguments.Add (arg); + } + else + { + typeArguments.Add (typeof (object)); + } } - } - // And change what type we are instantiating from MyClass to MyClass or MyClass - type = type.MakeGenericType (typeArguments.ToArray ()); + // And change what type we are instantiating from MyClass to MyClass or MyClass + type = type.MakeGenericType (typeArguments.ToArray ()); - break; - } + break; + } } // Ensure the type does not contain any generic parameters diff --git a/Examples/UICatalog/Scenarios/Bars.cs b/Examples/UICatalog/Scenarios/Bars.cs index 14218481db..e0228867a3 100644 --- a/Examples/UICatalog/Scenarios/Bars.cs +++ b/Examples/UICatalog/Scenarios/Bars.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Text; namespace UICatalog.Scenarios; @@ -13,264 +13,191 @@ public override void Main () app.Init (); using Runnable mainWindow = new (); + mainWindow.Id = "mainWindow"; - mainWindow.IsModalChanged += App_Loaded; + mainWindow.IsModalChanged += OnIsModalChanged; app.Run (mainWindow); } // Setting everything up in Loaded handler because we change the // QuitKey it only sticks if changed after init - private void App_Loaded (object sender, EventArgs e) + private void OnIsModalChanged (object sender, EventArgs e) { - if (sender is not Runnable mainWindow) + if (sender is not Runnable { IsRunning: true } mainWindow) { return; } - ObservableCollection eventSource = new (); - - ListView eventLog = new () + EventLog eventLog = new () { - Title = "Event Log", + Id = "eventLog", X = Pos.AnchorEnd (), - Width = Dim.Auto (), - Height = Dim.Fill (), // Make room for some wide things + Height = Dim.Fill (), SchemeName = "Runnable", - Source = new ListWrapper (eventSource) - }; - eventLog.Border!.Thickness = new (0, 1, 0, 0); - mainWindow.Add (eventLog); - - FrameView menuBarLikeExamples = new () - { - Title = "MenuBar-Like Examples", - X = 0, - Y = 0, - Width = Dim.Fill () - Dim.Width (eventLog), - Height = Dim.Percent (33) - }; - mainWindow.Add (menuBarLikeExamples); - - Label label = new () - { - Title = " Bar:", - X = 0, - Y = 0 - }; - menuBarLikeExamples.Add (label); - - Bar bar = new () - { - Id = "menuBar-like", - X = Pos.Right (label), - Y = Pos.Top (label), - Width = Dim.Fill () + BorderStyle = LineStyle.Double, + Title = "E_vents", + Arrangement = ViewArrangement.LeftResizable }; - ConfigMenuBar (bar); - menuBarLikeExamples.Add (bar); - - label = new () - { - Title = " MenuBar:", - X = 0, - Y = Pos.Bottom (bar) + 1 - }; - menuBarLikeExamples.Add (label); - - //bar = new MenuBar + //FrameView menuBarLikeExamples = new () //{ - // Id = "menuBar", - // X = Pos.Right (label), - // Y = Pos.Top (label), + // Title = "MenuBar-Like Examples", + // X = 0, + // Y = 0, + // Width = Dim.Fill (eventLog), + // Height = Dim.Percent (33) //}; + //mainWindow.Add (menuBarLikeExamples); + + //Label label = new () { Title = " Bar:", X = 0, Y = 0 }; + //menuBarLikeExamples.Add (label); + + //Bar bar = new () { Id = "menuBar-like", X = Pos.Right (label), Y = Pos.Top (label), Width = Dim.Fill () }; //ConfigMenuBar (bar); //menuBarLikeExamples.Add (bar); + //label = new Label { Title = " MenuBar:", X = 0, Y = Pos.Bottom (bar) + 1 }; + //menuBarLikeExamples.Add (label); + FrameView menuLikeExamples = new () { Title = "Menu-Like Examples", X = 0, Y = Pos.Center (), - Width = Dim.Fill () - Dim.Width (eventLog), + Width = Dim.Fill (eventLog), Height = Dim.Percent (33) }; mainWindow.Add (menuLikeExamples); - label = new () - { - Title = "Bar:", - X = 0, - Y = 0 - }; - menuLikeExamples.Add (label); + Label barLabel = new () { Title = "Bar:", X = 0, Y = 0 }; + menuLikeExamples.Add (barLabel); - bar = new () + var menuLikeBar = new Bar { Id = "menu-like", X = 0, - Y = Pos.Bottom (label), + Y = Pos.Bottom (barLabel), //Width = Dim.Percent (40), Orientation = Orientation.Vertical }; - ConfigureMenu (bar); + ConfigureMenu (menuLikeBar); - menuLikeExamples.Add (bar); + menuLikeExamples.Add (menuLikeBar); - label = new () - { - Title = "Menu:", - X = Pos.Right (bar) + 1, - Y = Pos.Top (label) - }; - menuLikeExamples.Add (label); + barLabel = new Label { Title = "Menu:", X = Pos.Right (menuLikeBar) + 1, Y = Pos.Top (barLabel) }; + menuLikeExamples.Add (barLabel); - bar = new () - { - Id = "menu", - X = Pos.Left (label), - Y = Pos.Bottom (label) - }; - ConfigureMenu (bar); - bar.Arrangement = ViewArrangement.RightResizable; + menuLikeBar = new Bar { Id = "menu", X = Pos.Left (barLabel), Y = Pos.Bottom (barLabel) }; + ConfigureMenu (menuLikeBar); + menuLikeBar.Arrangement = ViewArrangement.RightResizable; - menuLikeExamples.Add (bar); + menuLikeExamples.Add (menuLikeBar); - label = new () - { - Title = "PopOver Menu (Right click to show):", - X = Pos.Right (bar) + 1, - Y = Pos.Top (label) - }; - menuLikeExamples.Add (label); + barLabel = new Label { Title = "PopOver Menu (Right click to show):", X = Pos.Right (menuLikeBar) + 1, Y = Pos.Top (barLabel) }; + menuLikeExamples.Add (barLabel); - Menu popOverMenu = new () - { - Id = "popupMenu", - X = Pos.Left (label), - Y = Pos.Bottom (label) - }; - ConfigureMenu (popOverMenu); + //Menu popOverMenu = new () { Id = "popupMenu", X = Pos.Left (barlabel), Y = Pos.Bottom (barlabel) }; + //ConfigureMenu (popOverMenu); - popOverMenu.Arrangement = ViewArrangement.Overlapped; - popOverMenu.Visible = false; + //popOverMenu.Arrangement = ViewArrangement.Overlapped; + //popOverMenu.Visible = false; - //popOverMenu.Enabled = false; + //Shortcut toggleShortcut = new () { Title = "Toggle Hide", Text = "App", BindKeyToApplication = true, Key = Key.F4.WithCtrl }; + //popOverMenu.Add (toggleShortcut); - Shortcut toggleShortcut = new () - { - Title = "Toggle Hide", - Text = "App", - BindKeyToApplication = true, - Key = Key.F4.WithCtrl - }; - popOverMenu.Add (toggleShortcut); + //popOverMenu.Accepting += PopOverMenuOnAccept; - popOverMenu.Accepting += PopOverMenuOnAccept; + //menuLikeExamples.Add (popOverMenu); - menuLikeExamples.Add (popOverMenu); + //menuLikeExamples.MouseEvent += MenuLikeExamplesMouseEvent; - menuLikeExamples.MouseEvent += MenuLikeExamplesMouseEvent; + //FrameView statusBarLikeExamples = new () + //{ + // Title = "StatusBar-Like Examples", + // X = 0, + // Y = Pos.AnchorEnd (), + // Width = Dim.Fill (eventLog), + // Height = Dim.Percent (33) + //}; + //mainWindow.Add (statusBarLikeExamples); - FrameView statusBarLikeExamples = new () - { - Title = "StatusBar-Like Examples", - X = 0, - Y = Pos.AnchorEnd (), - Width = Dim.Width (menuLikeExamples), - Height = Dim.Percent (33) - }; - mainWindow.Add (statusBarLikeExamples); + //Label statusBarBarLabel = new Label { Title = " Bar:", X = 0, Y = 0 }; + //statusBarLikeExamples.Add (statusBarBarLabel); - label = new () - { - Title = " Bar:", - X = 0, - Y = 0 - }; - statusBarLikeExamples.Add (label); + //Bar statusBarLikeBar = new Bar + //{ + // Id = "statusBar-like", + // X = Pos.Right (statusBarBarLabel), + // Y = Pos.Top (statusBarBarLabel), + // Width = Dim.Fill (), + // Orientation = Orientation.Horizontal + //}; + //ConfigStatusBar (menuLikeBar); + //statusBarLikeExamples.Add (menuLikeBar); - bar = new() - { - Id = "statusBar-like", - X = Pos.Right (label), - Y = Pos.Top (label), - Width = Dim.Fill (), - Orientation = Orientation.Horizontal - }; - ConfigStatusBar (bar); - statusBarLikeExamples.Add (bar); + //statusBarBarLabel = new Label { Title = "StatusBar:", X = 0, Y = Pos.Bottom (menuLikeBar) + 1 }; + //statusBarLikeExamples.Add (statusBarBarLabel); - label = new () - { - Title = "StatusBar:", - X = 0, - Y = Pos.Bottom (bar) + 1 - }; - statusBarLikeExamples.Add (label); + //menuLikeBar = new Bar { Id = "statusBar", X = Pos.Right (statusBarBarLabel), Y = Pos.Top (statusBarBarLabel), Width = Dim.Fill () }; + //ConfigStatusBar (menuLikeBar); + //statusBarLikeExamples.Add (menuLikeBar); - bar = new () - { - Id = "statusBar", - X = Pos.Right (label), - Y = Pos.Top (label), - Width = Dim.Fill () - }; - ConfigStatusBar (bar); - statusBarLikeExamples.Add (bar); + //mainWindow.CommandsToBubbleUp = [Command.Accept]; + eventLog.SetViewToLog (mainWindow); foreach (FrameView frameView in mainWindow.SubViews.OfType ()) { + frameView.CommandsToBubbleUp = [Command.Accept, Command.Activate]; + eventLog.SetViewToLog (frameView); + foreach (Bar barView in frameView.SubViews.OfType ()) { + eventLog.SetViewToLog (barView); + foreach (Shortcut sh in barView.SubViews.OfType ()) { - sh.Accepting += (_, _) => - { - eventSource.Add ($"Accept: {sh!.SuperView!.Id} {sh!.CommandView.Text}"); - eventLog.MoveDown (); - - //args.Handled = true; - }; + eventLog.SetViewToLog (sh); + eventLog.SetViewToLog (sh.CommandView); } } } - return; - - - void MenuLikeExamplesMouseEvent (object _, Mouse mouse) - { - if (mouse.Flags.HasFlag (MouseFlags.RightButtonClicked)) - { - popOverMenu.X = mouse.Position!.Value.X; - popOverMenu.Y = mouse.Position!.Value.Y; - popOverMenu.Visible = true; - //popOverMenu.Enabled = popOverMenu.Visible; - popOverMenu.SetFocus (); - } - else - { - popOverMenu.Visible = false; - //popOverMenu.Enabled = popOverMenu.Visible; - } - } + mainWindow.Add (eventLog); - void PopOverMenuOnAccept (object o, CommandEventArgs args) - { - if (popOverMenu.Visible) - { - popOverMenu.Visible = false; - } - else - { - popOverMenu.Visible = true; - popOverMenu.SetFocus (); - } - } + //void MenuLikeExamplesMouseEvent (object _, Mouse mouse) + //{ + // if (mouse.Flags.HasFlag (MouseFlags.RightButtonClicked)) + // { + // popOverMenu.X = mouse.Position!.Value.X; + // popOverMenu.Y = mouse.Position!.Value.Y; + // popOverMenu.Visible = true; + + // //popOverMenu.Enabled = popOverMenu.Visible; + // popOverMenu.SetFocus (); + // } + // else + // { + // popOverMenu.Visible = false; + + // //popOverMenu.Enabled = popOverMenu.Visible; + // } + //} + + //void PopOverMenuOnAccept (object o, CommandEventArgs args) + //{ + // if (popOverMenu.Visible) + // { + // popOverMenu.Visible = false; + // } + // else + // { + // popOverMenu.Visible = true; + // popOverMenu.SetFocus (); + // } + //} } //private void SetupContentMenu () @@ -415,116 +342,60 @@ void PopOverMenuOnAccept (object o, CommandEventArgs args) private void ConfigMenuBar (Bar bar) { - Shortcut fileMenuBarItem = new () - { - Title = Strings.menuFile, - HelpText = "File Menu", - Key = Key.D0.WithAlt, - MouseHighlightStates = MouseState.In - }; + Shortcut fileMenuBarItem = new () { Title = Strings.menuFile, HelpText = "File Menu", Key = Key.D0.WithAlt }; - Shortcut editMenuBarItem = new () - { - Title = "_Edit", - HelpText = "Edit Menu", - Key = Key.D1.WithAlt, - MouseHighlightStates = MouseState.In - }; + Shortcut editMenuBarItem = new () { Title = "_Edit", HelpText = "Edit Menu", Key = Key.D1.WithAlt }; - Shortcut helpMenuBarItem = new () - { - Title = Strings.menuHelp, - HelpText = "Halp Menu", - Key = Key.D2.WithAlt, - MouseHighlightStates = MouseState.In - }; + Shortcut helpMenuBarItem = new () { Title = Strings.menuHelp, HelpText = "Halp Menu", Key = Key.D2.WithAlt }; bar.Add (fileMenuBarItem, editMenuBarItem, helpMenuBarItem); } private void ConfigureMenu (Bar bar) { - Shortcut shortcut1 = new () - { - Title = "Z_igzag", - Key = Key.I.WithCtrl, - Text = "Gonna zig zag", - MouseHighlightStates = MouseState.In - }; - - Shortcut shortcut2 = new () - { - Title = "Za_G", - Text = "Gonna zag", - Key = Key.G.WithAlt, - MouseHighlightStates = MouseState.In - }; - - Shortcut shortcut3 = new () - { - Title = "_Three", - Text = "The 3rd item", - Key = Key.D3.WithAlt, - MouseHighlightStates = MouseState.In - }; + Shortcut shortcut1 = new () { Title = "Z_igzag", Key = Key.I.WithCtrl, Text = "Gonna zig zag" }; - Line line = new () - { - X = -1, - Width = Dim.Fill ()! + 1 - }; + Line line = new (); - Shortcut shortcut4 = new () - { - Title = "_Four", - Text = "Below the line", - Key = Key.D3.WithAlt, - MouseHighlightStates = MouseState.In - }; + Shortcut shortcut4 = new () { Title = "_Borders", Text = "Borders", Key = Key.D4.WithAlt }; + shortcut4.CommandView = new CheckBox { Title = shortcut4.Title, CanFocus = false }; - shortcut4.CommandView = new CheckBox - { - Title = shortcut4.Title, - MouseHighlightStates = MouseState.None, - CanFocus = false - }; + shortcut4.Action += () => + { + if (shortcut4.CommandView is CheckBox cb) + { + bar.BorderStyle = cb.Value == CheckState.Checked ? LineStyle.Double : LineStyle.None; + } + }; // This ensures the checkbox state toggles when the hotkey of Title is pressed. shortcut4.Accepting += (_, args) => args.Handled = true; - bar.Add (shortcut1, shortcut2, shortcut3, line, shortcut4); + OptionSelector? schemeOptionSelector = new () { Title = "Scheme", CanFocus = true }; + Shortcut schemeShortcut = new () { Title = "Scheme", Text = "Scheme", Key = Key.S.WithCtrl, CommandView = schemeOptionSelector }; + + schemeOptionSelector!.ValueChanged += (_, args) => + { + if (args.Value is { } scheme) + { + bar.SchemeName = scheme.ToString (); + } + }; + + bar.Add (shortcut1, line, shortcut4, schemeShortcut); } public void ConfigStatusBar (Bar bar) { - Shortcut shortcut = new () - { - Text = "Quit", - Title = "Q_uit", - Key = Key.Z.WithCtrl - }; + Shortcut shortcut = new () { Text = "Quit", Title = "Q_uit", Key = Key.Z.WithCtrl }; bar.Add (shortcut); - shortcut = new () - { - Text = "Help Text", - Title = "Help", - Key = Key.F1 - }; + shortcut = new Shortcut { Text = "Help Text", Title = "Help", Key = Key.F1 }; bar.Add (shortcut); - shortcut = new () - { - Title = "_Show/Hide", - Key = Key.F10, - CommandView = new CheckBox - { - CanFocus = false, - Text = "_Show/Hide" - } - }; + shortcut = new Shortcut { Title = "_Show/Hide", Key = Key.F10, CommandView = new CheckBox { CanFocus = false, Text = "_Show/Hide" } }; bar.Add (shortcut); @@ -544,27 +415,15 @@ public void ConfigStatusBar (Bar bar) e.Handled = true; }; - bar.Add ( - new Label - { - HotKeySpecifier = new ('_'), - Text = "Fo_cusLabel", - CanFocus = true - }); + bar.Add (new Label { HotKeySpecifier = new Rune ('_'), Text = "Fo_cusLabel", CanFocus = true }); - Button middleButton = new () - { - Text = "Or me!" - }; + Button middleButton = new () { Text = "Or me!" }; middleButton.Accepting += (s, _) => (s as View)?.App!.RequestStop (); bar.Add (middleButton); return; - static void ButtonClicked (object sender, EventArgs e) - { - MessageBox.Query ((sender as View)?.App!, "Hi", $"You clicked {sender}"); - } + static void ButtonClicked (object sender, EventArgs e) => MessageBox.Query ((sender as View)?.App!, "Hi", $"You clicked {sender}"); } } diff --git a/Examples/UICatalog/Scenarios/Dialogs.cs b/Examples/UICatalog/Scenarios/Dialogs.cs index d170921018..a2d757b577 100644 --- a/Examples/UICatalog/Scenarios/Dialogs.cs +++ b/Examples/UICatalog/Scenarios/Dialogs.cs @@ -206,10 +206,7 @@ public override void Main () // --- Dialog Demo --- // Demonstrates using Dialog to return a typed result instead of a button index - Button showColorDialogButton = new () - { - X = Pos.Center (), Y = Pos.Bottom (buttonPressedLabel) + 2, Text = "Show Color Dialog" - }; + Button showColorDialogButton = new () { X = Pos.Center (), Y = Pos.Bottom (buttonPressedLabel) + 2, Text = "Show Color Dialog" }; mainWindow.Add (showColorDialogButton); View colorLabel = new () @@ -237,8 +234,7 @@ public override void Main () showColorDialogButton.Accepting += (_, e) => { - using ColorPickerDialog colorDialog = - new (selectedColorLabel.GetScheme ().Normal.Background); + using ColorPickerDialog colorDialog = new (selectedColorLabel.GetScheme ().Normal.Background); colorDialog.ButtonAlignment = alignmentOptionSelector.Value.Value; // Run the dialog and get the typed result @@ -250,8 +246,8 @@ public override void Main () selectedColorLabel.SetScheme (new Scheme { Normal = new Attribute (selectedColorLabel.GetScheme () - .Normal.Foreground, - result) + .Normal.Foreground, + result) }); } else @@ -266,10 +262,7 @@ public override void Main () // Demonstrates using Prompt with extension methods // This is a simpler alternative to creating custom Dialog subclasses - Button showPromptDialogButton = new () - { - X = Pos.Center (), Y = Pos.Bottom (selectedColorLabel) + 2, Text = "Prompt" - }; + Button showPromptDialogButton = new () { X = Pos.Center (), Y = Pos.Bottom (selectedColorLabel) + 2, Text = "Prompt" }; mainWindow.Add (showPromptDialogButton); View promptAttributeLabel = new () @@ -293,23 +286,19 @@ public override void Main () }; mainWindow.Add (promptSelectedAttributeLabel); - promptSelectedAttributeLabel.SetScheme (new Scheme - { - Normal = new Attribute (StandardColor.White, StandardColor.Cyan) - }); + promptSelectedAttributeLabel.SetScheme (new Scheme { Normal = new Attribute (StandardColor.White, StandardColor.Cyan) }); promptSelectedAttributeLabel.Text = promptSelectedAttributeLabel.GetScheme ().Normal.Background.ToString (); void OnShowPromptDialogButtonOnAccepting (object? _, CommandEventArgs e) { // Use the Prompt extension method - much simpler than custom Dialog! // mainWindow is an IRunnable so we can call Prompt on it - Attribute? result = - mainWindow.Prompt (input: promptSelectedAttributeLabel.GetScheme ().Normal, - beginInitHandler: prompt => - { - // Customize the Prompt dialog - prompt.Title = "Pick an Attribute"; - }); + Attribute? result = mainWindow.Prompt (input: promptSelectedAttributeLabel.GetScheme ().Normal, + beginInitHandler: prompt => + { + // Customize the Prompt dialog + prompt.Title = "Pick an Attribute"; + }); if (result is { } attribute) { @@ -329,6 +318,8 @@ void OnShowPromptDialogButtonOnAccepting (object? _, CommandEventArgs e) mainWindow.UsedHotKeys = frame.UsedHotKeys; mainWindow.AssignHotKeys = true; + mainWindow.CommandsToBubbleUp = [Command.Accept]; + frame.CommandsToBubbleUp = [Command.Accept]; app.Run (mainWindow); } @@ -453,16 +444,10 @@ public ColorPickerDialog (Color initialColor) } /// - protected override bool OnAccepting (CommandEventArgs args) + protected override void OnAccepted (ICommandContext? ctx) { - if (base.OnAccepting (args)) - { - return true; - } - + base.OnAccepted (ctx); Result = _colorPicker.Value!.Value; - - return false; } } diff --git a/Examples/UICatalog/Scenarios/Editor.cs b/Examples/UICatalog/Scenarios/Editor.cs index 241dfed2f3..602184cd2a 100644 --- a/Examples/UICatalog/Scenarios/Editor.cs +++ b/Examples/UICatalog/Scenarios/Editor.cs @@ -84,7 +84,7 @@ public override void Main () new MenuItem { Title = "Replace Ne_xt", Key = Key.R.WithCtrl.WithShift, Action = ReplaceNext }, new MenuItem { Title = "Replace Pre_vious", Key = Key.R.WithCtrl.WithShift.WithAlt, Action = ReplacePrevious }, new MenuItem { Title = "Replace _All", Key = Key.A.WithCtrl.WithShift.WithAlt, Action = ReplaceAll }, - new MenuItem { Title = Strings.ctxSelectAll, Key = Key.T.WithCtrl, Action = SelectAll } + new MenuItem { Title = Strings.cmdSelectAll, Key = Key.T.WithCtrl, Action = SelectAll } ])); menu.Add (new MenuBarItem ("_ScrollBars", CreateScrollBarsMenu ())); diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ArrangementEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ArrangementEditor.cs index 6303a635e5..e73d5fb40f 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ArrangementEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ArrangementEditor.cs @@ -2,58 +2,47 @@ namespace UICatalog.Scenarios; /// -/// Provides an editor UI for the Margin, Border, and Padding of a View. +/// Provides an editor UI for TabStop and related Navigation settings. /// -public sealed class ArrangementEditor : EditorBase +public sealed class NavigationEditor : EditorBase { - public ArrangementEditor () + public NavigationEditor () { - Title = "ArrangementEditor"; + Title = "NavigationEditor"; TabStop = TabBehavior.TabGroup; - Initialized += ArrangementEditor_Initialized; - - Add (_arrangementSelector); + Add (_tabBehaviorSelector); } - private readonly FlagSelector _arrangementSelector = new () { Orientation = Orientation.Vertical }; + private readonly OptionSelector _tabBehaviorSelector = new () { Orientation = Orientation.Vertical }; protected override void OnViewToEditChanged () { - _arrangementSelector.Enabled = ViewToEdit is { } and not Adornment; + _tabBehaviorSelector.Enabled = ViewToEdit is { } and not Adornment; - _arrangementSelector.ValueChanged -= ArrangementFlagsOnValueChanged; + _tabBehaviorSelector.ValueChanged -= TabStopOnValueChanged; - // Set the appropriate options in the slider based on _viewToEdit.Arrangement if (ViewToEdit is { }) { - _arrangementSelector.Value = ViewToEdit.Arrangement; + _tabBehaviorSelector.Value = ViewToEdit.TabStop; } - _arrangementSelector.ValueChanged += ArrangementFlagsOnValueChanged; + _tabBehaviorSelector.ValueChanged += TabStopOnValueChanged; } - private void ArrangementFlagsOnValueChanged (object? sender, EventArgs e) + private void TabStopOnValueChanged (object? sender, EventArgs e) { if (ViewToEdit is null || e.Value is null) { return; } - ViewToEdit.Arrangement = (ViewArrangement)e.Value; - - if (ViewToEdit.Arrangement.HasFlag (ViewArrangement.Overlapped)) - { - ViewToEdit.ShadowStyle = ShadowStyle.Transparent; - ViewToEdit.SchemeName = "Runnable"; - } - else - { - ViewToEdit.ShadowStyle = ShadowStyle.None; - ViewToEdit.SchemeName = ViewToEdit!.SuperView!.SchemeName; - } - - ViewToEdit.BorderStyle = ViewToEdit.Arrangement.HasFlag (ViewArrangement.Movable) ? LineStyle.Double : LineStyle.Single; + ViewToEdit.TabStop = (TabBehavior)e.Value; } - private void ArrangementEditor_Initialized (object? sender, EventArgs e) => _arrangementSelector.ValueChanged += ArrangementFlagsOnValueChanged; + /// + public override void EndInit () + { + base.EndInit (); + _tabBehaviorSelector.ValueChanged += TabStopOnValueChanged; + } } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/DimEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/DimEditor.cs index 4dccd196ae..cfd8c03821 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/DimEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/DimEditor.cs @@ -91,7 +91,7 @@ private void DimEditor_Initialized (object? sender, EventArgs e) var label = new Label { X = 0, Y = 0, - Text = $"{Title}:" + Text = $"{this.ToIdentifyingString ()}:" }; Add (label); _dimOptionSelector = new () { X = 0, Y = Pos.Bottom (label), Labels = _optionLabels }; diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs index f6f5d46d10..fa0a0a2d9d 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs @@ -10,7 +10,7 @@ protected EditorBase () CanFocus = true; - ExpanderButton = new () + ExpanderButton = new ExpanderButton { Orientation = Orientation.Vertical }; @@ -19,6 +19,12 @@ protected EditorBase () Initialized += OnInitialized; + AddCommand (Command.Accept, () => true); + + SchemeName = "Dialog"; + + return; + void OnInitialized (object? sender, EventArgs e) { Border?.Add (ExpanderButton); @@ -26,10 +32,6 @@ void OnInitialized (object? sender, EventArgs e) App!.Mouse.MouseEvent += ApplicationOnMouseEvent; App!.Navigation!.FocusedChanged += NavigationOnFocusedChanged; } - - AddCommand (Command.Accept, () => true); - - SchemeName = "Dialog"; } private readonly ExpanderButton? _expanderButton; diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EventLog.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EventLog.cs index e6efd46679..8f6b08f013 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EventLog.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EventLog.cs @@ -1,10 +1,11 @@ #nullable enable using System.Collections.ObjectModel; +using System.Text; namespace UICatalog.Scenarios; /// -/// An event log that automatically shows the events that are raised. +/// An event log that automatically shows the Command-related events that are raised. /// /// /// @@ -29,20 +30,17 @@ public EventLog () }); Height = Dim.Fill (); - ExpandButton = new () - { - Orientation = Orientation.Horizontal - }; + ExpandButton = new ExpanderButton { Orientation = Orientation.Horizontal }; Initialized += EventLog_Initialized; HorizontalScrollBar.AutoShow = true; VerticalScrollBar.AutoShow = true; - AddCommand ( - Command.DeleteAll, + AddCommand (Command.DeleteAll, () => { + SelectedItem = null; _eventSource.Clear (); return true; @@ -67,52 +65,100 @@ public View? ViewToLog return; } + UnsubscribeFromViewToLog (_viewToLog); + _viewToLog = value; - if (_viewToLog is not null) + if (_viewToLog is { }) { - _viewToLog.Initialized += (s, _) => - { - var sender = s as View; - Log ($"Initialized: {GetIdentifyingString (sender)}"); - }; - - _viewToLog.HandlingHotKey += (_, args) => { Log ($"HandlingHotKey: {args.Context}"); }; - _viewToLog.Activating += (_, args) => { Log ($"Activating: {args.Context}"); }; - _viewToLog.Accepting += (_, args) => { Log ($"Accepting: {args.Context}"); }; + SetViewToLog (_viewToLog); } } } - public void Log (string text) + private void UnsubscribeFromViewToLog (View? view) { - _eventSource.Add (text); - MoveEnd (); + view?.Initialized -= OnViewOnInitialized; + view?.HandlingHotKey -= OnViewOnHandlingHotKey; + view?.Activating -= OnViewOnActivating; + view?.Activated -= OnViewOnActivated; + view?.Accepting -= OnViewOnAccepting; + view?.Accepted -= OnViewOnAccepted; + + if (view is IValue valueView) + { + valueView.ValueChangedUntyped -= OnValueViewOnValueChanged; + } } - private void EventLog_Initialized (object? _, EventArgs e) + public void SetViewToLog (View? view) { - Border?.Add (ExpandButton!); - Source = new ListWrapper (_eventSource); + view?.Initialized += OnViewOnInitialized; + view?.HandlingHotKey += OnViewOnHandlingHotKey; + view?.Activating += OnViewOnActivating; + view?.Activated += OnViewOnActivated; + view?.Accepting += OnViewOnAccepting; + view?.Accepted += OnViewOnAccepted; + + if (view is IValue valueView) + { + valueView.ValueChangedUntyped += OnValueViewOnValueChanged; + } } - private string GetIdentifyingString (View? view) + private void OnViewOnInitialized (object? s, EventArgs _) => Log ($"{(s as View).ToIdentifyingString ()} Initialized"); + + private void OnViewOnAccepted (object? s, CommandEventArgs args) => Log ($"{(s as View).ToIdentifyingString ()} Accepted: {FormatContext (args.Context)}"); + + private void OnViewOnAccepting (object? s, CommandEventArgs args) => + Log ($"{(s as View).ToIdentifyingString ()} Accepting: {FormatContext (args.Context)}"); + + private void OnViewOnActivating (object? s, CommandEventArgs args) => + Log ($"{(s as View).ToIdentifyingString ()} Activating: {FormatContext (args.Context)}"); + + private void OnViewOnActivated (object? sender, EventArgs e) => + Log ($"{(sender as View).ToIdentifyingString ()} Activated: {FormatContext (e.Value)}"); + + private void OnViewOnHandlingHotKey (object? s, CommandEventArgs args) => + Log ($"{(s as View).ToIdentifyingString ()} HandlingHotKey: {FormatContext (args.Context)}"); + + private void OnValueViewOnValueChanged (object? s, ValueChangedEventArgs e) => + Log ($"{(s as View).ToIdentifyingString ()} ValueChanged: {e.OldValue} -> {e.NewValue}"); + + private string FormatContext (ICommandContext? context) { - if (view is null) + if (context is null) { return "null"; } - if (!string.IsNullOrEmpty (view.Title)) + StringBuilder sb = new (); + sb.Append ($"{context.Command}"); + + if (context.Binding is { } binding) { - return view.Title; + sb.Append ($", Binding={binding}"); } - if (!string.IsNullOrEmpty (view.Text)) + if (context.Source is { }) { - return view.Text; + sb.Append ($", Source={context.Source.ToIdentifyingString ()}"); } - return view.GetType ().Name; + return sb.ToString (); + } + + public void Log (string text) + { + // Logging.Debug (text); + _eventSource.Add (text); + MoveEnd (); + SelectedItem = null; + } + + private void EventLog_Initialized (object? _, EventArgs e) + { + Border?.Add (ExpandButton!); + Source = new ListWrapper (_eventSource); } } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ExpanderButton.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ExpanderButton.cs index 7d69cc7219..a1d1c63d46 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ExpanderButton.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ExpanderButton.cs @@ -36,6 +36,7 @@ public ExpanderButton () NoDecorations = true; NoPadding = true; + AddCommand (Command.Accept, Toggle); AddCommand (Command.HotKey, Toggle); AddCommand (Command.Toggle, Toggle); KeyBindings.Add (Key.F4, Command.Toggle); @@ -96,11 +97,7 @@ private void ExpanderButton_Initialized (object? sender, EventArgs e) /// bottom/left. /// /// - public Orientation Orientation - { - get => _orientation; - set => OnOrientationChanging (value); - } + public Orientation Orientation { get => _orientation; set => OnOrientationChanging (value); } /// Called when the orientation is changing. Invokes the event. /// @@ -118,15 +115,15 @@ protected virtual bool OnOrientationChanging (Orientation newOrientation) { X = Pos.AnchorEnd () - 1; Y = 0; - CollapseGlyph = new ('\u21d1'); // ⇑ - ExpandGlyph = new ('\u21d3'); // ⇓ + CollapseGlyph = new Rune ('\u21d1'); // ⇑ + ExpandGlyph = new Rune ('\u21d3'); // ⇓ } else { X = 0; Y = Pos.AnchorEnd () - 1; - CollapseGlyph = new ('\u21d0'); // ⇐ - ExpandGlyph = new ('\u21d2'); // ⇒ + CollapseGlyph = new Rune ('\u21d0'); // ⇐ + ExpandGlyph = new Rune ('\u21d2'); // ⇒ } ExpandOrCollapse (Collapsed); @@ -155,11 +152,7 @@ protected virtual bool OnOrientationChanging (Orientation newOrientation) /// /// Gets or sets a value indicating whether the view is collapsed. /// - public bool Collapsed - { - get => _collapsed; - set => OnCollapsedChanging (value); - } + public bool Collapsed { get => _collapsed; set => OnCollapsedChanging (value); } /// Called when the orientation is changing. Invokes the event. /// diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/NavigationEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/NavigationEditor.cs new file mode 100644 index 0000000000..6303a635e5 --- /dev/null +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/NavigationEditor.cs @@ -0,0 +1,59 @@ +#nullable enable +namespace UICatalog.Scenarios; + +/// +/// Provides an editor UI for the Margin, Border, and Padding of a View. +/// +public sealed class ArrangementEditor : EditorBase +{ + public ArrangementEditor () + { + Title = "ArrangementEditor"; + TabStop = TabBehavior.TabGroup; + + Initialized += ArrangementEditor_Initialized; + + Add (_arrangementSelector); + } + + private readonly FlagSelector _arrangementSelector = new () { Orientation = Orientation.Vertical }; + + protected override void OnViewToEditChanged () + { + _arrangementSelector.Enabled = ViewToEdit is { } and not Adornment; + + _arrangementSelector.ValueChanged -= ArrangementFlagsOnValueChanged; + + // Set the appropriate options in the slider based on _viewToEdit.Arrangement + if (ViewToEdit is { }) + { + _arrangementSelector.Value = ViewToEdit.Arrangement; + } + + _arrangementSelector.ValueChanged += ArrangementFlagsOnValueChanged; + } + + private void ArrangementFlagsOnValueChanged (object? sender, EventArgs e) + { + if (ViewToEdit is null || e.Value is null) + { + return; + } + ViewToEdit.Arrangement = (ViewArrangement)e.Value; + + if (ViewToEdit.Arrangement.HasFlag (ViewArrangement.Overlapped)) + { + ViewToEdit.ShadowStyle = ShadowStyle.Transparent; + ViewToEdit.SchemeName = "Runnable"; + } + else + { + ViewToEdit.ShadowStyle = ShadowStyle.None; + ViewToEdit.SchemeName = ViewToEdit!.SuperView!.SchemeName; + } + + ViewToEdit.BorderStyle = ViewToEdit.Arrangement.HasFlag (ViewArrangement.Movable) ? LineStyle.Double : LineStyle.Single; + } + + private void ArrangementEditor_Initialized (object? sender, EventArgs e) => _arrangementSelector.ValueChanged += ArrangementFlagsOnValueChanged; +} diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/PosEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/PosEditor.cs index a732b6034e..ab6710ea88 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/PosEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/PosEditor.cs @@ -85,7 +85,7 @@ private void PosEditor_Initialized (object? sender, EventArgs e) var label = new Label { X = 0, Y = 0, - Text = $"{Title}:" + Text = $"{this.ToIdentifyingString ()}:" }; Add (label); _posOptionSelector = new () { X = 0, Y = Pos.Bottom (label), Labels = _optionLabels }; diff --git a/Examples/UICatalog/Scenarios/FileDialogExamples.cs b/Examples/UICatalog/Scenarios/FileDialogExamples.cs index 52cf2a012b..980eb8b055 100644 --- a/Examples/UICatalog/Scenarios/FileDialogExamples.cs +++ b/Examples/UICatalog/Scenarios/FileDialogExamples.cs @@ -32,64 +32,60 @@ public override void Main () using Window win = new (); win.Title = GetQuitKeyAndName (); - _cbMustExist = new () { Value = CheckState.Checked, Y = y++, X = x, Text = "Must E_xist" }; + _cbMustExist = new CheckBox { Value = CheckState.Checked, Y = y++, X = x, Text = "Must E_xist" }; win.Add (_cbMustExist); - _cbUseColors = new () - { Value = FileDialogStyle.DefaultUseColors ? CheckState.Checked : CheckState.UnChecked, Y = y++, X = x, Text = "_Use Colors" }; + _cbUseColors = new CheckBox + { + Value = FileDialogStyle.DefaultUseColors ? CheckState.Checked : CheckState.UnChecked, Y = y++, X = x, Text = "_Use Colors" + }; win.Add (_cbUseColors); - _cbCaseSensitive = new () { Value = CheckState.UnChecked, Y = y++, X = x, Text = "_Case Sensitive Search" }; + _cbCaseSensitive = new CheckBox { Value = CheckState.UnChecked, Y = y++, X = x, Text = "_Case Sensitive Search" }; win.Add (_cbCaseSensitive); - _cbAllowMultipleSelection = new () { Value = CheckState.UnChecked, Y = y++, X = x, Text = "_Multiple" }; + _cbAllowMultipleSelection = new CheckBox { Value = CheckState.UnChecked, Y = y++, X = x, Text = "_Multiple" }; win.Add (_cbAllowMultipleSelection); - _cbShowTreeBranchLines = new () { Value = CheckState.Checked, Y = y++, X = x, Text = "Tree Branch _Lines" }; + _cbShowTreeBranchLines = new CheckBox { Value = CheckState.Checked, Y = y++, X = x, Text = "Tree Branch _Lines" }; win.Add (_cbShowTreeBranchLines); - _cbAlwaysTableShowHeaders = new () { Value = CheckState.Checked, Y = y++, X = x, Text = "Always Show _Headers" }; + _cbAlwaysTableShowHeaders = new CheckBox { Value = CheckState.Checked, Y = y++, X = x, Text = "Always Show _Headers" }; win.Add (_cbAlwaysTableShowHeaders); - _cbDrivesOnlyInTree = new () { Value = CheckState.UnChecked, Y = y++, X = x, Text = "Only Show _Drives" }; + _cbDrivesOnlyInTree = new CheckBox { Value = CheckState.UnChecked, Y = y++, X = x, Text = "Only Show _Drives" }; win.Add (_cbDrivesOnlyInTree); - _cbPreserveFilenameOnDirectoryChanges = new () { Value = CheckState.UnChecked, Y = y++, X = x, Text = "Preserve Filename" }; + _cbPreserveFilenameOnDirectoryChanges = new CheckBox { Value = CheckState.UnChecked, Y = y++, X = x, Text = "Preserve Filename" }; win.Add (_cbPreserveFilenameOnDirectoryChanges); y = 0; x = 24; - win.Add ( - new Line { Orientation = Orientation.Vertical, X = x++, Y = 1, Height = 4 } - ); + win.Add (new Line { Orientation = Orientation.Vertical, X = x++, Y = 1, Height = 4 }); win.Add (new Label { X = x++, Y = y++, Text = "Caption" }); - _osCaption = new () { X = x, Y = y }; + _osCaption = new OptionSelector { X = x, Y = y }; _osCaption.Labels = [Strings.btnOk, Strings.cmdOpen, Strings.cmdSave]; win.Add (_osCaption); y = 0; x = 34; - win.Add ( - new Line { Orientation = Orientation.Vertical, X = x++, Y = 1, Height = 4 } - ); + win.Add (new Line { Orientation = Orientation.Vertical, X = x++, Y = 1, Height = 4 }); win.Add (new Label { X = x++, Y = y++, Text = "OpenMode" }); - _osOpenMode = new () { X = x, Y = y }; + _osOpenMode = new OptionSelector { X = x, Y = y }; _osOpenMode.Labels = [Strings.menuFile, "D_irectory", "_Mixed"]; win.Add (_osOpenMode); y = 0; x = 48; - win.Add ( - new Line { Orientation = Orientation.Vertical, X = x++, Y = 1, Height = 4 } - ); + win.Add (new Line { Orientation = Orientation.Vertical, X = x++, Y = 1, Height = 4 }); win.Add (new Label { X = x++, Y = y++, Text = "Icons" }); - _osIcons = new () { X = x, Y = y }; + _osIcons = new OptionSelector { X = x, Y = y }; _osIcons.Labels = ["_None", "_Unicode", "Nerd_*"]; win.Add (_osIcons); @@ -99,31 +95,29 @@ public override void Main () y = 5; x = 24; - win.Add ( - new Line { Orientation = Orientation.Vertical, X = x++, Y = y + 1, Height = 4 } - ); + win.Add (new Line { Orientation = Orientation.Vertical, X = x++, Y = y + 1, Height = 4 }); win.Add (new Label { X = x++, Y = y++, Text = "Allowed" }); - _osAllowedTypes = new () { X = x, Y = y }; + _osAllowedTypes = new OptionSelector { X = x, Y = y }; _osAllowedTypes.Labels = ["An_y", "Cs_v (Recommended)", "Csv (S_trict)"]; win.Add (_osAllowedTypes); y = 5; x = 45; - win.Add ( - new Line { Orientation = Orientation.Vertical, X = x++, Y = y + 1, Height = 4 } - ); + win.Add (new Line { Orientation = Orientation.Vertical, X = x++, Y = y + 1, Height = 4 }); win.Add (new Label { X = x++, Y = y++, Text = "Buttons" }); win.Add (new Label { X = x, Y = y++, Text = "O_k Text:" }); - _tbOkButton = new () { X = x, Y = y++, Width = 12 }; + _tbOkButton = new TextField { X = x, Y = y++, Width = 12 }; win.Add (_tbOkButton); win.Add (new Label { X = x, Y = y++, Text = "_Cancel Text:" }); - _tbCancelButton = new () { X = x, Y = y++, Width = 12 }; + _tbCancelButton = new TextField { X = x, Y = y++, Width = 12 }; win.Add (_tbCancelButton); var btn = new Button { X = 1, Y = 9, IsDefault = true, Text = "Run Dialog" }; + win.CommandsToBubbleUp = [Command.Accept]; + win.Accepting += (s, e) => { try @@ -158,12 +152,11 @@ private void ConfirmOverwrite (object sender, FilesSelectedEventArgs e) private void CreateDialog (IApplication app) { - if (_osOpenMode.Value is not null) + if (_osOpenMode.Value is { }) { using FileDialog fd = new (); - fd.OpenMode = Enum.Parse ( - _osOpenMode.Labels + fd.OpenMode = Enum.Parse (_osOpenMode.Labels .Select (l => TextFormatter.FindHotKey (l, _osOpenMode.HotKeySpecifier, out int hotPos, out Key _) // Remove the hotkey specifier at the found position @@ -171,8 +164,7 @@ private void CreateDialog (IApplication app) // No hotkey found, return the label as is : l) - .ToArray () [_osOpenMode.Value.Value] - ); + .ToArray () [_osOpenMode.Value.Value]); fd.MustExist = _cbMustExist.Value == CheckState.Checked; fd.AllowsMultipleSelection = _cbAllowMultipleSelection.Value == CheckState.Checked; @@ -233,16 +225,14 @@ private void CreateDialog (IApplication app) if (result is null or 1) { - MessageBox.Query ( - app, - "Canceled", - "You canceled navigation and did not pick anything", - Strings.btnOk - ); + MessageBox.Query (app, "Canceled", "You canceled navigation and did not pick anything", Strings.btnOk); } else if (_cbAllowMultipleSelection.Value == CheckState.Checked) { - MessageBox.Query (app, "Chosen!", "You chose:" + Environment.NewLine + string.Join (Environment.NewLine, multiSelected.Select (m => m)), Strings.btnOk); + MessageBox.Query (app, + "Chosen!", + "You chose:" + Environment.NewLine + string.Join (Environment.NewLine, multiSelected.Select (m => m)), + Strings.btnOk); } else { @@ -254,7 +244,7 @@ private void CreateDialog (IApplication app) private class CaseSensitiveSearchMatcher : ISearchMatcher { private string _terms; - public void Initialize (string terms) { _terms = terms; } - public bool IsMatch (IFileSystemInfo f) { return f.Name.Contains (_terms, StringComparison.CurrentCulture); } + public void Initialize (string terms) => _terms = terms; + public bool IsMatch (IFileSystemInfo f) => f.Name.Contains (_terms, StringComparison.CurrentCulture); } } diff --git a/Examples/UICatalog/Scenarios/KeyBindings.cs b/Examples/UICatalog/Scenarios/KeyBindings.cs index a1cb35a37e..572037b7cc 100644 --- a/Examples/UICatalog/Scenarios/KeyBindings.cs +++ b/Examples/UICatalog/Scenarios/KeyBindings.cs @@ -172,8 +172,8 @@ public KeyBindingsDemo () Initialized += (_, _) => { - App?.Keyboard.KeyBindings.Add (Key.F4, this, Command.New); - App?.Keyboard.KeyBindings.Add (Key.Q.WithAlt, this, Command.Quit); + App?.Keyboard.KeyBindings.AddApp (Key.F4, this, Command.New); + App?.Keyboard.KeyBindings.AddApp (Key.Q.WithAlt, this, Command.Quit); }; AddCommand (Command.Quit, diff --git a/Examples/UICatalog/Scenarios/Menus.cs b/Examples/UICatalog/Scenarios/Menus.cs index b21eb3ea23..24a7aac3a5 100644 --- a/Examples/UICatalog/Scenarios/Menus.cs +++ b/Examples/UICatalog/Scenarios/Menus.cs @@ -1,6 +1,5 @@ #nullable enable -using System.Collections.ObjectModel; using System.Diagnostics; using Microsoft.Extensions.Logging; using Serilog; @@ -16,6 +15,8 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("Shortcuts")] public class Menus : Scenario { + private EventLog? _eventLog; + public override void Main () { ConfigurationManager.Enable (ConfigLocations.All); @@ -27,18 +28,16 @@ public override void Main () using Runnable runnable = new (); runnable.Title = GetQuitKeyAndName (); - ObservableCollection eventSource = []; - - ListView eventLog = new () + _eventLog = new EventLog { - Title = "Event Log", + Id = "eventLog", X = Pos.AnchorEnd (), - Width = Dim.Auto (), - Height = Dim.Fill (), // Make room for some wide things + Height = Dim.Fill (), SchemeName = "Runnable", - Source = new ListWrapper (eventSource) + BorderStyle = LineStyle.Double, + Title = "E_vents", + Arrangement = ViewArrangement.LeftResizable }; - eventLog.Border!.Thickness = new Thickness (0, 1, 0, 0); MenuHost menuHostView = new () { @@ -46,56 +45,44 @@ public override void Main () Title = $"Menu Host - Use {PopoverMenu.DefaultKey} for Popover Menu", X = 0, Y = 0, - Width = Dim.Fill ()! - Dim.Width (eventLog), + Width = Dim.Fill ()! - Dim.Width (_eventLog), Height = Dim.Fill (), BorderStyle = LineStyle.Dotted }; runnable.Add (menuHostView); - menuHostView.CommandNotBound += (o, args) => - { - if (o is not View sender || args.Handled) - { - return; - } - - Logging.Debug ($"{sender.Id} CommandNotBound: {args.Context?.Command}"); - eventSource.Add ($"{sender.Id} CommandNotBound: {args.Context?.Command}"); - eventLog.MoveDown (); - }; - - menuHostView.Accepting += (o, args) => - { - if (o is not View sender || args.Handled) - { - return; - } - - string sourceTitle = args.Context?.Source.ToIdentifyingString () ?? "(null)"; - Logging.Debug ($"{sender.Id} Accepting: {sourceTitle}"); - eventSource.Add ($"{sender.Id} Accepting: {sourceTitle}: "); - eventLog.MoveDown (); - }; - - menuHostView.ContextMenu!.Accepted += (o, args) => - { - if (o is not View sender || args.Handled) - { - return; - } + _eventLog.SetViewToLog (runnable); + _eventLog.SetViewToLog (menuHostView); - var sourceText = "(null)"; + runnable.Initialized += (_, _) => + { + _eventLog.SetViewToLog (menuHostView.MenuBar); - if (args.Context?.TryGetSource (out View? sourceView) == true) - { - sourceText = sourceView.Text; - } - Logging.Debug ($"{sender.Id} Accepted: {sourceText}"); - eventSource.Add ($"{sender.Id} Accepted: {sourceText}: "); - eventLog.MoveDown (); - }; + foreach (MenuItem menuItem in menuHostView?.MenuBar?.GetMenuItemsWith (v => true) ?? []) + { + _eventLog.SetViewToLog (menuItem); + _eventLog.SetViewToLog (menuItem.CommandView); + menuItem.Action += () => _eventLog.Log ($"{menuItem.ToIdentifyingString ()} Action!"); + } + + foreach (Menu menu in menuHostView?.SubViews.OfType ().Where (m => m.Id == "TestMenu")!) + { + _eventLog.SetViewToLog (menu); - runnable.Add (eventLog); + foreach (MenuItem mi in menu.SubViews.OfType ()) + { + _eventLog.SetViewToLog (mi); + _eventLog.SetViewToLog (mi.CommandView); + } + } + + if (menuHostView?.ContextMenu is { }) + { + _eventLog.SetViewToLog (menuHostView.ContextMenu); + } + }; + + runnable.Add (_eventLog); app.Run (runnable); } @@ -107,10 +94,18 @@ public class MenuHost : View { internal PopoverMenu? ContextMenu { get; private set; } + internal MenuBar? MenuBar { get; private set; } + public MenuHost () { CanFocus = true; BorderStyle = LineStyle.Dashed; + } + + /// + public override void EndInit () + { + base.EndInit (); AddCommand (Command.Context, _ => @@ -123,71 +118,41 @@ public MenuHost () MouseBindings.ReplaceCommands (MouseFlags.RightButtonClicked, Command.Context); KeyBindings.Add (PopoverMenu.DefaultKey, Command.Context); - //AddCommand ( - // Command.Cancel, - // ctx => - // { - // if (App?.Popover?.GetActivePopover () as PopoverMenu is { Visible: true } visiblePopover) - // { - // visiblePopover.Visible = false; - // } - - // return true; - // }); - - //MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Cancel); - Label lastCommandLabel = new () { Title = "_Last Command:", X = 15, Y = 10 }; View lastCommandText = new () { X = Pos.Right (lastCommandLabel) + 1, Y = Pos.Top (lastCommandLabel), Height = Dim.Auto (), Width = Dim.Auto () }; Add (lastCommandLabel, lastCommandText); - AddCommand (Command.New, HandleCommand); - HotKeyBindings.Add (Key.F2, Command.New); - - AddCommand (Command.Open, HandleCommand); - HotKeyBindings.Add (Key.F3, Command.Open); - - AddCommand (Command.Save, HandleCommand); - HotKeyBindings.Add (Key.F4, Command.Save); - - AddCommand (Command.SaveAs, HandleCommand); - HotKeyBindings.Add (Key.A.WithCtrl, Command.SaveAs); - AddCommand (Command.Quit, _ => { - Logging.Debug ("MenuHost Command.Quit - RequestStop"); + // Logging.Debug ("MenuHost Command.Quit - RequestStop"); App?.RequestStop (); return true; }); HotKeyBindings.Add (Application.QuitKey, Command.Quit); - AddCommand (Command.Cut, HandleCommand); - HotKeyBindings.Add (Key.X.WithCtrl, Command.Cut); - - AddCommand (Command.Copy, HandleCommand); - HotKeyBindings.Add (Key.C.WithCtrl, Command.Copy); - - AddCommand (Command.Paste, HandleCommand); - HotKeyBindings.Add (Key.V.WithCtrl, Command.Paste); - - AddCommand (Command.SelectAll, HandleCommand); - HotKeyBindings.Add (Key.T.WithCtrl, Command.SelectAll); - // BUGBUG: This must come before we create the MenuBar or it will not work. // BUGBUG: This is due to TODO's in PopoverMenu where key bindings are not // BUGBUG: updated after the MenuBar is created. App?.Keyboard.KeyBindings.Remove (Key.F5); - App?.Keyboard.KeyBindings.Add (Key.F5, this, Command.Edit); + App?.Keyboard.KeyBindings.AddApp (Key.F5, this, Command.Edit); + + MenuBar = new MenuBar { Title = "MenuHost MenuBar" }; + MenuBar.CommandsToBubbleUp = [Command.Accept, Command.Activate, Command.HotKey]; + + Add (MenuBar); - var menuBar = new MenuBar { Title = "MenuHost MenuBar" }; MenuHost host = this; - menuBar.EnableForDesign (ref host); + MenuBar?.EnableForDesign (ref host); - Add (menuBar); + // TODO: This needs to be done whenever a menuitem in any MenuBarItem changes + foreach (MenuBarItem? mbi in MenuBar?.SubViews.Select (s => s as MenuBarItem)!) + { + App?.Popover?.Register (mbi?.PopoverMenu); + } Label lastAcceptedLabel = new () { Title = "Last Accepted:", X = Pos.Left (lastCommandLabel), Y = Pos.Bottom (lastCommandLabel) }; @@ -203,7 +168,7 @@ public MenuHost () // set a Key (F10). MenuBar adds this key as a hotkey and thus if it's pressed, it toggles the MenuItem // CB. // So that is needed is to mirror the two check boxes. - var autoSaveMenuItemCb = menuBar.GetMenuItemsWithTitle ("_Auto Save").FirstOrDefault ()?.CommandView as CheckBox; + var autoSaveMenuItemCb = MenuBar.GetMenuItemsWith (mi => mi.Id == "AutoSave").FirstOrDefault ()?.CommandView as CheckBox; Debug.Assert (autoSaveMenuItemCb is { }); CheckBox autoSaveStatusCb = new () @@ -211,8 +176,8 @@ public MenuHost () Title = "AutoSave Status (MenuItem Binding to F10)", X = Pos.Left (lastAcceptedLabel), Y = Pos.Bottom (lastAcceptedLabel) }; - autoSaveStatusCb.ValueChanged += (_, _) => { autoSaveMenuItemCb!.Value = autoSaveStatusCb.Value; }; - autoSaveMenuItemCb.ValueChanged += (_, _) => { autoSaveStatusCb!.Value = autoSaveMenuItemCb.Value; }; + autoSaveStatusCb.ValueChanged += (_, _) => { autoSaveMenuItemCb.Value = autoSaveStatusCb.Value; }; + autoSaveMenuItemCb.ValueChanged += (_, _) => { autoSaveStatusCb.Value = autoSaveMenuItemCb.Value; }; Add (autoSaveStatusCb); @@ -227,33 +192,16 @@ public MenuHost () }; // The source of truth is our status CB; any time it changes, update the menu item - var enableOverwriteMenuItemCb = menuBar.GetMenuItemsWithTitle ("Overwrite").FirstOrDefault ()?.CommandView as CheckBox; + var enableOverwriteMenuItemCb = MenuBar.GetMenuItemsWith (mi => mi.Id == "Overwrite").FirstOrDefault ()?.CommandView as CheckBox; - enableOverwriteStatusCb.ValueChanged += (_, _) => - { - if (enableOverwriteMenuItemCb is { }) - { - enableOverwriteMenuItemCb.Value = enableOverwriteStatusCb.Value; - } - }; + enableOverwriteStatusCb.ValueChanged += (_, _) => { enableOverwriteMenuItemCb?.Value = enableOverwriteStatusCb.Value; }; - menuBar.Accepted += (_, args) => + MenuBar.Accepted += (_, args) => { - if (!(args.Context?.TryGetSource (out View? sourceView) == true) - || sourceView is not MenuItem mi - || mi.CommandView != enableOverwriteMenuItemCb) + if (args.Context?.Source?.TryGetTarget (out View? sourceView) != true || sourceView is not MenuItem mi) { - return; + lastCommandText.Text = args.Context?.Command!.ToString ()!; } - - Logging.Debug ($"menuBar.Accepted: {args.Context?.Source.ToIdentifyingString ()}"); - - // Set Cancel to true to stop propagation of Accepting to superview - args.Handled = true; - - // Since overwrite uses a MenuItem.Command the menu item CB is the source of truth - enableOverwriteStatusCb.Value = ((CheckBox)mi.CommandView).Value; - lastAcceptedText.Text = sourceView.Title; }; HotKeyBindings.Add (Key.W.WithCtrl, Command.EnableOverwrite); @@ -264,15 +212,15 @@ public MenuHost () // The command was invoked. Toggle the status Cb. enableOverwriteStatusCb.AdvanceCheckState (); - return HandleCommand (ctx); + return true; }); Add (enableOverwriteStatusCb); // MenuItem: EditMode - Demos App Level Key Bindings // In MenuBar.EnableForDesign, the edit mode MenuItem specifies a Command (Command.Edit). - // F5 is bound to Command.EnableOverwrite as an Applicatio-Level Key Binding + // F5 is bound to Command.EnableOverwrite as an Application-Level Key Binding // Thus when F5 is pressed the MenuBar never sees it, but the command is invoked on this, via - // a Application.KeyBinding. + // Application.KeyBinding. // If the user clicks on the MenuItem, Accept will be raised. CheckBox editModeStatusCb = new () { @@ -280,33 +228,18 @@ public MenuHost () }; // The source of truth is our status CB; any time it changes, update the menu item - var editModeMenuItemCb = menuBar.GetMenuItemsWithTitle ("EditMode").FirstOrDefault ()?.CommandView as CheckBox; + var editModeMenuItemCb = MenuBar.GetMenuItemsWith (mi => mi.Id == "EditMode").FirstOrDefault ()?.CommandView as CheckBox; - editModeStatusCb.ValueChanged += (_, _) => - { - if (editModeMenuItemCb is { }) - { - editModeMenuItemCb.Value = editModeStatusCb.Value; - } - }; + editModeStatusCb.ValueChanged += (_, _) => { editModeMenuItemCb?.Value = editModeStatusCb.Value; }; - menuBar.Accepted += (_, args) => + MenuBar.Accepted += (_, args) => { - if (!(args.Context?.TryGetSource (out View? sourceView) == true) - || sourceView is not MenuItem mi - || mi.CommandView != editModeMenuItemCb) + if (args.Context?.Source?.TryGetTarget (out View? sourceView) != true || sourceView is not MenuItem mi) { return; } - Logging.Debug ($"menuBar.Accepted: {args.Context?.Source.ToIdentifyingString ()}"); - - // Set Cancel to true to stop propagation of Accepting to superview - args.Handled = true; - - // Since overwrite uses a MenuItem.Command the menu item CB is the source of truth - editModeMenuItemCb.Value = ((CheckBox)mi.CommandView).Value; - lastAcceptedText.Text = sourceView.Title; + lastAcceptedText.Text = sourceView.Title!; }; AddCommand (Command.Edit, @@ -315,25 +248,36 @@ public MenuHost () // The command was invoked. Toggle the status Cb. editModeStatusCb.AdvanceCheckState (); - return HandleCommand (ctx); + return true; }); Add (editModeStatusCb); + OptionSelector? schemeOptionSelector = + MenuBar.GetMenuItemsWith (mi => mi.Id == "mutuallyExclusiveOptions").FirstOrDefault ()?.CommandView as OptionSelector; + + schemeOptionSelector!.ValueChanged += (_, args) => + { + if (args.Value is { } scheme) + { + MenuBar.SchemeName = scheme.ToString (); + } + }; + // Set up the Context Menu ContextMenu = new PopoverMenu { Title = "ContextMenu", Id = "ContextMenu" }; + ContextMenu?.EnableForDesign (ref host); - ContextMenu.EnableForDesign (ref host); - App?.Popover?.Register (ContextMenu); - - ContextMenu.Visible = false; + ContextMenu?.Visible = false; // Demo of PopoverMenu as a context menu // If we want Commands from the ContextMenu to be handled by the MenuHost // we need to subscribe to the ContextMenu's Accepted event. ContextMenu!.Accepted += (_, args) => { - Logging.Debug ($"ContextMenu.Accepted: {args.Context?.Source.ToIdentifyingString ()}"); + string sourceTitle = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Title : "null"; + + // Logging.Debug ($"ContextMenu.Accepted: {sourceTitle}"); // Forward the event to the MenuHost if (args.Context is { }) @@ -348,8 +292,9 @@ public MenuHost () openBtn.Accepting += (_, e) => { e.Handled = true; - Logging.Trace ($"openBtn.Accepting - Sending F9. {e.Context?.Source.ToIdentifyingString ()}"); - NewKeyDownEvent (menuBar.Key); + string sourceTitle = e.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Title : "null"; + Logging.Trace ($"openBtn.Accepting - Sending F9. {sourceTitle}"); + NewKeyDownEvent (MenuBar.Key); }; Add (openBtn); @@ -363,18 +308,45 @@ public MenuHost () //appWindow.Add (enableBtn); autoSaveStatusCb.SetFocus (); + App?.Popover?.Register (ContextMenu); - return; + Menu testMenu = new () { Y = Pos.Bottom (editModeStatusCb) + 1, Id = "TestMenu" }; + ConfigureTestMenu (testMenu); + Add (testMenu); + } - // Add the commands supported by this View - bool? HandleCommand (ICommandContext? ctx) - { - lastCommandText.Text = ctx?.Command!.ToString ()!; + private void ConfigureTestMenu (Menu menu) + { + MenuItem menuItem1 = new () { Title = "Z_igzag", Key = Key.I.WithCtrl, Text = "Gonna zig zag" }; - Logging.Debug ($"lastCommand: {lastCommandText.Text}"); + Line line = new (); - return true; - } + MenuItem menuItemBorders = new () { Title = "_Borders", Text = "Borders", Key = Key.D4.WithAlt }; + menuItemBorders.CommandView = new CheckBox { Title = menuItemBorders.Title, CanFocus = false }; + + menuItemBorders.Action += () => + { + if (menuItemBorders.CommandView is CheckBox cb) + { + menu.BorderStyle = cb.Value == CheckState.Checked ? LineStyle.Double : LineStyle.None; + } + }; + + // This ensures the checkbox state toggles when the hotkey of Title is pressed. + menuItemBorders.Accepting += (_, args) => args.Handled = true; + + OptionSelector? schemeOptionSelector = new () { Title = "Scheme", CanFocus = true }; + MenuItem menuItemScheme = new () { Title = "Scheme", Text = "Scheme", Key = Key.S.WithCtrl, CommandView = schemeOptionSelector }; + + schemeOptionSelector!.ValueChanged += (_, args) => + { + if (args.Value is { } scheme) + { + menu.SchemeName = scheme.ToString (); + } + }; + + menu.Add (menuItem1, line, menuItemBorders, menuItemScheme); } /// diff --git a/Examples/UICatalog/Scenarios/MessageBoxes.cs b/Examples/UICatalog/Scenarios/MessageBoxes.cs index 3951e67227..00ad823bf2 100644 --- a/Examples/UICatalog/Scenarios/MessageBoxes.cs +++ b/Examples/UICatalog/Scenarios/MessageBoxes.cs @@ -11,10 +11,7 @@ public override void Main () using IApplication app = Application.Create (); app.Init (); - using Window window = new () - { - Title = GetQuitKeyAndName () - }; + using Window window = new () { Title = GetQuitKeyAndName () }; FrameView frame = new () { @@ -30,7 +27,6 @@ public override void Main () { X = 0, Y = 0, - Height = 1, Width = 15, TextAlignment = Alignment.End, @@ -42,17 +38,16 @@ public override void Main () { X = Pos.Right (label) + 1, Y = Pos.Top (label), - Width = Dim.Fill (0, minimumContentDim: 50), + Width = Dim.Fill (0, 50), Height = 1, Text = "The title" }; frame.Add (titleEdit); - label = new () + label = new Label { X = 0, Y = Pos.Bottom (label), - Width = Dim.Width (label), Height = 1, TextAlignment = Alignment.End, @@ -65,17 +60,16 @@ public override void Main () Text = "Message line 1.\nMessage line two. This is a really long line to force wordwrap. It needs to be long for it to work.", X = Pos.Right (label) + 1, Y = Pos.Top (label), - Width = Dim.Fill (0, minimumContentDim: 50), + Width = Dim.Fill (0, 50), Height = 5, WordWrap = true }; frame.Add (messageEdit); - label = new () + label = new Label { X = 0, Y = Pos.Bottom (messageEdit), - Width = Dim.Width (label), Height = 1, TextAlignment = Alignment.End, @@ -93,11 +87,10 @@ public override void Main () }; frame.Add (numButtonsEdit); - label = new () + label = new Label { X = 0, Y = Pos.Bottom (label), - Width = Dim.Width (label), Height = 1, TextAlignment = Alignment.End, @@ -115,11 +108,10 @@ public override void Main () }; frame.Add (defaultButtonEdit); - label = new () + label = new Label { X = 0, Y = Pos.Bottom (label), - Width = Dim.Width (label), Height = 1, TextAlignment = Alignment.End, @@ -127,20 +119,13 @@ public override void Main () }; frame.Add (label); - OptionSelector styleOptionSelector = new () - { - X = Pos.Right (label) + 1, - Y = Pos.Top (label), - Labels = ["_Query", "_Error"], - Title = "Sty_le" - }; + OptionSelector styleOptionSelector = new () { X = Pos.Right (label) + 1, Y = Pos.Top (label), Labels = ["_Query", "_Error"], Title = "Sty_le" }; frame.Add (styleOptionSelector); - label = new () + label = new Label { X = 0, Y = Pos.Bottom (styleOptionSelector), - Width = Dim.Width (label), Height = 1, TextAlignment = Alignment.End, @@ -149,18 +134,13 @@ public override void Main () CheckBox ckbWrapMessage = new () { - X = Pos.Right (label) + 1, Y = Pos.Bottom (styleOptionSelector), - Value = CheckState.Checked, - Text = "_Wrap Message" + X = Pos.Right (label) + 1, Y = Pos.Bottom (styleOptionSelector), Value = CheckState.Checked, Text = "_Wrap Message" }; frame.Add (label, ckbWrapMessage); frame.ValidatePosDim = true; - label = new () - { - X = Pos.Center (), Y = Pos.Bottom (frame) + 2, TextAlignment = Alignment.End, Text = "Button Pressed:" - }; + label = new Label { X = Pos.Center (), Y = Pos.Bottom (frame) + 2, TextAlignment = Alignment.End, Text = "Button Pressed:" }; window.Add (label); Label buttonPressedLabel = new () @@ -172,56 +152,57 @@ public override void Main () Text = " " }; - Button showMessageBoxButton = new () - { - X = Pos.Center (), Y = Pos.Bottom (frame) + 2, IsDefault = true, Text = "_Show MessageBox" - }; + Button showMessageBoxButton = new () { X = Pos.Center (), Y = Pos.Bottom (frame) + 2, IsDefault = true, Text = "_Show MessageBox" }; + + window.CommandsToBubbleUp = [Command.Accept]; + frame.CommandsToBubbleUp = [Command.Accept]; window.Accepting += (_, e) => - { - try - { - int numButtons = int.Parse (numButtonsEdit.Text); - int defaultButton = int.Parse (defaultButtonEdit.Text); - - List messageBoxButtons = []; - - for (var i = 0; i < numButtons; i++) - { - messageBoxButtons.Add ($"_{NumberToWords.Convert (i)}"); - } - - if (styleOptionSelector.Value == 0) - { - buttonPressedLabel.Text = - $"{MessageBox.Query ( - app, - titleEdit.Text, - messageEdit.Text, - defaultButton, - ckbWrapMessage.Value == CheckState.Checked, - messageBoxButtons.ToArray () - )}"; - } - else - { - buttonPressedLabel.Text = - $"{MessageBox.ErrorQuery (app, - titleEdit.Text, - messageEdit.Text, - defaultButton, - ckbWrapMessage.Value == CheckState.Checked, - messageBoxButtons.ToArray () - )}"; - } - } - catch (FormatException) - { - buttonPressedLabel.Text = "Invalid Options"; - } - - e.Handled = true; - }; + { + try + { + int numButtons = int.Parse (numButtonsEdit.Text); + int defaultButton = int.Parse (defaultButtonEdit.Text); + + List messageBoxButtons = []; + + for (var i = 0; i < numButtons; i++) + { + messageBoxButtons.Add ($"_{NumberToWords.Convert (i)}"); + } + + if (styleOptionSelector.Value == 0) + { + buttonPressedLabel.Text = + $"{ + MessageBox.Query (app, + titleEdit.Text, + messageEdit.Text, + defaultButton, + ckbWrapMessage.Value == CheckState.Checked, + messageBoxButtons.ToArray ()) + }"; + } + else + { + buttonPressedLabel.Text = + $"{ + MessageBox.ErrorQuery (app, + titleEdit.Text, + messageEdit.Text, + defaultButton, + ckbWrapMessage.Value == CheckState.Checked, + messageBoxButtons.ToArray ()) + }"; + } + } + catch (FormatException) + { + buttonPressedLabel.Text = "Invalid Options"; + } + + e.Handled = true; + }; window.Add (showMessageBoxButton); window.Add (buttonPressedLabel); diff --git a/Examples/UICatalog/Scenarios/MouseTester.cs b/Examples/UICatalog/Scenarios/MouseTester.cs index 51c2e0b96e..9a8623ebda 100644 --- a/Examples/UICatalog/Scenarios/MouseTester.cs +++ b/Examples/UICatalog/Scenarios/MouseTester.cs @@ -268,63 +268,71 @@ public override void Main () demo.Activating += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + string sourceId = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Id : "null"; + commandLogList.Add ($"{sourceId}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; demo.Accepting += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + string sourceId = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Id : "null"; + commandLogList.Add ($"{sourceId}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; demo.CommandNotBound += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString ()}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; demoInPadding.Activating += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + string sourceId = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Id : "null"; + commandLogList.Add ($"{sourceId}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; demoInPadding.Accepting += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + string sourceId = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Id : "null"; + commandLogList.Add ($"{sourceId}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; sub1.Activating += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + string sourceId = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Id : "null"; + commandLogList.Add ($"{sourceId}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; sub1.Accepting += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + string sourceId = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Id : "null"; + commandLogList.Add ($"{sourceId}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; sub2.Activating += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + string sourceId = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Id : "null"; + commandLogList.Add ($"{sourceId}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; sub2.Accepting += (_, args) => { - commandLogList.Add ($"{args.Context!.Source.ToIdentifyingString()}:{args.Context!.Command}"); + string sourceId = args.Context?.Source?.TryGetTarget (out View? sourceView) == true ? sourceView.Id : "null"; + commandLogList.Add ($"{sourceId}:{args.Context!.Command}"); commandLog.MoveEnd (); args.Handled = true; }; diff --git a/Examples/UICatalog/Scenarios/Selectors.cs b/Examples/UICatalog/Scenarios/Selectors.cs index 4d20dc2bf4..788ad59694 100644 --- a/Examples/UICatalog/Scenarios/Selectors.cs +++ b/Examples/UICatalog/Scenarios/Selectors.cs @@ -19,24 +19,29 @@ public override void Main () appWindow.Title = GetQuitKeyAndName (); appWindow.BorderStyle = LineStyle.None; + EventLog eventLog = new () + { + Id = "eventLog", + X = Pos.AnchorEnd (), + Height = Dim.Fill (), + SchemeName = "Runnable", + BorderStyle = LineStyle.Double, + Title = "E_vents", + Arrangement = ViewArrangement.LeftResizable + }; + FrameView? optionSelectorsFrame = null; FrameView? flagSelectorsFrame = null; OptionSelector orientationSelector = new () { - Orientation = Orientation.Horizontal, - BorderStyle = LineStyle.Dotted, - Title = "Selector Or_ientation", - Value = Orientation.Vertical + Orientation = Orientation.Horizontal, BorderStyle = LineStyle.Dotted, Title = "Selector Or_ientation", Value = Orientation.Vertical }; orientationSelector.ValueChanged += OrientationSelectorOnSelectedItemChanged; FlagSelector stylesSelector = new () { - X = Pos.Right (orientationSelector) + 1, - Orientation = Orientation.Horizontal, - BorderStyle = LineStyle.Dotted, - Title = "Selector St_yles" + X = Pos.Right (orientationSelector) + 1, Orientation = Orientation.Horizontal, BorderStyle = LineStyle.Dotted, Title = "Selector St_yles" }; stylesSelector.ValueChanged += StylesSelectorOnValueChanged; @@ -71,19 +76,21 @@ public override void Main () }; canFocus.ValueChanged += CanFocusOnValueChanged; - optionSelectorsFrame = new () + OptionSelector tabBehaviorSelector = new () { - Y = Pos.Bottom (canFocus), - Width = Dim.Percent (50), - Height = Dim.Fill (), - Title = "O_ptionSelectors", - TabStop = TabBehavior.TabStop + X = Pos.Right (canFocus) + 1, + Y = Pos.Top (canFocus), + Orientation = Orientation.Horizontal, + BorderStyle = LineStyle.Dotted, + Title = "_Tab Behavior", + Value = orientationSelector.TabStop }; - Label label = new () - { - Title = "Fo_ur Options:" - }; + tabBehaviorSelector.ValueChanged += TabBehaviorSelectorOnValueChanged; + + optionSelectorsFrame = new FrameView { Y = Pos.Bottom (canFocus), Height = Dim.Fill (), Title = "O_ptionSelectors", TabStop = TabBehavior.TabStop }; + + Label label = new () { Title = "Fo_ur Options:" }; OptionSelector optionSelector = new () { @@ -98,11 +105,7 @@ public override void Main () }; optionSelectorsFrame.Add (label, optionSelector); - label = new () - { - Y = Pos.Bottom (optionSelector), - Title = ":" - }; + label = new Label { Y = Pos.Bottom (optionSelector), Title = ":" }; OptionSelector optionSelectorT = new () { @@ -116,20 +119,17 @@ public override void Main () optionSelectorsFrame.Add (label, optionSelectorT); - flagSelectorsFrame = new () + flagSelectorsFrame = new FrameView { Y = Pos.Top (optionSelectorsFrame), X = Pos.Right (optionSelectorsFrame), - Width = Dim.Fill (), + Width = Dim.Fill (eventLog), Height = Dim.Fill (), Title = "_FlagSelectors", TabStop = TabBehavior.TabStop }; - label = new () - { - Title = "FlagSelector _(uint):" - }; + label = new Label { Title = "FlagSelector _(uint):" }; FlagSelector flagSelector = new () { @@ -137,30 +137,12 @@ public override void Main () BorderStyle = LineStyle.Dotted, Title = "FlagSe_lector (uint)", AssignHotKeys = true, - Values = - [ - 0b_0001, - 0b_0010, - 0b_0100, - 0b_1000, - 0b_1111 - ], - Labels = - [ - "0x0001 One", - "0x0010 Two", - "0x0100 Quattro", - "0x1000 8", - "0x1111 Fifteen" - ] + Values = [0b_0001, 0b_0010, 0b_0100, 0b_1000, 0b_1111], + Labels = ["0x0001 One", "0x0010 Two", "0x0100 Quattro", "0x1000 8", "0x1111 Fifteen"] }; flagSelectorsFrame.Add (label, flagSelector); - label = new () - { - Y = Pos.Bottom (flagSelector), - Title = "_:" - }; + label = new Label { Y = Pos.Bottom (flagSelector), Title = "_:" }; FlagSelector flagSelectorT = new () { @@ -175,13 +157,46 @@ public override void Main () flagSelectorsFrame.Add (label, flagSelectorT); flagSelectorT.ValueChanged += (_, a) => { View.Diagnostics = (ViewDiagnosticFlags)a.Value!; }; - appWindow.Add (orientationSelector, stylesSelector, horizontalSpace, showBorderAndTitle, canFocus, optionSelectorsFrame, flagSelectorsFrame); + optionSelectorsFrame.Width = Dim.Func (view => (appWindow.Viewport.Width - eventLog.Frame.Width) / 2); + + appWindow.Add (orientationSelector, + stylesSelector, + horizontalSpace, + showBorderAndTitle, + canFocus, + tabBehaviorSelector, + optionSelectorsFrame, + flagSelectorsFrame, + eventLog); + + eventLog.SetViewToLog (orientationSelector); + eventLog.SetViewToLog (stylesSelector); + eventLog.SetViewToLog (tabBehaviorSelector); + + foreach (SelectorBase selector in GetAllSelectors ()) + { + eventLog.SetViewToLog (selector); + } // Run - Start the application. app.Run (appWindow); return; + void TabBehaviorSelectorOnValueChanged (object? sender, EventArgs e) + { + if (sender is not OptionSelector s) + { + return; + } + List selectors = GetAllSelectors (); + + foreach (SelectorBase selector in selectors) + { + selector.TabBehavior = s.Value!.Value; + } + } + void OrientationSelectorOnSelectedItemChanged (object? sender, EventArgs e) { if (sender is not OptionSelector s) @@ -240,7 +255,7 @@ void ShowBorderAndTitleOnValueChanged (object? sender, ValueChangedEventArgs e) { - _app!.TopRunnableView!.Title = GetQuitKeyAndName (); + if (!e.Value) + { + // Stopping + return; + } - ObservableCollection eventSource = new (); + _window!.Title = GetQuitKeyAndName (); - var eventLog = new ListView + EventLog eventLog = new () { Id = "eventLog", X = Pos.AnchorEnd (), - Y = 0, + Y = 1, Height = Dim.Fill (4), SchemeName = "Runnable", - Source = new ListWrapper (eventSource), BorderStyle = LineStyle.Double, Title = "E_vents" }; - eventLog.Width = Dim.Auto (); - _app?.TopRunnableView.Add (eventLog); + eventLog.Width = Dim.Auto (minimumContentDim: 3, maximumContentDim: Dim.Percent (50)); + _window.Add (eventLog); - var alignKeysShortcut = new Shortcut + CheckBox canFocusCb = new () { - Id = "alignKeysShortcut", - X = 0, + X = Pos.Left (eventLog), Y = 0, + Id = "canFocusCB", + Text = "*._CommandView.CanFocus", + + // Shortcut/MenuItem override GettingAttributeForRole to ensure CommandViews with multiple selectable items (like a ListView or Selector) + // show the selected item distinctly, but for a CommandView with only a single selectable item (like a CheckBox), + // we want it to look focused when selected, and unfocused when not, so set CanFocus false. + CanFocus = false + }; + _window.Add (canFocusCb); + + canFocusCb.ValueChanged += (_, args) => { SetCommandViewsCanFocus (args.NewValue == CheckState.Checked); }; + + Shortcut alignKeysShortcut = new () + { + Id = "alignKeys", Width = Dim.Fill () - Dim.Width (eventLog), HelpText = "Fill to log", CommandView = new CheckBox { Text = "_Align Keys", CanFocus = false, MouseHighlightStates = MouseState.None, Value = CheckState.Checked }, @@ -62,147 +76,136 @@ private void App_Loaded (object? sender, EventArgs e) ((CheckBox)alignKeysShortcut.CommandView).ValueChanging += (_, a) => { - if (alignKeysShortcut.CommandView is CheckBox cb) + if (alignKeysShortcut.CommandView is not CheckBox) { - bool align = a.NewValue == CheckState.Checked; - eventSource.Add ($"{alignKeysShortcut.Id}.CommandView.ValueChanging: {cb.Text}"); - eventLog.MoveDown (); - - AlignKeys (align); + return; } + bool align = a.NewValue == CheckState.Checked; + AlignKeys (align); }; - _app?.TopRunnableView.Add (alignKeysShortcut); + _window.Add (alignKeysShortcut); - var commandFirstShortcut = new Shortcut + Shortcut commandFirstShortcut = new () { - Id = "commandFirstShortcut", + Id = "commandFirst", X = 0, Y = Pos.Bottom (alignKeysShortcut), Width = Dim.Fill () - Dim.Width (eventLog), - HelpText = "Show _Command first", - CommandView = new CheckBox { Text = "Command _First", CanFocus = false, MouseHighlightStates = MouseState.None }, + HelpText = "Show Command first", + CommandView = new CheckBox { Id = "commandFirstCB", Text = "Command _First", CanFocus = false }, Key = Key.F.WithCtrl }; - ((CheckBox)commandFirstShortcut.CommandView).Value = - commandFirstShortcut.AlignmentModes.HasFlag (AlignmentModes.EndToStart) ? CheckState.UnChecked : CheckState.Checked; + ((CheckBox)commandFirstShortcut.CommandView).ValueChanged += (_, eventArgs) => + { + if (commandFirstShortcut.CommandView is not CheckBox) + { + return; + } - ((CheckBox)commandFirstShortcut.CommandView).ValueChanging += (_, eventArgs) => - { - if (commandFirstShortcut.CommandView is CheckBox cb) - { - eventSource.Add ($"{ - commandFirstShortcut.Id - }.CommandView.ValueChanging: { - cb.Text - }"); - eventLog.MoveDown (); - - foreach (Shortcut peer in _app!.TopRunnableView!.SubViews.OfType ()) - { - if (eventArgs.NewValue == CheckState.Checked) - { - peer.AlignmentModes &= ~AlignmentModes.EndToStart; - } - else - { - peer.AlignmentModes |= AlignmentModes.EndToStart; - } - } - } - }; - - _app?.TopRunnableView.Add (commandFirstShortcut); - - var canFocusShortcut = new Shortcut - { - Id = "canFocusShortcut", - X = 0, - Y = Pos.Bottom (commandFirstShortcut), - Width = Dim.Fill (eventLog), - Key = Key.F4, - HelpText = "Changes all CommandView.CanFocus", - CommandView = new CheckBox { Text = "_CommandView.CanFocus" } - }; + foreach (Shortcut peer in _window.SubViews.OfType ()) + { + if (eventArgs.NewValue == CheckState.Checked) + { + peer.AlignmentModes &= ~AlignmentModes.EndToStart; + } + else + { + peer.AlignmentModes |= AlignmentModes.EndToStart; + } + } + }; - ((CheckBox)canFocusShortcut.CommandView).ValueChanging += (_, a) => - { - if (canFocusShortcut.CommandView is CheckBox cb) - { - eventSource.Add ($"Toggle: {cb.Text}"); - eventLog.MoveDown (); + ((CheckBox)commandFirstShortcut.CommandView).Value = + commandFirstShortcut.AlignmentModes.HasFlag (AlignmentModes.EndToStart) ? CheckState.UnChecked : CheckState.Checked; - SetCanFocus (a.NewValue == CheckState.Checked); - } - }; - _app?.TopRunnableView.Add (canFocusShortcut); + _window.Add (commandFirstShortcut); - var appShortcut = new Shortcut + Shortcut appShortcut = new () { Id = "appShortcut", X = 0, - Y = Pos.Bottom (canFocusShortcut), - Width = Dim.Fill (eventLog), + Y = Pos.Bottom (commandFirstShortcut), + Width = 50, Title = "A_pp Shortcut", Key = Key.F1, - Text = "Width is DimFill", + Text = "Width is 50", BindKeyToApplication = true }; - _app?.TopRunnableView.Add (appShortcut); + appShortcut.Activated += (_, _) => { MessageBox.Query (_app!, "App Shortcut", "You activated the App scoped shortcut!", Strings.btnOk); }; - var buttonShortcut = new Shortcut + _window.Add (appShortcut); + + Shortcut buttonShortcut = new () { - Id = "buttonShortcut", + Id = "button", X = 0, Y = Pos.Bottom (appShortcut), Width = Dim.Fill (eventLog), HelpText = "Accepting pops MB", - CommandView = new Button { Title = "_Button", ShadowStyle = ShadowStyle.None, MouseHighlightStates = MouseState.None }, + CommandView = new Button + { + Id = "buttonBtn", + Title = "_Button", + + // Set the ShadowStyle to None as shadows look awkward on a single-line Button CommandView, and the Shortcut/MenuItem default + ShadowStyle = ShadowStyle.None, + + // Shortcut/MenuItem override GettingAttributeForRole to ensure CommandViews with multiple selectable items (like a ListView or Selector) + // show the selected item distinctly, but for a CommandView with only a single selectable item (like a CheckBox or Button), + // we want it to look focused when selected, and unfocused when not, so set CanFocus false. + CanFocus = false + }, Key = Key.K }; - buttonShortcut.Accepting += Button_Clicked; + buttonShortcut.Activated += ButtonShortcutOnActivated; + + _window.Add (buttonShortcut); - _app?.TopRunnableView.Add (buttonShortcut); + OptionSelector optionSelector = new () { Id = "optionSelector", Orientation = Orientation.Vertical }; + optionSelector.EnableForDesign (); - var optionSelectorShortcut = new Shortcut + Shortcut optionSelectorShortcut = new () { - Id = "optionSelectorShortcut", - HelpText = "Option Selector", + Id = "optionSelector", + HelpText = "Activating a Shortcut with an OptionSelector CommandView will cycle the options", X = 0, Y = Pos.Bottom (buttonShortcut), Key = Key.F2, Width = Dim.Fill (eventLog), - CommandView = new OptionSelector - { - Orientation = Orientation.Vertical, Labels = ["O_ne", "T_wo", "Th_ree", "Fo_ur"], MouseHighlightStates = MouseState.None - } + CommandView = optionSelector }; - ((OptionSelector)optionSelectorShortcut.CommandView).ValueChanged += (send, args) => - { - if (send is { }) - { - eventSource.Add ($"ValueChanged: { - send.GetType ().Name - } - { - args.NewValue - }"); - eventLog.MoveDown (); - } - }; - - _app?.TopRunnableView.Add (optionSelectorShortcut); - - var sliderShortcut = new Shortcut + _window.Add (optionSelectorShortcut); + + FlagSelector flagSelector = new () { Styles = SelectorStyles.ShowNoneFlag }; + flagSelector.AssignHotKeys = true; + flagSelector.Value = View.Diagnostics; + + flagSelector.ValueChanged += (_, args) => { View.Diagnostics = args.Value ?? ViewDiagnosticFlags.Off; }; + + Shortcut diagnosticShortcut = new () + { + Id = "diagnosticShortcut", + Y = Pos.Bottom (optionSelectorShortcut), + Key = Key.F3, + Width = Dim.Fill (eventLog), + CommandView = flagSelector, + HelpText = "View Diagnostics" + }; + + _window.Add (diagnosticShortcut); + + Shortcut sliderShortcut = new () { Id = "sliderShortcut", X = 0, - Y = Pos.Bottom (optionSelectorShortcut), + Y = Pos.Bottom (diagnosticShortcut), Width = Dim.Fill (eventLog), HelpText = "LinearRanges work!", - CommandView = new LinearRange { Orientation = Orientation.Horizontal, AllowEmpty = true }, + CommandView = new LinearRange { Id = "sliderLR", Orientation = Orientation.Horizontal, AllowEmpty = true }, Key = Key.F5 }; @@ -216,24 +219,29 @@ private void App_Loaded (object? sender, EventArgs e) { if (send is LinearRange lr) { - eventSource.Add ($"OptionsChanged: { + eventLog.Log ($"OptionsChanged: { lr.GetType ().Name } - { string.Join (",", lr.GetSetOptions ()) }"); - eventLog.MoveDown (); } }; - _app?.TopRunnableView.Add (sliderShortcut); + _window.Add (sliderShortcut); - // BUGBUG: Border causes issues with ListView sizing - var listView = new ListView { Height = Dim.Auto (), Width = Dim.Auto (), Title = "ListView", BorderStyle = LineStyle.Single }; + ListView listView = new () + { + Id = "listViewLV", + Height = Dim.Auto (), + Width = Dim.Auto (), + Title = "ListView", + BorderStyle = LineStyle.Single + }; listView.EnableForDesign (); - var listViewShortcut = new Shortcut + Shortcut listViewShortcut = new () { - Id = "listViewShortcut", + Id = "listView", X = 0, Y = Pos.Bottom (sliderShortcut), Width = Dim.Fill (eventLog), @@ -242,23 +250,46 @@ private void App_Loaded (object? sender, EventArgs e) Key = Key.F5.WithCtrl }; - _app?.TopRunnableView.Add (listViewShortcut); + _window.Add (listViewShortcut); - var noCommandShortcut = new Shortcut + Shortcut commandNewShortcut = new () { - Id = "noCommandShortcut", + Id = "commandNew", X = 0, Y = Pos.Bottom (listViewShortcut), Width = Dim.Width (listViewShortcut), + Key = Key.N.WithCtrl, + TargetView = _window, + Command = Command.New + }; + + _window.CommandNotBound += (o, args) => + { + if (args.Context?.Command != Command.New) + { + return; + } + MessageBox.Query (_app!, "Create something new!", "Command.New was invoked from the commandNewShortcut.", "Thanks!"); + args.Handled = true; + }; + + _window.Add (commandNewShortcut); + + Shortcut noCommandShortcut = new () + { + Id = "noCommand", + X = 0, + Y = Pos.Bottom (commandNewShortcut), + Width = Dim.Width (commandNewShortcut), HelpText = "No Command", Key = Key.D0 }; - _app?.TopRunnableView.Add (noCommandShortcut); + _window.Add (noCommandShortcut); - var noKeyShortcut = new Shortcut + Shortcut noKeyShortcut = new () { - Id = "noKeyShortcut", + Id = "noKey", X = 0, Y = Pos.Bottom (noCommandShortcut), Width = Dim.Width (noCommandShortcut), @@ -266,11 +297,11 @@ private void App_Loaded (object? sender, EventArgs e) HelpText = "Keyless" }; - _app?.TopRunnableView.Add (noKeyShortcut); + _window.Add (noKeyShortcut); - var noHelpShortcut = new Shortcut + Shortcut noHelpShortcut = new () { - Id = "noHelpShortcut", + Id = "noHelp", X = 0, Y = Pos.Bottom (noKeyShortcut), Width = Dim.Width (noKeyShortcut), @@ -279,16 +310,16 @@ private void App_Loaded (object? sender, EventArgs e) HelpText = "" }; - _app?.TopRunnableView.Add (noHelpShortcut); + _window.Add (noHelpShortcut); noHelpShortcut.SetFocus (); - var framedShortcut = new Shortcut + Shortcut framedShortcut = new () { - Id = "framedShortcut", + Id = "framed", X = 0, Y = Pos.Bottom (noHelpShortcut) + 1, Width = Dim.Width (noHelpShortcut), - Title = "Framed Shortcut", + Title = "Frame_d Shortcut", Key = Key.K.WithCtrl, Text = "Help: You can resize this", BorderStyle = LineStyle.Dotted, @@ -312,20 +343,50 @@ private void App_Loaded (object? sender, EventArgs e) } framedShortcut.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Runnable); - _app?.TopRunnableView.Add (framedShortcut); + _window.Add (framedShortcut); + + if (ConfigurationManager.IsEnabled) + { + OptionSelector themesSelector = new (); + themesSelector.Id = "themesSelector"; + themesSelector.Labels = ThemeManager.GetThemeNames ().ToArray (); + themesSelector.Value = ThemeManager.GetThemeNames ().IndexOf (ThemeManager.GetCurrentThemeName ()); + + themesSelector.ValueChanged += (_, args) => + { + if (args.NewValue is null) + { + return; + } + ThemeManager.Theme = ThemeManager.GetThemeNames () [(int)args.NewValue]; + }; + + Shortcut themesShortcut = new () + { + Id = "themes", + Width = Dim.Fill (eventLog), + Y = Pos.Bottom (framedShortcut) + 1, + CommandView = themesSelector, + HelpText = "Cycle Through Themes", + Key = Key.T.WithCtrl + }; + + _window.Add (themesShortcut); + } // Horizontal - var progressShortcut = new Shortcut + Shortcut progressShortcut = new () { - Id = "progressShortcut", + Id = "progress", X = Pos.Align (Alignment.Start, AlignmentModes.IgnoreFirstOrLast, 1), Y = Pos.AnchorEnd () - 1, Key = Key.F7, - HelpText = "Horizontal" + HelpText = "Cycle style" }; - progressShortcut.CommandView = new ProgressBar + ProgressBar progressBar = new () { + Id = "progressPB", Text = "Progress", Title = "P", Fraction = 0.5f, @@ -333,181 +394,160 @@ private void App_Loaded (object? sender, EventArgs e) Height = 1, ProgressBarStyle = ProgressBarStyle.Continuous }; - progressShortcut.CommandView.Width = 10; - progressShortcut.CommandView.Height = 1; - progressShortcut.CommandView.CanFocus = false; + progressShortcut.CommandView = progressBar; Timer timer = new (10) { AutoReset = true }; timer.Elapsed += (_, _) => { - if (progressShortcut.CommandView is ProgressBar pb) + if (progressShortcut.CommandView is not ProgressBar pb) { - if (pb.Fraction >= 1.0f) - { - pb.Fraction = 0; - } - - pb.Fraction += 0.01f; + return; + } - pb.SetNeedsDraw (); + if (pb.Fraction >= 1.0f) + { + pb.Fraction = 0; } + + pb.Fraction += 0.01f; + + pb.SetNeedsDraw (); }; timer.Start (); - _app?.TopRunnableView.Add (progressShortcut); + progressShortcut.Action = () => + { + progressBar.ProgressBarFormat = progressBar.ProgressBarFormat == ProgressBarFormat.Simple + ? ProgressBarFormat.SimplePlusPercentage + : ProgressBarFormat.Simple; + }; + _window.Add (progressShortcut); - var textField = new TextField { Text = "Edit me", Width = 10, Height = 1 }; + TextField textField = new () { Id = "textFieldTF", Text = "Edit me", Width = 14, Height = 1 }; - var textFieldShortcut = new Shortcut + Shortcut textFieldShortcut = new () { - Id = "textFieldShortcut", + Id = "textField", X = Pos.Align (Alignment.Start, AlignmentModes.IgnoreFirstOrLast, 1), Y = Pos.AnchorEnd () - 1, Key = Key.F8, HelpText = "TextField", - CanFocus = true, - CommandView = textField + CommandView = textField, + MouseHighlightStates = MouseState.None }; - textField.CanFocus = true; - _app?.TopRunnableView.Add (textFieldShortcut); + textFieldShortcut.Activated += (_, _) => { MessageBox.Query (_app!, "Hi", $"You entered \"{textField.Text}\"", Strings.btnOk); }; + + _window.Add (textFieldShortcut); + + // Set the CommandView to a ColorPicker16. This demonstrates how to support handling direct value changes + // when the user activates the CommandView and cycling the value if the user activates any other part + // of the Shortcut. When the activation comes from the CommandView (e.g., a mouse click on a color), + // let it continue so ColorPicker16.OnActivated runs and picks the color from the mouse position. + // When the activation comes from elsewhere (e.g., HotKey), cycle the color. + ColorPicker16 bgColor = new () { Id = "bgColorCP", BoxHeight = 1, BoxWidth = 1 }; - var bgColorShortcut = new Shortcut + Shortcut bgColorShortcut = new () { - Id = "bgColorShortcut", + Id = "bgColor", X = Pos.Align (Alignment.Start, AlignmentModes.IgnoreFirstOrLast, 1), Y = Pos.AnchorEnd (), Key = Key.F9, - HelpText = "Cycles BG Color" + HelpText = "Cycles BG Color", + CommandView = bgColor }; - var bgColor = new ColorPicker16 { BoxHeight = 1, BoxWidth = 1 }; + bgColorShortcut.Activating += (_, args) => + { + // If activation came from the CommandView (e.g., mouse click on a color), + // don't mark as Handled so ColorPicker16.OnActivated runs and picks the color. + if (args.Context.TryGetSource (out View? ctxSource) && ctxSource == bgColor) + { + return; + } - bgColorShortcut.Activating += (_, _) => { }; + // For all other sources (HotKey, programmatic), cycle the color. + args.Handled = true; - bgColorShortcut.Accepting += (_, args) => - { - if (bgColor.SelectedColor == ColorName16.White) - { - bgColor.SelectedColor = ColorName16.Black; + if (bgColor.SelectedColor == ColorName16.White) + { + bgColor.SelectedColor = ColorName16.Black; - return; - } + return; + } - bgColor.SelectedColor++; - args.Handled = true; - }; + bgColor.SelectedColor++; + }; bgColor.ValueChanged += (sendingView, args) => { if (sendingView is { }) { - eventSource.Add ($"ValueChanged: {sendingView.GetType ().Name} - {args.NewValue}"); - eventLog.MoveDown (); - - _app!.TopRunnableView!.SetScheme (new Scheme (_app.TopRunnableView.GetScheme ()) + _window.SetScheme (new Scheme (_window.GetScheme ()) { - Normal = - new Attribute (_app.TopRunnableView - .GetAttributeForRole (VisualRole.Normal) - .Foreground, - args.NewValue, - _app.TopRunnableView - .GetAttributeForRole (VisualRole.Normal) - .Style) + Normal = new Attribute (_window.GetAttributeForRole (VisualRole.Normal).Foreground, + args.NewValue, + _window.GetAttributeForRole (VisualRole.Normal).Style) }); } }; - bgColorShortcut.CommandView = bgColor; - - _app?.TopRunnableView.Add (bgColorShortcut); + _window.Add (bgColorShortcut); - var appQuitShortcut = new Shortcut + Shortcut appQuitShortcut = new () { - Id = "appQuitShortcut", + Id = "appQuit", X = Pos.Align (Alignment.Start, AlignmentModes.IgnoreFirstOrLast, 1), Y = Pos.AnchorEnd () - 1, - Key = Key.Esc, + Key = Key.Esc.WithShift, BindKeyToApplication = true, - Title = "Quit", - HelpText = "App Scope" + Title = "_Quit", + HelpText = "App Scope", + Action = () => _app?.RequestStop () }; - appQuitShortcut.Accepting += (sendingView, _) => { (sendingView as View)?.App?.RequestStop (); }; - _app!.TopRunnableView!.Add (appQuitShortcut); + _window.Add (appQuitShortcut); - foreach (Shortcut shortcut in _app!.TopRunnableView!.SubViews.OfType ()) + foreach (Shortcut shortcut in _window.SubViews.OfType ()) { - shortcut.Activating += (_, args) => - { - if (args.Handled) - { - return; - } - - eventSource.Add ($"{shortcut.Id}.Activating: {shortcut.CommandView.Text} {shortcut.CommandView.GetType ().Name}"); - eventLog.MoveDown (); - }; - - shortcut.CommandView.Activating += (_, args) => - { - if (args.Handled) - { - return; - } - - eventSource.Add ($"{ - shortcut.Id - }.CommandView.Activating: { - shortcut.CommandView.Text - } { - shortcut.CommandView.GetType ().Name - }"); - eventLog.MoveDown (); - }; - - shortcut.Accepting += (_, _) => - { - eventSource.Add ($"{shortcut.Id}.Accepting: {shortcut.CommandView.Text} {shortcut.CommandView.GetType ().Name}"); - eventLog.MoveDown (); - }; - - shortcut.CommandView.Accepting += (_, _) => - { - eventSource.Add ($"{ - shortcut.Id - }.CommandView.Accepting: { - shortcut.CommandView.Text - } { - shortcut.CommandView.GetType ().Name - }"); - eventLog.MoveDown (); - }; + eventLog.SetViewToLog (shortcut); + eventLog.SetViewToLog (shortcut.CommandView); + shortcut.Action += () => eventLog.Log ($"{shortcut.ToIdentifyingString ()} Action!"); } - SetCanFocus (false); - AlignKeys (true); + alignKeysShortcut.SetFocus (); + return; - void SetCanFocus (bool canFocus) + void ButtonShortcutOnActivated (object? s, EventArgs _) { - foreach (Shortcut peer in _app!.TopRunnableView!.SubViews.OfType ()) + if (s is View view) + { + MessageBox.Query (view.App!, "Hi", $"You clicked {view.Text}", Strings.btnOk); + } + } + + void SetCommandViewsCanFocus (bool canFocus) + { + View? focused = _window.MostFocused; + + foreach (Shortcut peer in _window.SubViews.OfType ()) { if (peer.CanFocus) { peer.CommandView.CanFocus = canFocus; } } + focused?.SetFocus (); } void AlignKeys (bool align) { var max = 0; - IEnumerable toAlign = _app!.TopRunnableView!.SubViews.OfType ().Where (s => !s.Y.Has (out _)); + IEnumerable toAlign = _window.SubViews.OfType ().Where (s => !s.Y.Has (out _)); Shortcut [] shortcuts = toAlign as Shortcut [] ?? toAlign.ToArray (); if (align) @@ -522,14 +562,4 @@ void AlignKeys (bool align) } } } - - private void Button_Clicked (object? sender, CommandEventArgs e) - { - e.Handled = true; - - if (sender is View view) - { - MessageBox.Query (view.App!, "Hi", $"You clicked {view.Text}", Strings.btnOk); - } - } } diff --git a/Examples/UICatalog/UICatalog.cs b/Examples/UICatalog/UICatalog.cs index aa82dba639..3e5ce1f31f 100644 --- a/Examples/UICatalog/UICatalog.cs +++ b/Examples/UICatalog/UICatalog.cs @@ -80,8 +80,7 @@ private static int Main (string [] args) // Get allowed driver names string? [] allowedDrivers = DriverRegistry.GetDriverNames ().ToArray (); - Option driverOption = new Option ("--driver", "The IDriver to use.") - .FromAmong (allowedDrivers!); + Option driverOption = new Option ("--driver", "The IDriver to use.").FromAmong (allowedDrivers!); driverOption.SetDefaultValue (string.Empty); driverOption.AddAlias ("-d"); driverOption.AddAlias ("--d"); @@ -98,8 +97,7 @@ private static int Main (string [] args) }); // Configuration Management - Option disableConfigManagement = new ( - "--disable-cm", + Option disableConfigManagement = new ("--disable-cm", "Indicates Configuration Management should not be enabled. Only `ConfigLocations.HardCoded` settings will be loaded."); disableConfigManagement.AddAlias ("-dcm"); disableConfigManagement.AddAlias ("--dcm"); @@ -108,13 +106,10 @@ private static int Main (string [] args) benchmarkFlag.AddAlias ("-b"); benchmarkFlag.AddAlias ("--b"); - Option force16ColorsOption = new ( - "--force-16-colors", - "Forces the driver to use 16-color mode instead of TrueColor."); + Option force16ColorsOption = new ("--force-16-colors", "Forces the driver to use 16-color mode instead of TrueColor."); force16ColorsOption.AddAlias ("-16"); - Option benchmarkTimeout = new ( - "--timeout", + Option benchmarkTimeout = new ("--timeout", () => Scenario.BenchmarkTimeout, $"The maximum time in milliseconds to run a benchmark for. Default is {Scenario.BenchmarkTimeout}ms."); benchmarkTimeout.AddAlias ("-t"); @@ -127,27 +122,29 @@ private static int Main (string [] args) // what's the app name? LogFilePath = $"{LOGFILE_LOCATION}/{Assembly.GetExecutingAssembly ().GetName ().Name}.log"; - Option debugLogLevel = new Option ("--debug-log-level", $"The level to use for logging (debug console and {LogFilePath})").FromAmong ( - Enum.GetNames () - ); + Option debugLogLevel = + new Option ("--debug-log-level", $"The level to use for logging (debug console and {LogFilePath})").FromAmong (Enum.GetNames ()); debugLogLevel.SetDefaultValue ("Warning"); debugLogLevel.AddAlias ("-dl"); debugLogLevel.AddAlias ("--dl"); - Argument scenarioArgument = new Argument ( - "scenario", - description: - "The name of the Scenario to run. If not provided, the UI Catalog UI will be shown.", - getDefaultValue: () => "none" - ).FromAmong ( - UICatalogRunnable.CachedScenarios.Select (s => s.GetName ()) - .Append ("none") - .ToArray () - ); + Argument scenarioArgument = + new Argument ("scenario", + description: "The name of the Scenario to run. If not provided, the UI Catalog UI will be shown.", + getDefaultValue: () => "none").FromAmong (UICatalogRunnable.CachedScenarios.Select (s => s.GetName ()) + .Append ("none") + .ToArray ()); var rootCommand = new RootCommand ("A comprehensive sample library and test app for Terminal.Gui") { - scenarioArgument, debugLogLevel, benchmarkFlag, benchmarkTimeout, resultsFile, driverOption, disableConfigManagement, force16ColorsOption + scenarioArgument, + debugLogLevel, + benchmarkFlag, + benchmarkTimeout, + resultsFile, + driverOption, + disableConfigManagement, + force16ColorsOption }; rootCommand.SetHandler (context => @@ -170,14 +167,11 @@ private static int Main (string [] args) // See https://github.com/dotnet/command-line-api/issues/796 for the rationale behind this hackery Options = options; - } - ); + }); var helpShown = false; - Parser parser = new CommandLineBuilder (rootCommand) - .UseHelp (_ => helpShown = true) - .Build (); + Parser parser = new CommandLineBuilder (rootCommand).UseHelp (_ => helpShown = true).Build (); parser.Invoke (args); @@ -207,9 +201,8 @@ private static int Main (string [] args) return 0; } - public static LogEventLevel LogLevelToLogEventLevel (LogLevel logLevel) - { - return logLevel switch + public static LogEventLevel LogLevelToLogEventLevel (LogLevel logLevel) => + logLevel switch { LogLevel.Trace => LogEventLevel.Verbose, LogLevel.Debug => LogEventLevel.Debug, @@ -220,31 +213,28 @@ public static LogEventLevel LogLevelToLogEventLevel (LogLevel logLevel) LogLevel.None => LogEventLevel.Fatal, // Default to Fatal if None is specified _ => LogEventLevel.Fatal // Default to Information for any unspecified LogLevel }; - } private static ILogger CreateLogger () { // Configure Serilog to write logs to a file LogLevelSwitch.MinimumLevel = LogLevelToLogEventLevel (Enum.Parse (Options.DebugLogLevel)); - Log.Logger = new LoggerConfiguration () - .MinimumLevel.ControlledBy (LogLevelSwitch) - .Enrich.FromLogContext () // Enables dynamic enrichment - .WriteTo.Debug () - .WriteTo.File ( - LogFilePath, - rollingInterval: RollingInterval.Day, - outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") - .CreateLogger (); + Log.Logger = new LoggerConfiguration ().MinimumLevel.ControlledBy (LogLevelSwitch) + .Enrich.FromLogContext () // Enables dynamic enrichment + .WriteTo.Debug () + .WriteTo.File (LogFilePath, + rollingInterval: RollingInterval.Day, + outputTemplate: + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger (); // Create a logger factory compatible with Microsoft.Extensions.Logging // Note: Don't use 'using' here - we need the factory to stay alive for ScenarioLogCapture ILoggerFactory loggerFactory = LoggerFactory.Create (builder => { - builder - .AddSerilog (dispose: true) // Integrate Serilog with ILogger - .AddProvider (LogCapture) // Add in-memory capture for scenario debugging - .SetMinimumLevel (LogLevel.Trace); // Set minimum log level + builder.AddSerilog (dispose: true) // Integrate Serilog with ILogger + .AddProvider (LogCapture) // Add in-memory capture for scenario debugging + .SetMinimumLevel (LogLevel.Trace); // Set minimum log level }); // Get an ILogger instance @@ -274,18 +264,11 @@ private static void UICatalogMain (UICatalogCommandLineOptions options) // run it and exit when done. if (options.Scenario != "none") { - BenchmarkResults? results = runner.RunScenario (options.Scenario, options.Benchmark); if (results is { }) { - Console.WriteLine ( - JsonSerializer.Serialize ( - results, - new JsonSerializerOptions - { - WriteIndented = true - })); + Console.WriteLine (JsonSerializer.Serialize (results, new JsonSerializerOptions { WriteIndented = true })); } #if DEBUG_IDISPOSABLE @@ -300,16 +283,18 @@ private static void UICatalogMain (UICatalogCommandLineOptions options) { List results = runner.BenchmarkAllScenarios (UICatalogRunnable.CachedScenarios!); - if (results.Count > 0) + if (results.Count <= 0) + { + return; + } + + if (!string.IsNullOrEmpty (Options.ResultsFile)) + { + Runner.SaveResultsToFile (results, Options.ResultsFile); + } + else { - if (!string.IsNullOrEmpty (Options.ResultsFile)) - { - Runner.SaveResultsToFile (results, Options.ResultsFile); - } - else - { - Runner.DisplayResultsUI (results); - } + Runner.DisplayResultsUI (results); } return; diff --git a/Examples/UICatalog/UICatalogRunnable.cs b/Examples/UICatalog/UICatalogRunnable.cs index da5f3b5eef..53a8611538 100644 --- a/Examples/UICatalog/UICatalogRunnable.cs +++ b/Examples/UICatalog/UICatalogRunnable.cs @@ -161,8 +161,9 @@ private MenuBar CreateMenuBar () buttons: Strings.btnOk), Key.A.WithCtrl) ]) - ]) - { Title = "menuBar", Id = "menuBar" }; + ]) { Title = "menuBar", Id = "menuBar" }; + + menuBar.CommandsToBubbleUp = [Command.Accept, Command.Activate, Command.HotKey]; return menuBar; @@ -170,72 +171,35 @@ View [] CreateThemeMenuItems () { List menuItems = []; - _force16ColorsMenuItemCb = new CheckBox - { - Title = "Force _16 Colors", - Value = Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked, - - // Best practice for CheckBoxes in menus is to disable focus and highlight states - CanFocus = false, - MouseHighlightStates = MouseState.None - }; - - _force16ColorsMenuItemCb.ValueChanging += (_, args) => - { - if (Driver.Force16Colors && args.NewValue == CheckState.UnChecked && !App!.Driver!.SupportsTrueColor) - { - args.Handled = true; - } - }; + _force16ColorsMenuItemCb = new CheckBox { Title = "Force _16 Colors", Value = Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked }; - _force16ColorsMenuItemCb.ValueChanged += (_, args) => - { - Driver.Force16Colors = args.NewValue == CheckState.Checked; - - _force16ColorsShortcutCb!.Value = args.NewValue; - SetNeedsDraw (); - }; - - menuItems.Add (new MenuItem { CommandView = _force16ColorsMenuItemCb }); + menuItems.Add (new MenuItem + { + CommandView = _force16ColorsMenuItemCb, + Action = () => + { + Driver.Force16Colors = !Driver.Force16Colors; + _force16ColorsShortcutCb!.Value = Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked; + SetNeedsDraw (); + } + }); menuItems.Add (new Line ()); if (ConfigurationManager.IsEnabled) { - _themesSelector = new OptionSelector - { - // MouseHighlightStates = MouseState.In, - CanFocus = true + _themesSelector = new OptionSelector { CanFocus = true }; + _themesSelector.ValueChanged += OnThemesSelectorOnValueChanged; - // InvertFocusAttribute = true - }; - - _themesSelector.ValueChanged += (_, args) => - { - if (args.NewValue is null) - { - return; - } - ThemeManager.Theme = ThemeManager.GetThemeNames () [(int)args.NewValue]; - }; + MenuItem menuItem = new () { CommandView = _themesSelector, HelpText = "Cycle Through Themes", Key = Key.T.WithCtrl }; - var menuItem = new MenuItem { CommandView = _themesSelector, HelpText = "Cycle Through Themes", Key = Key.T.WithCtrl }; menuItems.Add (menuItem); menuItems.Add (new Line ()); _topSchemesSelector = new OptionSelector (); - _topSchemesSelector.ValueChanged += (_, args) => - { - if (args.NewValue is null) - { - return; - } - CachedRunnableScheme = SchemeManager.GetSchemesForCurrentTheme ().Keys.ToArray () [(int)args.NewValue]; - SchemeName = CachedRunnableScheme; - SetNeedsDraw (); - }; + _topSchemesSelector.ValueChanged += OnTopSchemesSelectorOnValueChanged; menuItem = new MenuItem { @@ -252,35 +216,40 @@ View [] CreateThemeMenuItems () } return menuItems.ToArray (); + + void OnTopSchemesSelectorOnValueChanged (object? _, ValueChangedEventArgs args) + { + if (args.NewValue is null) + { + return; + } + CachedRunnableScheme = SchemeManager.GetSchemesForCurrentTheme ().Keys.ToArray () [(int)args.NewValue]; + SchemeName = CachedRunnableScheme; + SetNeedsDraw (); + } + + void OnThemesSelectorOnValueChanged (object? _, ValueChangedEventArgs args) + { + if (args.NewValue is null) + { + return; + } + ThemeManager.Theme = ThemeManager.GetThemeNames () [(int)args.NewValue]; + } } View [] CreateDiagnosticMenuItems () { List menuItems = []; - _diagnosticFlagsSelector = new FlagSelector { Styles = SelectorStyles.ShowNoneFlag, CanFocus = true }; + _diagnosticFlagsSelector = new FlagSelector { Styles = SelectorStyles.ShowNoneFlag }; _diagnosticFlagsSelector.UsedHotKeys.Add (Key.D); _diagnosticFlagsSelector.AssignHotKeys = true; _diagnosticFlagsSelector.Value = Diagnostics; - _diagnosticFlagsSelector.Activating += (_, args) => - { - if (args.Context?.TryGetSource (out View? sourceView) == true) - { - _diagnosticFlags = - (ViewDiagnosticFlags)(int)sourceView.Data!; // (ViewDiagnosticFlags)_diagnosticFlagsSelector.Value; - Diagnostics = _diagnosticFlags; - } - }; - - var diagFlagMenuItem = new MenuItem { CommandView = _diagnosticFlagsSelector, HelpText = "View Diagnostics" }; - - diagFlagMenuItem.Accepting += (_, _) => - { - //_diagnosticFlags = (ViewDiagnosticFlags)_diagnosticFlagsSelector.Value; - //Diagnostics = _diagnosticFlags; - //args.Handled = true; - }; + _diagnosticFlagsSelector.ValueChanged += OnDiagnosticFlagsSelectorOnValueChanged; + + MenuItem diagFlagMenuItem = new () { CommandView = _diagnosticFlagsSelector, HelpText = "View Diagnostics" }; menuItems.Add (diagFlagMenuItem); @@ -288,52 +257,34 @@ View [] CreateDiagnosticMenuItems () _disableMouseCb = new CheckBox { - Title = "_Disable MouseEventArgs", - Value = App!.Mouse.IsMouseDisabled ? CheckState.Checked : CheckState.UnChecked, - - // Best practice for CheckBoxes in menus is to disable focus and highlight states - CanFocus = false, - MouseHighlightStates = MouseState.None + Title = "_Disable MouseEventArgs", Value = App!.Mouse.IsMouseDisabled ? CheckState.Checked : CheckState.UnChecked }; - //_disableMouseCb.CheckedStateChanged += (_, args) => { Application.IsMouseDisabled = args.Value == CheckState.Checked; }; - _disableMouseCb.Activating += (_, _) => - { - App!.Mouse.IsMouseDisabled = !App!.Mouse.IsMouseDisabled; - _disableMouseCb.Value = App!.Mouse.IsMouseDisabled ? CheckState.Checked : CheckState.None; - }; + _disableMouseCb.ValueChanged += (_, args) => { App!.Mouse.IsMouseDisabled = args.NewValue == CheckState.Checked; }; menuItems.Add (new MenuItem { CommandView = _disableMouseCb, HelpText = "Disable MouseEventArgs" }); return menuItems.ToArray (); + + void OnDiagnosticFlagsSelectorOnValueChanged (object? _, EventArgs args) => + Diagnostics = args.Value ?? ViewDiagnosticFlags.Off; } View [] CreateLoggingMenuItems () { List menuItems = []; - LogLevel [] logLevels = Enum.GetValues (); - _logLevelSelector = new OptionSelector { AssignHotKeys = true, Labels = Enum.GetNames (), - Value = logLevels.ToList ().IndexOf (Enum.Parse (UICatalog.Options.DebugLogLevel)) - - // MouseHighlightStates = MouseState.In, + Value = Enum.GetValues ().ToList ().IndexOf (Enum.Parse (UICatalog.Options.DebugLogLevel)) }; - _logLevelSelector.ValueChanged += (_, args) => - { - UICatalog.Options = UICatalog.Options with - { - DebugLogLevel = Enum.GetName (logLevels [args.NewValue!.Value])! - }; + MenuItem logMenu = new () { CommandView = _logLevelSelector, HelpText = "Cycle Through Log Levels", Key = Key.L.WithCtrl }; - UICatalog.LogLevelSwitch.MinimumLevel = - UICatalog.LogLevelToLogEventLevel (Enum.Parse (UICatalog.Options.DebugLogLevel)); - }; + _logLevelSelector.ValueChanged += OnLogLevelSelectorOnValueChanged; - menuItems.Add (new MenuItem { CommandView = _logLevelSelector, HelpText = "Cycle Through Log Levels", Key = Key.L.WithCtrl }); + menuItems.Add (logMenu); // add a separator menuItems.Add (new Line ()); @@ -341,6 +292,13 @@ View [] CreateLoggingMenuItems () menuItems.Add (new MenuItem ("_Open Log Folder", string.Empty, () => OpenUrl (UICatalog.LOGFILE_LOCATION))); return menuItems.ToArray ()!; + + void OnLogLevelSelectorOnValueChanged (object? _, ValueChangedEventArgs args) + { + UICatalog.Options = UICatalog.Options with { DebugLogLevel = Enum.GetName (Enum.GetValues () [args.NewValue!.Value])! }; + + UICatalog.LogLevelSwitch.MinimumLevel = UICatalog.LogLevelToLogEventLevel (Enum.Parse (UICatalog.Options.DebugLogLevel)); + } } } @@ -351,7 +309,7 @@ private void UpdateThemesMenu () return; } - _themesSelector.Value = null; + //_themesSelector.Value = null; _themesSelector.AssignHotKeys = true; _themesSelector.UsedHotKeys.Clear (); _themesSelector.Labels = ThemeManager.GetThemeNames ().ToArray (); @@ -483,8 +441,7 @@ private ListView CreateCategoryList () X = 0, Y = Pos.Bottom (_menuBar!), Width = Dim.Auto (), - Height = Dim.Fill (to: _statusBar!), - + Height = Dim.Fill (_statusBar!), ShowMarks = false, CanFocus = true, Title = "_Categories", @@ -565,17 +522,12 @@ private StatusBar CreateStatusBar () { StatusBar statusBar = new () { AlignmentModes = AlignmentModes.IgnoreFirstOrLast, CanFocus = false }; - _shQuit = new Shortcut { CanFocus = false, Title = "Quit", Key = Application.QuitKey }; + // This demonstrates a shortcut that invokes RequestStop to quit the app + _shQuit = new Shortcut { CanFocus = false, Title = "Quit", Key = Application.QuitKey, Action = RequestStop }; _shVersion = new Shortcut { Title = "Version Info", CanFocus = false }; - Shortcut statusBarShortcut = new () { Key = Key.F10, Title = "Show/Hide Status Bar", CanFocus = false }; - - statusBarShortcut.Accepting += (_, args) => - { - ShowStatusBar = !ShowStatusBar; - args.Handled = true; - }; + Shortcut statusBarShortcut = new () { Key = Key.F10, Title = "Show/Hide Status Bar", CanFocus = false, Action = () => ShowStatusBar = !ShowStatusBar }; _force16ColorsShortcutCb = new CheckBox { @@ -588,16 +540,14 @@ private StatusBar CreateStatusBar () CommandView = _force16ColorsShortcutCb, HelpText = "", BindKeyToApplication = true, - Key = Key.F7 + Key = Key.F7, + Action = () => + { + Driver.Force16Colors = !Driver.Force16Colors; + _force16ColorsMenuItemCb!.Value = Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked; + SetNeedsDraw (); + } }; - - force16ColorsShortcut.Accepting += (_, args) => - { - Driver.Force16Colors = !Driver.Force16Colors; - _force16ColorsMenuItemCb!.Value = Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked; - SetNeedsDraw (); - args.Handled = true; - }; statusBar.Add (_shQuit, statusBarShortcut, force16ColorsShortcut, _shVersion); if (UICatalog.Options.DontEnableConfigurationManagement) diff --git a/Terminal.Gui.Analyzers.Tests/HandledEventArgsAnalyzerTests.cs b/Terminal.Gui.Analyzers.Tests/HandledEventArgsAnalyzerTests.cs deleted file mode 100644 index e509925469..0000000000 --- a/Terminal.Gui.Analyzers.Tests/HandledEventArgsAnalyzerTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Terminal.Gui.Analyzers; -using Terminal.Gui.Input; -using Terminal.Gui.Views; - -namespace Analyzers.Tests; - -public class HandledEventArgsAnalyzerTests -{ - [Theory] - [InlineData("e")] - [InlineData ("args")] - public async Task Should_ReportDiagnostic_When_EHandledNotSet_Lambda (string paramName) - { - var originalCode = $$""" - using Terminal.Gui.Views; - - class TestClass - { - void Setup() - { - var b = new Button(); - b.Accepting += (s, {{paramName}}) => - { - // Forgot {{paramName}}.Handled = true; - }; - } - } - """; - await new ProjectBuilder () - .WithSourceCode (originalCode) - .WithAnalyzer (new HandledEventArgsAnalyzer ()) - .ValidateAsync (); - } - - [Theory] - [InlineData ("e")] - [InlineData ("args")] - public async Task Should_ReportDiagnostic_When_EHandledNotSet_Method (string paramName) - { - var originalCode = $$""" - using Terminal.Gui.Views; - using Terminal.Gui.Input; - - class TestClass - { - void Setup() - { - var b = new Button(); - b.Accepting += BOnAccepting; - } - private void BOnAccepting (object? sender, CommandEventArgs {{paramName}}) - { - - } - } - """; - await new ProjectBuilder () - .WithSourceCode (originalCode) - .WithAnalyzer (new HandledEventArgsAnalyzer ()) - .ValidateAsync (); - } -} diff --git a/Terminal.Gui.Analyzers.Tests/ProjectBuilder.cs b/Terminal.Gui.Analyzers.Tests/ProjectBuilder.cs deleted file mode 100644 index 2611fb5329..0000000000 --- a/Terminal.Gui.Analyzers.Tests/ProjectBuilder.cs +++ /dev/null @@ -1,171 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.Diagnostics; -using System.Collections.Immutable; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Drawing; -using Microsoft.CodeAnalysis.CodeActions; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; -using Document = Microsoft.CodeAnalysis.Document; -using Formatter = Microsoft.CodeAnalysis.Formatting.Formatter; -using System.Reflection; -using JetBrains.Annotations; - -public sealed class ProjectBuilder -{ - private string? _sourceCode; - private string? _expectedFixedCode; - private DiagnosticAnalyzer? _analyzer; - private CodeFixProvider? _codeFix; - - public ProjectBuilder WithSourceCode (string source) - { - _sourceCode = source; - return this; - } - - public ProjectBuilder ShouldFixCodeWith (string expected) - { - _expectedFixedCode = expected; - return this; - } - - public ProjectBuilder WithAnalyzer (DiagnosticAnalyzer analyzer) - { - _analyzer = analyzer; - return this; - } - - public ProjectBuilder WithCodeFix (CodeFixProvider codeFix) - { - _codeFix = codeFix; - return this; - } - - public async Task ValidateAsync () - { - if (_sourceCode == null) - { - throw new InvalidOperationException ("Source code not set."); - } - - if (_analyzer == null) - { - throw new InvalidOperationException ("Analyzer not set."); - } - - // Parse original document - Document document = CreateDocument (_sourceCode); - Compilation? compilation = await document.Project.GetCompilationAsync (); - - ImmutableArray diagnostics = compilation!.GetDiagnostics (); - IEnumerable errors = diagnostics.Where (d => d.Severity == DiagnosticSeverity.Error); - - IEnumerable enumerable = errors as Diagnostic [] ?? errors.ToArray (); - - if (enumerable.Any ()) - { - string errorMessages = string.Join (Environment.NewLine, enumerable.Select (e => e.ToString ())); - throw new Exception ("Compilation failed with errors:" + Environment.NewLine + errorMessages); - } - - // Run analyzer - ImmutableArray analyzerDiagnostics = await GetAnalyzerDiagnosticsAsync (compilation, _analyzer); - - Assert.NotEmpty (analyzerDiagnostics); - - if (_expectedFixedCode != null) - { - if (_codeFix == null) - { - throw new InvalidOperationException ("Expected code fix but none was set."); - } - - Document? fixedDocument = await ApplyCodeFixAsync (document, analyzerDiagnostics.First (), _codeFix); - - if (fixedDocument is { }) - { - Document formattedDocument = await Formatter.FormatAsync (fixedDocument); - string fixedSource = (await formattedDocument.GetTextAsync ()).ToString (); - - Assert.Equal (_expectedFixedCode, fixedSource); - } - } - } - - private static Document CreateDocument (string source) - { - string dd = typeof (Enumerable).GetTypeInfo ().Assembly.Location; - DirectoryInfo coreDir = Directory.GetParent (dd) ?? throw new Exception ($"Could not find parent directory of dotnet sdk. Sdk directory was {dd}"); - - AdhocWorkspace workspace = new AdhocWorkspace (); - ProjectId projectId = ProjectId.CreateNewId (); - DocumentId documentId = DocumentId.CreateNewId (projectId); - - List references = - [ - MetadataReference.CreateFromFile (typeof (Button).Assembly.Location), - MetadataReference.CreateFromFile (typeof (View).Assembly.Location), - MetadataReference.CreateFromFile (typeof (System.IO.FileSystemInfo).Assembly.Location), - MetadataReference.CreateFromFile (typeof (System.Linq.Enumerable).Assembly.Location), - MetadataReference.CreateFromFile (typeof (object).Assembly.Location), - MetadataReference.CreateFromFile (typeof (MarshalByValueComponent).Assembly.Location), - MetadataReference.CreateFromFile (typeof (ObservableCollection).Assembly.Location), - - // New assemblies required by Terminal.Gui version 2 - MetadataReference.CreateFromFile (typeof (Size).Assembly.Location), - MetadataReference.CreateFromFile (typeof (CanBeNullAttribute).Assembly.Location), - - - MetadataReference.CreateFromFile (Path.Combine (coreDir.FullName, "mscorlib.dll")), - MetadataReference.CreateFromFile (Path.Combine (coreDir.FullName, "System.Runtime.dll")), - MetadataReference.CreateFromFile (Path.Combine (coreDir.FullName, "System.Collections.dll")), - MetadataReference.CreateFromFile (Path.Combine (coreDir.FullName, "System.Data.Common.dll")) - - // Add more as necessary - ]; - - - ProjectInfo projectInfo = ProjectInfo.Create ( - projectId, - VersionStamp.Create (), - "TestProject", - "TestAssembly", - LanguageNames.CSharp, - compilationOptions: new CSharpCompilationOptions (OutputKind.DynamicallyLinkedLibrary), - metadataReferences: references); - - Solution solution = workspace.CurrentSolution - .AddProject (projectInfo) - .AddDocument (documentId, "Test.cs", SourceText.From (source)); - - return solution.GetDocument (documentId)!; - } - - private static async Task> GetAnalyzerDiagnosticsAsync (Compilation compilation, DiagnosticAnalyzer analyzer) - { - CompilationWithAnalyzers compilationWithAnalyzers = compilation.WithAnalyzers ([analyzer]); - return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync (); - } - - private static async Task ApplyCodeFixAsync (Document document, Diagnostic diagnostic, CodeFixProvider codeFix) - { - CodeAction? codeAction = null; - var context = new CodeFixContext ((TextDocument)document, diagnostic, (action, _) => codeAction = action, CancellationToken.None); - - await codeFix.RegisterCodeFixesAsync (context); - - if (codeAction == null) - { - throw new InvalidOperationException ("Code fix did not register a fix."); - } - - ImmutableArray operations = await codeAction.GetOperationsAsync (CancellationToken.None); - Solution solution = operations.OfType ().First ().ChangedSolution; - return solution.GetDocument (document.Id); - } -} diff --git a/Terminal.Gui.Analyzers.Tests/Terminal.Gui.Analyzers.Tests.csproj b/Terminal.Gui.Analyzers.Tests/Terminal.Gui.Analyzers.Tests.csproj deleted file mode 100644 index 37e739d3d4..0000000000 --- a/Terminal.Gui.Analyzers.Tests/Terminal.Gui.Analyzers.Tests.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - enable - false - true - $(DefineConstants);JETBRAINS_ANNOTATIONS;CONTRACTS_FULL - portable - enable - true - true - true - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - \ No newline at end of file diff --git a/Terminal.Gui.Analyzers/AnalyzerReleases.Shipped.md b/Terminal.Gui.Analyzers/AnalyzerReleases.Shipped.md deleted file mode 100644 index d00904b7aa..0000000000 --- a/Terminal.Gui.Analyzers/AnalyzerReleases.Shipped.md +++ /dev/null @@ -1,6 +0,0 @@ -## Release 1.0.0 - -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|-------------------- diff --git a/Terminal.Gui.Analyzers/AnalyzerReleases.Unshipped.md b/Terminal.Gui.Analyzers/AnalyzerReleases.Unshipped.md deleted file mode 100644 index 77056a7b64..0000000000 --- a/Terminal.Gui.Analyzers/AnalyzerReleases.Unshipped.md +++ /dev/null @@ -1,5 +0,0 @@ -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|-------------------- -TGUI001 | Reliability | Warning | HandledEventArgsAnalyzer, [Documentation](./TGUI001.md) \ No newline at end of file diff --git a/Terminal.Gui.Analyzers/DiagnosticCategory.cs b/Terminal.Gui.Analyzers/DiagnosticCategory.cs deleted file mode 100644 index dd580aa4e5..0000000000 --- a/Terminal.Gui.Analyzers/DiagnosticCategory.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Terminal.Gui.Analyzers; - -/// -/// Categories commonly used for diagnostic analyzers, inspired by FxCop and .NET analyzers conventions. -/// -internal enum DiagnosticCategory -{ - /// - /// Issues related to naming conventions and identifiers. - /// - Naming, - - /// - /// API design, class structure, inheritance, etc. - /// - Design, - - /// - /// How code uses APIs or language features incorrectly or suboptimally. - /// - Usage, - - /// - /// Patterns that cause poor runtime performance. - /// - Performance, - - /// - /// Vulnerabilities or insecure coding patterns. - /// - Security, - - /// - /// Code patterns that can cause bugs, crashes, or unpredictable behavior. - /// - Reliability, - - /// - /// Code readability, complexity, or future-proofing concerns. - /// - Maintainability, - - /// - /// Code patterns that may not work on all platforms or frameworks. - /// - Portability, - - /// - /// Issues with culture, localization, or globalization support. - /// - Globalization, - - /// - /// Problems when working with COM, P/Invoke, or other interop scenarios. - /// - Interoperability, - - /// - /// Issues with missing or incorrect XML doc comments. - /// - Documentation, - - /// - /// Purely stylistic issues not affecting semantics (e.g., whitespace, order). - /// - Style -} diff --git a/Terminal.Gui.Analyzers/HandledEventArgsAnalyzer.cs b/Terminal.Gui.Analyzers/HandledEventArgsAnalyzer.cs deleted file mode 100644 index fa8e19bd43..0000000000 --- a/Terminal.Gui.Analyzers/HandledEventArgsAnalyzer.cs +++ /dev/null @@ -1,269 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Terminal.Gui.Analyzers; - -[DiagnosticAnalyzer (LanguageNames.CSharp)] -public class HandledEventArgsAnalyzer : DiagnosticAnalyzer -{ - public const string DiagnosticId = "TGUI001"; - private static readonly LocalizableString Title = "Accepting event handler should set Handled = true"; - private static readonly LocalizableString MessageFormat = "Accepting event handler does not set Handled = true"; - private static readonly LocalizableString Description = "Handlers for Accepting should mark the CommandEventArgs as handled by setting Handled = true otherwise subsequent Accepting event handlers may also fire (e.g. default buttons)."; - private static readonly string Url = "https://github.com/tznind/gui.cs/blob/analyzer-no-handled/Terminal.Gui.Analyzers/TGUI001.md"; - private const string Category = nameof(DiagnosticCategory.Reliability); - - private static readonly DiagnosticDescriptor _rule = new ( - DiagnosticId, - Title, - MessageFormat, - Category, - DiagnosticSeverity.Warning, - true, - Description, - helpLinkUri: Url); - - public override ImmutableArray SupportedDiagnostics => [_rule]; - - public override void Initialize (AnalysisContext context) - { - context.EnableConcurrentExecution (); - - // Only analyze non-generated code - context.ConfigureGeneratedCodeAnalysis (GeneratedCodeAnalysisFlags.None); - - // Register for b.Accepting += (s,e)=>{...}; - context.RegisterSyntaxNodeAction ( - AnalyzeLambdaOrAnonymous, - SyntaxKind.ParenthesizedLambdaExpression, - SyntaxKind.SimpleLambdaExpression, - SyntaxKind.AnonymousMethodExpression); - - // Register for b.Accepting += MyMethod; - context.RegisterSyntaxNodeAction ( - AnalyzeEventSubscriptionWithMethodGroup, - SyntaxKind.AddAssignmentExpression); - } - - private static void AnalyzeLambdaOrAnonymous (SyntaxNodeAnalysisContext context) - { - var lambda = (AnonymousFunctionExpressionSyntax)context.Node; - - // Check if this lambda is assigned to the Accepting event - if (!IsAssignedToAcceptingEvent (lambda.Parent, context)) - { - return; - } - - // Look for any parameter of type CommandEventArgs (regardless of name) - IParameterSymbol? eParam = GetCommandEventArgsParameter (lambda, context.SemanticModel); - - if (eParam == null) - { - return; - } - - // Analyze lambda body for e.Handled = true assignment - if (lambda.Body is BlockSyntax block) - { - bool setsHandled = block.Statements - .SelectMany (s => s.DescendantNodes ().OfType ()) - .Any (a => IsHandledAssignment (a, eParam, context)); - - if (!setsHandled) - { - var diag = Diagnostic.Create (_rule, lambda.GetLocation ()); - context.ReportDiagnostic (diag); - } - } - else if (lambda.Body is ExpressionSyntax) - { - // Expression-bodied lambdas unlikely for event handlers — skip - } - } - - /// - /// Finds the first parameter of type CommandEventArgs in any parameter list (method or lambda). - /// - /// - /// - /// - private static IParameterSymbol? GetCommandEventArgsParameter (SyntaxNode paramOwner, SemanticModel semanticModel) - { - SeparatedSyntaxList? parameters = paramOwner switch - { - AnonymousFunctionExpressionSyntax lambda => GetParameters (lambda), - MethodDeclarationSyntax method => method.ParameterList.Parameters, - _ => null - }; - - if (parameters == null || parameters.Value.Count == 0) - { - return null; - } - - foreach (ParameterSyntax param in parameters.Value) - { - IParameterSymbol? symbol = semanticModel.GetDeclaredSymbol (param); - - if (symbol != null && IsCommandEventArgsType (symbol.Type)) - { - return symbol; - } - } - - return null; - } - - private static bool IsAssignedToAcceptingEvent (SyntaxNode? node, SyntaxNodeAnalysisContext context) - { - if (node is AssignmentExpressionSyntax assignment && IsAcceptingEvent (assignment.Left, context)) - { - return true; - } - - if (node?.Parent is AssignmentExpressionSyntax parentAssignment && IsAcceptingEvent (parentAssignment.Left, context)) - { - return true; - } - - return false; - } - - private static bool IsCommandEventArgsType (ITypeSymbol? type) { return type != null && type.Name == "CommandEventArgs"; } - - private static void AnalyzeEventSubscriptionWithMethodGroup (SyntaxNodeAnalysisContext context) - { - var assignment = (AssignmentExpressionSyntax)context.Node; - - // Check event name: b.Accepting += ... - if (!IsAcceptingEvent (assignment.Left, context)) - { - return; - } - - // Right side: should be method group (IdentifierNameSyntax) - if (assignment.Right is IdentifierNameSyntax methodGroup) - { - // Resolve symbol of method group - SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo (methodGroup); - - if (symbolInfo.Symbol is IMethodSymbol methodSymbol) - { - // Find method declaration in syntax tree - ImmutableArray declRefs = methodSymbol.DeclaringSyntaxReferences; - - foreach (SyntaxReference declRef in declRefs) - { - var methodDecl = declRef.GetSyntax () as MethodDeclarationSyntax; - - if (methodDecl != null) - { - AnalyzeHandlerMethodBody (context, methodDecl, methodSymbol); - } - } - } - } - } - - private static void AnalyzeHandlerMethodBody (SyntaxNodeAnalysisContext context, MethodDeclarationSyntax methodDecl, IMethodSymbol methodSymbol) - { - // Look for any parameter of type CommandEventArgs - IParameterSymbol? eParam = GetCommandEventArgsParameter (methodDecl, context.SemanticModel); - - if (eParam == null) - { - return; - } - - // Analyze method body - if (methodDecl.Body != null) - { - bool setsHandled = methodDecl.Body.Statements - .SelectMany (s => s.DescendantNodes ().OfType ()) - .Any (a => IsHandledAssignment (a, eParam, context)); - - if (!setsHandled) - { - var diag = Diagnostic.Create (_rule, methodDecl.Identifier.GetLocation ()); - context.ReportDiagnostic (diag); - } - } - } - - private static SeparatedSyntaxList GetParameters (AnonymousFunctionExpressionSyntax lambda) - { - switch (lambda) - { - case ParenthesizedLambdaExpressionSyntax p: - return p.ParameterList.Parameters; - case SimpleLambdaExpressionSyntax s: - // Simple lambda has a single parameter, wrap it in a list - return SyntaxFactory.SeparatedList (new [] { s.Parameter }); - case AnonymousMethodExpressionSyntax a: - return a.ParameterList?.Parameters ?? default (SeparatedSyntaxList); - default: - return default (SeparatedSyntaxList); - } - } - - private static bool IsAcceptingEvent (ExpressionSyntax expr, SyntaxNodeAnalysisContext context) - { - // Check if expr is b.Accepting or similar - - // Get symbol info - SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo (expr); - ISymbol? symbol = symbolInfo.Symbol; - - if (symbol == null) - { - return false; - } - - // Accepting event symbol should be an event named "Accepting" - if (symbol.Kind == SymbolKind.Event && symbol.Name == "Accepting") - { - return true; - } - - return false; - } - - private static bool IsHandledAssignment (AssignmentExpressionSyntax assignment, IParameterSymbol eParamSymbol, SyntaxNodeAnalysisContext context) - { - // Check if left side is "e.Handled" and right side is "true" - // Left side should be MemberAccessExpression: e.Handled - - if (assignment.Left is MemberAccessExpressionSyntax memberAccess) - { - // Check that member access expression is "e.Handled" - ISymbol? exprSymbol = context.SemanticModel.GetSymbolInfo (memberAccess.Expression).Symbol; - - if (exprSymbol == null) - { - return false; - } - - if (!SymbolEqualityComparer.Default.Equals (exprSymbol, eParamSymbol)) - { - return false; - } - - if (memberAccess.Name.Identifier.Text != "Handled") - { - return false; - } - - // Check right side is true literal - if (assignment.Right is LiteralExpressionSyntax literal && literal.IsKind (SyntaxKind.TrueLiteralExpression)) - { - return true; - } - } - - return false; - } -} diff --git a/Terminal.Gui.Analyzers/TGUI001.md b/Terminal.Gui.Analyzers/TGUI001.md deleted file mode 100644 index 457a321805..0000000000 --- a/Terminal.Gui.Analyzers/TGUI001.md +++ /dev/null @@ -1,34 +0,0 @@ -# TGUI001: Describe what your rule checks - -**Category:** Reliability -**Severity:** Warning -**Enabled by default:** Yes - -## Cause - -When registering an event handler for `Accepting`, you should set Handled to true, this prevents other subsequent Views from responding to the same input event. - -## Reason for rule - -If you do not do this then you may see unpredictable behaviour such as clicking a Button resulting in another `IsDefault` button in the View also firing. - -See: - -- https://github.com/gui-cs/Terminal.Gui/issues/3913 -- https://github.com/gui-cs/Terminal.Gui/issues/4170 - -## How to fix violations - -Set Handled to `true` in your event handler - -### Examples - -```diff -var b = new Button(); -b.Accepting += (s, e) => -{ - // Do something - -+ e.Handled = true; -}; -``` \ No newline at end of file diff --git a/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj b/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj deleted file mode 100644 index d1390b5b86..0000000000 --- a/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - netstandard2.0 - false - true - - enable - - - - - - - - - - diff --git a/Terminal.Gui/App/ApplicationNavigation.cs b/Terminal.Gui/App/ApplicationNavigation.cs index eb9e6e287b..1412198903 100644 --- a/Terminal.Gui/App/ApplicationNavigation.cs +++ b/Terminal.Gui/App/ApplicationNavigation.cs @@ -82,6 +82,11 @@ internal void SetFocused (View? value) return; } + if (_focused is { } && App?.Mouse.IsGrabbed (_focused) == true) + { + App.Mouse.UngrabMouse (); + } + //Debug.Assert (value is null or { CanFocus: true, HasFocus: true }); _focused = value; diff --git a/Terminal.Gui/App/ApplicationPopover.cs b/Terminal.Gui/App/ApplicationPopover.cs index 89669b0c01..f9014e90d6 100644 --- a/Terminal.Gui/App/ApplicationPopover.cs +++ b/Terminal.Gui/App/ApplicationPopover.cs @@ -122,9 +122,10 @@ public void Hide (IPopover? popover) } popover.Current ??= App?.TopRunnableView as IRunnable; - if (popover is View popoverView) + if (popover is View { IsInitialized: false } popoverView) { - popoverView.App = App; + popoverView.BeginInit (); + popoverView.EndInit (); } _popovers.Add (popover); diff --git a/Terminal.Gui/App/Keyboard/KeyboardImpl.cs b/Terminal.Gui/App/Keyboard/KeyboardImpl.cs index b8ae1f258b..4f2f399ad6 100644 --- a/Terminal.Gui/App/Keyboard/KeyboardImpl.cs +++ b/Terminal.Gui/App/Keyboard/KeyboardImpl.cs @@ -73,7 +73,7 @@ public void Dispose () public IApplication? App { get; set; } /// - public KeyBindings KeyBindings { get; internal set; } = new (null); + public KeyBindings KeyBindings { get; internal set; } = new (); /// public Key QuitKey @@ -222,7 +222,7 @@ public bool RaiseKeyDownEvent (Key key) return null; } - handled = binding.Target?.InvokeCommands (binding.Commands, binding); + handled = binding.Target?.InvokeCommands (binding.Commands, binding with { Source = binding.Target is { } ? new WeakReference (binding.Target) : null }); } else { @@ -250,7 +250,7 @@ public bool RaiseKeyDownEvent (Key key) if (_commandImplementations.TryGetValue (command, out View.CommandImplementation? implementation)) { - CommandContext context = new (command, null, binding); // Create the context here + CommandContext context = new CommandContext { Command = command, Source = null, Binding = binding }; // Create the context here return implementation (context); } @@ -304,10 +304,10 @@ internal void AddKeyBindings () while (viewToArrange is { Arrangement: ViewArrangement.Fixed }) { viewToArrange = viewToArrange switch - { - Adornment adornmentView => adornmentView.Parent, - _ => viewToArrange.SuperView - }; + { + Adornment adornmentView => adornmentView.Parent, + _ => viewToArrange.SuperView + }; } if (viewToArrange is { }) diff --git a/Terminal.Gui/App/PopoverBaseImpl.cs b/Terminal.Gui/App/PopoverBaseImpl.cs index 4f564a3fa8..662a4937f8 100644 --- a/Terminal.Gui/App/PopoverBaseImpl.cs +++ b/Terminal.Gui/App/PopoverBaseImpl.cs @@ -52,7 +52,9 @@ public abstract class PopoverBaseImpl : View, IPopover /// protected PopoverBaseImpl () { +#if DEBUG Id = "popoverBaseImpl"; +#endif CanFocus = true; Width = Dim.Fill (); Height = Dim.Fill (); @@ -65,6 +67,7 @@ protected PopoverBaseImpl () AddCommand (Command.Quit, Quit); KeyBindings.Add (Application.QuitKey, Command.Quit); + KeyBindings.Remove (Key.Enter); return; @@ -81,16 +84,14 @@ protected PopoverBaseImpl () } } - private IRunnable? _current; - /// public IRunnable? Current { - get => _current; + get; set { - _current = value; - App ??= (_current as View)?.App; + field = value; + App ??= (field as View)?.App; } } diff --git a/Terminal.Gui/Input/Command.cs b/Terminal.Gui/Input/Command.cs index 851f804da7..22a42b2e70 100644 --- a/Terminal.Gui/Input/Command.cs +++ b/Terminal.Gui/Input/Command.cs @@ -18,6 +18,12 @@ namespace Terminal.Gui.Input; /// See the Commands Deep Dive for more information: . /// /// +/// +/// +/// +/// +/// + public enum Command { /// @@ -30,10 +36,8 @@ public enum Command /// /// Accepts the current state of the View (e.g. list selection, button press, checkbox state, etc.). /// - /// The default implementation in calls . If the event is not handled, - /// the command is invoked on: - /// - Any peer-view that is a with set to . - /// - The . This enables default Accept behavior. + /// The default implementation in raises the and + /// events. /// /// Accept, @@ -41,8 +45,8 @@ public enum Command /// /// Performs a hot key action (e.g. setting focus, accepting, and/or moving focus to the next View). /// - /// The default implementation in calls and then - /// . + /// The default implementation in raises the event + /// and if that is not handled, invokes . /// /// HotKey, @@ -51,7 +55,8 @@ public enum Command /// Activates the View or an item in the View, changing its state or preparing it for interaction /// (e.g. toggling a checkbox, selecting a list item, focusing a button, navigating a menu item) without necessarily accepting it. /// - /// The default implementation in calls . + /// The default implementation in raises the event. If is not + /// handled, will be called and the event will be raised. /// /// Activate, diff --git a/Terminal.Gui/Input/CommandBinding.cs b/Terminal.Gui/Input/CommandBinding.cs new file mode 100644 index 0000000000..c19d2ae3aa --- /dev/null +++ b/Terminal.Gui/Input/CommandBinding.cs @@ -0,0 +1,54 @@ +namespace Terminal.Gui.Input; + +/// +/// A generic command binding used for programmatic command invocations +/// or when a specific binding type is not needed. +/// +/// +/// +/// Use when invoking commands programmatically via +/// or when a binding is needed +/// but is not associated with a specific key or mouse event. +/// +/// +/// is the View that called g. If +/// the +/// CommandBinding instance was created dynamically (not from a mouse or keyboard binding). +/// +/// +/// Pattern match on binding types to discriminate between input sources: +/// +/// if (ctx.Binding is KeyBinding kb) { /* key input */ } +/// else if (ctx.Binding is MouseBinding mb) { /* mouse input */ } +/// else if (ctx.Binding is CommandBinding ib) { /* programmatic */ } +/// +/// +/// +/// +/// +/// +public readonly record struct CommandBinding : ICommandBinding +{ + /// Initializes a new instance. + /// The commands this binding will invoke. + /// The view that is the origin of this binding. + /// Arbitrary context data that can be associated with this binding. + public CommandBinding (Command [] commands, View? source = null, object? data = null) + { + Commands = commands; + Source = source is { } ? new WeakReference (source) : null; + Data = data; + } + + /// + public Command [] Commands { get; init; } + + /// + public object? Data { get; init; } + + /// + public WeakReference? Source { get; init; } + + /// + public override string ToString () => $"[{string.Join (", ", Commands)}]{(Source is { } ? $", Source={Source.ToIdentifyingString ()}" : "")}{(Data is { } ? $", Data={Data}" : "")}"; +} diff --git a/Terminal.Gui/Input/InputBindings.cs b/Terminal.Gui/Input/CommandBindingsBase.cs similarity index 88% rename from Terminal.Gui/Input/InputBindings.cs rename to Terminal.Gui/Input/CommandBindingsBase.cs index 75e2e8aead..d0402c1253 100644 --- a/Terminal.Gui/Input/InputBindings.cs +++ b/Terminal.Gui/Input/CommandBindingsBase.cs @@ -8,17 +8,17 @@ namespace Terminal.Gui.Input; /// /// The type of the event (e.g. or ). /// The binding type (e.g. ). -public abstract class InputBindings where TBinding : IInputBinding, new() where TEvent : notnull +public abstract class CommandBindingsBase where TBinding : ICommandBinding, new () where TEvent : notnull { /// /// Initializes a new instance. /// /// /// - protected InputBindings (Func constructBinding, IEqualityComparer equalityComparer) + protected CommandBindingsBase (Func constructBinding, IEqualityComparer equalityComparer) { _constructBinding = constructBinding; - _bindings = new (equalityComparer); + _bindings = new ConcurrentDictionary (equalityComparer); } /// @@ -26,7 +26,7 @@ protected InputBindings (Func constructBinding, IE /// private readonly ConcurrentDictionary _bindings; - private readonly Func _constructBinding; + private readonly Func _constructBinding; /// Adds a bound to to the collection. /// @@ -75,7 +75,7 @@ public void Add (TEvent eventArgs, params Command [] commands) throw new ArgumentException (@"Invalid newEventArgs", nameof (eventArgs)); } - TBinding binding = _constructBinding (commands, eventArgs); + TBinding binding = _constructBinding (commands, eventArgs, null); if (!_bindings.TryAdd (eventArgs, binding)) { @@ -84,7 +84,7 @@ public void Add (TEvent eventArgs, params Command [] commands) } /// Removes all objects from the collection. - public void Clear () { _bindings.Clear (); } + public void Clear () => _bindings.Clear (); /// /// Removes all bindings that trigger the given command set. Views can have multiple different @@ -96,9 +96,7 @@ public void Add (TEvent eventArgs, params Command [] commands) public void Clear (params Command [] command) { // ToArray() creates a snapshot to avoid modification during enumeration - KeyValuePair [] kvps = _bindings - .Where (kvp => kvp.Value.Commands.SequenceEqual (command)) - .ToArray (); + KeyValuePair [] kvps = _bindings.Where (kvp => kvp.Value.Commands.SequenceEqual (command)).ToArray (); foreach (KeyValuePair kvp in kvps) { @@ -127,21 +125,19 @@ public void Clear (params Command [] command) /// the /// set of commands was not found. /// - public IEnumerable GetAllFromCommands (params Command [] commands) - { + public IEnumerable GetAllFromCommands (params Command [] commands) => + // ToList() creates a snapshot to ensure thread-safe enumeration - return _bindings.Where (a => a.Value.Commands.SequenceEqual (commands)).Select (a => a.Key).ToList (); - } + _bindings.Where (a => a.Value.Commands.SequenceEqual (commands)).Select (a => a.Key).ToList (); /// /// Gets the bindings. /// /// - public IEnumerable> GetBindings () - { + public IEnumerable> GetBindings () => + // ConcurrentDictionary provides a snapshot enumeration that is safe for concurrent access - return _bindings; - } + _bindings; /// Gets the array of s bound to if it exists. /// The to check. @@ -168,7 +164,7 @@ public Command [] GetCommands (TEvent eventArgs) /// The first matching bound to the set of commands specified by /// . if the set of commands was not found. /// - public TEvent? GetFirstFromCommands (params Command [] commands) { return _bindings.FirstOrDefault (a => a.Value.Commands.SequenceEqual (commands)).Key; } + public TEvent? GetFirstFromCommands (params Command [] commands) => _bindings.FirstOrDefault (a => a.Value.Commands.SequenceEqual (commands)).Key; /// /// Tests whether is valid or not. @@ -179,7 +175,7 @@ public Command [] GetCommands (TEvent eventArgs) /// Removes a from the collection. /// - public void Remove (TEvent eventArgs) { _bindings.TryRemove (eventArgs, out _); } + public void Remove (TEvent eventArgs) => _bindings.TryRemove (eventArgs, out _); /// Replaces a combination already bound to a set of s. /// @@ -208,10 +204,9 @@ public void Replace (TEvent oldEventArgs, TEvent newEventArgs) // Thread-safe: Atomically add/update newEventArgs with the binding from oldEventArgs // The updateValueFactory is only called if the key already exists, ensuring we don't // accidentally overwrite a binding that was added by another thread - _bindings.AddOrUpdate ( - newEventArgs, - binding, // Add this binding if newEventArgs doesn't exist - (_, _) => binding); + _bindings.AddOrUpdate (newEventArgs, + binding, // Add this binding if newEventArgs doesn't exist + (_, _) => binding); // Thread-safe: Remove oldEventArgs only after newEventArgs has been set // This ensures we don't lose the binding if another thread is reading it @@ -228,7 +223,7 @@ public void Replace (TEvent oldEventArgs, TEvent newEventArgs) /// The set of commands to replace the old ones with. public void ReplaceCommands (TEvent eventArgs, params Command [] newCommands) { - TBinding newBinding = _constructBinding (newCommands, eventArgs); + TBinding newBinding = _constructBinding (newCommands, eventArgs, null); // Thread-safe: Add or update atomically _bindings.AddOrUpdate (eventArgs, newBinding, (_, _) => newBinding); @@ -244,5 +239,5 @@ public void ReplaceCommands (TEvent eventArgs, params Command [] newCommands) /// found; otherwise, null. This parameter is passed uninitialized. /// /// if the is bound; otherwise . - public bool TryGet (TEvent eventArgs, out TBinding? binding) { return _bindings.TryGetValue (eventArgs, out binding); } + public bool TryGet (TEvent eventArgs, out TBinding? binding) => _bindings.TryGetValue (eventArgs, out binding); } diff --git a/Terminal.Gui/Input/CommandContext.cs b/Terminal.Gui/Input/CommandContext.cs index c0e6907048..6b97f8709a 100644 --- a/Terminal.Gui/Input/CommandContext.cs +++ b/Terminal.Gui/Input/CommandContext.cs @@ -10,14 +10,14 @@ /// /// if (ctx.Binding is KeyBinding kb) { /* key input */ } /// else if (ctx.Binding is MouseBinding mb) { /* mouse input */ } -/// else if (ctx.Binding is InputBinding ib) { /* programmatic */ } +/// else if (ctx.Binding is CommandBinding ib) { /* programmatic */ } /// /// /// /// /// . #pragma warning restore CS1574, CS0419 // XML comment has cref attribute that could not be resolved -public record struct CommandContext : ICommandContext +public readonly record struct CommandContext : ICommandContext { /// /// Initializes a new instance with the specified . @@ -25,22 +25,40 @@ public record struct CommandContext : ICommandContext /// The command being invoked. /// A weak reference to the view that is the source of the command invocation. /// The binding that triggered the command, if any. - public CommandContext (Command command, WeakReference? source, IInputBinding? binding) + public CommandContext (Command command, WeakReference? source, ICommandBinding? binding) { Command = command; Binding = binding; Source = source; + Routing = CommandRouting.Direct; } /// - public Command Command { get; set; } + public required Command Command { get; init; } /// - public WeakReference? Source { get; set; } + public WeakReference? Source { get; init; } /// - public IInputBinding? Binding { get; set; } + public ICommandBinding? Binding { get; init; } /// - public override string ToString () => $"{Command} (Source={Source.ToIdentifyingString ()}, Binding={Binding})"; + public CommandRouting Routing { get; init; } + + /// + /// Creates a new context with a different command, preserving all other fields. + /// + /// The new command. + /// A new context with the updated command. + public CommandContext WithCommand (Command command) => this with { Command = command }; + + /// + /// Creates a new context with different routing, preserving all other fields. + /// + /// The new routing mode. + /// A new context with the updated routing mode. + public CommandContext WithRouting (CommandRouting routing) => this with { Routing = routing }; + + /// + public override string ToString () => $"{(Routing == CommandRouting.BubblingUp ? Glyphs.UpArrow : Routing == CommandRouting.DispatchingDown ? Glyphs.DownArrow : "")}{Command} ({(Source is { } ? $"Source={Source.ToIdentifyingString ()}" : "")}{(Binding is { } ? $", Binding={Binding}" : "")})"; } diff --git a/Terminal.Gui/Input/CommandEventArgs.cs b/Terminal.Gui/Input/CommandEventArgs.cs index 659d0db643..fda1f1ea82 100644 --- a/Terminal.Gui/Input/CommandEventArgs.cs +++ b/Terminal.Gui/Input/CommandEventArgs.cs @@ -15,4 +15,7 @@ public class CommandEventArgs : HandledEventArgs /// If the command was invoked without context. /// public required ICommandContext? Context { get; init; } + + /// + public override string ToString () => $"{Context}"; } diff --git a/Terminal.Gui/Input/CommandOutcome.cs b/Terminal.Gui/Input/CommandOutcome.cs new file mode 100644 index 0000000000..4e00fcfcb3 --- /dev/null +++ b/Terminal.Gui/Input/CommandOutcome.cs @@ -0,0 +1,29 @@ +namespace Terminal.Gui.Input; + +/// +/// Describes the outcome of a command handler. +/// Replaces the previous three-valued ? return type (null = not found, false = not handled, true = handled). +/// +/// +/// +/// This enum provides explicit semantics for command handling outcomes, making the routing logic self-documenting. +/// +/// +public enum CommandOutcome +{ + /// + /// The command was not handled; routing continues to the next handler or bubbles up the view hierarchy. + /// + NotHandled, + + /// + /// The command was handled successfully; routing stops immediately. + /// + HandledStop, + + /// + /// The command was handled but routing may continue (notification semantics). + /// This allows multiple handlers to observe the same command without blocking each other. + /// + HandledContinue +} diff --git a/Terminal.Gui/Input/CommandRouting.cs b/Terminal.Gui/Input/CommandRouting.cs new file mode 100644 index 0000000000..92322e6c2b --- /dev/null +++ b/Terminal.Gui/Input/CommandRouting.cs @@ -0,0 +1,39 @@ +namespace Terminal.Gui.Input; + +/// +/// Describes the routing mode of a command invocation. +/// Replaces the previous two boolean flags ( and ) +/// with a single enum that includes an explicit state for cross-boundary routing via . +/// +/// +/// +/// This enum enables structural recursion protection by making the routing direction explicit in the command context. +/// +/// +public enum CommandRouting +{ + /// + /// Direct invocation (programmatic or from this view's own bindings). + /// This is the default routing mode when a command is invoked directly via . + /// + Direct, + + /// + /// Command is propagating upward through the SuperView chain. + /// Used during bubbling to prevent infinite loops and re-entry. + /// + BubblingUp, + + /// + /// A SuperView is dispatching downward to a specific SubView. + /// Used by composite controls to route commands to their primary interactive subview. + /// + DispatchingDown, + + /// + /// Command is crossing a non-containment boundary via CommandBridge. + /// Used when routing commands between views that are not in a SuperView/SubView relationship + /// (e.g., MenuItem to its detached SubMenu, MenuBarItem to its PopoverMenu). + /// + Bridged +} diff --git a/Terminal.Gui/Input/IAcceptTarget.cs b/Terminal.Gui/Input/IAcceptTarget.cs new file mode 100644 index 0000000000..f1dbf75c5a --- /dev/null +++ b/Terminal.Gui/Input/IAcceptTarget.cs @@ -0,0 +1,41 @@ +namespace Terminal.Gui; + +/// +/// Interface for views that handle as terminal destinations. +/// Views implementing this interface can bubble their commands up to +/// their SuperView or be treated as the default accept target depending on . +/// +/// +/// +/// When a view implementing invokes : +/// +/// +/// +/// If is , the command flows normally without +/// redirection or bubbling. This view IS the . +/// +/// +/// If is , the command bubbles up through the +/// SuperView hierarchy, allowing parent views (like ) to handle +/// it and determine which accept target was activated. +/// +/// +/// +/// When a view that does NOT implement invokes , +/// the command may be redirected to the (typically a Button +/// with = ). +/// +/// +/// implements this interface using its existing +/// property. Other views that want to be terminal Accept handlers should also implement it. +/// +/// +public interface IAcceptTarget +{ + /// + /// Gets or sets a value indicating whether this view is the default accept target. + /// When , this view will NOT redirect Accept commands to another + /// DefaultAcceptView and will NOT bubble up when it is the DefaultAcceptView. + /// + bool IsDefault { get; set; } +} diff --git a/Terminal.Gui/Input/ICommandBinding.cs b/Terminal.Gui/Input/ICommandBinding.cs new file mode 100644 index 0000000000..471b9ec845 --- /dev/null +++ b/Terminal.Gui/Input/ICommandBinding.cs @@ -0,0 +1,39 @@ +namespace Terminal.Gui.Input; + +/// +/// Describes command binding. Used to bind a set of objects to a specific user event and +/// passed as part of command invocations (see ). Bindings are immutable. +/// +/// +public interface ICommandBinding +{ + /// + /// Gets or sets the commands this command binding will invoke. + /// + Command [] Commands { get; init; } + + /// + /// Arbitrary context that can be associated with this command binding. + /// + public object? Data { get; init; } + + /// + /// Gets or sets a weak reference to the that registered the binding. + /// + /// + /// + /// For key bindings, this is the view that registered the binding. + /// + /// + /// For mouse bindings, this is the view that received the mouse event. + /// + /// + /// For programmatic invocations, this is the view that called . + /// + /// + /// Uses WeakReference to prevent memory leaks and access to disposed views when views are disposed during command + /// propagation. + /// + /// + public WeakReference? Source { get; init; } +} diff --git a/Terminal.Gui/Input/ICommandContext.cs b/Terminal.Gui/Input/ICommandContext.cs index 4fb8c8f503..5366eed3e9 100644 --- a/Terminal.Gui/Input/ICommandContext.cs +++ b/Terminal.Gui/Input/ICommandContext.cs @@ -3,18 +3,18 @@ #pragma warning disable CS1574 // XML comment has cref attribute that could not be resolved /// /// Describes the context in which a is being invoked. -/// When a is invoked, +/// When a is invoked via /// a context object is passed to Command handlers as an reference. /// -/// -/// . +/// +/// #pragma warning restore CS1574 // XML comment has cref attribute that could not be resolved public interface ICommandContext { /// /// The that is being invoked. /// - public Command Command { get; set; } + public Command Command { get; } /// /// A weak reference to the View that was the source of the command invocation, if any. @@ -22,9 +22,10 @@ public interface ICommandContext /// Use Source?.TryGetTarget(out View? view) to safely access the source view. /// /// - /// Uses WeakReference to prevent memory leaks when views are disposed during command propagation. + /// Uses WeakReference to prevent memory leaks and access to disposed views when views are disposed during command + /// propagation. /// - public WeakReference? Source { get; set; } + public WeakReference? Source { get; } /// /// The binding that triggered the command. @@ -35,9 +36,42 @@ public interface ICommandContext /// /// if (ctx.Binding is KeyBinding kb) { /* key binding */ } /// else if (ctx.Binding is MouseBinding mb) { /* mouse binding */ } - /// else if (ctx.Binding is InputBinding ib) { /* programmatic */ } + /// else if (ctx.Binding is CommandBinding ib) { /* programmatic */ } /// /// /// - public IInputBinding? Binding { get; } + public ICommandBinding? Binding { get; } + + /// + /// Gets the routing mode for this command invocation. + /// + /// + /// + /// The routing mode determines how the command is propagating through the view hierarchy + /// and provides structural recursion protection. + /// + /// + public CommandRouting Routing { get; } + + /// + /// Gets whether this command is being dispatched downward to a SubView. When , + /// will skip bubbling, preventing re-entry. + /// + /// + /// + /// This property is provided for backward compatibility. New code should use instead. + /// + /// + public bool IsBubblingDown => Routing == CommandRouting.DispatchingDown; + + /// + /// Gets whether this command is being dispatched upward to a SuperView. When , + /// will skip bubbling, preventing re-entry. + /// + /// + /// + /// This property is provided for backward compatibility. New code should use instead. + /// + /// + public bool IsBubblingUp => Routing == CommandRouting.BubblingUp; } diff --git a/Terminal.Gui/Input/IInputBinding.cs b/Terminal.Gui/Input/IInputBinding.cs deleted file mode 100644 index f360cea9ed..0000000000 --- a/Terminal.Gui/Input/IInputBinding.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Terminal.Gui.Input; - -/// -/// Describes an input binding. Used to bind a set of objects to a specific input event. -/// -public interface IInputBinding -{ - /// - /// Gets or sets the commands this input binding will invoke. - /// - Command [] Commands { get; set; } - - /// - /// Arbitrary context that can be associated with this input binding. - /// - public object? Data { get; set; } - - /// - /// Gets or sets the that is the origin of this binding. - /// - /// - /// - /// For key bindings, this is the view where the binding was added. - /// - /// - /// For mouse bindings, this is the view that received the mouse event. - /// - /// - /// For programmatic invocations, this is the view that called . - /// - /// - public View? Source { get; set; } -} diff --git a/Terminal.Gui/Input/InputBinding.cs b/Terminal.Gui/Input/InputBinding.cs deleted file mode 100644 index 60e11cd7d6..0000000000 --- a/Terminal.Gui/Input/InputBinding.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Terminal.Gui.Input; - -/// -/// A generic input binding used for programmatic command invocations -/// or when a specific binding type is not needed. -/// -/// -/// -/// Use when invoking commands programmatically via -/// or when a binding is needed -/// but is not associated with a specific key or mouse event. -/// -/// -/// Pattern match on binding types to discriminate between input sources: -/// -/// if (ctx.Binding is KeyBinding kb) { /* key input */ } -/// else if (ctx.Binding is MouseBinding mb) { /* mouse input */ } -/// else if (ctx.Binding is InputBinding ib) { /* programmatic */ } -/// -/// -/// -/// -/// -/// -public record struct InputBinding : IInputBinding -{ - /// Initializes a new instance. - /// The commands this input binding will invoke. - /// The view that is the origin of this binding. - /// Arbitrary context data that can be associated with this binding. - public InputBinding (Command [] commands, View? source = null, object? data = null) - { - Commands = commands; - Source = source; - Data = data; - } - - /// The commands this binding will invoke. - public Command [] Commands { get; set; } - - /// - public object? Data { get; set; } - - /// - public View? Source { get; set; } -} diff --git a/Terminal.Gui/Input/Keyboard/Key.cs b/Terminal.Gui/Input/Keyboard/Key.cs index 1c8a552d76..cb95cc8b5d 100644 --- a/Terminal.Gui/Input/Keyboard/Key.cs +++ b/Terminal.Gui/Input/Keyboard/Key.cs @@ -458,13 +458,13 @@ public override bool Equals (object? obj) /// /// /// - public static bool operator == (Key a, Key b) { return a!.Equals (b); } + public static bool operator == (Key a, Key b) { return a.Equals (b); } /// Compares two s for not equality. /// /// /// - public static bool operator != (Key? a, Key? b) { return !a!.Equals (b); } + public static bool operator != (Key? a, Key? b) { return a is { } && !a.Equals (b); } /// Compares two s for less-than. /// diff --git a/Terminal.Gui/Input/Keyboard/KeyBinding.cs b/Terminal.Gui/Input/Keyboard/KeyBinding.cs index 7f1b4ce7be..9d7f3aa34b 100644 --- a/Terminal.Gui/Input/Keyboard/KeyBinding.cs +++ b/Terminal.Gui/Input/Keyboard/KeyBinding.cs @@ -4,25 +4,26 @@ namespace Terminal.Gui.Input; /// -/// Provides a collection of objects stored in . +/// Provides a collection of objects stored in . Carried +/// as context in command invocations (see ). /// /// -/// -/// -public record struct KeyBinding : IInputBinding +/// +/// +public record struct KeyBinding : ICommandBinding { /// Initializes a new instance. /// The commands this key binding will invoke. - /// Arbitrary context that can be associated with this key binding. - public KeyBinding (Command [] commands, object? context = null) + /// Arbitrary context that can be associated with this key binding. + public KeyBinding (Command [] commands, object? data = null) { Commands = commands; - Data = context; + Data = data; } /// Initializes a new instance. /// The commands this key binding will invoke. - /// The view the key binding is bound to. + /// For Application-level HotKey Bindings; the view the key binding is bound to. /// Arbitrary data that can be associated with this key binding. public KeyBinding (Command [] commands, View? target, object? data = null) { @@ -31,11 +32,29 @@ public KeyBinding (Command [] commands, View? target, object? data = null) Data = data; } - /// The commands this key binding will invoke. - public Command [] Commands { get; set; } + /// + /// Initializes a new instance. + /// + /// The commands this key binding will invoke. + /// The key this binding is associated with. + /// The view where this key binding was created. + /// For Application-level HotKey Bindings; the view the key binding is bound to. + /// Arbitrary data that can be associated with this key binding. + /// + public KeyBinding (Command [] commands, Key newKey, View? source = null, View? target = null, object? data = null) + { + Commands = commands; + Source = source is { } ? new WeakReference (source) : null; + Key = newKey; + Target = target; + Data = data; + } + + /// + public Command [] Commands { get; init; } /// - public object? Data { get; set; } + public object? Data { get; init; } /// /// The Key that is bound to the . @@ -43,8 +62,9 @@ public KeyBinding (Command [] commands, View? target, object? data = null) public Key? Key { get; set; } /// - public View? Source { get; set; } + public WeakReference? Source { get; init; } + // TODO: Determine if Target is duplicative of Source /// /// The view the key binding is bound to. /// @@ -58,4 +78,7 @@ public KeyBinding (Command [] commands, View? target, object? data = null) /// /// public View? Target { get; set; } + + /// + public override string ToString () => $"[{string.Join (", ", Commands)}], Key={Key}{(Source is { } ? $", Source={Source.ToIdentifyingString ()}" : "")}{(Target is { } ? $", Target={Target.ToIdentifyingString ()}" : "")}{(Data is { } ? $", Data={Data}" : "")}"; } diff --git a/Terminal.Gui/Input/Keyboard/KeyBindings.cs b/Terminal.Gui/Input/Keyboard/KeyBindings.cs index 46a7081de1..f1fff58a60 100644 --- a/Terminal.Gui/Input/Keyboard/KeyBindings.cs +++ b/Terminal.Gui/Input/Keyboard/KeyBindings.cs @@ -1,5 +1,3 @@ - - namespace Terminal.Gui.Input; /// @@ -8,20 +6,18 @@ namespace Terminal.Gui.Input; /// /// /// -public class KeyBindings : InputBindings +public class KeyBindings : CommandBindingsBase { - /// Initializes a new instance bound to . - public KeyBindings (View? target) : base ((commands, key) => new (commands), new KeyEqualityComparer ()) - { - Target = target; - } + /// Initializes a new instance. + public KeyBindings () : base ((commands, key, source) => new KeyBinding (commands, source), new KeyEqualityComparer ()) { } - /// - public override bool IsValid (Key eventArgs) { return eventArgs.IsValid; } + /// + public override bool IsValid (Key eventArgs) => eventArgs.IsValid; /// /// - /// Adds a new key combination that will trigger the commands in on the View + /// For Application-level HotKey Bindings (); Adds key + /// will trigger the commands in on the View /// specified by . /// /// @@ -32,26 +28,16 @@ public class KeyBindings : InputBindings /// /// /// The key to check. - /// - /// The View the commands will be invoked on. If , the key will be bound to - /// . - /// + /// For Application-level HotKey Bindings; the view the key binding is bound to. /// /// The command to invoked on the when is pressed. When - /// multiple commands are provided,they will be applied in sequence. The bound strike will be + /// multiple commands are provided,they will be applied in sequence. The bound will be /// consumed if any took effect. /// - public void Add (Key key, View? target, params Command [] commands) + /// + public void AddApp (Key key, View? target, params Command [] commands) { - KeyBinding binding = new (commands, target); + KeyBinding binding = new (commands, key, source: null, target: target, data: null); Add (key, binding); } - - /// - /// The view that the are bound to. - /// - /// - /// If the KeyBindings object is being used for Application.KeyBindings. - /// - public View? Target { get; init; } } diff --git a/Terminal.Gui/Input/Mouse/Mouse.cs b/Terminal.Gui/Input/Mouse/Mouse.cs index 2964351a9f..52cd7d8f20 100644 --- a/Terminal.Gui/Input/Mouse/Mouse.cs +++ b/Terminal.Gui/Input/Mouse/Mouse.cs @@ -63,42 +63,47 @@ public Mouse () { } /// /// Gets a value indicating whether a mouse button was pressed. /// - public bool IsPressed => Flags.HasFlag (MouseFlags.LeftButtonPressed) - || Flags.HasFlag (MouseFlags.MiddleButtonPressed) - || Flags.HasFlag (MouseFlags.RightButtonPressed) - || Flags.HasFlag (MouseFlags.Button4Pressed); + public bool IsPressed => + Flags.HasFlag (MouseFlags.LeftButtonPressed) + || Flags.HasFlag (MouseFlags.MiddleButtonPressed) + || Flags.HasFlag (MouseFlags.RightButtonPressed) + || Flags.HasFlag (MouseFlags.Button4Pressed); /// /// Gets a value indicating whether a mouse button was released. /// - public bool IsReleased => Flags.HasFlag (MouseFlags.LeftButtonReleased) - || Flags.HasFlag (MouseFlags.MiddleButtonReleased) - || Flags.HasFlag (MouseFlags.RightButtonReleased) - || Flags.HasFlag (MouseFlags.Button4Released); + public bool IsReleased => + Flags.HasFlag (MouseFlags.LeftButtonReleased) + || Flags.HasFlag (MouseFlags.MiddleButtonReleased) + || Flags.HasFlag (MouseFlags.RightButtonReleased) + || Flags.HasFlag (MouseFlags.Button4Released); /// /// Gets a value indicating whether a single-click mouse event occurred. /// - public bool IsSingleClicked => Flags.HasFlag (MouseFlags.LeftButtonClicked) - || Flags.HasFlag (MouseFlags.MiddleButtonClicked) - || Flags.HasFlag (MouseFlags.RightButtonClicked) - || Flags.HasFlag (MouseFlags.Button4Clicked); + public bool IsSingleClicked => + Flags.HasFlag (MouseFlags.LeftButtonClicked) + || Flags.HasFlag (MouseFlags.MiddleButtonClicked) + || Flags.HasFlag (MouseFlags.RightButtonClicked) + || Flags.HasFlag (MouseFlags.Button4Clicked); /// /// Gets a value indicating whether a double-click mouse event occurred. /// - public bool IsDoubleClicked => Flags.HasFlag (MouseFlags.LeftButtonDoubleClicked) - || Flags.HasFlag (MouseFlags.MiddleButtonDoubleClicked) - || Flags.HasFlag (MouseFlags.RightButtonDoubleClicked) - || Flags.HasFlag (MouseFlags.Button4DoubleClicked); + public bool IsDoubleClicked => + Flags.HasFlag (MouseFlags.LeftButtonDoubleClicked) + || Flags.HasFlag (MouseFlags.MiddleButtonDoubleClicked) + || Flags.HasFlag (MouseFlags.RightButtonDoubleClicked) + || Flags.HasFlag (MouseFlags.Button4DoubleClicked); /// /// Gets a value indicating whether a triple-click mouse event occurred. /// - public bool IsTripleClicked => Flags.HasFlag (MouseFlags.LeftButtonTripleClicked) - || Flags.HasFlag (MouseFlags.MiddleButtonTripleClicked) - || Flags.HasFlag (MouseFlags.RightButtonTripleClicked) - || Flags.HasFlag (MouseFlags.Button4TripleClicked); + public bool IsTripleClicked => + Flags.HasFlag (MouseFlags.LeftButtonTripleClicked) + || Flags.HasFlag (MouseFlags.MiddleButtonTripleClicked) + || Flags.HasFlag (MouseFlags.RightButtonTripleClicked) + || Flags.HasFlag (MouseFlags.Button4TripleClicked); /// /// Gets a value indicating whether a single, double, or triple-click mouse event occurred. @@ -120,12 +125,13 @@ public Mouse () { } /// /// Gets a value indicating whether a mouse wheel event occurred. /// - public bool IsWheel => Flags.HasFlag (MouseFlags.WheeledDown) - || Flags.HasFlag (MouseFlags.WheeledUp) - || Flags.HasFlag (MouseFlags.WheeledLeft) - || Flags.HasFlag (MouseFlags.WheeledRight); + public bool IsWheel => + Flags.HasFlag (MouseFlags.WheeledDown) + || Flags.HasFlag (MouseFlags.WheeledUp) + || Flags.HasFlag (MouseFlags.WheeledLeft) + || Flags.HasFlag (MouseFlags.WheeledRight); /// Returns a string that represents the current mouse event. /// A string that represents the current mouse event. - public override string ToString () { return $"{Timestamp:ss.fff}:{ScreenPosition}:{Flags}:{View?.Id}:{Position}"; } + public override string ToString () => $"{Flags}:{ScreenPosition}:{(Position is { } ? Position.ToString () : "")}"; } diff --git a/Terminal.Gui/Input/Mouse/MouseBinding.cs b/Terminal.Gui/Input/Mouse/MouseBinding.cs index 750b319143..a225aab7eb 100644 --- a/Terminal.Gui/Input/Mouse/MouseBinding.cs +++ b/Terminal.Gui/Input/Mouse/MouseBinding.cs @@ -1,20 +1,23 @@ namespace Terminal.Gui.Input; /// -/// Provides a collection of bound to s. +/// Provides a collection of objects stored in . Carried +/// as context in command invocations (see ). /// /// -/// -public record struct MouseBinding : IInputBinding +/// +/// +public record struct MouseBinding : ICommandBinding { /// Initializes a new instance. /// The commands this mouse binding will invoke. /// The mouse flags that triggered this binding. - public MouseBinding (Command [] commands, MouseFlags mouseFlags) + /// + public MouseBinding (Command [] commands, MouseFlags mouseFlags, View? source = null) { Commands = commands; - MouseEvent = new Mouse { Timestamp = DateTime.Now, Flags = mouseFlags }; + Source = source is { } ? new WeakReference (source) : null; } /// Initializes a new instance. @@ -26,11 +29,14 @@ public MouseBinding (Command [] commands, Mouse args) MouseEvent = args; } - /// The commands this binding will invoke. - public Command [] Commands { get; set; } + /// + public Command [] Commands { get; init; } + + /// + public object? Data { get; init; } /// - public object? Data { get; set; } + public WeakReference? Source { get; init; } /// /// The mouse event data associated with this binding. @@ -38,5 +44,6 @@ public MouseBinding (Command [] commands, Mouse args) public Mouse? MouseEvent { get; set; } /// - public View? Source { get; set; } + public override string ToString () => + $"[{string.Join (", ", Commands)}] (MouseEvent={MouseEvent}{(Source is { } ? $", Source={Source.ToIdentifyingString ()}" : "")}{(Data is { } ? $", Data={Data}" : "")}"; } diff --git a/Terminal.Gui/Input/Mouse/MouseBindings.cs b/Terminal.Gui/Input/Mouse/MouseBindings.cs index 255be246ba..14ede2b16e 100644 --- a/Terminal.Gui/Input/Mouse/MouseBindings.cs +++ b/Terminal.Gui/Input/Mouse/MouseBindings.cs @@ -1,4 +1,3 @@ - namespace Terminal.Gui.Input; /// @@ -6,16 +5,14 @@ namespace Terminal.Gui.Input; /// /// /// -public class MouseBindings : InputBindings +public class MouseBindings : CommandBindingsBase { /// /// Initializes a new instance. /// - public MouseBindings () : base ( - (commands, flags) => new (commands, flags), - EqualityComparer.Default) + public MouseBindings () : base ((commands, flags, source) => new MouseBinding (commands, flags, source), EqualityComparer.Default) { } - /// - public override bool IsValid (MouseFlags eventArgs) { return eventArgs != MouseFlags.None; } + /// + public override bool IsValid (MouseFlags eventArgs) => eventArgs != MouseFlags.None; } diff --git a/Terminal.Gui/Resources/Strings.Designer.cs b/Terminal.Gui/Resources/Strings.Designer.cs index 0ac855ffcd..d47ad67f22 100644 --- a/Terminal.Gui/Resources/Strings.Designer.cs +++ b/Terminal.Gui/Resources/Strings.Designer.cs @@ -180,9 +180,9 @@ public static string cmdCopy { /// /// Looks up a localized string similar to Copy to clipboard. /// - public static string cmdCopyHelp { + public static string cmdCopy_Help { get { - return ResourceManager.GetString("cmdCopyHelp", resourceCulture); + return ResourceManager.GetString("cmdCopy_Help", resourceCulture); } } @@ -200,7 +200,7 @@ public static string cmdCut { /// public static string cmdCut_Help { get { - return ResourceManager.GetString("cmdCut.Help", resourceCulture); + return ResourceManager.GetString("cmdCut_Help", resourceCulture); } } @@ -214,7 +214,7 @@ public static string cmdFind { } /// - /// Looks up a localized string similar to _New file. + /// Looks up a localized string similar to _New. /// public static string cmdNew { get { @@ -225,9 +225,9 @@ public static string cmdNew { /// /// Looks up a localized string similar to New file. /// - public static string cmdNewHelp { + public static string cmdNew_Help { get { - return ResourceManager.GetString("cmdNewHelp", resourceCulture); + return ResourceManager.GetString("cmdNew_Help", resourceCulture); } } @@ -245,7 +245,7 @@ public static string cmdOpen { /// public static string cmdOpen_Help { get { - return ResourceManager.GetString("cmdOpen.Help", resourceCulture); + return ResourceManager.GetString("cmdOpen_Help", resourceCulture); } } @@ -261,9 +261,9 @@ public static string cmdPaste { /// /// Looks up a localized string similar to Paste from clipboard. /// - public static string cmdPasteHelp { + public static string cmdPaste_Help { get { - return ResourceManager.GetString("cmdPasteHelp", resourceCulture); + return ResourceManager.GetString("cmdPaste_Help", resourceCulture); } } @@ -277,11 +277,11 @@ public static string cmdQuit { } /// - /// Looks up a localized string similar to . + /// Looks up a localized string similar to Exit the application. /// - public static string cmdQuitHelp { + public static string cmdQuit_Help { get { - return ResourceManager.GetString("cmdQuitHelp", resourceCulture); + return ResourceManager.GetString("cmdQuit_Help", resourceCulture); } } @@ -295,29 +295,29 @@ public static string cmdSave { } /// - /// Looks up a localized string similar to Save _As.... + /// Looks up a localized string similar to Save file. /// - public static string cmdSaveAs { + public static string cmdSave_Help { get { - return ResourceManager.GetString("cmdSaveAs", resourceCulture); + return ResourceManager.GetString("cmdSave_Help", resourceCulture); } } /// - /// Looks up a localized string similar to Save file as. + /// Looks up a localized string similar to Save _As.... /// - public static string cmdSaveAsHelp { + public static string cmdSaveAs { get { - return ResourceManager.GetString("cmdSaveAsHelp", resourceCulture); + return ResourceManager.GetString("cmdSaveAs", resourceCulture); } } /// - /// Looks up a localized string similar to Save. + /// Looks up a localized string similar to Save file as. /// - public static string cmdSaveHelp { + public static string cmdSaveAs_Help { get { - return ResourceManager.GetString("cmdSaveHelp", resourceCulture); + return ResourceManager.GetString("cmdSaveAs_Help", resourceCulture); } } @@ -333,84 +333,93 @@ public static string cmdSelectAll { /// /// Looks up a localized string similar to Select all. /// - public static string cmdSelectAllHelp { + public static string cmdSelectAll_Help { get { - return ResourceManager.GetString("cmdSelectAllHelp", resourceCulture); + return ResourceManager.GetString("cmdSelectAll_Help", resourceCulture); } } /// - /// Looks up a localized string similar to Co_lors. + /// Looks up a localized string similar to Close. /// - public static string ctxColors { + public static string cmdClose_Help { get { - return ResourceManager.GetString("ctxColors", resourceCulture); + return ResourceManager.GetString("cmdClose_Help", resourceCulture); } } - + /// - /// Looks up a localized string similar to _Copy. + /// Looks up a localized string similar to _Delete All. /// - public static string ctxCopy { + public static string cmdDeleteAll { get { - return ResourceManager.GetString("ctxCopy", resourceCulture); + return ResourceManager.GetString("cmdDeleteAll", resourceCulture); } } - + /// - /// Looks up a localized string similar to Cu_t. + /// Looks up a localized string similar to Delete all. /// - public static string ctxCut { + public static string cmdDeleteAll_Help { get { - return ResourceManager.GetString("ctxCut", resourceCulture); + return ResourceManager.GetString("cmdDeleteAll_Help", resourceCulture); } } - + /// - /// Looks up a localized string similar to _Delete All. + /// Looks up a localized string similar to _Edit. /// - public static string ctxDeleteAll { + public static string cmdEdit { get { - return ResourceManager.GetString("ctxDeleteAll", resourceCulture); + return ResourceManager.GetString("cmdEdit", resourceCulture); } } - + /// - /// Looks up a localized string similar to _Paste. + /// Looks up a localized string similar to Edit. /// - public static string ctxPaste { + public static string cmdEdit_Help { get { - return ResourceManager.GetString("ctxPaste", resourceCulture); + return ResourceManager.GetString("cmdEdit_Help", resourceCulture); } } - + /// /// Looks up a localized string similar to _Redo. /// - public static string ctxRedo { + public static string cmdRedo { get { - return ResourceManager.GetString("ctxRedo", resourceCulture); + return ResourceManager.GetString("cmdRedo", resourceCulture); } } - + /// - /// Looks up a localized string similar to _Select All. + /// Looks up a localized string similar to Redo last action. /// - public static string ctxSelectAll { + public static string cmdRedo_Help { get { - return ResourceManager.GetString("ctxSelectAll", resourceCulture); + return ResourceManager.GetString("cmdRedo_Help", resourceCulture); } } - + /// /// Looks up a localized string similar to _Undo. /// - public static string ctxUndo { + public static string cmdUndo { get { - return ResourceManager.GetString("ctxUndo", resourceCulture); + return ResourceManager.GetString("cmdUndo", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Undo last action. + /// + public static string cmdUndo_Help { + get { + return ResourceManager.GetString("cmdUndo_Help", resourceCulture); + } + } + /// /// Looks up a localized string similar to Date Picker. /// diff --git a/Terminal.Gui/Resources/Strings.fr-FR.resx b/Terminal.Gui/Resources/Strings.fr-FR.resx index 89da62d87a..fd2bc9a3ec 100644 --- a/Terminal.Gui/Resources/Strings.fr-FR.resx +++ b/Terminal.Gui/Resources/Strings.fr-FR.resx @@ -117,27 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - Tout _sélectionner - - - _Tout supprimer - - - _Copier - - - Co_uper - - - C_oller - - - _Annuler - - - _Rétablir - _Dossier @@ -180,9 +159,6 @@ Sélecteur de Date - - Cou_leurs - _Ouvrir @@ -205,36 +181,63 @@ Tout _sélectionner - + _Nouveau - - + + Ouvrir un fichier - - + + Quitter l'application - - - - - + + Enregistrer sous - - + + Couper vers le presse-papiers - - + + Copier vers le presse-papiers - - + + Tout sélectionner - - + + Nouveau - - + + Coller depuis le presse-papiers - + _Quitter + + + _Annuler + + + Annuler la dernière action + + + _Rétablir + + + Rétablir la dernière action + + + _Tout supprimer + + + Supprimer tout + + + É_diter + + + Éditer + + + Fermer + + + Enregistrer le fichier \ No newline at end of file diff --git a/Terminal.Gui/Resources/Strings.ja-JP.resx b/Terminal.Gui/Resources/Strings.ja-JP.resx index 092481d240..f2a3f6f76f 100644 --- a/Terminal.Gui/Resources/Strings.ja-JP.resx +++ b/Terminal.Gui/Resources/Strings.ja-JP.resx @@ -117,27 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - 全て選択 (_S) - - - 全て削除 (_D) - - - コピー (_C) - - - 切り取り (_T) - - - 貼り付け (_P) - - - 元に戻す (_U) - - - やり直し (_R) - ディレクトリ @@ -273,9 +252,6 @@ 日付ピッカー - - 絵の具 (_L) - 開く (_O) @@ -292,7 +268,7 @@ コピー (_C) - + 貼り付け (_P) 全て選択 (_S) @@ -300,37 +276,64 @@ 新規 (_N) - - + + ファイルを開く - - + + アプリケーションを終了 - - - - - + + 名前を付けて保存 - - + + クリップボードに切り取り - - + + クリップボードにコピー - - + + すべて選択 - - + + 新規 検索を入力 - - + + クリップボードから貼り付け - + 終了 (_X) + + + 元に戻す (_U) + + + 元に戻す + + + やり直し (_R) + + + やり直し + + + 全て削除 (_D) + + + すべて削除 + + + 編集 (_E) + + + 編集 + + + 閉じる + + + ファイルを保存 \ No newline at end of file diff --git a/Terminal.Gui/Resources/Strings.pt-PT.resx b/Terminal.Gui/Resources/Strings.pt-PT.resx index 134c28ff18..a5fda417c9 100644 --- a/Terminal.Gui/Resources/Strings.pt-PT.resx +++ b/Terminal.Gui/Resources/Strings.pt-PT.resx @@ -117,27 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - _Selecionar Tudo - - - _Apagar Tudo - - - _Copiar - - - Cor_tar - - - Co_lar - - - _Desfazer - - - _Refazer - Diretório @@ -180,9 +159,6 @@ Seletor de Data - - Co_res - _Abrir @@ -205,36 +181,63 @@ _Selecionar Tudo - + _Novo - - + + Abrir um ficheiro - - + + Sair da aplicação - - - - - + + Guardar como - - + + Cortar para a área de transferência - - + + Copiar para a área de transferência - - + + Selecionar tudo - - + + Novo - - + + Colar da área de transferência - + _Sair + + + _Desfazer + + + Desfazer a última ação + + + _Refazer + + + Refazer a última ação + + + _Apagar Tudo + + + Apagar tudo + + + _Editar + + + Editar + + + Fechar + + + Guardar o ficheiro \ No newline at end of file diff --git a/Terminal.Gui/Resources/Strings.resx b/Terminal.Gui/Resources/Strings.resx index 1861177cde..e232d92960 100644 --- a/Terminal.Gui/Resources/Strings.resx +++ b/Terminal.Gui/Resources/Strings.resx @@ -117,27 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - _Select All - - - _Delete All - - - _Copy - - - Cu_t - - - _Paste - - - _Undo - - - _Redo - Directory @@ -277,9 +256,6 @@ Date Picker - - Co_lors - Getting Codepoint Information @@ -320,30 +296,27 @@ _Select all - _New file + _New - + Open a file - - + + Exit the application - - Save - - + Save file as - + Cut to clipboard - + Copy to clipboard - + Select all - + New file @@ -353,7 +326,7 @@ _Find - + Paste from clipboard @@ -374,4 +347,34 @@ _Help + + Save file + + + _Undo + + + Undo last action + + + _Redo + + + Redo last action + + + _Delete All + + + Delete all + + + _Edit + + + Edit + + + Close + \ No newline at end of file diff --git a/Terminal.Gui/Resources/Strings.zh-Hans.resx b/Terminal.Gui/Resources/Strings.zh-Hans.resx index 369175611e..4fec45959c 100644 --- a/Terminal.Gui/Resources/Strings.zh-Hans.resx +++ b/Terminal.Gui/Resources/Strings.zh-Hans.resx @@ -117,27 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - 全选 (_S) - - - 清空 (_D) - - - 复制 (_C) - - - 剪切 (_T) - - - 粘贴 (_P) - - - 撤销 (_U) - - - 重做 (_R) - 目录 @@ -273,9 +252,6 @@ 日期选择器 - - 旗帜 (_L) - 打开 (_O) @@ -300,37 +276,64 @@ 新建 (_N) - - + + 打开文件 - - + + 退出应用程序 - - - - - + + 另存为 - - + + 剪切到剪贴板 - - + + 复制到剪贴板 - - + + 全选 - - + + 新建 输入以搜索 - - + + 从剪贴板粘贴 - + 退出 (_X) + + + 撤销 (_U) + + + 撤销上一步操作 + + + 重做 (_R) + + + 重做上一步操作 + + + 清空 (_D) + + + 全部删除 + + + 编辑 (_E) + + + 编辑 + + + 关闭 + + + 保存文件 \ No newline at end of file diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index cd7edd3196..765c46305d 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -64,9 +64,6 @@ - - - @@ -164,26 +161,6 @@ - - - - - - - - - - - - - - - - - - - - invoke["View.InvokeCommand(command)"] - invoke --> |Command.Activate| act_pre["OnActivating + Activating handlers"] - invoke --> |Command.Accept| acc_pre["OnAccepting + Accepting handlers"] - - act_pre --> |canceled| act_stop["Stop"] - act_pre --> |not canceled| act_handler["Execute command handler"] - act_handler --> act_done["Complete (returns bool?)"] - - acc_pre --> |canceled| acc_stop["Stop"] - acc_pre --> |not canceled| acc_handler["Execute command handler"] - acc_handler --> acc_prop["Propagate to default button/superview if unhandled"] - acc_prop --> acc_done["Complete (returns bool?)"] + input[User input - key/mouse] --> invoke[View.InvokeCommand] + invoke --> |Command.Activate| act_pre[RaiseActivating] + invoke --> |Command.Accept| acc_pre[RaiseAccepting] + invoke --> |Command.HotKey| hk_pre[RaiseHandlingHotKey] + + act_pre --> |handled| act_stop[Stop - returns true] + act_pre --> |not handled| act_handler[SetFocus + RaiseActivated] + act_handler --> act_done[Complete - returns true] + + acc_pre --> |handled| acc_stop[Stop - returns true] + acc_pre --> |not handled| acc_default{DefaultAcceptView exists?} + acc_default --> |yes| acc_bubble_down[BubbleDown to DefaultAcceptView] + acc_bubble_down --> acc_accepted[RaiseAccepted] + acc_default --> |no| acc_accepted + acc_accepted --> acc_done[Returns true if redirected or IsBubblingUp or IAcceptTarget] + + hk_pre --> |handled| hk_cancel[Returns false - key not consumed] + hk_pre --> |not handled| hk_handler[SetFocus + RaiseHotKeyCommand + InvokeCommand Activate] + hk_handler --> hk_done[Complete - returns true] ``` -## Command System Summary - -| Aspect | `Command.Activate` | `Command.Accept` | -|--------|-------------------|------------------| -| **Semantic Meaning** | "Interact with this view / select an item" - changes view state or prepares for interaction | "Perform the view's primary action" - confirms action or accepts current state | -| **Typical Triggers** | • Spacebar
• Single mouse click
• Navigation keys (arrows)
• Mouse enter (menus) | • Enter key
• Double-click (via framework or application timing) | -| **Event Name** | `Activating` | `Accepting` | -| **Virtual Method** | `OnActivating` | `OnAccepting` | -| **Propagation** | (Current Behavior; See [#4473](https://github.com/gui-cs/Terminal.Gui/issues/4473)) **Local only** - No propagation to superview
Relies on view-specific events (e.g., `SelectedMenuItemChanged`) | (Current Behavior; See [#4473](https://github.com/gui-cs/Terminal.Gui/issues/4473)) - **Hierarchical** - Propagates to:
• Default button (`IsDefault = true`)
• Superview
• SuperMenuItem (menus) | -| **Post-Event** | None (use view-specific events like `CheckedStateChanged`, `SelectedMenuItemChanged`) | `Accepted` (in `Menu`, `MenuBar` - not in base `View`) | -| **Example: Button** | Sets focus (if `CanFocus`)
No state change | Invokes button's primary action (e.g., submit dialog) | -| **Example: CheckBox** | Toggles `CheckedState` (spacebar) | Confirms current `CheckedState` (Enter) | -| **Example: ListView** | Selects item (single click, navigation) | Opens/enters selected item (double-click or Enter) | -| **Example: Menu/MenuBar** | Focuses `MenuItem` (arrow keys, mouse enter)
Raises `SelectedMenuItemChanged` | Executes command / opens submenu (Enter)
Raises `Accepted` to close menu | -| **Mouse → Command Pipeline** | See [Mouse Pipeline](mouse.md#complete-mouse-event-pipeline)
**Default:** `LeftButtonReleased` → `Activate` (aligns with industry standards - allows cancellation)
**Alternative:** `LeftButtonPressed` → `Activate` (immediate feedback, no cancellation)
`LeftButtonDoubleClicked` → `Accept` (framework-provided) | See [Mouse Pipeline](mouse.md#complete-mouse-event-pipeline)
**Current:** Applications track timing manually
**Recommended:** `LeftButtonDoubleClicked` → `Accept` | -| **Return Value Semantics** | `null`: no handler
`false`: executed but not handled
`true`: handled/canceled | Same as Activate | -| **Current Limitation** | No generic propagation mechanism for hierarchical views | Relies on view-specific logic (e.g., `SuperMenuItem`) instead of generic propagation | -| **Proposed Enhancement** | [#4473](https://github.com/gui-cs/Terminal.Gui/issues/4473) | Standardize propagation via subscription model instead of special properties | +## Activate/Accept/HotKey System Summary + +| Aspect | `Command.Activate` | `Command.Accept` | `Command.HotKey` | +|--------|-------------------|------------------|-------------------| +| **Semantic Meaning** | "Interact with this view / select an item" - changes view state or prepares for interaction | "Perform the view's primary action" - confirms action or accepts current state | "The view's HotKey was pressed" - sets focus and activates | +| **Typical Triggers** | Spacebar, single mouse click, navigation keys (arrows), mouse enter (menus) | Enter key, double-click | HotKey letter (e.g., Alt+F), `Shortcut.Key` | +| **Pre-Virtual Method** | `OnActivating` | `OnAccepting` | `OnHandlingHotKey` | +| **Pre-Event Name** | `Activating` | `Accepting` | `HandlingHotKey` | +| **Post-Virtual Method** | `OnActivated` | `OnAccepted` | `OnHotKeyCommand` | +| **Post-Event Name** | `Activated` | `Accepted` | `HotKeyCommand` | +| **Bubbling** | Opt-in via `CommandsToBubbleUp` | Opt-in via `CommandsToBubbleUp` + `DefaultAcceptView` | Opt-in via `CommandsToBubbleUp` | ## View Command Behaviors -The following table documents how each View subclass binds or handles keyboard and mouse events. This provides a comprehensive reference for understanding which commands are bound to specific inputs or whether views handle events directly through method overrides. +The following table documents how `View` and each View subclass binds or handles keyboard and mouse events. This provides a comprehensive reference for understanding which commands are bound to specific inputs or whether views handle events directly through method overrides. | View | Space | Enter | HotKey | Pressed | Released | Clicked | DoubleClicked | |------|-------|-------|--------|---------|----------|---------|---------------| -| **View** (base) | `Command.Activate` (default) | `Command.Accept` (default) | `Command.HotKey` (default) | Base OnMouseEvent (updates MouseState) | `Command.Activate` (default) | Not bound by default | Not bound by default | -| **Button** | `Command.HotKey` | `Command.HotKey` | `Command.HotKey` | OnMouseEvent (updates MouseState) | OnMouseEvent (updates MouseState) | `Command.HotKey` | `Command.HotKey` | -| **CheckBox** | `Command.Activate` | `Command.Accept` | `Command.HotKey` | `Command.Activate` | Base OnMouseEvent | `Command.Activate` | `Command.Accept` | -| **ComboBox** | Handled by SubViews | Handled by SubViews | `Command.HotKey` | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | -| **ListView** | Custom handler (selection) | `Command.Accept` | `Command.HotKey` | Base OnMouseEvent | Base OnMouseEvent | OnMouseEvent (selects item) | `Command.Accept` | -| **TableView** | Custom handler (toggle selection) | `Command.Accept` | `Command.HotKey` | OnMouseEvent (cell selection) | OnMouseEvent (end drag) | OnMouseEvent (cell selection) | `Command.Accept` | -| **TreeView** | `Command.Accept` | `Command.Accept` | `Command.HotKey` | Base OnMouseEvent | Base OnMouseEvent | OnMouseEvent (node selection) | `Command.Accept` | -| **TextField** | OnKeyDown (inserts space) | `Command.Accept` | `Command.HotKey` | OnMouseEvent (set cursor) | OnMouseEvent (end drag) | OnMouseEvent (position cursor) | OnMouseEvent (select word) | -| **TextView** | OnKeyDown (inserts space) | OnKeyDown (inserts newline) | `Command.HotKey` | OnMouseEvent (set cursor) | OnMouseEvent (end drag) | OnMouseEvent (position cursor) | OnMouseEvent (select word) | -| **OptionSelector** | Forwards to SubView | `Command.Accept` | Forwards to SubView HotKey | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | -| **FlagSelector** | Forwards to SubView | `Command.Accept` | Forwards to SubView HotKey | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | -| **Menu** | Handled by SubViews | `Command.Accept` | `Command.HotKey` | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | -| **MenuBar** | Handled by SubViews | `Command.Accept` | `Command.HotKey` | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | -| **MenuItem** | Base handler | `Command.Accept` | `Command.HotKey` | Base OnMouseEvent | Base OnMouseEvent | `Command.Activate` | `Command.Accept` | -| **Shortcut** | `Command.HotKey` | `Command.HotKey` | `Command.HotKey` | OnMouseEvent (updates MouseState) | OnMouseEvent (updates MouseState) | `Command.HotKey` | `Command.HotKey` | +| **View** (base) | `Command.Activate` | `Command.Accept` | `Command.HotKey` | Not bound | `Command.Activate` | Not bound | Not bound | +| **Button** | `Command.Accept` | `Command.Accept` | `Command.HotKey` → `Command.Accept` | Configurable via `MouseHoldRepeat` | Configurable via `MouseHoldRepeat` | `Command.Accept` | `Command.Accept` | +| **CheckBox** | `Command.Activate` (advances state) | `Command.Accept` | `Command.HotKey` | Not bound | Not bound (removed) | `Command.Activate` | `Command.Accept` | +| **ListView** | `Command.Activate` (marks item) | `Command.Accept` | `Command.HotKey` | Not bound | Not bound | `Command.Activate` | `Command.Accept` | +| **TableView** | Not bound | `Command.Accept` (CellActivationKey) | `Command.HotKey` | Not bound | Not bound | `Command.Activate` | Not bound | +| **TreeView** | Not bound | `Command.Activate` (ObjectActivationKey) | `Command.HotKey` | Not bound | Not bound | OnMouseEvent (node selection) | OnMouseEvent (ObjectActivationButton) | +| **TextField** | Removed (text input) | `Command.Accept` | `Command.HotKey` (cancels if focused) | OnMouseEvent (set cursor) | OnMouseEvent (end drag) | Not bound | OnMouseEvent (select word) | +| **TextView** | Removed (text input) | `Command.NewLine` or `Command.Accept` | `Command.HotKey` | Not bound | Not bound | Not bound | Not bound | +| **OptionSelector** | Forwards to CheckBox SubView | `Command.Accept` | Restores focus, advances Active | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | +| **FlagSelector** | Removed (forwards to SubView) | Removed (forwards to SubView) | Restores focus (no-op if focused) | Not bound (cleared) | Not bound (cleared) | Not bound (cleared) | Not bound (cleared) | +| **HexView** | Removed | Removed | Not bound | Not bound | Not bound | `Command.Activate` | `Command.Activate` | +| **ColorPicker** | Not bound | Not bound | Not bound | Not bound | Not bound | Not bound (removed) | `Command.Accept` | +| **Label** | Not bound | Not bound | Forwards to next focusable peer | Not bound | Not bound | Not bound | Not bound | +| **TabView** | Not bound | Not bound | `Command.HotKey` | Handled by SubViews | Handled by SubViews | Handled by SubViews | Not bound | +| **NumericUpDown** | Handled by SubViews | Handled by SubViews | `Command.HotKey` | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | | **Dialog** | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | | **Wizard** | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | | **FileDialog** | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | -| **TabView** | Not bound | Not bound | `Command.HotKey` | Handled by SubViews | Handled by SubViews | Handled by SubViews | Not bound | -| **ScrollBar** | Not bound | Not bound | Not bound | OnMouseEvent (auto-repeat/jump) | OnMouseEvent (auto-repeat) | OnMouseEvent (jump position) | Not bound | -| **HexView** | OnKeyDown (custom) | Not bound | Not bound | OnMouseEvent (position cursor) | Base OnMouseEvent | OnMouseEvent (position cursor) | OnMouseEvent (toggle side) | -| **NumericUpDown** | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | | **DatePicker** | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | -| **ColorPicker** | OnKeyDown (custom) | Not bound | Handled by SubViews | OnMouseEvent (adjust value) | Base OnMouseEvent | OnMouseEvent (adjust value) | `Command.Accept` | +| **ComboBox** | Handled by SubViews | Handled by SubViews | `Command.HotKey` | OnMouseEvent (toggle) | Handled by SubViews | Handled by SubViews | Handled by SubViews | +| **Shortcut** | `Command.Activate` (BubbleDown to CommandView, invokes TargetView if set) | `Command.Accept` (BubbleDown to CommandView, invokes TargetView if set) | `Command.HotKey` → `Command.Activate` | Not bound | `Command.Activate` | Not bound | Not bound | +| **MenuItem** | `Command.Activate` (inherited from Shortcut, invokes TargetView.Command) | `Command.Accept` (inherited from Shortcut, invokes TargetView.Command) | `Command.HotKey` → `Command.Activate` | Not bound | `Command.Activate` | Not bound | Not bound | +| **Menu** | Handled by MenuItems | Handled by MenuItems | Handled by MenuItems | Handled by MenuItems | Handled by MenuItems | Handled by MenuItems | Handled by MenuItems | +| **Bar** | Handled by Shortcuts | Handled by Shortcuts | Handled by Shortcuts | Handled by Shortcuts | Handled by Shortcuts | Handled by Shortcuts | Handled by Shortcuts | +| **ScrollBar** | Not bound | Not bound | Not bound | OnMouseEvent | OnMouseEvent | OnMouseEvent | Not bound | | **ProgressBar** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | | **SpinnerView** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | -| **Bar** | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | -| **Label** | Not bound | Not bound | Forwards to next focusable | Not bound | Not bound | Not bound | Not bound | ### Notes on Command Behaviors @@ -96,747 +95,430 @@ The table shows how each view handles keyboard and mouse input using one of thes - **Base OnMouseEvent** - Input uses the base `View.OnMouseEvent` implementation (updates MouseState) - **Custom handler** - Input uses a view-specific handler method (not a command) - **Handled by SubViews** - Composite views delegate input handling to their contained SubViews -- **Forwards to SubView** - Input is forwarded to a specific SubView (e.g., OptionSelector → CheckBox) +- **Forwards to SubView** - Input is forwarded to a specific SubView (e.g., OptionSelector -> CheckBox) - **Not bound** - Input is not handled or bound by this view #### Key Points -1. **View Base Class**: The first row shows the default behavior provided by the base `View` class. Space and Enter are bound to `Command.Activate` and `Command.Accept` respectively in `SetupCommands()`. Mouse events use the base `OnMouseEvent` implementation which updates `MouseState`. Subclasses typically override these bindings or add MouseBindings for Clicked/DoubleClicked events. +1. **View Base Class**: The first row shows the default behavior provided by the base `View` class. Space and Enter are bound to `Command.Activate` and `Command.Accept` respectively in `SetupCommands ()`. `MouseFlags.LeftButtonReleased` is bound to `Command.Activate` by default. Subclasses typically override these bindings or add MouseBindings for Clicked/DoubleClicked events. -2. **Composite Views** (Dialog, Wizard, FileDialog, DatePicker, NumericUpDown, Bar): These views delegate input handling to their SubViews. The parent view may intercept commands to coordinate actions (e.g., Dialog intercepting `Accept` to set `Result`). +2. **Composite Views** (Dialog, Wizard, FileDialog, DatePicker, NumericUpDown, ComboBox): These views delegate input handling to their SubViews. The SuperView may intercept commands to coordinate actions (e.g., Dialog intercepting `Accept` to set `Result`). -3. **Display-Only Views** (ProgressBar, SpinnerView, Label): These views typically have `CanFocus = false` and do not handle keyboard or mouse input directly. +3. **Display-Only Views** (ProgressBar, SpinnerView, Label): These views typically have `CanFocus = false` and do not handle keyboard or mouse input directly. `Label` forwards its HotKey to the next focusable peer view. -4. **Command Bindings vs. Event Handlers**: Views with simple, standardized behaviors use **command bindings** (KeyBinding/MouseBinding → Command). Views requiring custom logic (e.g., text editing, cursor positioning, drag selection) override **OnKeyDown** or **OnMouseEvent** directly. +4. **Command Bindings vs. Event Handlers**: Views with simple, standardized behaviors use **command bindings** (KeyBinding/MouseBinding -> Command). Views requiring custom logic (e.g., text editing, cursor positioning, drag selection) override **OnKeyDown** or **OnMouseEvent** directly. -5. **TreeView Special Case**: Both Space and Enter are bound to `Command.Accept`, which invokes the same handler (`ActivateSelectedObjectIfAny`). +5. **Button**: Implements `IAcceptTarget`. Space, Enter, Clicked, and DoubleClicked all map to `Command.Accept`. Mouse bindings are managed dynamically by the `MouseHoldRepeat` property. -6. **Shortcut and Button Unified Handling**: Space, Enter, Clicked, and DoubleClicked all map to `Command.HotKey`, providing consistent activation behavior. +6. **Selector Views** (OptionSelector, FlagSelector): These inherit from `SelectorBase` which sets `CommandsToBubbleUp = [Command.Activate, Command.Accept]`. Space/Enter are forwarded to the focused CheckBox via `BubbleDown`. HotKey behavior differs: OptionSelector restores focus and advances the active selection; FlagSelector restores focus (when not focused) but does not change active flags (no-op when focused). -7. **Selector Views** (OptionSelector, FlagSelector): These forward Space and HotKey inputs to the focused CheckBox's handlers, enabling keyboard-driven selection changes. +7. **Text Input Views** (TextField, TextView): These remove the `Key.Space` binding so space characters can be typed as text. TextField cancels HotKey processing when already focused (via `OnHandlingHotKey`) so the HotKey character can be typed as text. Enter maps to `Command.Accept` in TextField (submit), and to `Command.NewLine` in multi-line TextView (or `Command.Accept` in single-line mode). -8. **Text Input Views** (TextField, TextView): These override OnKeyDown to handle Space (inserts space character) and OnMouseEvent for cursor positioning, text selection, and drag operations. Enter is bound to `Command.Accept` in TextField (submit), but handled directly in TextView (inserts newline). - -9. **Mouse Event Columns**: +8. **Mouse Event Columns**: - **Pressed**: `MouseFlags.LeftButtonPressed` - button initially pressed down - **Released**: `MouseFlags.LeftButtonReleased` - button released after press - **Clicked**: `MouseFlags.LeftButtonClicked` - synthesized from press+release in same location - **DoubleClicked**: `MouseFlags.LeftButtonDoubleClicked` - synthesized from timing of two clicks - For detailed information about the mouse event pipeline and how events are synthesized, see the [Mouse Deep Dive](mouse.md). +9. **Shortcut**: Uses `CommandsToBubbleUp = [Command.Activate, Command.Accept]` and `BubbleDown` to coordinate commands between its SubViews (CommandView, HelpView, KeyView). See the [Shortcut Deep Dive](shortcut.md) for details. `Shortcut` has `TargetView` and `Command` properties; both `OnActivated` and `OnAccepted` invoke the command on the `TargetView` if set. **MenuItem** extends Shortcut, inheriting this TargetView dispatching behavior. **Menu** is a vertical `Bar` container for MenuItems. **MenuBar** is being redesigned; see source code for current behavior. + 10. **Implementation Patterns**: To understand how bindings work, see: - `Terminal.Gui/ViewBase/Mouse/View.Mouse.cs` - Base mouse handling and MouseBindings - `Terminal.Gui/ViewBase/Keyboard/View.Keyboard.cs` - Base keyboard handling and KeyBindings - Individual view source files for view-specific overrides and custom handlers -11. **Default Activation on Release**: The base `View` class binds `LeftButtonReleased` to `Command.Activate`, following industry-standard GUI conventions. This allows users to: - - Press the button → See visual feedback (MouseState.Pressed) - - Drag away → Realize mistake - - Release outside → Cancel action without triggering - - This matches behavior in Windows (WPF/WinForms), macOS (Cocoa), Web (HTML click), GTK4, and Qt. To activate on press instead (immediate feedback, no cancellation), replace the binding: - ```csharp - view.MouseBindings.ReplaceCommands (MouseFlags.LeftButtonPressed, Command.Activate); - view.MouseBindings.Remove (MouseFlags.LeftButtonReleased); - ``` - ### Key Takeaways -1. **`Activate` = Interaction/Selection** (immediate, local) +1. **`Activate` = Interaction/Selection** (immediate, local by default) - Changes view state or sets focus - - Does NOT propagate to SuperView + - Views that implement `IValue` will emit `ValueChanging`/`ValueChanged` events. - Views can emit view-specific events for notification (e.g., `CheckedStateChanged`, `SelectedMenuItemChanged`) + - Bubbles to SuperView only if `SuperView.CommandsToBubbleUp` includes `Command.Activate` 2. **`Accept` = Confirmation/Action** (final, hierarchical) - Confirms current state or executes primary action - - DOES propagate to default button or SuperView + - `View.DefaultAcceptView` is the SubView that has `Command.Accept` invoked on it if no other SubView handles `Accept`. + - Bubbles to SuperView if `SuperView.CommandsToBubbleUp` includes `Command.Accept` - Enables dialog/menu close scenarios +3. **`HotKey` = Focus + Activate** (delegated) + - Sets focus to the view and then invokes `Command.Activate` + - Bubbles to SuperView only if `SuperView.CommandsToBubbleUp` includes `Command.HotKey` + ## Overview of the Command System The `Command` system in Terminal.Gui defines a set of standard actions via the `Command` enum (e.g., `Command.Activate`, `Command.Accept`, `Command.HotKey`, `Command.StartOfPage`). These actions are triggered by user inputs (e.g., key presses, mouse clicks) or programmatically, enabling consistent view interactions. ### Key Components -- **Command Enum**: Defines actions like `Select` (state change or interaction preparation), `Accept` (action confirmation), `HotKey` (hotkey activation), and others (e.g., `StartOfPage` for navigation). +- **Command Enum**: Defines actions like `Activate` (state change or interaction preparation), `Accept` (action confirmation), `HotKey` (hotkey activation), and others (e.g., `StartOfPage` for navigation). - **Command Handlers**: Views register handlers using `View.AddCommand`, specifying a `CommandImplementation` delegate that returns `bool?` (`null`: no command executed; `false`: executed but not handled; `true`: handled or canceled). - **Command Routing**: Commands are invoked via `View.InvokeCommand`, executing the handler or raising `CommandNotBound` if no handler exists. -- **Cancellable Work Pattern**: Command execution uses events (e.g., `Activating`, `Accepting`) and virtual methods (e.g., `OnActivating`, `OnAccepting`) for modification or cancellation, with `Cancel` indicating processing should stop. +- **Cancellable Work Pattern**: Command execution uses events (e.g., `Activating`, `Accepting`) and virtual methods (e.g., `OnActivating`, `OnAccepting`) for modification or cancellation, with `Handled` indicating processing should stop. ### Role in Terminal.Gui The `Command` system bridges user input and view behavior, enabling: -- **Consistency**: Standard commands ensure predictable interactions (e.g., `Enter` triggers `Accept` in buttons, menus, checkboxes). +- **Consistency**: Standard commands ensure predictable interactions (e.g., `Enter` and `Double-click` trigger `Accept` in buttons, menus, checkboxes). - **Extensibility**: Custom handlers and events allow behavior customization. -- **Decoupling**: Events reduce reliance on sub-classing, though current propagation mechanisms may require subview-superview coordination. - -### Note on `Cancel` Property -The `CommandEventArgs` class uses a `Cancel` property to indicate that a command event (e.g., `Accepting`) should stop processing. This is misleading, as it implies action negation rather than completion. A filed issue proposes replacing `Cancel` with `Handled` to align with input events (e.g., `Key.Handled`). This document uses `Cancel` to reflect the current implementation, with the appendix summarizing the proposed change. +- **Decoupling**: Events reduce reliance on sub-classing, and `CommandsToBubbleUp` provides structured command propagation up the view hierarchy. ## Implementation in View.Command -The `View.Command` APIs in the `View` class provide infrastructure for registering, invoking, and routing commands, adhering to the *Cancellable Work Pattern*. +The `View.Command` APIs in the `View` class provide infrastructure for registering, invoking, and routing commands, adhering to the *Cancellable Work Pattern*. `View` provides default implementations of four commands: -### Command Registration -Views register commands using `View.AddCommand`, associating a `Command` with a `CommandImplementation` delegate. The delegate’s `bool?` return controls processing flow. - -**Example**: Default commands in `View.SetupCommands`: -```csharp -private void SetupCommands() -{ - AddCommand(Command.Accept, RaiseAccepting); - AddCommand(Command.Activate, ctx => - { - if (RaiseActivating(ctx) is true) - { - return true; - } - if (CanFocus) - { - SetFocus(); - return true; - } - return false; - }); - AddCommand(Command.HotKey, () => - { - if (RaiseHandlingHotKey() is true) - { - return true; - } - SetFocus(); - return true; - }); - AddCommand(Command.NotBound, RaiseCommandNotBound); -} -``` +* `Command.Activate` - Bound to `Key.Space` and `MouseFlags.LeftButtonReleased`. The default handler (`DefaultActivateHandler`) calls `RaiseActivating`; if not handled, sets focus, calls `RaiseActivated`, and returns `true`. +* `Command.Accept` - Bound to `Key.Enter`. The default handler (`DefaultAcceptHandler`) calls `RaiseAccepting`; if not handled, redirects to `DefaultAcceptView` via `BubbleDown` (if available), calls `RaiseAccepted`, and returns `true` if redirected, bubbling up, or the view is an `IAcceptTarget`. +* `Command.HotKey` - Bound to `View.HotKey`. The default handler (`DefaultHotKeyHandler`) calls `RaiseHandlingHotKey`; if handled, returns `false` (allowing the key through as text input); if not handled, sets focus, calls `RaiseHotKeyCommand`, invokes `Command.Activate`, and returns `true`. +* `Command.NotBound` - Invoked when an unregistered command is triggered. Raises the `CommandNotBound` event. -- **Default Commands**: `Accept`, `Select`, `HotKey`, `NotBound`. -- **Customization**: Views override or add commands (e.g., `CheckBox` for state toggling, `MenuItem` for menu actions). +### Command Registration +Views register commands using `View.AddCommand`, associating a `Command` with a `CommandImplementation` delegate. The delegate's `bool?` return controls processing flow. ### Command Invocation Commands are invoked via `View.InvokeCommand` or `View.InvokeCommands`, passing an `ICommandContext` for context (e.g., source view, binding details). Unhandled commands trigger `CommandNotBound`. **Example**: ```csharp -public bool? InvokeCommand(Command command, ICommandContext? ctx) +public bool? InvokeCommand (Command command, ICommandContext? ctx) { - if (!_commandImplementations.TryGetValue(command, out CommandImplementation? implementation)) + if (!_commandImplementations.TryGetValue (command, out CommandImplementation? implementation)) { - _commandImplementations.TryGetValue(Command.NotBound, out implementation); + _commandImplementations.TryGetValue (Command.NotBound, out implementation); } - return implementation!(ctx); + + return implementation! (ctx); } ``` -### Command Routing -Most commands route directly to the target view. `Command.Activate` and `Command.Accept` have special routing: -- `Command.Activate`: Handled locally, with no propagation to superviews, relying on view-specific events (e.g., `SelectedMenuItemChanged` in `Menu`) for hierarchical coordination. -- `Command.Accept`: Propagates to a default button (if `IsDefault = true`), superview, or `SuperMenuItem` (in menus). +### Command Routing and Bubbling + +By default, commands route directly to the target view and processing stops after the view's handler returns. Command **bubbling** - where an unhandled command propagates up to the SuperView - is **opt-in** and controlled by `View.CommandsToBubbleUp`. + +#### `CommandsToBubbleUp` + +`CommandsToBubbleUp` is a property on `View` that specifies which commands should bubble up from unhandled SubViews to the SuperView. When a SubView raises a command that is not handled, and that command is in the SuperView's `CommandsToBubbleUp` list, the command will be invoked on the SuperView. + +```csharp +public IReadOnlyList CommandsToBubbleUp { get; set; } = []; +``` + +By default, `CommandsToBubbleUp` is empty (no bubbling). Views that need hierarchical command propagation opt in explicitly: + +| View | `CommandsToBubbleUp` | +|------|---------------------| +| **Shortcut** | `[Command.Activate, Command.Accept]` | +| **Bar** | `[Command.Accept, Command.Activate]` | +| **Dialog** | `[Command.Accept]` | +| **Menu** (inherits Bar) | `[Command.Accept, Command.Activate]` | +| **SelectorBase** (OptionSelector, FlagSelector) | `[Command.Activate, Command.Accept]` | + +#### `TryBubbleUp` + +All three `Raise` methods (`RaiseAccepting`, `RaiseActivating`, `RaiseHandlingHotKey`) call the unified `TryBubbleUp` helper when the command is not handled. This method: + +1. **Checks `IsBubblingDown`**: If the context has `IsBubblingDown = true` (set by `BubbleDown`), returns `false` immediately to prevent infinite recursion. +2. **Handles `Command.Accept` with `IAcceptTarget`** (only for `Command.Accept`): If a `DefaultAcceptView` exists and the source is a non-default `IAcceptTarget`, bubbles up to the SuperView with `IsBubblingUp = true`. If the source is the default `IAcceptTarget`, flows normally without redirect. +3. **Checks `SuperView.CommandsToBubbleUp`**: If the current command is in the SuperView's `CommandsToBubbleUp` list, the command is invoked on the SuperView with `IsBubblingUp = true`. +4. **Handles the Padding edge cases**: If the SuperView is a `Padding` adornment, checks the Padding's parent View's `CommandsToBubbleUp` instead. Also handles the case where `this` is a `Padding`. + +```mermaid +flowchart TD + start[TryBubbleUp] --> check_handled{Already handled?} + check_handled --> |yes| return_true[return true] + check_handled --> |no| check_down{IsBubblingDown?} + check_down --> |yes| return_false_down[return false - skip bubbling] + check_down --> |no| check_accept{Command is Accept?} + check_accept --> |yes| check_default{DefaultAcceptView exists?} + check_default --> |yes| check_source{Source is IAcceptTarget?} + check_source --> |yes, non-default| bubble_up_accept[Bubble up to SuperView with IsBubblingUp] + check_source --> |yes, IsDefault| return_false_default[return false - flow normally] + check_source --> |no| check_bubble + check_default --> |no| check_bubble + check_accept --> |no| check_bubble{Command in SuperView.CommandsToBubbleUp?} + check_bubble --> |yes| invoke_super[Invoke command on SuperView with IsBubblingUp] + check_bubble --> |no| check_padding{SuperView is Padding?} + check_padding --> |yes| check_parent{Command in Padding.Parent.CommandsToBubbleUp?} + check_parent --> |yes| invoke_parent[Invoke command on Padding.Parent] + check_parent --> |no| check_self_padding{this is Padding?} + check_padding --> |no| check_self_padding + check_self_padding --> |yes| check_self_parent{Command in self.Parent.CommandsToBubbleUp?} + check_self_parent --> |yes| invoke_self_parent[Invoke command on self.Parent] + check_self_parent --> |no| return_false[return false] + check_self_padding --> |no| return_false +``` + +#### `BubbleDown` + +`BubbleDown` is the inverse of `TryBubbleUp`. Where bubbling up propagates an unhandled command from a SubView to its SuperView, `BubbleDown` dispatches a command from a SuperView down to a specific SubView with bubbling suppressed. + +```csharp +protected bool? BubbleDown (View target, ICommandContext? ctx) +``` + +This method: +1. Creates a new `CommandContext` with `IsBubblingDown = true` and no binding +2. Invokes the command on the target +3. Because `IsBubblingDown` is true, `TryBubbleUp` in the target's Raise method skips bubbling, preventing infinite recursion + +`BubbleDown` is used by composite views (like `Shortcut` and `SelectorBase`) that need to forward a command to a SubView without the SubView's command bubbling back up to the SuperView that dispatched it. + +```mermaid +flowchart LR + super[SuperView receives command] --> bubble_down[BubbleDown to subView] + bubble_down --> new_ctx[Create CommandContext with IsBubblingDown true] + new_ctx --> invoke[subView.InvokeCommand] + invoke --> raise[SubView raises Activating/Accepting] + raise --> try_bubble[TryBubbleUp checks IsBubblingDown] + try_bubble --> skip[IsBubblingDown true - skip bubbling] +``` + +#### `DefaultAcceptView` + +`DefaultAcceptView` is a special property on `View` that identifies the SubView that should receive `Command.Accept` when no other SubView handles it. By default, it returns the first SubView implementing `IAcceptTarget` with `IsDefault = true` (e.g., a `Button`), but can be set explicitly. -**Example**: `Command.Accept` in `RaiseAccepting`: ```csharp -protected bool? RaiseAccepting(ICommandContext? ctx) +public View? DefaultAcceptView { - CommandEventArgs args = new () { Context = ctx }; - args.Cancel = OnAccepting(args) || args.Cancel; - if (!args.Cancel && Accepting is {}) + get { - Accepting?.Invoke(this, args); - } - if (!args.Cancel) - { - View? isDefaultView = SuperView?.InternalSubViews.FirstOrDefault(v => v is Button { IsDefault: true }); - if (isDefaultView != this && isDefaultView is Button { IsDefault: true } button) - { - bool? handled = isDefaultView.InvokeCommand(Command.Accept, ctx); - if (handled == true) - { - return true; - } - } - if (SuperView is {}) + if (field is null) { - return SuperView?.InvokeCommand(Command.Accept, ctx); + return GetSubViews (includePadding: true) + .FirstOrDefault (v => v is IAcceptTarget { IsDefault: true }); } + + return field; } - return args.Cancel; + set; +} +``` + +This enables the common pattern where pressing Enter in a `TextField` within a `Dialog` activates the default "OK" button. + +#### `IAcceptTarget` + +`IAcceptTarget` is an interface implemented by views that serve as terminal destinations for `Command.Accept` (e.g., `Button`). It has a single property: + +```csharp +public interface IAcceptTarget +{ + bool IsDefault { get; set; } } ``` +The `IAcceptTarget` interface affects command flow in three ways: + +1. **`DefaultAcceptView` resolution**: When a view looks for a default accept target, it searches for SubViews implementing `IAcceptTarget { IsDefault: true }`. +2. **`DefaultAcceptHandler` return value**: The handler returns `true` (indicating the command was handled) when the view implements `IAcceptTarget`, signaling that Accept has reached its terminal destination. +3. **`TryBubbleUp` behavior**: When a non-default `IAcceptTarget` source raises Accept and a `DefaultAcceptView` exists, the command bubbles up to the SuperView with `IsBubblingUp = true` so the SuperView can determine which accept target was activated. A default `IAcceptTarget` source flows normally without redirect. + ## The Activating and Accepting Concepts The `Activating` and `Accepting` events, along with their corresponding commands (`Command.Activate`, `Command.Accept`), are designed to handle the most common user interactions with views: -- **Activating**: Changing a view’s state or preparing it for further interaction, such as highlighting an item in a list, toggling a checkbox, or focusing a menu item. +- **Activating**: Changing a view's state or preparing it for further interaction, such as highlighting an item in a list, toggling a checkbox, or focusing a menu item. - **Accepting**: Confirming an action or state, such as submitting a form, activating a button, or finalizing a selection. -These concepts are opinionated, reflecting Terminal.Gui’s view that most UI interactions can be modeled as either state changes/preparation (selecting) or action confirmations (accepting). Below, we explore each concept, their implementation, use cases, and propagation behavior, using `Cancel` to reflect the current implementation. - - +These concepts are opinionated, reflecting Terminal.Gui's view that most UI interactions can be modeled as either state changes/preparation (selecting) or action confirmations (accepting). Below, we explore each concept, their implementation, use cases, and propagation behavior, using `Handled` to reflect the current implementation. ### Activating -- **Definition**: `Activating` represents a user action that changes a view’s state or prepares it for further interaction, such as selecting an item in a `ListView`, toggling a `CheckBox`, or focusing a `MenuItem`. It is associated with `Command.Activate`, typically triggered by a spacebar press, single mouse click, navigation keys (e.g., arrow keys), or mouse enter (e.g., in menus). + +- **Definition**: `Activating` represents a user action that changes a view's state or prepares it for further interaction, such as selecting an item in a `ListView`, toggling a `CheckBox`, or focusing a `MenuItem`. It is associated with `Command.Activate`, typically triggered by a spacebar press, single mouse click, navigation keys (e.g., arrow keys), or mouse enter (e.g., in menus). - **Event**: The `Activating` event is raised by `RaiseActivating`, allowing external code to modify or cancel the state change. - **Virtual Method**: `OnActivating` enables subclasses to preprocess or cancel the action. -- **Implementation**: - ```csharp - protected bool? RaiseActivating(ICommandContext? ctx) - { - CommandEventArgs args = new () { Context = ctx }; - if (OnActivating(args) || args.Cancel) - { - return true; - } - Activating?.Invoke(this, args); - return Activating is null ? null : args.Cancel; - } - ``` - - **Default Behavior**: Sets focus if `CanFocus` is true (via `SetupCommands`). - - **Cancellation**: `args.Cancel` or `OnActivating` returning `true` halts the command. - - **Context**: `ICommandContext` provides invocation details. +- **Flow**: `RaiseActivating` follows the Cancellable Work Pattern: + 1. Calls `OnActivating (args)` - subclasses can handle/cancel + 2. Raises `Activating` event - subscribers can handle/cancel + 3. If not handled, calls `TryBubbleUp` - bubbles if SuperView's `CommandsToBubbleUp` includes `Command.Activate` + - **Default Behavior**: If not handled, the default handler (`DefaultActivateHandler`) sets focus (if `CanFocus` is true), raises `Activated`, and returns `true`. When `IsBubblingUp` is true (command bubbled from a SubView), the default handler returns `false` — only the `Activating` notification fires; `Activated`, `SetFocus`, and other side effects are skipped. Views that need to consume bubbled activations (e.g., `OptionSelector`, `FlagSelector`) override `OnActivating` to apply state changes and return `true`. + - **Cancellation**: `args.Handled` or `OnActivating` returning `true` halts the command. + - **Context**: `ICommandContext` provides invocation details (source view, binding). - **Use Cases**: - **ListView**: Activating an item (e.g., via arrow keys or mouse click) raises `Activating` to update the highlighted item. - - **CheckBox**: Toggling the checked state (e.g., via spacebar) raises `Activating` to change the state, as seen in the `AdvanceAndSelect` method: - ```csharp - private bool? AdvanceAndSelect(ICommandContext? commandContext) - { - bool? cancelled = AdvanceCheckState(); - if (cancelled is true) - { - return true; - } - if (RaiseActivating(commandContext) is true) - { - return true; - } - return commandContext?.Command == Command.HotKey ? cancelled : cancelled is false; - } - ``` - - **OptionSelector**: Activating an OpitonSelector option raises `Activating` to update the selected option. - - **Menu** and **MenuBar**: Activating a `MenuItem` (e.g., via mouse enter or arrow keys) sets focus, tracked by `SelectedMenuItem` and raising `SelectedMenuItemChanged`: - ```csharp - protected override void OnFocusedChanged(View? previousFocused, View? focused) - { - base.OnFocusedChanged(previousFocused, focused); - SelectedMenuItem = focused as MenuItem; - RaiseSelectedMenuItemChanged(SelectedMenuItem); - } - ``` - - **FlagSelector**: Activating a `CheckBox` subview toggles a flag, updating the `Value` property and raising `ValueChanged`, though it incorrectly triggers `Accepting`: - ```csharp - checkbox.Activating += (sender, args) => - { - if (RaiseActivating(args.Context) is true) - { - args.Cancel = true; - return; - } - if (RaiseAccepting(args.Context) is true) - { - args.Cancel = true; - } - }; - ``` + - **CheckBox**: Toggling the checked state (e.g., via spacebar) triggers `Command.Activate`, which raises `Activating`/`Activated`. The `OnActivated` override calls `AdvanceCheckState ()` to cycle the checkbox value. + - **OptionSelector**: Activating an OptionSelector option raises `Activating` to update the selected option. + - **Menu** and **MenuBar**: Activating a `MenuItem` (e.g., via mouse enter or arrow keys) sets focus, tracked by `SelectedMenuItem` and raising `SelectedMenuItemChanged`. - **Views without State**: For views like `Button`, `Activating` typically sets focus but does not change state, making it less relevant. -- **Propagation**: `Command.Activate` is handled locally by the target view. If the command is unhandled (`null` or `false`), processing stops without propagating to the superview or other views. This is evident in `Menu`, where `SelectedMenuItemChanged` is used for hierarchical coordination, and in `CheckBox` and `FlagSelector`, where state changes are internal. +- **Propagation**: `Command.Activate` bubbling is opt-in. If the command is unhandled and the SuperView's `CommandsToBubbleUp` includes `Command.Activate`, the command is invoked on the SuperView. Views that enable this include `Shortcut`, `Menu`, and `SelectorBase`. ### Accepting -- **Definition**: `Accepting` represents a user action that confirms or finalizes a view’s state or triggers an action, such as submitting a dialog, activating a button, or confirming a selection in a list. It is associated with `Command.Accept`, typically triggered by the Enter key or double-click. + +- **Definition**: `Accepting` represents a user action that confirms or finalizes a view's state or triggers an action, such as submitting a dialog, activating a button, or confirming a selection in a list. It is associated with `Command.Accept`, typically triggered by the Enter key or double-click. - **Event**: The `Accepting` event is raised by `RaiseAccepting`, allowing external code to modify or cancel the action. - **Virtual Method**: `OnAccepting` enables subclasses to preprocess or cancel the action. -- **Implementation**: As shown above in `RaiseAccepting`. - - **Default Behavior**: Raises `Accepting` and propagates to a default button (if present in the superview with `IsDefault = true`) or the superview if not canceled. - - **Cancellation**: `args.Cancel` or `OnAccepting` returning `true` halts the command. +- **Flow**: `RaiseAccepting` follows the Cancellable Work Pattern: + 1. Calls `OnAccepting (args)` - subclasses can handle/cancel + 2. Raises `Accepting` event - subscribers can handle/cancel + 3. If not handled, calls `TryBubbleUp` - handles `DefaultAcceptView` redirection and `CommandsToBubbleUp` bubbling + - **Default Behavior**: If `RaiseAccepting` is not handled, `DefaultAcceptHandler` performs additional steps: + 1. Checks for `DefaultAcceptView` and calls `BubbleDown` to it — but skips this redirect if Accept will also bubble to an ancestor via `CommandsToBubbleUp` (prevents double-path reaching the same ancestor) + 2. Calls `RaiseAccepted` + 3. Returns `true` if: Accept was redirected to `DefaultAcceptView`, Accept will bubble to an ancestor, the command is bubbling up (`IsBubblingUp`), or the view implements `IAcceptTarget` + - **Cancellation**: `args.Handled` or `OnAccepting` returning `true` halts the command. - **Context**: `ICommandContext` provides invocation details. - **Use Cases**: - **Button**: Pressing Enter raises `Accepting` to activate the button (e.g., submit a dialog). - **ListView**: Double-clicking or pressing Enter raises `Accepting` to confirm the selected item(s). - **TextField**: Pressing Enter raises `Accepting` to submit the input. - - **Menu** and **MenuBar**: Pressing Enter on a `MenuItem` raises `Accepting` to execute a command or open a submenu, followed by the `Accepted` event to hide the menu or deactivate the menu bar: - ```csharp - protected void RaiseAccepted(ICommandContext? ctx) - { - CommandEventArgs args = new () { Context = ctx }; - OnAccepted(args); - Accepted?.Invoke(this, args); - } - ``` - - **CheckBox**: Pressing Enter raises `Accepting` to confirm the current `CheckedState` without modifying it, as seen in its command setup: - ```csharp - AddCommand(Command.Accept, RaiseAccepting); - ``` - - **FlagSelector**: Pressing Enter raises `Accepting` to confirm the current `Value`, though its subview `Activating` handler incorrectly triggers `Accepting`, which should be reserved for parent-level confirmation. + - **Menu** and **MenuBar**: Pressing Enter on a `MenuItem` raises `Accepting` to execute a command or open a submenu, followed by the `Accepted` event to hide the menu or deactivate the menu bar. + - **CheckBox**: Pressing Enter raises `Accepting` to confirm the current `CheckedState` without modifying it. - **Dialog**: `Accepting` on a default button closes the dialog or triggers an action. -- **Propagation**: `Command.Accept` propagates to: - - A default button (if present in the superview with `IsDefault = true`). - - The superview, enabling hierarchical handling (e.g., a dialog processes `Accept` if no button handles it). - - In `Menu`, propagation extends to the `SuperMenuItem` for submenus in popovers, as seen in `OnAccepting`: - ```csharp - protected override bool OnAccepting(CommandEventArgs args) - { - if (args.Context?.Binding is KeyBinding { Key: { } key } && key == Application.QuitKey) - { - return true; - } - if (SuperView is null && SuperMenuItem is {}) - { - return SuperMenuItem?.InvokeCommand(Command.Accept, args.Context) is true; - } - return false; - } - ``` - - Similarly, `MenuBar` customizes propagation to show popovers: - ```csharp - protected override bool OnAccepting(CommandEventArgs args) - { - if (Visible && Enabled && args.Context?.Source is MenuBarItemv2 { PopoverMenuOpen: false } sourceMenuBarItem) - { - if (!CanFocus) - { - Active = true; - ShowItem(sourceMenuBarItem); - if (!sourceMenuBarItem.HasFocus) - { - sourceMenuBarItem.SetFocus(); - } - } - else - { - ShowItem(sourceMenuBarItem); - } - return true; - } - return false; - } - ``` - -### Key Differences -| Aspect | Activating | Accepting | -|--------|-----------|-----------| -| **Purpose** | Change view state or prepare for interaction (e.g., focus menu item, toggle checkbox, select list item) | Confirm action or state (e.g., execute menu command, submit, activate) | -| **Trigger** | Spacebar, single click, navigation keys, mouse enter | Enter, double-click | -| **Event** | `Activating` | `Accepting` | -| **Virtual Method** | `OnActivating` | `OnAccepting` | -| **Propagation** | Local to the view | Propagates to default button, superview, or SuperMenuItem (in menus) | -| **Use Cases** | `Menu`, `MenuBar`, `CheckBox`, `FlagSelector`, `ListView`, `Button` | `Menu`, `MenuBar`, `CheckBox`, `FlagSelector`, `Button`, `ListView`, `Dialog` | -| **State Dependency** | Often stateful, but includes focus for stateless views | May be stateless (triggers action) | - -### Critical Evaluation: Activating vs. Accepting -The distinction between `Activating` and `Accepting` is clear in theory: -- `Activating` is about state changes or preparatory actions, such as choosing an item in a `ListView` or toggling a `CheckBox`. -- `Accepting` is about finalizing an action, such as submitting a selection or activating a button. - -However, practical challenges arise: -- **Overlapping Triggers**: In `ListView`, pressing Enter might both select an item (`Activating`) and confirm it (`Accepting`), depending on the interaction model, potentially confusing developers. Similarly, in `Menu`, navigation (e.g., arrow keys) triggers `Activating`, while Enter triggers `Accepting`, but the overlap in user intent can blur the lines. -- **Stateless Views**: For views like `Button` or `MenuItem`, `Activating` is limited to setting focus, which dilutes its purpose as a state-changing action and may confuse developers expecting a more substantial state change. -- **Propagation Limitations**: The local handling of `Command.Activate` restricts hierarchical coordination. For example, `MenuBar` relies on `SelectedMenuItemChanged` to manage `PopoverMenu` visibility, which is view-specific and not generalizable. This highlights a need for a propagation mechanism that maintains subview-superview decoupling. -- **FlagSelector Design Flaw**: In `FlagSelector`, the `CheckBox.Activating` handler incorrectly triggers both `Activating` and `Accepting`, conflating state changes (toggling flags) with action confirmation (submitting the flag set). This violates the intended separation and requires a design fix to ensure `Activating` is limited to subview state changes and `Accepting` is reserved for parent-level confirmation. - -**Recommendation**: Enhance documentation to clarify the `Activating`/`Accepting` model: -- Define `Activating` as state changes or interaction preparation (e.g., item selection, toggling, focusing) and `Accepting` as action confirmations (e.g., submission, activation). -- Explicitly note that `Command.Activate` may set focus in stateless views (e.g., `Button`, `MenuItem`) but is primarily for state changes. -- Address `FlagSelector`’s conflation by refactoring its `Activating` handler to separate state changes from confirmation. - -## Evaluating Selected/Accepted Events - -The need for `Selected` and `Accepted` events is under consideration, with `Accepted` showing utility in specific views (`Menu`, `MenuBar`) but not universally required across all views. These events would serve as post-events, notifying that a `Activating` or `Accepting` action has completed, similar to other *Cancellable Work Pattern* post-events like `ClearedViewport` in `View.Draw` or `OrientationChanged` in `OrientationHelper`. - -### Need for Selected/Accepted Events -- **Selected Event**: - - **Purpose**: A `Selected` event would notify that a `Activating` action has completed, indicating that a state change or preparatory action (e.g., a new item highlighted, a checkbox toggled) has taken effect. - - **Use Cases**: - - **Menu** and **MenuBar**: Notify when a new `MenuItem` is focused, currently handled by the `SelectedMenuItemChanged` event, which tracks focus changes: - ```csharp - protected override void OnFocusedChanged(View? previousFocused, View? focused) - { - base.OnFocusedChanged(previousFocused, focused); - SelectedMenuItem = focused as MenuItem; - RaiseSelectedMenuItemChanged(SelectedMenuItem); - } - ``` - - **CheckBox**: Notify when the `CheckedState` changes, handled by the `CheckedStateChanged` event, which is raised after a state toggle: - ```csharp - private bool? ChangeCheckedState(CheckState value) +- **Propagation**: `Command.Accept` bubbling is opt-in via `CommandsToBubbleUp`, with the added special case that `DefaultAcceptView` is checked first. This enables the pattern where pressing Enter in a `TextField` activates the dialog's default button. Views that enable Accept bubbling include `Dialog` (`[Command.Accept]`), `Shortcut` (`[Command.Activate, Command.Accept]`), and `Menu` (`[Command.Accept, Command.Activate]`). + +### HotKey + +- **Definition**: `HotKey` represents the user pressing a view's designated hot key. It is associated with `Command.HotKey`, typically triggered by the view's `HotKey` property or a `Shortcut.Key`. +- **Event**: The `HandlingHotKey` event is raised by `RaiseHandlingHotKey`, allowing external code to cancel the hot key handling. +- **Virtual Method**: `OnHandlingHotKey` enables subclasses to preprocess or cancel the action. +- **Implementation**: + The default handler (`DefaultHotKeyHandler`) follows this sequence: + 1. Calls `RaiseHandlingHotKey` (which calls `OnHandlingHotKey`, raises `HandlingHotKey`, and attempts bubbling if unhandled) + 2. If not handled, calls `SetFocus ()` (if `CanFocus`) + 3. Calls `RaiseHotKeyCommand` (calls `OnHotKeyCommand` and raises `HotKeyCommand`) + 4. Invokes `Command.Activate` on the view + + ```csharp + internal bool? DefaultHotKeyHandler (ICommandContext? ctx) + { + if (RaiseHandlingHotKey (ctx) is true) { - if (_checkedState == value || (value is CheckState.None && !AllowCheckStateNone)) - { - return null; - } - CancelEventArgs e = new(in _checkedState, ref value); - if (OnCheckedStateChanging(e)) - { - return true; - } - CheckedStateChanging?.Invoke(this, e); - if (e.Cancel) - { - return e.Cancel; - } - _checkedState = value; - UpdateTextFormatterText(); - SetNeedsLayout(); - EventArgs args = new(in _checkedState); - OnCheckedStateChanged(args); - CheckedStateChanged?.Invoke(this, args); + // Return false so the key is not consumed and can be processed + // as normal input (e.g. text input in a TextField whose HotKey + // matches the character being typed). return false; } - ``` - - **FlagSelector**: Notify when the `Value` changes due to a flag toggle, handled by the `ValueChanged` event, which is raised after a `CheckBox` state change: - ```csharp - checkbox.CheckedStateChanged += (sender, args) => - { - uint? newValue = Value; - if (checkbox.CheckedState == CheckState.Checked) - { - if (flag == default!) - { - newValue = 0; - } - else - { - newValue = newValue | flag; - } - } - else - { - newValue = newValue & ~flag; - } - Value = newValue; - }; - ``` - - **ListView**: Notify when a new item is selected, typically handled by `SelectedItemChanged` or similar custom events. - - **Button**: Less relevant, as `Activating` typically only sets focus, and no state change occurs to warrant a `Selected` notification. - - **Current Approach**: Views like `Menu`, `CheckBox`, and `FlagSelector` use custom events (`SelectedMenuItemChanged`, `CheckedStateChanged`, `ValueChanged`) to signal state changes, bypassing a generic `Selected` event. These view-specific events provide context (e.g., the selected `MenuItem`, the new `CheckedState`, or the updated `Value`) that a generic `Selected` event would struggle to convey without additional complexity. - - **Pros**: - - A standardized `Selected` event could unify state change notifications across views, reducing the need for custom events in some cases. - - Aligns with the *Cancellable Work Pattern*’s post-event phase, providing a consistent way to react to completed `Activating` actions. - - Could simplify scenarios where external code needs to monitor state changes without subscribing to view-specific events. - - **Cons**: - - Overlaps with existing view-specific events, which are more contextually rich (e.g., `CheckedStateChanged` provides the new `CheckState`, whereas `Selected` would need additional data). - - Less relevant for stateless views like `Button`, where `Activating` only sets focus, leading to inconsistent usage across view types. - - Adds complexity to the base `View` class, potentially bloating the API for a feature not universally needed. - - Requires developers to handle generic `Selected` events with less specific information, which could lead to more complex event handling logic compared to targeted view-specific events. - - **Context Insight**: The use of `SelectedMenuItemChanged` in `Menu` and `MenuBar`, `CheckedStateChanged` in `CheckBox`, and `ValueChanged` in `FlagSelector` suggests that view-specific events are preferred for their specificity and context. These events are tailored to the view’s state (e.g., `MenuItem` instance, `CheckState`, or `Value`), making them more intuitive for developers than a generic `Selected` event. The absence of a `Selected` event in the current implementation indicates that it hasn’t been necessary for most use cases, as view-specific events adequately cover state change notifications. - - **Verdict**: A generic `Selected` event could provide a standardized way to notify state changes, but its benefits are outweighed by the effectiveness of view-specific events like `SelectedMenuItemChanged`, `CheckedStateChanged`, and `ValueChanged`. These events offer richer context and are sufficient for current use cases across `Menu`, `CheckBox`, `FlagSelector`, and other views. Adding `Selected` to the base `View` class is not justified at this time, as it would add complexity without significant advantages over existing mechanisms. - -- **Accepted Event**: - - **Purpose**: An `Accepted` event would notify that an `Accepting` action has completed (i.e., was not canceled via `args.Cancel`), indicating that the action has taken effect, aligning with the *Cancellable Work Pattern*’s post-event phase. - - **Use Cases**: - - **Menu** and **MenuBar**: The `Accepted` event is critical for signaling that a menu command has been executed or a submenu action has completed, triggering actions like hiding the menu or deactivating the menu bar. In `Menu`, it’s raised by `RaiseAccepted` and used hierarchically: - ```csharp - protected void RaiseAccepted(ICommandContext? ctx) - { - CommandEventArgs args = new () { Context = ctx }; - OnAccepted(args); - Accepted?.Invoke(this, args); - } - ``` - In `MenuBar`, it deactivates the menu bar: - ```csharp - protected override void OnAccepted(CommandEventArgs args) + + if (CanFocus) { - base.OnAccepted(args); - if (SubViews.OfType().Contains(args.Context?.Source)) - { - return; - } - Active = false; + SetFocus (); } - ``` - - **CheckBox**: Could notify that the current `CheckedState` was confirmed (e.g., in a dialog context), though this is not currently implemented, as `Accepting` suffices for confirmation without a post-event. - - **FlagSelector**: Could notify that the current `Value` was confirmed, but this is not implemented, and the incorrect triggering of `Accepting` by subview `Activating` complicates its use. - - **Button**: Could notify that the button was activated, typically handled by a custom event like `Clicked`. - - **ListView**: Could notify that a selection was confirmed (e.g., Enter pressed), often handled by custom events. - - **Dialog**: Could notify that an action was completed (e.g., OK button clicked), useful for hierarchical scenarios. - - **Current Approach**: `Menu` and `MenuItem` implement `Accepted` to signal action completion, with hierarchical handling via subscriptions (e.g., `MenuItem.Accepted` triggers `Menu.RaiseAccepted`, which triggers `MenuBar.OnAccepted`). Other views like `CheckBox` and `FlagSelector` rely on the completion of the `Accepting` event (i.e., not canceled) or custom events (e.g., `Button.Clicked`) to indicate action completion, without a generic `Accepted` event. - - **Pros**: - - Provides a standardized way to react to confirmed actions, particularly valuable in composite or hierarchical views like `Menu`, `MenuBar`, and `Dialog`, where superviews need to respond to action completion (e.g., closing a menu or dialog). - - Aligns with the *Cancellable Work Pattern*’s post-event phase, offering a consistent mechanism for post-action notifications. - - Simplifies hierarchical scenarios by providing a unified event for action completion, reducing reliance on view-specific events in some cases. - - **Cons**: - - May duplicate existing view-specific events (e.g., `Button.Clicked`, `Menu.Accepted`), leading to redundancy in views where custom events are already established. - - Adds complexity to the base `View` class, especially for views like `CheckBox` or `FlagSelector` where `Accepting`’s completion is often sufficient without a post-event. - - Requires clear documentation to distinguish `Accepted` from `Accepting` and to clarify when it should be used over view-specific events. - - **Context Insight**: The implementation of `Accepted` in `Menu` and `MenuBar` demonstrates its utility in hierarchical contexts, where it facilitates actions like menu closure or menu bar deactivation. For example, `MenuItem` raises `Accepted` to trigger `Menu`’s `RaiseAccepted`, which propagates to `MenuBar`: - ```csharp - protected void RaiseAccepted(ICommandContext? ctx) + + RaiseHotKeyCommand (ctx); + + // Pass the original binding so downstream handlers can distinguish + // a user-initiated HotKey activation from a programmatic one. + InvokeCommand (Command.Activate, ctx?.Binding); + + return true; + } + ``` + + > **Important**: When `RaiseHandlingHotKey` returns `true` (indicating the hotkey was handled/cancelled), `DefaultHotKeyHandler` returns `false`. This is intentional: it allows the key character to pass through to text input processing. For example, a `TextField` with HotKey `_E` that already has focus will cancel the hotkey in `OnHandlingHotKey` so the 'E' character can be typed as text input. + +- **Propagation**: Like `Activate` and `Accept`, `HotKey` bubbling is opt-in via `CommandsToBubbleUp`. `RaiseHandlingHotKey` calls `TryBubbleUp` when unhandled. + +## Shortcut Command Dispatching + +`Shortcut` is a composite view that contains three SubViews: `CommandView`, `HelpView`, and `KeyView`. It uses `CommandsToBubbleUp = [Command.Activate, Command.Accept]` to receive commands from its SubViews. + +### The BubbleDown Pattern + +Because `Shortcut` is a composite view, commands can originate from different SubViews (e.g., clicking on the `CommandView`, clicking on the `KeyView`, or pressing a hotkey on the `Shortcut` itself). The `Shortcut` uses `BubbleDown` to coordinate command flow. + +When a command arrives at `Shortcut` via `OnActivating` or `OnAccepting`, the Shortcut checks the command's binding source: + +- **From `CommandView`** (binding source is the `CommandView`): The `CommandView` already processed the command (e.g., a `CheckBox` toggled itself). The Shortcut skips `BubbleDown` to avoid double-processing. +- **From Shortcut itself, `HelpView`, or `KeyView`** (binding source exists but is not `CommandView`): The Shortcut calls `BubbleDown (CommandView, args.Context)` to forward the command to `CommandView` with bubbling suppressed, allowing `CommandView` to update its state. +- **No binding** (programmatic `InvokeCommand`): The Shortcut skips `BubbleDown` since there is no user interaction to forward. + +```csharp +protected override bool OnActivating (CommandEventArgs args) +{ + if (base.OnActivating (args)) + { + return true; + } + + // Only bubble down to CommandView when the activation came from user interaction + // with this Shortcut or its non-CommandView SubViews (HelpView/KeyView). + // Skip when the command bubbled up from CommandView or was directly invoked (no binding). + // When IsBubblingUp, skip BubbleDown here so the Activating event handler gets a chance + // to handle/cancel first. The Activate command handler will BubbleDown after if needed. + if (args.Context?.IsBubblingUp != true && args.Context?.Binding is { Source: { } source } && source != CommandView) { - CommandEventArgs args = new () { Context = ctx }; - OnAccepted(args); - Accepted?.Invoke(this, args); + return BubbleDown (CommandView, args.Context) is null; } - ``` - In contrast, `CheckBox` and `FlagSelector` do not use `Accepted` - - **Verdict**: The `Accepted` event is highly valuable in composite and hierarchical views like `Menu`, `MenuBar`, and potentially `Dialog`, where it supports coordinated action completion (e.g., closing menus or dialogs). However, adding it to the base `View` class is premature without broader validation across more view types, as many views (e.g., `CheckBox`, `FlagSelector`) function effectively without it, using `Accepting` or custom events. Implementing `Accepted` in specific views or base classes like `Bar` or `Runnable` (e.g., for menus and dialogs) and reassessing its necessity for the base `View` class later is a prudent approach. This balances the demonstrated utility in hierarchical scenarios with the need to avoid unnecessary complexity in simpler views. -**Recommendation**: Avoid adding `Selected` or `Accepted` events to the base `View` class for now. Instead: -- Continue using view-specific events (e.g., `Menu.SelectedMenuItemChanged`, `CheckBox.CheckedStateChanged`, `FlagSelector.ValueChanged`, `ListView.SelectedItemChanged`, `Button.Clicked`) for their contextual specificity and clarity. -- Maintain and potentially formalize the use of `Accepted` in views like `Menu`, `MenuBar`, and `Dialog`, tracking its utility to determine if broader adoption in a base class like `Bar` or `Runnable` is warranted. -- If `Selected` or `Accepted` events are added in the future, ensure they fire only when their respective events (`Activating`, `Accepting`) are not canceled (i.e., `args.Cancel` is `false`), maintaining consistency with the *Cancellable Work Pattern*’s post-event phase. + return false; +} +``` -## Propagation of Activating +#### Flow Diagram -The current implementation of `Command.Activate` is local, but `MenuBar` requires propagation to manage `PopoverMenu` visibility, highlighting a limitation in the system’s ability to support hierarchical coordination without view-specific mechanisms. +```mermaid +flowchart TD + input[User action on Shortcut] --> check_binding{Has Binding with Source} -### Current Behavior -- **Activating**: `Command.Activate` is handled locally by the target view, with no propagation to the superview or other views. If the command is unhandled (returns `null` or `false`), processing stops without further routing. - - **Rationale**: `Activating` is typically view-specific, as state changes (e.g., highlighting a `ListView` item, toggling a `CheckBox`) or preparatory actions (e.g., focusing a `MenuItem`) are internal to the view. This is evident in `CheckBox`, where state toggling is self-contained: - ```csharp - private bool? AdvanceAndSelect(ICommandContext? commandContext) + check_binding --> |No binding| skip[Skip BubbleDown - programmatic invoke] + skip --> raise_events1[Shortcut raises Activating/Accepting normally] + + check_binding --> |Yes| check_source{Binding.Source is CommandView} + + check_source --> |Yes - from CommandView| skip2[Skip BubbleDown - CommandView already processed] + skip2 --> raise_events2[Shortcut raises Activating/Accepting normally] + + check_source --> |No - from Shortcut/HelpView/KeyView| bubble[BubbleDown to CommandView] + bubble --> invoke[CommandView.InvokeCommand with IsBubblingDown true] + invoke --> cv_update[CommandView updates state] + cv_update --> no_rebubble[TryBubbleUp skips - IsBubblingDown true] + no_rebubble --> raise_events3[Shortcut raises Activating/Accepting normally] +``` + +### SelectorBase Command Dispatching + +`SelectorBase` (used by `OptionSelector` and `FlagSelector`) follows a similar `BubbleDown` pattern. `SelectorBase` sets `CommandsToBubbleUp = [Command.Activate, Command.Accept]` so that commands from CheckBox SubViews bubble up to the selector. In their `OnActivating` overrides, `FlagSelector` and `OptionSelector` use `BubbleDown` to forward programmatic activations to the focused `CheckBox` SubView: + +```csharp +// Simplified from FlagSelector.OnActivating +protected override bool OnActivating (CommandEventArgs args) +{ + if (base.OnActivating (args)) { - bool? cancelled = AdvanceCheckState(); - if (cancelled is true) - { - return true; - } - if (RaiseActivating(commandContext) is true) - { - return true; - } - return commandContext?.Command == Command.HotKey ? cancelled : cancelled is false; + return true; } - ``` - - **Context Across Views**: - - In `Menu`, `Activating` sets focus and raises `SelectedMenuItemChanged` to track changes, but this is a view-specific mechanism: - ```csharp - protected override void OnFocusedChanged(View? previousFocused, View? focused) - { - base.OnFocusedChanged(previousFocused, focused); - SelectedMenuItem = focused as MenuItem; - RaiseSelectedMenuItemChanged(SelectedMenuItem); - } - ``` - - In `MenuBar`, `SelectedMenuItemChanged` is used to manage `PopoverMenu` visibility, but this relies on custom event handling rather than a generic propagation model: - ```csharp - protected override void OnSelectedMenuItemChanged(MenuItem? selected) - { - if (IsOpen() && selected is MenuBarItemv2 { PopoverMenuOpen: false } selectedMenuBarItem) - { - ShowItem(selectedMenuBarItem); - } - } - ``` - - In `CheckBox` and `FlagSelector`, `Activating` is local, with state changes (e.g., `CheckedState`, `Value`) handled internally or via view-specific events (`CheckedStateChanged`, `ValueChanged`), requiring no superview involvement. - - In `ListView`, `Activating` updates the highlighted item locally, with no need for propagation in typical use cases. - - In `Button`, `Activating` sets focus, which is inherently local. - -- **Accepting**: `Command.Accept` propagates to a default button (if present), the superview, or a `SuperMenuItem` (in menus), enabling hierarchical handling. - - **Rationale**: `Accepting` often involves actions that affect the broader UI context (e.g., closing a dialog, executing a menu command), requiring coordination with parent views. This is evident in `Menu`'s propagation to `SuperMenuItem` and `MenuBar`'s handling of `Accepted`: - ```csharp - protected override void OnAccepting (CommandEventArgs args) + + // Skip BubbleDown when re-entering, no focused view, or source is a SubView + if (args.Context?.IsBubblingDown == true + || Focused is null + || (args.Context?.TryGetSource (out View? ctxSource) is true && ctxSource != this)) { - // Pattern match on binding type using ICommandContext.Binding - if (args.Context?.Binding is KeyBinding kb && kb.Key == Application.QuitKey) - { - return true; - } - if (SuperView is null && SuperMenuItem is { }) - { - return SuperMenuItem?.InvokeCommand (Command.Accept, args.Context) is true; - } return false; } - ``` -### Should Activating Propagate? -The local handling of `Command.Activate` is sufficient for many views, but `MenuBar`’s need to manage `PopoverMenu` visibility highlights a gap in the current design, where hierarchical coordination relies on view-specific events like `SelectedMenuItemChanged`. + BubbleDown (Focused, args.Context); -- **Arguments For Propagation**: - - **Hierarchical Coordination**: In `MenuBar`, propagation would allow the menu bar to react to `MenuItem` selections (e.g., focusing a menu item via arrow keys or mouse enter) to show or hide popovers, streamlining the interaction model. Without propagation, `MenuBar` depends on `SelectedMenuItemChanged`, which is specific to `Menu` and not reusable for other hierarchical components. - - **Consistency with Accepting**: `Command.Accept`’s propagation model supports hierarchical actions (e.g., dialog submission, menu command execution), suggesting that `Command.Activate` could benefit from a similar approach to enable broader UI coordination, particularly in complex views like menus or dialogs. - - **Future-Proofing**: Propagation could support other hierarchical components, such as `TabView` (coordinating tab selection) or nested dialogs (tracking subview state changes), enhancing the `Command` system’s flexibility for future use cases. + return false; +} +``` -- **Arguments Against Propagation**: - - **Locality of State Changes**: `Activating` is inherently view-specific in most cases, as state changes (e.g., `CheckBox` toggling, `ListView` item highlighting) or preparatory actions (e.g., `Button` focus) are internal to the view. Propagating `Activating` events could flood superviews with irrelevant events, requiring complex filtering logic. For example, `CheckBox` and `FlagSelector` operate effectively without propagation: - ```csharp - checkbox.CheckedStateChanged += (sender, args) => - { - uint? newValue = Value; - if (checkbox.CheckedState == CheckState.Checked) - { - if (flag == default!) - { - newValue = 0; - } - else - { - newValue = newValue | flag; - } - } - else - { - newValue = newValue & ~flag; - } - Value = newValue; - }; - ``` - - **Performance and Complexity**: Propagation increases event handling overhead and complicates the API, as superviews must process or ignore `Activating` events. This could lead to performance issues in deeply nested view hierarchies or views with frequent state changes. - - **Existing Alternatives**: View-specific events like `SelectedMenuItemChanged`, `CheckedStateChanged`, and `ValueChanged` already provide mechanisms for superview coordination, negating the need for generic propagation in many cases. For instance, `MenuBar` uses `SelectedMenuItemChanged` to manage popovers, albeit in a view-specific way: - ```csharp - protected override void OnSelectedMenuItemChanged(MenuItem? selected) +### Shortcut's `Action` and `TargetView` Properties + +`Shortcut` has an `Action` property that is invoked in `OnActivated` and `OnAccepted`. Additionally, if `TargetView` is set (and `Command` is not `NotBound`), `Shortcut` invokes the command on the target view. If no `TargetView` is set but `Key` is valid, it falls back to invoking application-bound key commands. This enables both simple callback and target-view-based command dispatching: + +```csharp +protected override void OnActivated (ICommandContext? ctx) +{ + base.OnActivated (ctx); + Action?.Invoke (); + + // Translate the incoming command to Command + if (Command != Command.NotBound && ctx is { }) { - if (IsOpen() && selected is MenuBarItemv2 { PopoverMenuOpen: false } selectedMenuBarItem) - { - ShowItem(selectedMenuBarItem); - } + ctx.Command = Command; } - ``` - Similarly, `CheckBox` and `FlagSelector` use `CheckedStateChanged` and `ValueChanged` to notify superviews or external code of state changes, which is sufficient for most scenarios. - - **Semantics of `Cancel`**: Propagation would occur only if `args.Cancel` is `false`, implying an unhandled selection, which is counterintuitive since `Activating` typically completes its action (e.g., setting focus or toggling a state) within the view. This could confuse developers expecting propagation to occur for all `Activating` events. - -- **Context Insight**: The `MenuBar` implementation demonstrates a clear need for propagation to manage `PopoverMenu` visibility, as it must react to `MenuItem` selections (e.g., focus changes) across its submenu hierarchy. The reliance on `SelectedMenuItemChanged` works but is specific to `Menu`, limiting its applicability to other hierarchical components. In contrast, `CheckBox` and `FlagSelector` show that local handling is adequate for most stateful views, where state changes are self-contained or communicated via view-specific events. `ListView` similarly operates locally, with `SelectedItemChanged` or similar events handling external notifications. `Button`’s focus-based `Activating` is inherently local, requiring no propagation. This dichotomy suggests that while propagation is critical for certain hierarchical scenarios (e.g., menus), it’s unnecessary for many views, and any propagation mechanism must avoid coupling subviews to superviews to maintain encapsulation. - -- **Verdict**: The local handling of `Command.Activate` is sufficient for most views, including `CheckBox`, `FlagSelector`, `ListView`, and `Button`, where state changes or preparatory actions are internal or communicated via view-specific events. However, `MenuBar`’s requirement for hierarchical coordination to manage `PopoverMenu` visibility highlights a gap in the current design, where view-specific events like `SelectedMenuItemChanged` are used as a workaround. A generic propagation model would enhance flexibility for hierarchical components, but it must ensure that subviews (e.g., `MenuItem`) remain decoupled from superviews (e.g., `MenuBar`) to avoid implementation-specific dependencies. The current lack of propagation is a limitation, particularly for menus, but adding it requires careful design to avoid overcomplicating the API or impacting performance for views that don’t need it. - -**Recommendation**: Maintain the local handling of `Command.Activate` for now, as it meets the needs of most views like `CheckBox`, `FlagSelector`, and `ListView`. For `MenuBar`, continue using `SelectedMenuItemChanged` as a temporary solution, but prioritize developing a generic propagation mechanism that supports hierarchical coordination without coupling subviews to superviews. This mechanism should allow superviews to opt-in to receiving `Activating` events from subviews, ensuring encapsulation (see appendix for a proposed solution). - -## Recommendations for Refining the Design - -Based on the analysis of the current `Command` and `View.Command` system, as implemented in `Menu`, `MenuBar`, `CheckBox`, and `FlagSelector`, the following recommendations aim to refine the system’s clarity, consistency, and flexibility while addressing identified limitations: - -1. **Clarify Activating/Accepting in Documentation**: - - Explicitly define `Activating` as state changes or interaction preparation (e.g., toggling a `CheckBox`, focusing a `MenuItem`, selecting a `ListView` item) and `Accepting` as action confirmations (e.g., executing a menu command, submitting a dialog). - - Emphasize that `Command.Activate` may set focus in stateless views (e.g., `Button`, `MenuItem`) but is primarily intended for state changes, to reduce confusion for developers. - - Provide examples for each view type (e.g., `Menu`, `CheckBox`, `FlagSelector`, `ListView`, `Button`) to illustrate their distinct roles. For instance: - - `Menu`: “`Activating` focuses a `MenuItem` via arrow keys, while `Accepting` executes the selected command.” - - `CheckBox`: “`Activating` toggles the `CheckedState`, while `Accepting` confirms the current state.” - - `FlagSelector`: “`Activating` toggles a subview flag, while `Accepting` confirms the entire flag set.” - - Document the `Cancel` property’s role in `CommandEventArgs`, noting its current limitation (implying negation rather than completion) and the planned replacement with `Handled` to align with input events like `Key.Handled`. - -2. **Address FlagSelector Design Flaw**: - - Refactor `FlagSelector`’s `CheckBox.Activating` handler to separate `Activating` and `Accepting` actions, ensuring `Activating` is limited to subview state changes (toggling flags) and `Accepting` is reserved for parent-level confirmation of the `Value`. This resolves the conflation issue where subview `Activating` incorrectly triggers `Accepting`. - - Proposed fix: - ```csharp - checkbox.Activating += (sender, args) => - { - if (RaiseActivating(args.Context) is true) - { - args.Cancel = true; - } - }; - ``` - - This ensures `Activating` only propagates state changes to the parent `FlagSelector` via `RaiseActivating`, and `Accepting` is triggered separately (e.g., via Enter on the `FlagSelector` itself) to confirm the `Value`. - -3. **Enhance ICommandContext with View-Specific State**: - - The `ICommandContext` interface includes a `Binding` property that provides polymorphic access to the binding that triggered the command. - - **Note**: `CommandContext` (the implementation of `ICommandContext`) is now **non-generic**. Previous versions used `CommandContext` with a generic type parameter for the binding. This was removed to simplify the type system and enable easier pattern matching. - ```csharp - public interface ICommandContext - { - Command Command { get; } - View? Source { get; set; } - IInputBinding? Binding { get; } // Polymorphic access to the binding - } - public record struct CommandContext : ICommandContext // Non-generic - { - public Command Command { get; set; } - public View? Source { get; set; } - public IInputBinding? Binding { get; set; } - } - ``` - - Pattern match on `ctx.Binding` to access specific binding types: - ```csharp - if (ctx.Binding is KeyBinding kb) - { - // Handle key binding - access kb.Key, kb.Target, etc. - } - else if (ctx.Binding is MouseBinding mb) - { - // Handle mouse binding - access mb.MouseEvent, etc. - } - else if (ctx.Binding is InputBinding ib) - { - // Handle programmatic/generic binding - } - ``` - - A future `State` property could include view-specific data (e.g., the selected `MenuItem` in `Menu`, the new `CheckedState` in `CheckBox`). This would enhance the flexibility of event handlers. - -4. **Monitor Use Cases for Propagation Needs**: - - Track the usage of `Activating` and `Accepting` in real-world applications, particularly in `Menu`, `MenuBar`, `CheckBox`, and `FlagSelector`, to identify scenarios where propagation of `Activating` events could simplify hierarchical coordination. - - Collect feedback on whether the reliance on view-specific events (e.g., `SelectedMenuItemChanged` in `Menu`) is sufficient or if a generic propagation model would reduce complexity for hierarchical components like `MenuBar`. This will inform the design of a propagation mechanism that maintains subview-superview decoupling (see appendix). - - Example focus areas: - - `MenuBar`: Assess whether `SelectedMenuItemChanged` adequately handles `PopoverMenu` visibility or if propagation would streamline the interaction model. - - `Dialog`: Evaluate whether `Activating` propagation could enhance subview coordination (e.g., tracking checkbox toggles within a dialog). - - `TabView`: Consider potential needs for tab selection coordination if implemented in the future. - -5. **Improve Propagation for Hierarchical Views**: - - Recognize the limitation in `Command.Activate`’s local handling for hierarchical components like `MenuBar`, where superviews need to react to subview selections (e.g., focusing a `MenuItem` to manage popovers). The current reliance on `SelectedMenuItemChanged` is effective but view-specific, limiting reusability. - - Develop a propagation mechanism that allows superviews to opt-in to receiving `Activating` events from subviews without requiring subviews to know superview details, ensuring encapsulation. This could involve a new event or property in `View` to enable propagation while maintaining decoupling (see appendix for a proposed solution). - - Example: For `MenuBar`, a propagation mechanism could allow it to handle `Activating` events from `MenuItem` subviews to show or hide popovers, replacing the need for `SelectedMenuItemChanged`: - ```csharp - // Current workaround in MenuBar - protected override void OnSelectedMenuItemChanged(MenuItem? selected) - { - if (IsOpen() && selected is MenuBarItemv2 { PopoverMenuOpen: false } selectedMenuBarItem) - { - ShowItem(selectedMenuBarItem); - } - } - ``` - -6. **Standardize Hierarchical Handling for Accepting**: - - Refine the propagation model for `Command.Accept` to reduce reliance on view-specific logic, such as `Menu`’s use of `SuperMenuItem` for submenu propagation. The current approach, while functional, introduces coupling: - ```csharp - if (SuperView is null && SuperMenuItem is {}) + if (TargetView is { }) { - return SuperMenuItem?.InvokeCommand(Command.Accept, args.Context) is true; + TargetView.InvokeCommand (Command, ctx); } - ``` - - Explore a more generic mechanism, such as allowing superviews to subscribe to `Accepting` events from subviews, to streamline propagation and improve encapsulation. This could be addressed in conjunction with `Activating` propagation (see appendix). - - Example: In `Menu`, a subscription-based model could replace `SuperMenuItem` logic: - ```csharp - // Hypothetical subscription in Menu - SubViewAdded += (sender, args) => - { - if (args.View is MenuItem menuItem) - { - menuItem.Accepting += (s, e) => RaiseAccepting(e.Context); - } - }; - ``` - -## Conclusion - -The `Command` and `View.Command` system in Terminal.Gui provides a robust framework for handling view actions, with `Activating` and `Accepting` serving as opinionated mechanisms for state changes/preparation and action confirmations. The system is effectively implemented across `Menu`, `MenuBar`, `CheckBox`, and `FlagSelector`, supporting a range of stateful and stateless interactions. However, limitations in terminology (`Select`’s ambiguity), cancellation semantics (`Cancel`’s misleading implication), and propagation (local `Activating` handling) highlight areas for improvement. - -The `Activating`/`Accepting` distinction is clear in principle but requires careful documentation to avoid confusion, particularly for stateless views where `Activating` is focus-driven and for views like `FlagSelector` where implementation flaws conflate the two concepts. View-specific events like `SelectedMenuItemChanged`, `CheckedStateChanged`, and `ValueChanged` are sufficient for post-selection notifications, negating the need for a generic `Selected` event. The `Accepted` event is valuable in hierarchical views like `Menu` and `MenuBar` but not universally required, suggesting inclusion in `Bar` or `Runnable` rather than `View`. - -By clarifying terminology, fixing implementation flaws (e.g., `FlagSelector`), enhancing `ICommandContext`, and developing a decoupled propagation model, Terminal.Gui can enhance the `Command` system’s clarity and flexibility, particularly for hierarchical components like `MenuBar`. The appendix summarizes proposed changes to address these limitations, aligning with a filed issue to guide future improvements. - -## Appendix: Summary of Changes and Remaining Proposals to Command System - -A filed issue proposed enhancements to the `Command` system to address limitations in terminology, cancellation semantics, and propagation, informed by `Menu`, `MenuBar`, `CheckBox`, and `FlagSelector`. The renaming from `Command.Select` to `Command.Activate` has been completed. Remaining proposed changes aim to improve clarity, consistency, and flexibility. - -### Completed Changes -1. **Renamed `Command.Select` to `Command.Activate`** ✓: - - Replaced `Command.Select`, `Selecting` event, `OnSelecting`, and `RaiseSelecting` with `Command.Activate`, `Activating`, `OnActivating`, and `RaiseActivating`. - - Rationale: "Select" was ambiguous for stateless views (e.g., `Button` focus) and imprecise for non-list state changes (e.g., `CheckBox` toggling). "Activate" better captures state changes and preparation. - - Impact: Breaking change requiring codebase updates and migration guidance. - -### Remaining Proposed Changes - -2. **Replace `Cancel` with `Handled` in `CommandEventArgs`**: - - Replace `Cancel` with `Handled` to indicate command completion, aligning with `Key.Handled` (issue #3913). - - Rationale: `Cancel` implies negation, not completion. - - Impact: Clarifies semantics, requires updating event handlers. - -3. **Introduce `PropagateActivating` Event**: - - Add `event EventHandler? PropagateActivating` to `View`, allowing superviews (e.g., `MenuBar`) to subscribe to subview propagation requests. - - Rationale: Enables hierarchical coordination (e.g., `MenuBar` managing `PopoverMenu` visibility) without coupling subviews to superviews, addressing the current reliance on view-specific events like `SelectedMenuItemChanged`. - - Impact: Enhances flexibility for hierarchical views, requires subscription management in superviews like `MenuBar`. -### Benefits -- **Clarity**: `Activate` improves terminology for all views. -- **Consistency**: `Handled` aligns with input events. -- **Decoupling**: `PropagateActivating` supports hierarchical needs without subview-superview dependencies. -- **Extensibility**: Applicable to other hierarchies (e.g., dialogs, `TabView`). - -### Implementation Notes -- Update `Command` enum, `View`, and derived classes for the rename. -- Modify `CommandEventArgs` for `Handled`. -- Implement `PropagateActivating` and test in `MenuBar`. -- Revise documentation to reflect changes. - -For details, refer to the filed issue in the Terminal.Gui repository. \ No newline at end of file + else if (Key.IsValid && Command != Command.NotBound) + { + App?.Keyboard.InvokeCommandsBoundToKey (Key); + } +} +``` + +`OnAccepted` follows the same pattern: it invokes `Action`, then dispatches to `TargetView` or application-bound keys. diff --git a/docfx/docs/events.md b/docfx/docs/events.md index 291a3cdb22..e8ba83a1b1 100644 --- a/docfx/docs/events.md +++ b/docfx/docs/events.md @@ -74,10 +74,10 @@ public class MyDataView : View public event EventHandler>? DataSourceChanged; // Virtual method for subclasses (pre-change) - returns true to cancel - protected virtual bool OnDataSourceChanging(ValueChangingEventArgs args) => false; + protected virtual bool OnDataSourceChanging (ValueChangingEventArgs args) => false; // Virtual method for subclasses (post-change) - void, cannot cancel - protected virtual void OnDataSourceChanged(ValueChangedEventArgs args) { } + protected virtual void OnDataSourceChanged (ValueChangedEventArgs args) { } } ``` @@ -131,13 +131,14 @@ myDataView.DataSourceChanged += (sender, args) => // Subclass (virtual method override) public class MyCustomDataView : MyDataView { - protected override bool OnDataSourceChanging(ValueChangingEventArgs args) + protected override bool OnDataSourceChanging (ValueChangingEventArgs args) { // Validate new data source if (args.NewValue is ICollection { Count: 0 }) { return true; // Cancel - don't allow empty collections } + return false; } } @@ -153,6 +154,10 @@ public class MyCustomDataView : MyDataView #### Option A: Manual CWP Implementation +> [!NOTE] +> This recipe uses `CancelEventArgs.Cancel` for standalone workflows that are not part of the command system. +> For command-related events (e.g., `Accepting`, `Activating`), use `CommandEventArgs.Handled` instead (see [Command Deep Dive](command.md)). + ```csharp public class MyProcessor : View { @@ -160,35 +165,37 @@ public class MyProcessor : View public event EventHandler? Processing; // Virtual method for subclasses - protected virtual bool OnProcessing(CancelEventArgs args) + protected virtual bool OnProcessing (CancelEventArgs args) { return false; // Return true to cancel } // Internal method that implements CWP - public bool Process() + public bool Process () { CancelEventArgs args = new (); // Step 1: Call virtual method (subclass gets first chance) - if (OnProcessing(args) || args.Cancel) + if (OnProcessing (args) || args.Cancel) { return false; // Cancelled } // Step 2: Raise event (external subscribers get a chance) - Processing?.Invoke(this, args); + Processing?.Invoke (this, args); + if (args.Cancel) { return false; // Cancelled } // Step 3: Execute default behavior - DoProcessing(); + DoProcessing (); + return true; } - private void DoProcessing() + private void DoProcessing () { // Default processing logic } @@ -202,28 +209,28 @@ public class MyProcessor : View { public event EventHandler>? Processing; - protected virtual bool OnProcessing(ResultEventArgs args) + protected virtual bool OnProcessing (ResultEventArgs args) { return false; // Return true to cancel } - public bool? Process() + public bool? Process () { ResultEventArgs args = new (); - return CWPWorkflowHelper.Execute( + return CWPWorkflowHelper.Execute ( onMethod: OnProcessing, eventHandler: Processing, args: args, defaultAction: () => { // Default processing logic - DoProcessing(); + DoProcessing (); args.Result = true; }); } - private void DoProcessing() + private void DoProcessing () { // Processing logic } @@ -247,36 +254,36 @@ public class MyView : View public event EventHandler? SelectionMade; // Virtual method for subclasses - NO-OP by default - protected virtual void OnSelectionMade() + protected virtual void OnSelectionMade () { // Does nothing by default. // Subclasses override this to react to the selection. } // Internal method that raises the notification - private void RaiseSelectionMade() + private void RaiseSelectionMade () { // 1. Call virtual method first (subclasses get priority) - OnSelectionMade(); + OnSelectionMade (); // 2. Raise event (external subscribers) - SelectionMade?.Invoke(this, EventArgs.Empty); + SelectionMade?.Invoke (this, EventArgs.Empty); } - private void HandleSelection() + private void HandleSelection () { // ... selection logic ... - RaiseSelectionMade(); + RaiseSelectionMade (); } } // Subclass example public class MyCustomView : MyView { - protected override void OnSelectionMade() + protected override void OnSelectionMade () { // React to selection in subclass - UpdateStatusBar(); + UpdateStatusBar (); } } ``` @@ -302,7 +309,7 @@ public class ViewModel : INotifyPropertyChanged if (_name != value) { _name = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); + PropertyChanged?.Invoke (this, new PropertyChangedEventArgs (nameof (Name))); } } } @@ -344,43 +351,53 @@ When handling command events, rich context is available through `ICommandContext ```csharp public interface ICommandContext { - Command Command { get; set; } // The command being invoked - View? Source { get; set; } // The view that first invoked the command - IInputBinding? Binding { get; } // The binding that triggered the command + Command Command { get; set; } // The command being invoked + WeakReference? Source { get; set; } // Weak ref to the originating view + ICommandBinding? Binding { get; } // The binding that triggered the command + bool IsBubblingDown { get; } // True when dispatched downward via BubbleDown + bool IsBubblingUp { get; } // True when bubbling up to a SuperView } ``` +> [!NOTE] +> `Source` is a `WeakReference` to prevent memory leaks during command propagation. +> Use `ctx.Source?.TryGetTarget (out View? view)` to safely access the source view. + ### Binding Types and Pattern Matching Terminal.Gui provides three binding types. Use pattern matching to access binding-specific details: ```csharp -public override bool OnAccepting(object? sender, CommandEventArgs e) +protected override bool OnAccepting (CommandEventArgs args) { // Determine what triggered the command - switch (e.Context?.Binding) + switch (args.Context?.Binding) { case KeyBinding kb: // Keyboard-triggered - Key key = kb.Key; + Key? key = kb.Key; + break; case MouseBinding mb: // Mouse-triggered Point position = mb.MouseEvent.Position; MouseFlags flags = mb.MouseEvent.Flags; + break; - case InputBinding ib: + case CommandBinding ib: // Programmatic invocation object? data = ib.Data; + break; } + return false; } // Or use property patterns for concise access: -if (e.Context?.Binding is MouseBinding { MouseEvent: { } mouse }) +if (args.Context?.Binding is MouseBinding { MouseEvent: { } mouse }) { Point position = mouse.Position; } @@ -392,24 +409,147 @@ Understanding the difference between sources is important during event propagati | Property | Description | Changes During Propagation? | |----------|-------------|----------------------------| -| `ICommandContext.Source` | View that first invoked the command | No (constant) | -| `IInputBinding.Source` | View where binding was defined | No (constant) | +| `ICommandContext.Source` | `WeakReference` to the view that first invoked the command | No (constant) | +| `ICommandBinding.Source` | View where binding was defined | No (constant) | | `sender` (event parameter) | View currently raising the event | **Yes** | ```csharp -public override bool OnAccepting(object? sender, CommandEventArgs e) +protected override bool OnAccepting (CommandEventArgs args) { - // sender = current view raising the event (changes as it bubbles) - // e.Context?.Source = original view that started the command (constant) + // In the Accepting event handler, `this` is the view raising the event (changes as it bubbles). + // args.Context?.Source is a WeakReference to the original view that started the command (constant). + + View? originalView = null; + args.Context?.Source?.TryGetTarget (out originalView); - View? currentView = sender as View; - View? originalView = e.Context?.Source; - ... + return false; } ``` --- +### Value Access: IValue and IValue<T> + +Views that represent user-selectable values implement the `IValue` interface, providing standardized access to their primary value. This enables generic programming, value propagation during command handling, and consistent event patterns. + +#### IValue Interfaces + +```csharp +/// +/// Non-generic interface for accessing a View's value as a boxed object. +/// Used by command propagation to carry values without knowing the generic type. +/// +public interface IValue +{ + /// Gets the value as a boxed object. + object? GetValue (); +} + +/// +/// Interface for Views that provide a strongly-typed value. +/// +public interface IValue : IValue +{ + /// Gets or sets the value. + TValue? Value { get; set; } + + /// + /// Raised when is about to change. + /// Set to cancel. + /// + event EventHandler>? ValueChanging; + + /// + /// Raised when has changed. + /// + event EventHandler>? ValueChanged; + + /// + object? IValue.GetValue () => Value; +} +``` + +#### Views Implementing IValue<T> + +| View | Value Type | Meaning | +|------|-----------|---------| +| `CheckBox` | `CheckState` | Current checked state (Unchecked, Checked, CheckedMark) | +| `TextField` | `string` | Text content | +| `TextView` | `string` | Full text content | +| `DateField` | `DateTime?` | Selected date and time | +| `TimeField` | `TimeSpan` | Selected time | +| `ScrollBar` | `int` | Current scroll position | +| `Slider` | `int` | Current slider value | +| `ListView` | `int` | Selected item index | +| `OptionSelector` | `int` | Selected option index | +| `RadioGroup` | `int` | Selected radio button index | +| `LineCanvas` | `List` | Collection of lines | +| `CharMap` | `Rune` | Selected character | + +#### Using IValue in Handlers + +**Example: Accessing value during command propagation:** + +```csharp +menuBar.Accepting += (_, args) => +{ + // Access the value from the originating view via WeakReference + View? sourceView = null; + args.Context?.Source?.TryGetTarget (out sourceView); + + if (sourceView is IValue valueView) + { + object? value = valueView.GetValue (); + + // Pattern match on value type + if (value is CheckState checkState) + { + _autoSave = checkState == CheckState.Checked; + } + else if (value is int optionIndex) + { + _selectedOption = optionIndex; + } + } +}; +``` + +**Example: Generic value handling:** + +```csharp +void ProcessValueView (IValue valueView) +{ + T? currentValue = valueView.Value; + + valueView.ValueChanged += (sender, args) => + { + T? newValue = args.NewValue; + T? oldValue = args.OldValue; + // Handle value change + }; +} +``` + +#### Value vs Legacy Properties + +Many Views have legacy properties (e.g., `TextField.Text`, `CheckBox.CheckedState`) that predate the `IValue` interface. The `Value` property typically maps to these legacy properties: + +```csharp +// CheckBox example +public class CheckBox : View, IValue +{ + public CheckState CheckedState { get; set; } // Legacy property + + public CheckState? Value // IValue property + { + get => CheckedState; + set => CheckedState = value ?? CheckState.None; + } +} +``` + +**Best practice:** Use the `Value` property for new code, as it provides consistent access patterns across all value-bearing Views. + ## Best Practices ### Naming Conventions @@ -431,30 +571,31 @@ public override bool OnAccepting(object? sender, CommandEventArgs e) 5. **Unsubscribe in Dispose** to prevent memory leaks ```csharp -// ✅ CORRECT order: Virtual → Event → Default behavior -protected void DoSomething() +// CORRECT order: Virtual -> Event -> Default behavior +protected void DoSomething () { SomeEventArgs args = new (); // 1. Virtual method first - if (OnDoingSomething(args)) + if (OnDoingSomething (args)) { return; // Cancelled by subclass } // 2. Event second - DoingSomething?.Invoke(this, args); + DoingSomething?.Invoke (this, args); + if (args.Handled) { return; // Cancelled by subscriber } - // 3. Default behavior - ExecuteDefaultBehavior(); + // 3. Default behavior + ExecuteDefaultBehavior (); // 4. Post-change notification (if applicable) - OnDidSomething(new DidSomethingEventArgs(...)); - DidSomething?.Invoke(this, new DidSomethingEventArgs(...)); + OnDidSomething (new DidSomethingEventArgs ()); + DidSomething?.Invoke (this, new DidSomethingEventArgs ()); } ``` @@ -465,17 +606,18 @@ protected void DoSomething() ### 1. Memory Leaks from Unsubscribed Events ```csharp -// ❌ BAD: Potential memory leak +// BAD: Potential memory leak view.Accepting += OnAccepting; -// ✅ GOOD: Unsubscribe in Dispose -protected override void Dispose(bool disposing) +// GOOD: Unsubscribe in Dispose +protected override void Dispose (bool disposing) { if (disposing) { view.Accepting -= OnAccepting; } - base.Dispose(disposing); + + base.Dispose (disposing); } ``` @@ -492,28 +634,28 @@ args.Handled = true; ### 3. Wrong Order of Virtual Method and Event ```csharp -// ❌ WRONG: Event raised before virtual method -DoingSomething?.Invoke(this, args); -if (OnDoingSomething(args)) { return; } // Too late! +// WRONG: Event raised before virtual method +DoingSomething?.Invoke (this, args); +if (OnDoingSomething (args)) { return; } // Too late! -// ✅ CORRECT: Virtual method first, then event -if (OnDoingSomething(args)) { return; } -DoingSomething?.Invoke(this, args); +// CORRECT: Virtual method first, then event +if (OnDoingSomething (args)) { return; } +DoingSomething?.Invoke (this, args); ``` ### 4. Forgetting to Check Both Cancellation Points ```csharp -// ❌ WRONG: Only checking virtual method -if (OnDoingSomething(args)) { return; } -DoingSomething?.Invoke(this, args); -ExecuteDefault(); // Bug: Event subscribers can't cancel! - -// ✅ CORRECT: Check both virtual method AND event args -if (OnDoingSomething(args) || args.Handled) { return; } -DoingSomething?.Invoke(this, args); +// WRONG: Only checking virtual method +if (OnDoingSomething (args)) { return; } +DoingSomething?.Invoke (this, args); +ExecuteDefault (); // Bug: Event subscribers can't cancel! + +// CORRECT: Check both virtual method AND event args +if (OnDoingSomething (args) || args.Handled) { return; } +DoingSomething?.Invoke (this, args); if (args.Handled) { return; } -ExecuteDefault(); +ExecuteDefault (); ``` --- diff --git a/docfx/docs/shortcut.md b/docfx/docs/shortcut.md new file mode 100644 index 0000000000..3fae1cd537 --- /dev/null +++ b/docfx/docs/shortcut.md @@ -0,0 +1,424 @@ +# Deep Dive into Shortcut + +## See Also + +* [Command Deep Dive](command.md) +* [Cancellable Work Pattern](cancellable-work-pattern.md) +* [Events](events.md) +* [Mouse Deep Dive](mouse.md) + +## From the User's Perspective + +A `Shortcut` is a single, clickable row in a menu, toolbar, or status bar. It shows three things: + +``` +┌─────────────────────────────────────────────────┐ +│ [CommandView] [HelpView] [KeyView] │ +│ _Open File Opens a file Ctrl+O │ +└─────────────────────────────────────────────────┘ +``` + +**What the user expects:** + +1. **Clicking anywhere on the Shortcut** activates it: toggles a checkbox, invokes the action, etc. +2. **Pressing the keyboard shortcut** (shown in KeyView, e.g., Ctrl+O) does the same thing, regardless of focus. +3. **Pressing the HotKey** (the underlined letter in CommandView, e.g., `O` in `_Open`) does the same thing. +4. **Pressing Space** while the Shortcut has focus activates it. +5. **Pressing Enter** while the Shortcut has focus accepts it (confirms/executes). +6. **Every interaction produces exactly one state change.** Clicking a Shortcut with a CheckBox toggles it once, not twice. + +### CommandView Variants + +The CommandView can be any View. Common configurations: + +| CommandView Type | Activate Behavior | Accept Behavior | +|-----------------|-------------------|-----------------| +| **View** (default) | Invokes `Action` | Invokes `Action` | +| **CheckBox** | Toggles check state, invokes `Action` | Invokes `Action` (no toggle) | +| **Button** | Invokes `Action` | Invokes Button's Accept | +| **ColorPicker16** | Opens color dialog or cycles | Invokes `Action` | + +### Key Principle: Single Responsibility + +From the user's perspective, a Shortcut is **one control**. The fact that it contains three SubViews (CommandView, HelpView, KeyView) is an implementation detail. Whether the user clicks on the command text, the help text, the key text, or the gap between them, the result is the same. + +## Design + +### Commands and Their Semantics + +Shortcut participates in the standard Command system with three commands: + +| Command | Trigger | What It Does | +|---------|---------|-------------| +| **`Command.Activate`** | Space, click, `Shortcut.Key` press | Changes state (e.g., toggles CheckBox) and invokes `Action` | +| **`Command.Accept`** | Enter, double-click | Confirms/executes without state change; invokes `Action` | +| **`Command.HotKey`** | HotKey letter, `Shortcut.Key` | Sets focus, then invokes `Command.Activate` | + +### CommandsToBubbleUp + +`Shortcut` sets `CommandsToBubbleUp = [Command.Activate, Command.Accept]` in its constructor. This enables commands from SubViews (like CommandView) to bubble up to the Shortcut for centralized handling. + +### The BubbleDown Pattern + +Because Shortcut is a composite view, it must coordinate command flow between itself and its CommandView. The core pattern is: + +1. **User interacts** with the Shortcut (clicks, presses key, etc.) +2. The command reaches `Shortcut.OnActivating` or `Shortcut.OnAccepting` +3. Shortcut **forwards the command down** to CommandView via `BubbleDown` +4. CommandView processes the command (e.g., CheckBox toggles) +5. `BubbleDown` suppresses re-bubbling (via `IsBubblingDown = true`), preventing infinite loops +6. Shortcut raises its own events and invokes `Action` + +### When to BubbleDown (and When Not To) + +The critical design decision is **when** Shortcut should forward a command to CommandView. The rule is: + +``` +BubbleDown to CommandView ONLY when: + - The command has a Binding (i.e., it came from user interaction, not programmatic invoke) + - AND the Binding.Source is NOT the CommandView (i.e., it didn't already come from CommandView) +``` + +This produces three paths: + +| Origin | Has Binding? | Binding.Source | BubbleDown? | Reason | +|--------|-------------|---------------|-------------|--------| +| CommandView click/key | Yes | CommandView | **No** | CommandView already processed it; it bubbled up via `CommandsToBubbleUp` | +| Shortcut/HelpView/KeyView click, or Shortcut.Key press | Yes | Shortcut (or HelpView/KeyView) | **Yes** | CommandView hasn't seen this command yet | +| Programmatic `InvokeCommand` | No (null) | N/A | **No** | No user interaction to forward | + +### Implementation + +```csharp +protected override bool OnActivating (CommandEventArgs args) +{ + if (base.OnActivating (args)) + { + return true; + } + + // Only bubble down when binding exists and source is not CommandView + if (args.Context?.Binding is { Source: { } source } && source != CommandView) + { + return BubbleDown (CommandView, args.Context) is null; + } + + return false; +} +``` + +### OnAccepting Behavior + +When `Command.Accept` is invoked on a Shortcut: + +1. `OnAccepting` is called +2. If the command came from a user binding (not from CommandView), it forwards `Accept` to CommandView via `BubbleDown` +3. `Action` is invoked via `OnAccepted` + +**Accept does NOT invoke Activate.** These are separate command paths. Accept is for confirmation/execution; Activate is for state change. + +```csharp +protected override bool OnAccepting (CommandEventArgs args) +{ + if (base.OnAccepting (args)) + { + return true; + } + + // Same BubbleDown logic as OnActivating + if (args.Context?.Binding is { Source: { } source } && source != CommandView) + { + return BubbleDown (CommandView, args.Context) is null; + } + + return false; +} + +protected override void OnAccepted (ICommandContext? ctx) => Action?.Invoke (); +``` + +### OnActivated Behavior + +After activation completes successfully (not cancelled), `OnActivated` invokes `Action`: + +```csharp +protected override void OnActivated (ICommandContext? ctx) +{ + base.OnActivated (ctx); + Action?.Invoke (); +} +``` + +### CommandView_Activated — Deferred Activation + +Shortcut subscribes to `CommandView.Activated`. When a command bubbles up from within CommandView and reaches Shortcut's `HandleActivate` with `IsBubblingUp = true`, Shortcut defers its own `RaiseActivated` until `CommandView.Activated` fires. This ensures the CommandView completes its state change (e.g., CheckBox toggles) before Shortcut raises its own Activated event. + +`CommandView_Activated` handles two cases: +1. **Deferred path**: `HandleActivate` set `_activationBubbledUp = true` — fires deferred `RaiseActivated` with the saved context. +2. **Consumed-by-CommandView path**: The CommandView (e.g., `OptionSelector`, `FlagSelector`) consumed the bubble in its `OnActivating` (before it reached Shortcut's `HandleActivate`), then called `RaiseActivated` directly. In this case, the event context has `IsBubblingUp = true` — Shortcut's `CommandView_Activated` detects this and fires `RaiseActivated`. + +## Detailed Command Flows + +### Flow 1: Click on CommandView + +When the user clicks on the CommandView area: + +``` +User clicks CommandView + → CommandView.InvokeCommand(Activate) [from mouse binding] + → CommandView.RaiseActivating() + → CommandView.Activating event fires + → TryBubbleUpToSuperView (Shortcut has Activate in CommandsToBubbleUp) + → Shortcut.InvokeCommand(Activate) [with IsBubblingUp=true] + → Shortcut.OnActivating(args) + → args.Context.Binding.Source == CommandView → skip BubbleDown + → return false + → Shortcut.Activating event fires + → CommandView.RaiseActivated() + → CommandView state changes here (e.g., CheckBox toggles) + → Shortcut.RaiseActivated() + → Action?.Invoke() +``` + +**Result:** CommandView activates once. Shortcut events fire. Action invoked. + +### Flow 2: Click on HelpView/KeyView/Shortcut Background + +When the user clicks outside of CommandView but within the Shortcut: + +Because Shortcut has `MouseHighlightStates = MouseState.In`, it intercepts mouse events for its entire area. The click is attributed to the Shortcut itself. + +``` +User clicks on Shortcut (not CommandView) + → Shortcut.InvokeCommand(Activate) [from mouse binding, Source=Shortcut] + → Shortcut.RaiseActivating() + → Shortcut.OnActivating(args) + → args.Context.Binding.Source == Shortcut (not CommandView) → BubbleDown! + → BubbleDown(CommandView, ctx) + → CommandView.InvokeCommand(Activate) [IsBubblingDown=true] + → CommandView.RaiseActivating() + → TryBubbleUpToSuperView: IsBubblingDown=true → skip + → CommandView.RaiseActivated() + → State changes here (e.g., CheckBox toggles) + → Shortcut.Activating event fires + → Shortcut.RaiseActivated() + → Action?.Invoke() +``` + +**Result:** CommandView activates once (via BubbleDown). Shortcut events fire. Action invoked. + +### Flow 3: Shortcut.Key Press (e.g., Ctrl+O) + +``` +User presses Shortcut.Key + → Shortcut.InvokeCommand(HotKey) [from HotKeyBinding, Binding.Source=Shortcut] + → Shortcut.DefaultHotKeyHandler(ctx) + → RaiseHandlingHotKey(ctx) → HandlingHotKey event + → SetFocus() (if CanFocus) + → RaiseHotKeyCommand(ctx) → HotKeyCommand event + → InvokeCommand(Activate, ctx.Binding) [passes original binding through] + → Shortcut.RaiseActivating() + → Shortcut.OnActivating(args) + → args.Context.Binding.Source == Shortcut → BubbleDown! + → BubbleDown(CommandView, ctx) + → CommandView activates (state change) + → Shortcut.Activating event fires + → Shortcut.RaiseActivated() + → Action?.Invoke() +``` + +**Key detail:** `DefaultHotKeyHandler` passes `ctx.Binding` when invoking `Command.Activate`, preserving the binding source so `OnActivating` can detect it was user-initiated and BubbleDown to CommandView. + +### Flow 4: CommandView HotKey Press (e.g., Alt+O for "_Open") + +``` +User presses CommandView's HotKey letter + → CommandView.InvokeCommand(HotKey) [from HotKeyBinding] + → CommandView.DefaultHotKeyHandler(ctx) + → RaiseHandlingHotKey → HandlingHotKey event on CommandView + → SetFocus() (if CanFocus) + → RaiseHotKeyCommand + → InvokeCommand(Activate, ctx.Binding) [Source=CommandView] + → CommandView.RaiseActivating() + → Bubbles up to Shortcut (Activate in CommandsToBubbleUp) + → Shortcut.OnActivating: Binding.Source == CommandView → skip BubbleDown + → CommandView.RaiseActivated() → state changes + → Shortcut.RaiseActivated() → Action?.Invoke() +``` + +### Flow 5: Space Key (Shortcut Focused) + +``` +User presses Space (Shortcut has focus) + → Shortcut.InvokeCommand(Activate) [from KeyBinding, Source=Shortcut] + → Same as Flow 2 (BubbleDown to CommandView) +``` + +### Flow 6: Enter Key (Shortcut Focused) + +``` +User presses Enter (Shortcut has focus) + → Shortcut.InvokeCommand(Accept) [from KeyBinding, Source=Shortcut] + → Shortcut.RaiseAccepting() + → Shortcut.OnAccepting(args) + → Binding.Source == Shortcut → BubbleDown(CommandView, Accept) + → CommandView processes Accept + → Shortcut.Accepting event fires + → Shortcut.RaiseAccepted() + → Action?.Invoke() +``` + +### Flow 7: Programmatic InvokeCommand + +``` +Code calls shortcut.InvokeCommand(Command.Activate) + → Shortcut.RaiseActivating() + → Shortcut.OnActivating(args) + → args.Context.Binding == null → skip BubbleDown + → return false + → Shortcut.Activating event fires + → Shortcut.RaiseActivated() + → Action?.Invoke() +``` + +**Result:** Action invokes, but CommandView does NOT change state. This is by design: programmatic invocations should use `commandView.InvokeCommand(Command.Activate)` directly if they want to change CommandView state. + +## MouseHighlightStates and Event Routing + +`Shortcut` defaults to `MouseHighlightStates = MouseState.In`, which causes it to highlight on mouse hover and intercept mouse events for its entire area. + +### With MouseHighlightStates = MouseState.In (Default) + +- Clicks **anywhere** on the Shortcut are attributed to the **Shortcut** itself +- `Binding.Source` is the Shortcut +- Path: BubbleDown to CommandView (Flow 2) + +### With MouseHighlightStates = MouseState.None + +- Clicks on CommandView are attributed to **CommandView** +- `Binding.Source` is CommandView +- Path: Bubbles up from CommandView, skip BubbleDown (Flow 1) +- Clicks on HelpView/KeyView are attributed to those views, which bubble up to Shortcut + +**Both paths produce the same result:** CommandView activates once, Shortcut events fire, Action invokes. + +## Event Summary + +### Events on Shortcut (for SuperView subscribers) + +| Event | When Fired | Can Cancel? | +|-------|-----------|-------------| +| `HandlingHotKey` | When `Shortcut.Key` is pressed | Yes | +| `Activating` | During activation flow | Yes | +| `Activated` | After successful activation; `Action` invoked | No | +| `Accepting` | When `Command.Accept` invoked | Yes | +| `Accepted` | After successful accept; `Action` invoked | No | + +### Events on CommandView (if subscribed directly) + +| Event | When Fired | Notes | +|-------|-----------|-------| +| `Activating` | When CommandView activates | Fires once per interaction | +| `Activated` | After CommandView activates | State changes here for CheckBox | + +### CheckBox-Specific Events + +| Event | When Fired | +|-------|-----------| +| `CheckedStateChanging` | Before state toggle (cancellable) | +| `CheckedStateChanged` | After state toggle | + +## Action Property + +The `Action` property is invoked in two places: + +1. **`OnActivated`**: After `Command.Activate` completes successfully +2. **`OnAccepted`**: After `Command.Accept` completes successfully + +This means `Action` fires regardless of whether the Shortcut was activated (Space/click) or accepted (Enter). + +## How To + +### Handle Activation Differently Based on Source + +Use `args.Context.TryGetSource()` in the `Activating` event handler to determine whether the user interacted with the CommandView directly or with the Shortcut: + +```csharp +Shortcut shortcut = new () +{ + Key = Key.F9, + HelpText = "Cycles BG Color", + CommandView = bgColor +}; + +shortcut.Activating += (_, args) => +{ + if (args.Context.TryGetSource (out View? source) && source == shortcut.CommandView) + { + // User clicked directly on the CommandView — don't set Handled so + // the CommandView's OnActivated runs (e.g., picks color from mouse position). + return; + } + + // User pressed F9 or clicked elsewhere on the Shortcut — cycle the color. + args.Handled = true; + bgColor.SelectedColor++; +}; +``` + +### Use Shortcut with a CheckBox + +```csharp +Shortcut shortcut = new () +{ + Key = Key.F6, + CommandView = new CheckBox { Text = "Force 16 Colors" } +}; + +// Subscribe to the CheckBox state changes +((CheckBox)shortcut.CommandView).CheckedStateChanged += (_, args) => +{ + bool isChecked = args.CurrentValue == CheckState.Checked; + // React to state change +}; + +// Or subscribe to the Shortcut's Action for simple callbacks +shortcut.Action = () => DoSomething (); +``` + +## Design Rationale + +### Why BubbleDown? + +Without BubbleDown, clicking on the HelpView or KeyView area would not toggle a CheckBox CommandView. BubbleDown ensures that **all** user interactions with the Shortcut reach the CommandView, maintaining the "single control" illusion. + +### Why Check Binding.Source? + +The three-way check (has binding? source is CommandView? programmatic?) prevents: + +1. **Double-processing**: When CommandView raises Activate and it bubbles up to Shortcut, Shortcut should not BubbleDown back to CommandView (infinite loop / double toggle). +2. **Unwanted side effects**: Programmatic `InvokeCommand` on the Shortcut should not automatically change CommandView state - the caller should be explicit. + +### Why Accept Does Not Invoke Activate? + +Accept and Activate are distinct semantic actions: + +- **Activate** = "interact with this control" (toggle, select, change state) +- **Accept** = "confirm/execute" (submit, close menu, run command) + +Conflating them causes confusion in composite views like Menu, where Accept on a MenuItem should execute the command and close the menu, but Activate should just highlight/focus the item. + +### Comparison with SelectorBase/FlagSelector + +`FlagSelector` is another composite view that uses `BubbleDown`, but with intentionally different semantics: + +| | Shortcut | FlagSelector | +|--|---------|-------------| +| **Check** | `Binding.Source` | `Context.Source` (via `TryGetSource`) | +| **Programmatic invoke** | Skip BubbleDown | BubbleDown to focused checkbox | +| **From SubView** | Skip (already processed) | Skip (already processed) | +| **From self** | BubbleDown to CommandView | BubbleDown to focused checkbox | + +**Why the difference?** FlagSelector is a container for N equivalent checkboxes; programmatic `InvokeCommand(Activate)` naturally means "toggle the focused item." Shortcut is a composite with one CommandView; programmatic invoke should raise Shortcut's own events/Action without implicitly changing CommandView state. Callers who want to change CommandView state should call `commandView.InvokeCommand(Activate)` directly. + +`OptionSelector` takes a different approach entirely: it subscribes to checkbox `Activating` events and manually calls `InvokeCommand(Command.Activate, args.Context)` on itself, bypassing the BubbleDown pattern. This works but has a TODO noting it shouldn't be needed. diff --git a/plans/change-default-activation-to-released.md b/plans/change-default-activation-to-released.md deleted file mode 100644 index 8319d2becb..0000000000 --- a/plans/change-default-activation-to-released.md +++ /dev/null @@ -1,553 +0,0 @@ -# Plan: Change View Default Activation to Released - -**Status:** Draft -**Created:** 2026-02-03 -**Author:** Claude Opus 4.5 -**Related Issue:** #4674 - ---- - -## Executive Summary - -Change View's default mouse activation behavior from **LeftButtonPressed** to **LeftButtonReleased** to align with industry standards across all major UI frameworks (Windows WPF/WinForms, macOS Cocoa, Web HTML, GTK4, Qt). - -**Key Benefits:** -- Aligns with universal GUI conventions (40+ years of established UX patterns) -- Enables cancellation of accidental clicks (press, drag away, release) -- Matches user expectations across all platforms -- Provides better visual feedback before commitment - ---- - -## Research Summary - -All major UI frameworks activate on **release**: - -| Framework | Activation Event | Cancellation Support | -|-----------|------------------|----------------------| -| Web (HTML) | click (mousedown + mouseup) | ✅ Yes | -| Windows (WPF/WinForms) | MouseUp | ✅ Yes | -| macOS (Cocoa) | Mouse release | ✅ Yes | -| GTK4 | clicked (press + release) | ✅ Yes | -| Qt | clicked() signal | ✅ Yes | - -**Industry Pattern:** "Activate on release" allows users to: -1. Press button → see visual feedback -2. Realize mistake → drag away -3. Release outside → cancel action without triggering - -**Sources:** -- [Element: mouseup event - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseup_event) -- [GTK4 Button Class](https://docs.gtk.org/gtk4/class.Button.html) -- [QAbstractButton - Qt](https://doc.qt.io/qt-6/qabstractbutton.html) - ---- - -## Current State Analysis - -### Location -`Terminal.Gui/ViewBase/Mouse/View.Mouse.cs` lines 13-23 - -### Current Default Bindings -```csharp -internal void SetupMouse () -{ - MouseBindings.Clear (); - - // Current: Activate on PRESSED - MouseBindings.Add (MouseFlags.LeftButtonPressed, Command.Activate); - MouseBindings.Add (MouseFlags.LeftButtonPressed | MouseFlags.Ctrl, Command.Context); - - // Released bindings added/removed dynamically based on MouseHoldRepeat -} -``` - -### Why This Matters -- **No cancellation:** Users cannot abort accidental presses -- **Inconsistent UX:** Differs from every other GUI framework users know -- **Unexpected behavior:** Trained muscle memory from other apps doesn't work - ---- - -## Implementation Plan - -### Phase 1: Change Default Binding - -**File:** `Terminal.Gui/ViewBase/Mouse/View.Mouse.cs` - -**Change:** Lines 13-23 in `SetupMouse()` - -```csharp -// BEFORE (current) -MouseBindings.Add (MouseFlags.LeftButtonPressed, Command.Activate); -MouseBindings.Add (MouseFlags.LeftButtonPressed | MouseFlags.Ctrl, Command.Context); - -// AFTER (proposed) -MouseBindings.Add (MouseFlags.LeftButtonReleased, Command.Activate); -MouseBindings.Add (MouseFlags.LeftButtonReleased | MouseFlags.Ctrl, Command.Context); -``` - -**Rationale:** -- Minimal change (2 lines) -- Leverages existing Released event infrastructure (already fixed in #4674) -- Auto-grab behavior already handles press/release lifecycle correctly - ---- - -### Phase 2: Update Tests - -#### 2.1 Update Existing Tests - -**Files to audit:** -- `Tests/UnitTests/ViewBase/Mouse/*.cs` -- `Tests/UnitTestsParallelizable/ViewBase/Mouse/*.cs` - -**Actions:** -1. Identify tests that depend on `LeftButtonPressed → Command.Activate` -2. Update to expect `LeftButtonReleased → Command.Activate` -3. Ensure tests follow press → release sequence (not just single event) - -#### 2.2 Add New Tests - -**File:** `Tests/UnitTestsParallelizable/ViewBase/Mouse/DefaultActivationTests.cs` (new) - -**Test coverage:** -- ✅ Default activation fires on Released, not Pressed -- ✅ Cancellation: Press inside, drag outside, release → no activation -- ✅ Normal flow: Press inside, release inside → activation fires -- ✅ Multiple views: Press on view1, release on view2 → only view1 processes -- ✅ Modifier keys: Ctrl+Released invokes Command.Context -- ✅ AutoGrab lifecycle: Grab on press, ungrab on release -- ✅ Backward compatibility: Custom Pressed bindings still work - -**Example test:** -```csharp -// Claude - Opus 4.5 -[Fact] -public void DefaultActivation_FiresOnRelease_NotOnPress () -{ - // Arrange - VirtualTimeProvider time = new (); - using IApplication app = Application.Create (time); - app.Init (DriverRegistry.Names.ANSI); - IRunnable runnable = new Runnable (); - - View view = new () { Width = 10, Height = 10 }; - (runnable as View)?.Add (view); - app.Begin (runnable); - - var activatedOnPress = false; - var activatedOnRelease = false; - - view.Activating += (_, _) => - { - // Check which event triggered this - if (app.Mouse.LastMouseEvent?.Flags.HasFlag (MouseFlags.LeftButtonPressed) ?? false) - activatedOnPress = true; - if (app.Mouse.LastMouseEvent?.Flags.HasFlag (MouseFlags.LeftButtonReleased) ?? false) - activatedOnRelease = true; - }; - - // Act - app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new (0, 0) }); - app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new (0, 0) }); - - // Assert - Assert.False (activatedOnPress, "Should NOT activate on press"); - Assert.True (activatedOnRelease, "Should activate on release"); - - (runnable as View)?.Dispose (); -} - -[Fact] -public void DefaultActivation_Cancellation_DragAwayBeforeRelease () -{ - // Arrange - VirtualTimeProvider time = new (); - using IApplication app = Application.Create (time); - app.Init (DriverRegistry.Names.ANSI); - IRunnable runnable = new Runnable (); - - View view = new () { X = 0, Y = 0, Width = 10, Height = 10, MouseHighlightStates = MouseState.Pressed }; - (runnable as View)?.Add (view); - app.Begin (runnable); - - var activated = false; - view.Activating += (_, _) => activated = true; - - // Act - Press inside, move outside, release outside - app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new (5, 5) }); - Assert.True (app.Mouse.IsGrabbed (view), "View should grab mouse on press"); - - app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new (50, 50) }); // Outside - - // Assert - Assert.False (activated, "Should NOT activate when released outside"); - Assert.False (app.Mouse.IsGrabbed (view), "Mouse should be ungrabbed after release"); - - (runnable as View)?.Dispose (); -} -``` - ---- - -### Phase 3: Update Examples - -**File:** `Examples/UICatalog/Scenarios/MouseTester.cs` - -**Actions:** -1. Update comments to reflect new default behavior -2. Add visual demonstration of cancellation behavior -3. Show difference between Pressed, Released, and Clicked bindings - -**Optional enhancement:** -Add a demo section showing: -- Default behavior: "Click (release) to activate" -- Custom Pressed binding: "Press to activate (instant feedback)" -- Comparison side-by-side - ---- - -### Phase 4: Documentation Updates - -#### 4.1 API Documentation - -**File:** `Terminal.Gui/ViewBase/Mouse/View.Mouse.cs` - -Update XML comments in `SetupMouse()`: - -```csharp -/// -/// Initializes the default mouse bindings for this View. -/// -/// -/// Default bindings: -/// -/// - Standard activation (aligns with industry conventions) -/// + Ctrl → - Context menu -/// -/// -/// Views activate on button release (not press) to allow cancellation: press the button, -/// move cursor away, then release to abort the action without triggering it. -/// This matches the behavior of all major GUI frameworks (Windows, macOS, Web, GTK, Qt). -/// -/// -/// To customize activation behavior, use to add bindings for -/// (immediate activation) or -/// (full click cycle required). -/// -/// -``` - -#### 4.2 Update command.md - -**File:** `docfx/docs/command.md` - -**Change 1: Update Line 47 (Command System Summary table)** - -```markdown - -| **Mouse → Command Pipeline** | See [Mouse Pipeline](mouse.md#complete-mouse-event-pipeline)
**Current:** `LeftButtonClicked` → `Activate`
**Recommended:** `LeftButtonClicked` → `Activate` (first click)
`LeftButtonDoubleClicked` → `Accept` (framework-provided) | - - -| **Mouse → Command Pipeline** | See [Mouse Pipeline](mouse.md#complete-mouse-event-pipeline)
**Default:** `LeftButtonReleased` → `Activate` (aligns with industry standards - allows cancellation)
**Alternative:** `LeftButtonPressed` → `Activate` (immediate feedback, no cancellation)
`LeftButtonDoubleClicked` → `Accept` (framework-provided) | -``` - -**Change 2: Update View Command Behaviors Table (Lines 56-86)** - -Update the **View** (base) row in the table: - -```markdown - -| **View** (base) | `Command.Activate` (default) | `Command.Accept` (default) | `Command.HotKey` (default) | Base OnMouseEvent (updates MouseState) | Base OnMouseEvent (updates MouseState) | Not bound by default | Not bound by default | - - -| **View** (base) | `Command.Activate` (default) | `Command.Accept` (default) | `Command.HotKey` (default) | Base OnMouseEvent (updates MouseState) | `Command.Activate` (default) | Not bound by default | Not bound by default | -``` - -Explanation: The "Released" column (5th column) should show `Command.Activate` (default) instead of "Base OnMouseEvent (updates MouseState)" - -**Change 3: Add Note About Cancellation Behavior** - -Add to the "Notes on Command Behaviors" section (after line 130): - -```markdown -11. **Default Activation on Release**: The base `View` class binds `LeftButtonReleased` to `Command.Activate`, following industry-standard GUI conventions. This allows users to: - - Press the button → See visual feedback (MouseState.Pressed) - - Drag away → Realize mistake - - Release outside → Cancel action without triggering - - This matches behavior in Windows (WPF/WinForms), macOS (Cocoa), Web (HTML click), GTK4, and Qt. To activate on press instead (immediate feedback, no cancellation), replace the binding: - ```csharp - view.MouseBindings.ReplaceCommands (MouseFlags.LeftButtonPressed, Command.Activate); - view.MouseBindings.Remove (MouseFlags.LeftButtonReleased); - ``` -``` - -#### 4.3 Conceptual Documentation (Optional) - -**File:** `docfx/docs/mouse.md` (create if doesn't exist) - -Add section: - -```markdown -## Default Mouse Activation Behavior - -Terminal.Gui follows industry-standard GUI conventions for mouse activation: - -### Activation on Release (Default) - -By default, views activate when the mouse button is **released** (not pressed). This allows users to: - -1. **Press** the button → View provides visual feedback (highlight, pressed state) -2. **Drag away** (optional) → User realizes this wasn't the intended action -3. **Release outside** → Action is cancelled, nothing happens - -This "release to commit" pattern matches all major GUI frameworks: -- Windows (WPF, WinForms) -- macOS (Cocoa/AppKit) -- Web browsers (HTML click events) -- GTK4 and Qt - -### Customizing Activation - -To change when a view activates, modify its `MouseBindings`: - -```csharp -// Activate immediately on press (instant feedback, no cancellation) -view.MouseBindings.Clear (); -view.MouseBindings.Add (MouseFlags.LeftButtonPressed, Command.Activate); - -// Activate on full click cycle (press AND release on same view) -view.MouseBindings.Clear (); -view.MouseBindings.Add (MouseFlags.LeftButtonClicked, Command.Activate); - -// Activate on release (default - explicit example) -view.MouseBindings.Clear (); -view.MouseBindings.Add (MouseFlags.LeftButtonReleased, Command.Activate); -``` - -### Why Release Instead of Clicked? - -Terminal.Gui uses `LeftButtonReleased` (not `LeftButtonClicked`) as the default because: - -- **Matches Windows conventions:** Win32 WM_LBUTTONUP, not WM_LBUTTONDBLCLK -- **Simpler mental model:** One event (release) instead of lifecycle (press → release → clicked) -- **Flexible:** Released events fire regardless of click count (single/double/triple) -- **Performance:** No click detection delay - -The `Clicked` event remains available for use cases requiring full click cycle validation. -``` - -#### 4.3 Migration Guide - -**File:** `docfx/docs/migration-v2.md` (or create `docfx/docs/breaking-changes-v2-alpha.md`) - -Add section: - -```markdown -## Mouse Activation Changed from Pressed to Released - -**Breaking Change:** Default mouse activation changed from `LeftButtonPressed` to `LeftButtonReleased`. - -### What Changed - -| Version | Default Binding | Behavior | -|---------|----------------|----------| -| v2 Alpha (before) | `LeftButtonPressed → Command.Activate` | Activates immediately on press | -| v2 Alpha (after) | `LeftButtonReleased → Command.Activate` | Activates on release (cancellable) | - -### Migration - -If your application depends on immediate activation (press, not release): - -```csharp -// Restore old behavior (activate on press) -view.MouseBindings.ReplaceCommands (MouseFlags.LeftButtonPressed, Command.Activate); -view.MouseBindings.Remove (MouseFlags.LeftButtonReleased); -``` - -### Why This Change? - -To align with industry-standard GUI conventions across all major frameworks (Windows, macOS, Web, GTK, Qt), -which activate on release to allow cancellation of accidental clicks. -``` - ---- - -## Testing Strategy - -### Automated Tests - -1. **Unit tests** (parallelizable): - - Default binding is Released, not Pressed - - Cancellation behavior (press inside, release outside) - - AutoGrab lifecycle (grab on press, ungrab on release) - - Custom Pressed bindings still work - - Modifier keys with Released (Ctrl+Released) - -2. **Integration tests**: - - Button click behavior - - Dialog button activation - - Menu item selection - - All core widgets maintain expected behavior - -3. **Regression tests**: - - Run full test suite (UnitTests + UnitTestsParallelizable) - - Ensure no existing tests break (or fix them appropriately) - -### Manual Testing - -**Test Plan:** - -1. **UICatalog MouseTester scenario:** - - Verify default activation on release - - Test cancellation (press, drag out, release) - - Test different MouseHighlightStates - -2. **Core widgets:** - - Button click behavior - - CheckBox toggle - - RadioGroup selection - - ListView item selection - - Dialog button activation - -3. **Edge cases:** - - Multiple views overlapping - - Modal dialogs - - Disabled views - - Views with custom bindings - ---- - -## Migration Considerations - -### Backward Compatibility - -**Breaking Change:** This IS a breaking change in default behavior. - -**Mitigation:** -- Document clearly in release notes -- Provide migration code snippet (restore old behavior) -- Version: v2 is still Alpha, breaking changes expected - -### User Impact Assessment - -**Low Risk:** -- v2 is still Alpha (not stable release) -- New behavior matches user expectations from other apps -- Change aligns with industry standards -- Easy to revert for specific views if needed - -**Potential Issues:** -1. **Automated tests in user code:** May expect Pressed behavior - - **Solution:** Update tests or restore old binding -2. **Muscle memory during development:** Developers used to Pressed - - **Solution:** Quick adaptation, new behavior is more intuitive -3. **Custom controls relying on default:** Rare, but possible - - **Solution:** Explicit binding in custom control constructor - ---- - -## Risks and Mitigations - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Breaks existing v2 Alpha apps | Medium | Medium | Document migration path, provide code snippet | -| Test suite failures | High | Low | Update tests to match new behavior | -| User confusion during transition | Low | Low | Clear documentation, matches industry standards | -| Performance regression | Very Low | Low | No new logic, just changed binding flag | -| Introduces new bugs | Low | Medium | Comprehensive testing, leverage existing Released infrastructure | - ---- - -## Implementation Checklist - -### Code Changes -- [ ] Update `SetupMouse()` in `View.Mouse.cs` (2 lines changed) -- [ ] Add `DefaultActivationTests.cs` with comprehensive coverage -- [ ] Update existing tests that depend on Pressed activation -- [ ] Run full test suite (UnitTests + UnitTestsParallelizable) -- [ ] Update `MouseTester.cs` example with new behavior demo - -### Documentation -- [ ] Update XML comments in `View.Mouse.cs` -- [ ] Update `command.md` with new default behavior and table changes -- [ ] Create/update `docfx/docs/mouse.md` with activation section (optional) -- [ ] Add migration guide to `docfx/docs/migration-v2.md` -- [ ] Update release notes with breaking change notice - -### AI Agent Guidance -- [ ] Update `AGENTS.md` and/or `CLAUDE.md` to document that plans should be created in `./plans` directory - - Add to "For Library Contributors" section in AGENTS.md - - Add to "Contributor Guide" section in CLAUDE.md - - Guidance: "When creating implementation plans, place them in `./plans/` directory (not `~/.claude/plans/`)" - -### Testing -- [ ] Unit tests pass (all) -- [ ] Integration tests pass (all) -- [ ] Manual testing of core widgets (Button, CheckBox, etc.) -- [ ] Manual testing of UICatalog MouseTester scenario -- [ ] Verify cancellation behavior works as expected - -### Review -- [ ] Code review by maintainers -- [ ] Documentation review for clarity -- [ ] Test coverage review (should maintain or increase coverage) -- [ ] Migration path validated with sample code - ---- - -## Timeline Estimate - -**No time estimates provided per project policy.** - -**Scope:** -- **Minimal:** 2-line code change + focused test updates -- **Full:** Code + comprehensive tests + documentation + examples - -**Dependencies:** -- None (Released event infrastructure already complete via #4674) - ---- - -## Open Questions - -1. **Should we add a global setting** to restore v1/old behavior? - - **Recommendation:** No, adds complexity. Per-view binding is sufficient. - -2. **Should Button/CheckBox/etc. override with custom behavior?** - - **Recommendation:** No, all should use View default for consistency. - -3. **Should we emit a warning when old Pressed binding is used?** - - **Recommendation:** No, Pressed bindings are valid use cases (e.g., drag handles). - -4. **Should MouseHoldRepeat default change as well?** - - **Recommendation:** No, MouseHoldRepeat is opt-in, leave as-is. - ---- - -## References - -- **Issue:** #4674 - MouseBindings for Released events not invoking commands -- **Commit:** d1b7a8885 - Fix Released binding invocation -- **Related Files:** - - `Terminal.Gui/ViewBase/Mouse/View.Mouse.cs` - - `Terminal.Gui/Input/Mouse/MouseBindings.cs` - - `Tests/UnitTestsParallelizable/ViewBase/Mouse/MouseReleasedBindingTests.cs` - - `Examples/UICatalog/Scenarios/MouseTester.cs` - -- **Industry Research:** - - [MDN: Element mouseup event](https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseup_event) - - [GTK4 Button Documentation](https://docs.gtk.org/gtk4/class.Button.html) - - [Qt QAbstractButton](https://doc.qt.io/qt-6/qabstractbutton.html) - - [QuirksMode: Click Events](https://www.quirksmode.org/dom/events/click.html) - ---- - -## Sign-off - -**Plan Author:** Claude Opus 4.5 -**Date:** 2026-02-03 -**Status:** Ready for review and implementation - diff --git a/plans/command-system-redesign-requirements.md b/plans/command-system-redesign-requirements.md new file mode 100644 index 0000000000..7aa528bce4 --- /dev/null +++ b/plans/command-system-redesign-requirements.md @@ -0,0 +1,723 @@ +# Terminal.Gui v2 Command System — Clean Slate Redesign (Synthesized) + +## Problem Statement + +The current v2 command system works, but it is hard to reason about and hard to evolve +safely. Behavior is distributed across `View.Command` default handlers, view-specific +overrides, keyboard/mouse binding layers, and special-case routing (`CommandsToBubbleUp`, +`DefaultAcceptView`, `BubbleDown`, boundary bridging). The result is high coupling, subtle +ordering dependencies, and fragile composite-view behavior. + +This redesign is clean-slate. v2 is alpha, so compatibility is negotiable. Correctness, +clarity, and architectural durability take priority. + +This document synthesizes the rigorous requirements from the "Terminal.Gui v2 Command +System Clean Slate Redesign" plan (R-001 through R-172) with a pragmatic, small-surface-area +design approach that maximizes code deletion in view classes while minimizing new abstractions. + +--- + +# Part 1: Requirements + +> Requirements R-001 through R-172 are adopted verbatim from the committed +> "Terminal.Gui v2 Command System Clean Slate Redesign.md" plan. They are the +> authoritative behavioral spec. Refer to that document for the full text. +> +> Key requirements called out below are ones that directly drive design decisions. + +## Requirements That Drive Design Decisions + +| Requirement | Design Impact | +|-------------|--------------| +| **R-004**: Activate/Accept semantically distinct | Preserved. No change needed. | +| **R-006**: Replace `bool?` with typed outcome | **New**: `CommandOutcome` enum | +| **R-007**: Immutable context for full route | **New**: `CommandContext` becomes immutable; views cannot mutate `ctx.Command` | +| **R-009**: Preserve source/binding provenance end-to-end | Preserved + **fix**: `ICommandBinding.Source` → `WeakReference` | +| **R-011**: First-class router with deterministic phases | **Refined**: Phases integrated into existing `RaiseXxx` methods, not a separate CommandRouter object | +| **R-013**: Structural recursion protection | **New**: `CommandRouting` enum replaces ad-hoc boolean flags | +| **R-015**: Explicit bridge nodes for out-of-hierarchy boundaries | **New**: `CommandBridge` class | +| **R-023**: Composite controls declare delegation declaratively | **New**: `GetDispatchTarget` + `ConsumeDispatch` virtuals | +| **R-024**: Single gesture → single state mutation without hacks | Framework-enforced via dispatch + consume machinery | +| **R-035/R-036**: Command tracing, duplicate/cycle detection | **New**: Built-in route tracing in `RaiseXxx` methods | +| **R-045–R-048**: Migration explicit and bounded | Phased migration with backward-compat shims | + +--- + +# Part 2: Design + +## The Unifying Observation + +Every view that participates in non-trivial command routing is doing one of these jobs: + +### Pattern A: "I am one control made of parts" (Dispatch-to-Primary) + +**Shortcut** has CommandView, HelpView, KeyView. Any interaction anywhere dispatches to +CommandView. There is exactly **one** primary target, fixed at construction. + +### Pattern B: "I am a group of interactive items" (Dispatch-to-Focused) + +**OptionSelector** has N CheckBoxes, one active (focused) at a time. Radio semantics — +the selector consumes the bubble and applies the value change itself. + +**FlagSelector** same structure, but multi-select. Each CheckBox contributes a bit to the +composite Value. + +### Pattern C: "I am a transparent container" (Relay) + +**Bar** has N Shortcuts. It doesn't transform commands — it just declares +`CommandsToBubbleUp = [Accept, Activate]` and lets everything pass through. + +**Menu** is the same as Bar but vertical, with one extra behavior: it signals +"close" when a leaf item is selected. + +### Pattern D: "I own a view that isn't my subview" (Bridge) + +**MenuItem** owns a **SubMenu**. **MenuBarItem** owns a **PopoverMenu** that is +registered with Application.Popover, outside the SuperView hierarchy. + +### Pattern Summary + +| | Target | Cardinality | Consume? | Boundary | +|-|--------|-------------|----------|----------| +| Shortcut | CommandView | 1, fixed | No (relay + dispatch) | Same tree | +| OptionSelector | Focused CheckBox | N, dynamic | Yes (prevents double-toggle) | Same tree | +| FlagSelector | Focused CheckBox | N, dynamic | Yes (toggles directly) | Same tree | +| Bar | (none — relay only) | N | No | Same tree | +| Menu | (relay + close signal) | N | No (but fires close) | Same tree | +| MenuItem→SubMenu | SubMenu | 1, set later | No | Detached | +| MenuBarItem→PopoverMenu | PopoverMenu | 1, set later | No | Cross-boundary | + +--- + +## Design Changes + +### Change 1: `CommandOutcome` Enum (replaces `bool?`) + +**Satisfies**: R-006 + +```csharp +public enum CommandOutcome +{ + /// Command was not handled; routing continues. + NotHandled, + + /// Command was handled; routing stops. + HandledStop, + + /// Command was handled but routing may continue (notification semantics). + HandledContinue, +} +``` + +Replaces the current three-valued `bool?` (`null` = not found, `false` = not handled, +`true` = handled). Every return site becomes self-documenting. + +**Migration**: A `CommandOutcomeExtensions.ToBool()` shim bridges old code during transition. + +**Files**: New `CommandOutcome.cs`, update `View.Command.cs` return types, update all +`AddCommand` handler signatures. + +### Change 2: `CommandRouting` Enum (replaces two booleans) + +**Satisfies**: R-013 + +```csharp +public enum CommandRouting +{ + /// Direct invocation (programmatic or from this view's own bindings). + Direct, + + /// Command is propagating upward through the SuperView chain. + BubblingUp, + + /// A SuperView is dispatching downward to a specific SubView. + DispatchingDown, + + /// Command is crossing a non-containment boundary via CommandBridge. + Bridged, +} +``` + +Single property on `ICommandContext` replaces `IsBubblingUp` and `IsBubblingDown`. +Four states (not three) — `Bridged` covers cross-boundary routing explicitly. + +**Files**: `ICommandContext.cs`, `CommandContext.cs`, `View.Command.cs` + +### Change 3: Immutable `CommandContext` + +**Satisfies**: R-007, R-009, R-010 + +```csharp +public readonly record struct CommandContext : ICommandContext +{ + public required Command Command { get; init; } + public required WeakReference? Source { get; init; } + public required ICommandBinding? Binding { get; init; } + public CommandRouting Routing { get; init; } + + /// Creates a new context with a different command, preserving all other fields. + public CommandContext WithCommand (Command command) => this with { Command = command }; + + /// Creates a new context with different routing, preserving all other fields. + public CommandContext WithRouting (CommandRouting routing) => this with { Routing = routing }; +} +``` + +Context is a `readonly record struct` — no mutation after creation. Views that need +to change the command (e.g., Shortcut translating to TargetCommand) create a new +context via `WithCommand`. + +**Fix**: `ICommandBinding.Source` changes from `View?` to `WeakReference?` to +match `ICommandContext.Source` and prevent leaks. This is a known bug in the current system. + +### Change 4: `GetDispatchTarget` + `ConsumeDispatch` (Composite Pattern) + +**Satisfies**: R-023, R-024, R-025, R-027 + +```csharp +// On View: + +/// Gets the subview to dispatch commands to. Return null to skip dispatch. +/// The framework calls this during RaiseActivating/RaiseAccepting after the +/// OnXxxing virtual and Xxxing event have had a chance to cancel. +protected virtual View? GetDispatchTarget (ICommandContext? ctx) => null; + +/// If true, dispatching to the target consumes the command, preventing the +/// original subview from completing its own activation/acceptance. +/// If false (default), the dispatch is a relay and the original subview +/// completes normally. +protected virtual bool ConsumeDispatch => false; +``` + +**Framework behavior in `RaiseActivating` / `RaiseAccepting`**: + +``` +1. Create CommandEventArgs +2. Call OnActivating(args) — subclass can cancel +3. Fire Activating event — subscriber can cancel +4. If not handled: + a. Call GetDispatchTarget(ctx) + b. If target is non-null + AND routing is not DispatchingDown (prevents re-entry) + AND source is not within the target (prevents loops): + - Dispatch to target with Routing = DispatchingDown + - If ConsumeDispatch: mark as handled (target owns mutation) +5. TryBubbleUp (opt-in via CommandsToBubbleUp, unchanged) +6. If not handled: proceed to RaiseActivated/RaiseAccepted + - If dispatch occurred: defer Xxxed until target's Xxxed fires +``` + +Step 6 is the key: the framework **defers** the container's post-event until the +dispatch target completes. This replaces Shortcut's `_activationBubbledUp` / +`CommandView_Activated` deferred-completion hack. + +**View overrides**: + +| View | `GetDispatchTarget` | `ConsumeDispatch` | +|------|--------------------|--------------------| +| Shortcut | `=> CommandView` | `false` (default) | +| OptionSelector | `=> Focused` | `true` | +| FlagSelector | `=> Focused` | `true` | +| Bar | not overridden (null) | not overridden | +| Menu | not overridden (null) | not overridden | +| Dialog | not overridden (null) | not overridden | + +### Change 5: `CommandBridge` (Cross-Boundary Routing) + +**Satisfies**: R-015, R-161 + +```csharp +public class CommandBridge : IDisposable +{ + // Both references are weak — the bridge must not prevent GC of either view. + private readonly WeakReference _owner; + private readonly WeakReference _remote; + private readonly Command [] _commands; + + /// Connects owner to a remote view for the specified commands. + /// Subscribes to remote's Activated/Accepted events and re-invokes + /// commands on owner with fresh immutable CommandContexts preserving + /// source + binding provenance. Routing is set to Bridged. + public static CommandBridge Connect ( + View owner, + View remote, + params Command [] commands); + + /// Tears down subscriptions. Called automatically when either view disposes. + public void Dispose (); +} +``` + +The bridge: +- Subscribes to the remote view's `Activated`/`Accepted` events +- On fire: creates a new `CommandContext` with `Routing = Bridged`, preserving source + binding +- Invokes the command on the owner via `InvokeCommand` (fires full CWP chain) +- Auto-disposes when either view is disposed or when remote is reassigned +- Becomes inert if either `WeakReference` target is collected + +This replaces all manual event-subscription bridging in: +- `MenuBarItem` (OnPopoverMenuOnActivated, OnPopoverMenuOnAccepted) +- `PopoverMenu` (MenuActivating, MenuAccepted) +- `Menu.OnSubViewAdded` (dual Accepting+Activated wiring) + +### Change 6: Explicit Menu Close Signal + +**Satisfies**: R-136, R-158, R-159 + +```csharp +// On Menu: +public event EventHandler? ItemSelected; +``` + +Menu fires `ItemSelected` when any leaf MenuItem is activated or accepted. +PopoverMenu subscribes to `ItemSelected` → `Visible = false`. + +This separates the "close the menu" concern from command routing. Currently +Menu conflates them by wiring `menuItem.Activated → RaiseAccepted`, which is +confusing (why does activation trigger acceptance?). + +### Change 7: Route Tracing + +**Satisfies**: R-035, R-036, R-037 + +```csharp +// On View: +[Conditional ("DEBUG")] +internal static void TraceRoute ( + View view, + Command command, + CommandRouting routing, + CommandOutcome outcome, + string phase); +``` + +Built into `RaiseActivating`, `RaiseAccepting`, `RaiseHandlingHotKey`, `BubbleDown`, +`TryBubbleUp`, and `CommandBridge`. Outputs structured log entries via `Logging.Trace`. + +In DEBUG builds, also detects: +- Duplicate dispatch (same view receiving the same command twice in one route) +- Route cycles (A → B → A) + +Traces are captured in unit tests via `Logging` infrastructure — no terminal rendering required. + +### Change 8: `ICommandBinding.Source` → `WeakReference?` + +**Satisfies**: R-009 + +```csharp +// In ICommandBinding: +WeakReference? Source { get; init; } // was: View? Source +``` + +This fixes a known memory leak where bindings hold strong references to views. +The current system already uses `WeakReference` for `ICommandContext.Source` +but inconsistently uses strong `View?` for `ICommandBinding.Source`. + +Affects: `ICommandBinding`, `CommandBinding`, `KeyBinding`, `MouseBinding`. + +--- + +## What Stays the Same + +These parts of the current design work well and are preserved: + +- **CWP pattern** (OnXxxing → Xxxing → OnXxxed → Xxxed) — maps to Preview → Execute → Notify phases +- **Opt-in bubbling** via `CommandsToBubbleUp` — no hidden defaults (R-016) +- **Command enum** (Activate, Accept, HotKey, NotBound) — stable, well-tested +- **DefaultAcceptView / IAcceptTarget** for Dialog redirect — works correctly +- **KeyBindings / HotKeyBindings split** — correct scoping model +- **InvokeCommand overloads** — public API preserved +- **CommandEventArgs with Handled property** — CWP cancellation mechanism +- **WeakReference source tracking** — already correct on context, now extended to bindings +- **InvokeCommand remains synchronous** (R-049) — no async, no deferred queueing + +--- + +## Impact Assessment + +| View | Changes Required | +|------|-----------------| +| **View.Command.cs** | Add `CommandOutcome`, `CommandRouting`, `GetDispatchTarget`, `ConsumeDispatch`, integrate dispatch into `RaiseActivating`/`RaiseAccepting`, add route tracing, refactor `TryBubbleUp` Padding checks | +| **ICommandContext / CommandContext** | Immutable `readonly record struct`, `Routing` enum, `WithCommand`/`WithRouting` methods | +| **ICommandBinding / KeyBinding / MouseBinding** | `Source` → `WeakReference?` | +| **CommandBridge** | New class | +| **Shortcut** | Override `GetDispatchTarget → CommandView`. Delete ~120 lines (HandleActivate, deferred flags, IsWithinCommandView, CommandView_Activated, OnActivating, OnAccepting) | +| **OptionSelector** | Override `GetDispatchTarget → Focused`, `ConsumeDispatch → true`. Delete OnActivating (~23 lines). Simplify OnActivated. | +| **FlagSelector** | Override `GetDispatchTarget → Focused`, `ConsumeDispatch → true`. Delete OnActivating (~45 lines), `_suppressHotKeyActivate` flag. Simplify OnHandlingHotKey and OnActivated. | +| **Bar** | No change | +| **Menu** | Add `ItemSelected` event. Remove `OnSubViewAdded` dual event wiring. | +| **MenuItem** | No command changes | +| **MenuBarItem** | Replace manual event handlers with `CommandBridge.Connect` | +| **PopoverMenu** | Replace manual event subscriptions with `CommandBridge` + `ItemSelected` | +| **Dialog / Dialog\** | No change (already clean) | +| **Button** | No change | +| **CheckBox** | No change | +| **SelectorBase** | Update return types to `CommandOutcome`. Move Enter→Activate from `OnAccepting` to `OnAccepted`. | +| **All `AddCommand` handlers** | Return `CommandOutcome` instead of `bool?` | + +--- + +## Code Samples + +### A. CheckBox.cs (Redesigned) + +CheckBox is a leaf view — no dispatch, no bridge, no relay. Barely changes. + +```csharp +public class CheckBox : View, IValue +{ + public CheckBox () + { + Width = Dim.Auto (DimAutoStyle.Text); + Height = Dim.Auto (DimAutoStyle.Text, 1); + CanFocus = true; + + // Single-click → Activate (toggle state) + // Double-click → Accept (confirm without toggle) + MouseBindings.Remove (MouseFlags.LeftButtonReleased); + MouseBindings.Add (MouseFlags.LeftButtonClicked, Command.Activate); + MouseBindings.Add (MouseFlags.LeftButtonDoubleClicked, Command.Accept); + + // Space → Activate and Enter → Accept inherited from View + TitleChanged += Checkbox_TitleChanged; + MouseHighlightStates = DefaultMouseHighlightStates; + } + + // No GetDispatchTarget override (null — no subviews to dispatch to) + // No ConsumeDispatch override (irrelevant — no dispatch) + // No OnActivating/OnAccepting overrides (base View behavior is correct) + + /// + protected override void OnActivated (ICommandContext? ctx) + { + base.OnActivated (ctx); + AdvanceCheckState (); + } + + // State management (Value, AdvanceCheckState, ValueChanging/ValueChanged, + // AllowCheckStateNone, RadioStyle, drawing) — all unchanged. +} +``` + +**What changed**: Only `AddCommand` handler return types change from `bool?` to +`CommandOutcome` (framework-wide migration). Zero command-routing logic changes. + +--- + +### B. Shortcut.cs (Redesigned) + +The entire `#region Accept/Activate/HotKey Command Handling` collapses. + +```csharp +public class Shortcut : View, IOrientation, IDesignable +{ + public Shortcut (Key key, string? commandText, Action? action, string? helpText = null) + { + MouseHighlightStates = MouseState.In; + CanFocus = true; + Border?.Settings &= ~BorderSettings.Title; + Width = GetWidthDimAuto (); + Height = Dim.Auto (DimAutoStyle.Content, 1); + + _orientationHelper = new OrientationHelper (this); + _orientationHelper.OrientationChanging += (_, e) => OrientationChanging?.Invoke (this, e); + _orientationHelper.OrientationChanged += (_, e) => OrientationChanged?.Invoke (this, e); + + CommandsToBubbleUp = [Command.Activate, Command.Accept]; + + // NOTE: No AddCommand (Command.Activate, HandleActivate). + // The framework calls GetDispatchTarget and handles dispatch/deferred-completion + // automatically via the default handlers. + + TitleChanged += Shortcut_TitleChanged; + CommandView = new View { Width = Dim.Auto (), Height = Dim.Fill () }; + Title = commandText ?? string.Empty; + HelpView.Text = helpText ?? string.Empty; + // KeyView setup, GettingAttributeForRole wiring — unchanged ... + + Key = key; + Action = action; + ShowHide (); + } + + // ──── Command Coordination (THE ENTIRE DISPATCH LOGIC) ──── + + /// + /// Shortcut dispatches all commands to CommandView. The framework handles: + /// - Source guard (skip if source is already within CommandView) + /// - Programmatic guard (skip if no binding) + /// - Deferred completion (Shortcut.Activated fires after CommandView.Activated) + /// + protected override View? GetDispatchTarget (ICommandContext? ctx) => CommandView; + + // ConsumeDispatch defaults to false — CommandView completes its own activation + // (e.g., CheckBox.OnActivated calls AdvanceCheckState). + + /// + protected override void OnActivated (ICommandContext? ctx) + { + base.OnActivated (ctx); + Action?.Invoke (); + InvokeOnTargetOrApp (ctx); + } + + /// + protected override void OnAccepted (ICommandContext? ctx) + { + base.OnAccepted (ctx); + Action?.Invoke (); + InvokeOnTargetOrApp (ctx); + } + + private void InvokeOnTargetOrApp (ICommandContext? ctx) + { + View? target = TargetView ?? GetTopSuperView (); + + if (target is { } && Command != Command.NotBound) + { + // Create new immutable context with the target command + CommandContext targetCtx = ((CommandContext)ctx!).WithCommand (Command); + target.InvokeCommand (Command, targetCtx); + + return; + } + + if (!Key.IsValid || Command == Command.NotBound) + { + return; + } + + App?.Keyboard.InvokeCommandsBoundToKey (Key); + } + + // ──── DELETED (now handled by framework via GetDispatchTarget) ──── + // + // - HandleActivate (~40 lines) + // - IsWithinCommandView (~15 lines) + // - _activationBubbledUp + _deferredActivationContext (flags) + // - OnActivating override (~20 lines) + // - OnAccepting override (~20 lines) + // - CommandView_Activated (~18 lines) + // + // Total: ~120 lines of nuanced routing replaced by 1 override. + + // ──── Properties (unchanged) ──── + public View? TargetView { get; set; } + public Command Command { get; set; } = Command.NotBound; + public Action? Action { get; set; } + public bool BindKeyToApplication { get; set; } + + // CommandView, HelpView, KeyView, Key, ShowHide, layout — all unchanged. +} +``` + +--- + +### C. OptionSelector.cs (Redesigned) + +```csharp +public class OptionSelector : SelectorBase, IDesignable +{ + public OptionSelector () => base.Value = 0; + + // ──── Command Coordination ──── + + /// Dispatch to whichever CheckBox has focus. + protected override View? GetDispatchTarget (ICommandContext? ctx) => Focused; + + /// Consume: OptionSelector owns selection state, not the individual CheckBoxes. + protected override bool ConsumeDispatch => true; + + /// + protected override void OnActivated (ICommandContext? ctx) + { + base.OnActivated (ctx); + + // Apply the value change. Runs for ALL activation paths uniformly. + // No routing-direction check needed — the framework handled dispatch/consumption. + ApplyActivation (ctx); + } + + // ──── DELETED ──── + // - OnActivating override (~23 lines) — replaced by GetDispatchTarget + ConsumeDispatch + + private void ApplyActivation (ICommandContext? ctx) + { + // Unchanged from current implementation + if (ctx?.Source?.TryGetTarget (out View? sourceView) != true + || sourceView is not CheckBox checkBox) + { + Cycle (); + + return; + } + + if (ctx.Binding is KeyBinding keyBinding + && (int)checkBox.Data! == Value + && keyBinding.Key is { } + && keyBinding.Key == Key.Space) + { + Cycle (); + } + else + { + if (Value == (int)checkBox.Data!) + { + return; + } + + Value = (int)checkBox.Data!; + } + } + + // OnSubViewAdded (RadioStyle=true), Cycle, UpdateChecked, FocusedItem — all unchanged. +} +``` + +--- + +### D. FlagSelector.cs (Redesigned) + +```csharp +public class FlagSelector : SelectorBase, IDesignable +{ + public FlagSelector () + { + KeyBindings.Remove (Key.Space); + KeyBindings.Remove (Key.Enter); + MouseBindings.Clear (); + } + + // ──── Command Coordination ──── + + /// Dispatch to whichever CheckBox has focus. + protected override View? GetDispatchTarget (ICommandContext? ctx) => Focused; + + /// Consume: FlagSelector owns toggle semantics. + protected override bool ConsumeDispatch => true; + + /// + protected override bool OnHandlingHotKey (CommandEventArgs args) + { + if (base.OnHandlingHotKey (args)) + { + return true; + } + + // When focused, HotKey is a no-op + if (HasFocus) + { + return true; + } + + // Not focused: restore focus only. No _suppressHotKeyActivate flag needed — + // DefaultHotKeyHandler calls InvokeCommand(Activate) without a binding, + // so GetDispatchTarget dispatch is skipped by the framework's + // programmatic-invoke guard (binding is null → no dispatch). + if (CanFocus) + { + SetFocus (); + } + + return false; + } + + /// + protected override void OnActivated (ICommandContext? ctx) + { + base.OnActivated (ctx); + + // Toggle the source CheckBox's value directly. + // ConsumeDispatch=true means CheckBox.OnActivated/AdvanceCheckState was suppressed. + if (ctx?.Source?.TryGetTarget (out View? source) == true && source is CheckBox checkBox) + { + checkBox.Value = checkBox.Value == CheckState.Checked + ? CheckState.UnChecked + : CheckState.Checked; + } + + // CheckboxOnValueChanged handler updates FlagSelector.Value bitmask + } + + // ──── DELETED ──── + // - _suppressHotKeyActivate flag + // - OnActivating override (~45 lines, 4 code paths) + // Total: ~50 lines replaced by GetDispatchTarget + ConsumeDispatch + + // OnSubViewAdded, OnCheckboxOnValueChanging, CheckboxOnValueChanged, + // Value, UpdateChecked, CreateSubViews — all unchanged. +} +``` + +--- + +### E. Lines Changed Summary + +| View | `GetDispatchTarget` | `ConsumeDispatch` | Lines Deleted | Lines Added | +|------|--------------------|--------------------|---------------|-------------| +| CheckBox | — | — | 0 | 0 | +| Shortcut | `=> CommandView` | `false` (default) | ~120 | 1 | +| OptionSelector | `=> Focused` | `true` | ~23 | 2 | +| FlagSelector | `=> Focused` | `true` | ~50 | 2 | +| MenuBarItem | — | — | ~30 | 1 (`CommandBridge.Connect`) | +| PopoverMenu | — | — | ~40 | 1 (`CommandBridge.Connect`) + `ItemSelected` sub | + +**Leaf views don't change. Composite views replace N lines of hand-written routing +with 1–2 declarative overrides. Bridge views replace manual event wiring with +one `CommandBridge.Connect` call.** + +--- + +## Migration Path + +1. **Phase A — Foundation**: Add `CommandOutcome` enum alongside `bool?`. Add + `CommandRouting` enum with backward-compat `IsBubblingUp`/`IsBubblingDown` + computed properties. Make `CommandContext` a `readonly record struct` with + `WithCommand`/`WithRouting` methods. Fix `ICommandBinding.Source` → `WeakReference?`. + *All existing tests continue to pass.* + +2. **Phase B — Dispatch**: Add `GetDispatchTarget` / `ConsumeDispatch` to View. + Integrate dispatch logic into `RaiseActivating`/`RaiseAccepting`. Migrate + Shortcut first (most complex, best test coverage), then OptionSelector, + then FlagSelector. + *Shortcut/Selector tests validate correctness.* + +3. **Phase C — Bridge**: Implement `CommandBridge`. Migrate MenuBarItem and + PopoverMenu. Add `ItemSelected` to Menu. Simplify PopoverMenu close logic. + *Menu/PopoverMenu tests validate correctness. Skipped CommandBubblingTests unblocked.* + +4. **Phase D — Outcome**: Migrate all `AddCommand` handlers from `bool?` to + `CommandOutcome`. Remove backward-compat `bool?` shims. + *Full test suite validates.* + +5. **Phase E — Cleanup**: Remove `IsBubblingUp`/`IsBubblingDown` compat properties. + Add route tracing. Delete dead code. Update `docfx/docs/command.md`. + +Each phase is independently shippable and testable. + +--- + +## Verification + +- All tests in ViewCommandTests, ShortcutTests.Command, BarTests, MenuTests, + MenuBarTests, PopoverMenuTests, DialogTests, SelectorBaseTests must pass +- Skipped CommandBubblingTests (Phase 5 targets) must be unblocked by Phase C +- UICatalog scenarios (Shortcuts, Menus, Selectors, Dialogs, Bars) must work identically +- Route tracing (Phase E) must detect any duplicate dispatches in the test suite +- `dotnet test Tests/UnitTestsParallelizable --no-build` +- `dotnet test Tests/UnitTests --no-build` + +--- + +## Resolved Decisions + +1. **`GetDispatchTarget` is framework-driven.** Called from within `RaiseActivating`/ + `RaiseAccepting` (step 4 in the flow), not from view overrides. Views that need + to skip dispatch return `null`. This keeps the dispatch logic in one place. + +2. **Deferred completion is synchronous.** `BubbleDown` is already synchronous and + returns after the target completes, so the framework fires `RaiseActivated` + immediately after `BubbleDown` returns. No event-subscription machinery needed. + +3. **`CommandBridge` is one-way.** Remote fires event → owner receives command. + If bidirectional is needed, create two bridges.