Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions Terminal.Gui/ViewBase/View.Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -296,19 +296,41 @@ private void SetupCommands ()
}

/// <summary>
/// Called when a command that has not been bound is invoked.
/// Called when a command that has not been bound (via <c>AddCommand</c>) is invoked on this View.
/// Set CommandEventArgs.Handled to <see langword="true"/> and return <see langword="true"/> to indicate the event was
/// handled and processing should stop.
/// </summary>
/// <remarks>
/// <para>
/// This is part of the command bubbling pipeline. When a <see cref="MouseBindings"/> entry fires a command
/// that has no <c>AddCommand</c> handler, this method is called. If the command is in an ancestor's
/// <see cref="CommandsToBubbleUp"/> list, <see cref="TryBubbleUp"/> will forward it to that ancestor.
/// </para>
/// <para>
/// Adding a <see cref="MouseBindings"/> entry without a corresponding <c>AddCommand</c> handler is
/// intentional and idiomatic — it signals that the command should bubble to an ancestor.
/// </para>
/// </remarks>
/// <param name="args">The event arguments.</param>
/// <returns><see langword="true"/> to stop processing.</returns>
protected virtual bool OnCommandNotBound (CommandEventArgs args) => false;

/// <summary>
/// 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 <c>AddCommand</c>) is invoked.
/// Set CommandEventArgs.Handled to <see langword="true"/> to indicate the event was handled and processing should
/// stop.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="MouseBindings"/>) without adding a local handler.
/// The unhandled command fires this event, and if not cancelled here, <see cref="TryBubbleUp"/> forwards
/// the command to the nearest ancestor whose <see cref="CommandsToBubbleUp"/> includes it.
/// </para>
/// <para>
/// See also: <see cref="CommandsToBubbleUp"/>, <see cref="TryBubbleUp"/>.
/// </para>
/// </remarks>
public event EventHandler<CommandEventArgs>? CommandNotBound;

#region Accept
Expand Down Expand Up @@ -1256,17 +1278,32 @@ private static bool IsSourceWithinView (View target, ICommandContext? ctx)

/// <summary>
/// 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
/// <see cref="CommandsToBubbleUp"/> list, the command will be invoked on the SuperView.
/// </summary>
/// <remarks>
/// <para>
/// For SubViews inside an <see cref="AdornmentView"/> (e.g., a button in Padding or Border),
/// the bubble target is <see cref="IAdornment.Parent"/> rather than <see cref="SuperView"/>.
/// For SubViews inside an <see cref="AdornmentView"/> (e.g., a view in Padding or Border),
/// the bubble target is <see cref="IAdornment.Parent"/> (the owning View) rather than
/// <see cref="SuperView"/>. This means a view added to <c>editor.Padding</c> will bubble
/// commands directly to <c>editor</c>, not to the <c>PaddingView</c>.
/// </para>
/// <para>
/// <b>Mouse-wheel forwarding pattern:</b> A SubView can add a <see cref="MouseBindings"/> entry
/// (e.g., <c>MouseBindings.Add(MouseFlags.WheeledUp, Command.ScrollUp)</c>) without calling
/// <c>AddCommand</c> for the command. The unhandled command will fire
/// <see cref="CommandNotBound"/> and then bubble via <see cref="TryBubbleUp"/> to the nearest
/// ancestor whose <see cref="CommandsToBubbleUp"/> contains it.
/// </para>
/// <para>
/// Example — enable scroll command bubbling:
/// <code>
/// editor.CommandsToBubbleUp = [Command.ScrollUp, Command.ScrollDown];
/// </code>
/// </para>
/// <para>
/// e.g. to enable <see cref="Command.Activate"/> bubbling for hierarchical views:
/// Example — enable <see cref="Command.Activate"/> bubbling for hierarchical views:
/// <code>
/// menuBar.CommandsToBubbleUp = [Command.Activate];
/// </code>
Expand Down
36 changes: 36 additions & 0 deletions docfx/docs/command-diagrams.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,39 @@ flowchart TD
- <xref:Terminal.Gui.Views.MenuBarItem> holds a <xref:Terminal.Gui.Views.PopoverMenu> (not a `SubMenu`). <xref:Terminal.Gui.Views.MenuItem> holds a `SubMenu` (a nested <xref:Terminal.Gui.Views.Menu>).
- <xref:Terminal.Gui.Input.CommandBridge> connects non-containment boundaries (e.g., <xref:Terminal.Gui.Views.PopoverMenu> ↔ <xref:Terminal.Gui.Views.MenuBarItem>) so <xref:Terminal.Gui.ViewBase.View.Accepted>/<xref:Terminal.Gui.ViewBase.View.Activated> from the remote view re-enters the owner's full command pipeline with `Routing = Bridged`.
- <xref:Terminal.Gui.Views.MenuBar> uses consume dispatch (<xref:Terminal.Gui.ViewBase.View.ConsumeDispatch> = true, <xref:Terminal.Gui.ViewBase.View.GetDispatchTarget*> → `Focused`) — inner activations are consumed and do not propagate to <xref:Terminal.Gui.Views.MenuBar>'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<br/>WheeledUp → Command.ScrollUp"]
mb --> invoke["Gutter.InvokeCommand(ScrollUp)"]
invoke --> lookup{"AddCommand handler<br/>exists for ScrollUp?"}

lookup --> |"no"| notbound["DefaultCommandNotBoundHandler"]
notbound --> raise["RaiseCommandNotBound:<br/> OnCommandNotBound (virtual)<br/> → CommandNotBound event"]
raise --> |"not handled"| bubble["TryBubbleUp"]

bubble --> check_sv{"SuperView is<br/>AdornmentView?"}
check_sv --> |"yes (Gutter in Padding)"| adorn_check{"Adornment.Parent<br/>.CommandsToBubbleUp<br/>contains ScrollUp?"}
adorn_check --> |"yes"| parent_invoke["Editor.InvokeCommand(ScrollUp)<br/>(Routing = BubblingUp)"]
parent_invoke --> editor_handler["Editor.ScrollVertical(-1)<br/>→ returns true"]

check_sv --> |"no (normal SubView)"| sv_check{"SuperView<br/>.CommandsToBubbleUp<br/>contains ScrollUp?"}
sv_check --> |"yes"| sv_invoke["SuperView.InvokeCommand(ScrollUp)<br/>(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 <xref:Terminal.Gui.ViewBase.Adornment.AdornmentView> (Padding, Border, Margin), the bubble target is `Adornment.Parent` (the owning View), not `SuperView`.
- <xref:Terminal.Gui.ViewBase.View.CommandWillBubbleToAncestor*> checks both the `SuperView` path and the `AdornmentView` path.
- The <xref:Terminal.Gui.ViewBase.View.CommandNotBound> event can intercept and cancel bubbling by setting `args.Handled = true`.
73 changes: 73 additions & 0 deletions docfx/docs/mouse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <xref:Terminal.Gui.ViewBase.View.CommandsToBubbleUp>.
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 → <xref:Terminal.Gui.ViewBase.View.TryBubbleUp*> 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 <xref:Terminal.Gui.ViewBase.View.TryBubbleUp*> and <xref:Terminal.Gui.ViewBase.View.CommandWillBubbleToAncestor*>.

### 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 <xref:Terminal.Gui.ViewBase.View.CommandNotBound>.

## Best Practices

* **Use Mouse Bindings and Commands** for simple interactions - integrates with keyboard bindings
Expand Down
Loading