This document provides a comprehensive overview of how events work in Terminal.Gui. For the conceptual overview of the Cancellable Work Pattern, see Cancellable Work Pattern.
[!INCLUDE Events Lexicon]
Terminal.Gui uses several types of events:
The Cancellable Work Pattern (CWP) is a core pattern in Terminal.Gui that provides a consistent way to handle cancellable operations. An "event" has two components:
protected virtual OnMethod() that can be overridden in a subclass so the subclass can participatepublic event EventHandler<> that allows external subscribers to participateThe virtual method is called first, letting subclasses have priority. Then the event is invoked.
Optional CWP Helper Classes are provided to provide consistency.
The basic CWP pattern combines a protected virtual method with a public event:
public class MyView : View
{
// Public event
public event EventHandler<MouseEventArgs>? MouseEvent;
// Protected virtual method
protected virtual bool OnMouseEvent(MouseEventArgs args)
{
// Return true to handle the event and stop propagation
return false;
}
// Internal method to raise the event
internal bool RaiseMouseEvent(MouseEventArgs args)
{
// Call virtual method first
if (OnMouseEvent(args) || args.Handled)
{
return true;
}
// Then raise the event
MouseEvent?.Invoke(this, args);
return args.Handled;
}
}
Terminal.Gui provides static helper classes to implement CWP:
For property changes, use CWPPropertyHelper.ChangeProperty:
public class MyView : View
{
private string _text = string.Empty;
public event EventHandler<ValueChangingEventArgs<string>>? TextChanging;
public event EventHandler<ValueChangedEventArgs<string>>? TextChanged;
public string Text
{
get => _text;
set
{
if (CWPPropertyHelper.ChangeProperty(
currentValue: _text,
newValue: value,
onChanging: args => OnTextChanging(args),
changingEvent: TextChanging,
onChanged: args => OnTextChanged(args),
changedEvent: TextChanged,
out string finalValue))
{
_text = finalValue;
}
}
}
// Virtual method called before the change
protected virtual bool OnTextChanging(ValueChangingEventArgs<string> args)
{
// Return true to cancel the change
return false;
}
// Virtual method called after the change
protected virtual void OnTextChanged(ValueChangedEventArgs<string> args)
{
// React to the change
}
}
For general workflows, use CWPWorkflowHelper:
public class MyView : View
{
public bool? ExecuteWorkflow()
{
ResultEventArgs<bool> args = new();
return CWPWorkflowHelper.Execute(
onMethod: args => OnExecuting(args),
eventHandler: Executing,
args: args,
defaultAction: () =>
{
// Main execution logic
DoWork();
args.Result = true;
});
}
// Virtual method called before execution
protected virtual bool OnExecuting(ResultEventArgs<bool> args)
{
// Return true to cancel execution
return false;
}
public event EventHandler<ResultEventArgs<bool>>? Executing;
}
For simple callbacks without cancellation, use Action. For example, in Shortcut:
public class Shortcut : View
{
/// <summary>
/// Gets or sets the action to be invoked when the shortcut key is pressed or the shortcut is clicked on with the
/// mouse.
/// </summary>
/// <remarks>
/// Note, the <see cref="View.Accepting"/> event is fired first, and if cancelled, the event will not be invoked.
/// </remarks>
public Action? Action { get; set; }
internal virtual bool? DispatchCommand(ICommandContext? commandContext)
{
bool cancel = base.DispatchCommand(commandContext) == true;
if (cancel)
{
return true;
}
if (Action is { })
{
Logging.Debug($"{Title} ({commandContext?.Source?.Title}) - Invoke Action...");
Action.Invoke();
// Assume if there's a subscriber to Action, it's handled.
cancel = true;
}
return cancel;
}
}
For property change notifications, implement INotifyPropertyChanged. For example, in Aligner:
public class Aligner : INotifyPropertyChanged
{
private Alignment _alignment;
public event PropertyChangedEventHandler? PropertyChanged;
public Alignment Alignment
{
get => _alignment;
set
{
_alignment = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Alignment)));
}
}
}
Events in Terminal.Gui often propagate through the view hierarchy. For example, in Button, the Activating and Accepting events are raised as part of the command handling process:
private bool? HandleHotKeyCommand (ICommandContext commandContext)
{
bool cachedIsDefault = IsDefault; // Supports "Swap Default" in Buttons scenario where IsDefault changes
if (RaiseActivating (commandContext) is true)
{
return true;
}
bool? handled = RaiseAccepting (commandContext);
if (handled == true)
{
return true;
}
SetFocus ();
// If Accept was not handled...
if (cachedIsDefault && SuperView is { })
{
return SuperView.InvokeCommand (Command.Accept);
}
return false;
}
This example shows how Button first raises the Activating event, and if not canceled, proceeds to raise the Accepting event. If Accepting is not handled and the button is the default, it invokes the Accept command on the SuperView, demonstrating event propagation up the view hierarchy.
Terminal.Gui provides rich context through event arguments. For example, CommandEventArgs:
public class CommandEventArgs : EventArgs
{
public ICommandContext? Context { get; set; }
public bool Handled { get; set; }
public bool Cancel { get; set; }
}
Command execution includes context through ICommandContext:
public interface ICommandContext
{
View Source { get; }
object? Parameter { get; }
IDictionary<string, object> State { get; }
}
Event Naming:
Clicked, Changed)Clicking, Changing)Event Handler Implementation:
Event Context:
Event Propagation:
PropagatedCommands for hierarchical viewsMemory Leaks:
// BAD: Potential memory leak
view.Activating += OnActivating;
// GOOD: Unsubscribe in Dispose
protected override void Dispose(bool disposing)
{
if (disposing)
{
view.Activating -= OnActivating;
}
base.Dispose(disposing);
}
Incorrect Event Cancellation:
// BAD: Using Cancel for event handling
args.Cancel = true; // Wrong for MouseEventArgs
// GOOD: Using Handled for event handling
args.Handled = true; // Correct for MouseEventArgs
// GOOD: Using Cancel for operation cancellation
args.Cancel = true; // Correct for CancelEventArgs
Missing Context:
// BAD: Missing context
Activating?.Invoke(this, new CommandEventArgs());
// GOOD: Including context
Activating?.Invoke(this, new CommandEventArgs { Context = ctx });
TG follows the naming advice provided in .NET Naming Guidelines - Names of Events.
The Cancellable Work Pattern in View.Command currently supports local Command.Activate and propagating Command.Accept. To address hierarchical coordination needs (e.g., MenuBar popovers, Dialog closing), a PropagatedCommands property is proposed (Issue #4050):
IReadOnlyList<Command> PropagatedCommands to View, defaulting to [Command.Accept]. Raise* methods propagate if the command is in SuperView?.PropagatedCommands and args.Handled is false.Example:
public IReadOnlyList<Command> PropagatedCommands { get; set; } = new List<Command> { Command.Accept };
protected bool? RaiseAccepting(ICommandContext? ctx)
{
CommandEventArgs args = new() { Context = ctx };
if (OnAccepting(args) || args.Handled)
{
return true;
}
Accepting?.Invoke(this, args);
if (!args.Handled && SuperView?.PropagatedCommands.Contains(Command.Accept) == true)
{
return SuperView.InvokeCommand(Command.Accept, ctx);
}
return Accepting is null ? null : args.Handled;
}
Impact: Enables Command.Activate propagation for MenuBar while preserving Command.Accept propagation, maintaining decoupling and avoiding noise from irrelevant commands.
CheckBox.Activating triggers Accepting, conflating state change and confirmation.Recommendation: Refactor to separate Activating and Accepting:
checkbox.Activating += (sender, args) =>
{
if (RaiseAccepting(args.Context) is true)
{
args.Handled = true;
}
};
Command.Activate restricts MenuBar coordination; Command.Accept uses hacks (#3925).PropagatedCommands to enable targeted propagation, as proposed.View.Draw's multi-phase workflow can be complex for developers to customize.