diff --git a/Terminal.Gui/ViewBase/View.Command.cs b/Terminal.Gui/ViewBase/View.Command.cs index ba8fd0e307..427620807f 100644 --- a/Terminal.Gui/ViewBase/View.Command.cs +++ b/Terminal.Gui/ViewBase/View.Command.cs @@ -296,19 +296,41 @@ private void SetupCommands () } /// - /// Called when a command that has not been bound is invoked. + /// Called when a command that has not been bound (via AddCommand) is invoked on this View. /// Set CommandEventArgs.Handled to and return to indicate the event was /// handled and processing should stop. /// + /// + /// + /// This is part of the command bubbling pipeline. When a entry fires a command + /// that has no AddCommand handler, this method is called. If the command is in an ancestor's + /// list, will forward it to that ancestor. + /// + /// + /// Adding a entry without a corresponding AddCommand handler is + /// intentional and idiomatic — it signals that the command should bubble to an ancestor. + /// + /// /// The event arguments. /// to stop processing. protected virtual bool OnCommandNotBound (CommandEventArgs args) => false; /// - /// Cancelable event raised when a command that has not been bound is invoked. + /// Cancelable event raised when a command that has not been bound (via AddCommand) is invoked. /// Set CommandEventArgs.Handled to to indicate the event was handled and processing should /// stop. /// + /// + /// + /// This event fires as part of the command bubbling mechanism. A common use case is a SubView that binds + /// mouse-wheel events to scroll commands (via ) without adding a local handler. + /// The unhandled command fires this event, and if not cancelled here, forwards + /// the command to the nearest ancestor whose includes it. + /// + /// + /// See also: , . + /// + /// public event EventHandler? CommandNotBound; #region Accept @@ -1256,17 +1278,32 @@ private static bool IsSourceWithinView (View target, ICommandContext? ctx) /// /// Gets or sets the list of commands that should bubble up to this View from unhandled SubViews - /// or from SubViews within this View's adornments (Padding, Border). + /// or from SubViews within this View's adornments (Padding, Border, Margin). /// When a SubView raises a command that is not handled, and the command is in the SuperView's /// list, the command will be invoked on the SuperView. /// /// /// - /// For SubViews inside an (e.g., a button in Padding or Border), - /// the bubble target is rather than . + /// For SubViews inside an (e.g., a view in Padding or Border), + /// the bubble target is (the owning View) rather than + /// . This means a view added to editor.Padding will bubble + /// commands directly to editor, not to the PaddingView. + /// + /// + /// Mouse-wheel forwarding pattern: A SubView can add a entry + /// (e.g., MouseBindings.Add(MouseFlags.WheeledUp, Command.ScrollUp)) without calling + /// AddCommand for the command. The unhandled command will fire + /// and then bubble via to the nearest + /// ancestor whose contains it. + /// + /// + /// Example — enable scroll command bubbling: + /// + /// editor.CommandsToBubbleUp = [Command.ScrollUp, Command.ScrollDown]; + /// /// /// - /// e.g. to enable bubbling for hierarchical views: + /// Example — enable bubbling for hierarchical views: /// /// menuBar.CommandsToBubbleUp = [Command.Activate]; /// diff --git a/docfx/docs/command-diagrams.md b/docfx/docs/command-diagrams.md index 08657ce9bd..84e1f8ec18 100644 --- a/docfx/docs/command-diagrams.md +++ b/docfx/docs/command-diagrams.md @@ -130,3 +130,39 @@ flowchart TD - holds a (not a `SubMenu`). holds a `SubMenu` (a nested ). - connects non-containment boundaries (e.g., ) so / from the remote view re-enters the owner's full command pipeline with `Routing = Bridged`. - uses consume dispatch ( = true, → `Focused`) — inner activations are consumed and do not propagate to 's SuperView. + +### Level 4: Mouse Event Forwarding via CommandNotBound Bubbling + +This diagram shows how a child view can forward mouse-wheel events to an ancestor via the `CommandNotBound` → `TryBubbleUp` mechanism. The child adds a `MouseBinding` without an `AddCommand` handler, causing the command to bubble. + +```mermaid +flowchart TD + input["Mouse wheel on Gutter (in Editor.Padding)"] --> mb["MouseBindings maps
WheeledUp → Command.ScrollUp"] + mb --> invoke["Gutter.InvokeCommand(ScrollUp)"] + invoke --> lookup{"AddCommand handler
exists for ScrollUp?"} + + lookup --> |"no"| notbound["DefaultCommandNotBoundHandler"] + notbound --> raise["RaiseCommandNotBound:
OnCommandNotBound (virtual)
→ CommandNotBound event"] + raise --> |"not handled"| bubble["TryBubbleUp"] + + bubble --> check_sv{"SuperView is
AdornmentView?"} + check_sv --> |"yes (Gutter in Padding)"| adorn_check{"Adornment.Parent
.CommandsToBubbleUp
contains ScrollUp?"} + adorn_check --> |"yes"| parent_invoke["Editor.InvokeCommand(ScrollUp)
(Routing = BubblingUp)"] + parent_invoke --> editor_handler["Editor.ScrollVertical(-1)
→ returns true"] + + check_sv --> |"no (normal SubView)"| sv_check{"SuperView
.CommandsToBubbleUp
contains ScrollUp?"} + sv_check --> |"yes"| sv_invoke["SuperView.InvokeCommand(ScrollUp)
(Routing = BubblingUp)"] + sv_check --> |"no"| willbubble{"CommandWillBubbleToAncestor?"} + willbubble --> |"yes"| ret_handled["return true (consumed)"] + willbubble --> |"no"| ret_null["return null (unhandled)"] + + adorn_check --> |"no"| willbubble + raise --> |"handled (args.Handled = true)"| ret_true["return true (stop)"] + lookup --> |"yes"| exec["Execute handler directly"] +``` + +**Key Points:** +- Adding a `MouseBinding` without a corresponding `AddCommand` handler is **intentional and idiomatic** — it declares that the command should bubble to an ancestor. +- For views inside an (Padding, Border, Margin), the bubble target is `Adornment.Parent` (the owning View), not `SuperView`. +- checks both the `SuperView` path and the `AdornmentView` path. +- The event can intercept and cancel bubbling by setting `args.Handled = true`. diff --git a/docfx/docs/mouse.md b/docfx/docs/mouse.md index c331ff2f61..49418feddd 100644 --- a/docfx/docs/mouse.md +++ b/docfx/docs/mouse.md @@ -604,6 +604,79 @@ Platform API ? InputProcessorImpl ? AnsiResponseParser ? MouseInterpreter ? Appl This ensures consistent mouse behavior across platforms while maintaining platform-specific optimizations. +## Mouse Event Forwarding via Command Bubbling + +A common need is forwarding mouse-wheel events from a child view to an ancestor (e.g., a gutter subview inside a scrollable editor's Padding that should scroll the editor). Terminal.Gui supports this idiomatically via the **CommandNotBound bubbling** mechanism. + +### The Pattern + +1. **Child view**: Add a `MouseBinding` for the wheel event **without** calling `AddCommand` for that command. +2. **Parent view**: Include the scroll commands in . +3. **Result**: When the child receives the wheel event, the mouse binding fires the command. Because the child has no handler (`AddCommand` was not called), `DefaultCommandNotBoundHandler` runs → finds the ancestor with the command in `CommandsToBubbleUp` → invokes it on the ancestor. + +### Example: Gutter Forwards Wheel to Editor + +```csharp +// Parent (Editor) handles scroll commands and opts into bubbling +public class Editor : View +{ + public Editor () + { + AddCommand (Command.ScrollUp, () => ScrollVertical (-1)); + AddCommand (Command.ScrollDown, () => ScrollVertical (1)); + + // Allow scroll commands from SubViews/adornment SubViews to bubble here + CommandsToBubbleUp = [Command.ScrollUp, Command.ScrollDown]; + } +} + +// Child (Gutter) binds wheel events but does NOT add command handlers +public class Gutter : View +{ + public Gutter () + { + // Bind mouse wheel to scroll commands — no AddCommand needed. + // The unhandled command will bubble up to the nearest ancestor + // whose CommandsToBubbleUp includes it. + MouseBindings.Add (MouseFlags.WheeledUp, Command.ScrollUp); + MouseBindings.Add (MouseFlags.WheeledDown, Command.ScrollDown); + } +} +``` + +### How It Works + +``` +Mouse wheel on Gutter + → MouseBindings maps WheeledUp → Command.ScrollUp + → Gutter.InvokeCommand(ScrollUp) + → No handler found (AddCommand was never called) + → DefaultCommandNotBoundHandler runs + → RaiseCommandNotBound → TryBubbleUp + → Finds ancestor (Editor) with Command.ScrollUp in CommandsToBubbleUp + → Editor.InvokeCommand(ScrollUp) → scrolls +``` + +### AdornmentView Special Case + +For views hosted inside an adornment (Padding, Border, or Margin), the bubble target is the **adornment's Parent** (the owning View), not the `SuperView`. This means a view added to `editor.Padding` will bubble commands directly to `editor`, skipping the `PaddingView` intermediary. + +```csharp +// Gutter added to Editor's Padding — bubbles to Editor automatically +editor.Padding.Add (gutter); +``` + +This is handled internally by and . + +### When to Use This Pattern + +* A SubView should forward wheel/scroll events to its container without handling them itself. +* A view in Padding/Border needs to delegate commands to the owning View. +* You want clean separation: the child declares *what* user gesture maps to *which* command, and the ancestor decides *how* to handle it. + +> [!TIP] +> Adding a `MouseBinding` without a corresponding `AddCommand` handler is **intentional and idiomatic**. It signals that the command should bubble to an ancestor rather than being handled locally. See . + ## Best Practices * **Use Mouse Bindings and Commands** for simple interactions - integrates with keyboard bindings