Files
Terminal.Gui/Terminal.Gui/Views/DialogTResult.cs
Copilot f2d260a853 Add TryGetSource extension methods for WeakReference<View> access (#4694)
* Initial plan

* CommandContext infrastructure: WeakReference and extension methods

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* Align CommandContext infrastructure with PR #4620

- Update ViewExtensions.cs to use Terminal.Gui.ViewBase namespace
- Update WeakReferenceExtensions.cs to match PR #4620 format
- Change CommandContext constructor to take WeakReference<View>? directly
- Add CommandContext.ToString() implementation
- Update ICommandContext.Source documentation
- Update View.Command.cs to explicitly create WeakReference
- Update Shortcut.cs to handle WeakReference Source
- Update ComboBox.cs Source comparison
- Update Dialog.cs and DialogTResult.cs pattern matching
- Update MenuBar.cs and PopoverMenu.cs to work with WeakReference
- Update ScrollSlider.cs and OptionSelector.cs

Remaining: Fix Examples code and tests

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* Fix Examples code for WeakReference Source

- Update UICatalogRunnable.cs to use TryGetTarget for Data access
- Update Menus.cs to use ToIdentifyingString() and TryGetTarget
- Update MouseTester.cs to use ToIdentifyingString() for all Source.Id access

All Examples now build successfully

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* Update tests for WeakReference Source

- Fix CommandContextTests.cs to wrap Source in WeakReference
- Fix InputBindingTests.cs to wrap Source in WeakReference
- Update assertions to use TryGetTarget for safe access
- All CommandContext and InputBinding tests pass

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* Add TryGetSource extension methods and replace usage

- Add TryGetSource extension to WeakReferenceExtensions.cs
- Add CommandContextExtensions.cs with TryGetSource for ICommandContext
- Replace 14 instances of Source?.TryGetTarget pattern across codebase
- Updated Terminal.Gui files: ComboBox, Dialog, DialogTResult, MenuBar, PopoverMenu, OptionSelector, Shortcut
- Updated Examples files: UICatalogRunnable, Menus
- All files build successfully

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* Add tests for TryGetSource extension methods

- Add 6 tests for TryGetSource extension methods
- Test WeakReference<View>.TryGetSource with valid/null references
- Test ICommandContext.TryGetSource with valid/null contexts
- Test pattern matching usage with TryGetSource
- All 23 CommandContext tests pass
- Full test suite: 15,094 passed

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* code cleanup

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tig <585482+tig@users.noreply.github.com>
Co-authored-by: Tig <tig@users.noreply.github.com>
2026-02-06 14:44:04 -07:00

481 lines
16 KiB
C#

namespace Terminal.Gui.Views;
/// <summary>
/// A generic modal dialog window with buttons across the bottom. Derive from this class
/// to create dialogs that return custom result types.
/// </summary>
/// <typeparam name="TResult">
/// The type of result data returned when the dialog closes.
/// Since <see cref="IRunnable{TResult}.Result"/> is <c>TResult?</c>, use non-nullable types
/// (e.g., <c>string</c> not <c>string?</c>) to allow <c>null</c> to indicate cancellation.
/// </typeparam>
/// <remarks>
/// <para>
/// By default, <see cref="Dialog{TResult}"/> is centered with <see cref="Dim.Auto"/> sizing and uses the
/// <see cref="Schemes.Dialog"/> color scheme when running.
/// </para>
/// <para>
/// To run modally, pass the dialog to <see cref="IApplication.Run(IRunnable, Func{Exception, bool})"/>.
/// The dialog executes until terminated by <see cref="Application.QuitKey"/> (Esc by default),
/// a press of one of the <see cref="Buttons"/>, or if any subview receives the <see cref="Command.Accept"/>
/// command
/// and does not handle it.
/// </para>
/// <para>
/// Buttons are added via <see cref="AddButton"/> or the <see cref="Buttons"/> property. The last button added
/// becomes the default (<see cref="Button.IsDefault"/>). Button alignment is controlled by
/// <see cref="ButtonAlignment"/> and <see cref="ButtonAlignmentModes"/>.
/// </para>
/// <para>
/// Subclasses should set <see cref="IRunnable{TResult}.Result"/> before calling <see cref="Runnable.RequestStop"/>
/// to return a value. If Result is not set (remains <c>null</c>), the dialog is considered canceled.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Custom dialog returning a Color
/// public class ColorDialog : Dialog&lt;Color&gt;
/// {
/// private ColorPicker _picker;
///
/// public ColorDialog (Color initialColor)
/// {
/// _picker = new () { Value = initialColor };
/// Add (_picker);
/// AddButton (new () { Text = "_Cancel" });
/// AddButton (new () { Text = "_Ok" });
/// }
///
/// protected override bool OnAccepting (CommandEventArgs args)
/// {
/// if (base.OnAccepting (args))
/// {
/// return true;
/// }
/// Result = _picker.Value;
/// RequestStop ();
/// return false;
/// }
/// }
/// </code>
/// </example>
public class Dialog<TResult> : Runnable<TResult>, IDesignable
{
/// <summary>
/// The container view that holds the dialog buttons in the Padding area.
/// </summary>
protected readonly View? _buttonContainer;
/// <summary>
/// Initializes a new instance of the <see cref="Dialog{TResult}"/> class with no buttons.
/// </summary>
/// <remarks>
/// The dialog is positioned at <see cref="Pos.Center"/> with <see cref="Dim.Auto"/> sizing,
/// limited to 100% of <see cref="IApplication.TopRunnableView"/> (or screen dimensions).
/// </remarks>
public Dialog ()
{
X = Pos.Center ();
Y = Pos.Center ();
// Set automatic width and height, with minimums based on content size. Also, subtract
// Padding thickness in case the scrollbar is visible
Width = Dim.Auto (minimumContentDim: Dim.Func (_ => GetMinimumDialogWidth () - (VerticalScrollBar.Visible ? 1 : 0)),
maximumContentDim: Dim.Percent (100) - Dim.Func (_ => GetAdornmentsThickness ().Horizontal));
Height = Dim.Auto (minimumContentDim: Dim.Func (_ => GetMinimumDialogHeight () - _minimumButtonsSize.Height - (HorizontalScrollBar.Visible ? 1 : 0)),
maximumContentDim: Dim.Percent (100) - Dim.Func (_ => GetAdornmentsThickness ().Vertical));
ButtonAlignment = Dialog.DefaultButtonAlignment;
ButtonAlignmentModes = Dialog.DefaultButtonAlignmentModes;
BorderStyle = Dialog.DefaultBorderStyle;
base.ShadowStyle = Dialog.DefaultShadow;
SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Dialog);
_buttonContainer = new View
{
Id = "Dialog.ButtonContainer",
CanFocus = true,
X = 0,
Y = Pos.AnchorEnd (),
Width = Dim.Fill (),
Height = Dim.Auto ()
};
Padding!.Add (_buttonContainer);
SetStyle ();
AddCommand (Command.Accept,
ctx =>
{
View? isDefaultView = _buttonContainer?.GetSubViews (includePadding: true).FirstOrDefault (v => v is Button { IsDefault: true });
if (isDefaultView == this || isDefaultView is not Button { IsDefault: true })
{
return RaiseAccepting (ctx);
}
bool? handled = isDefaultView.InvokeCommand (Command.Accept, ctx);
if (handled == true)
{
return true;
}
return RaiseAccepting (ctx);
});
}
private Size _minimumSubViewsSize;
/// <inheritdoc/>
public override void EndInit ()
{
base.EndInit ();
UpdateSizes ();
}
/// <inheritdoc/>
protected override void OnSubViewAdded (View view)
{
_minimumSubViewsSize = new Size (GetWidthRequiredForSubViews (), GetHeightRequiredForSubViews ());
UpdateSizes ();
base.OnSubViewAdded (view);
}
/// <inheritdoc/>
protected override void OnSubViewLayout (LayoutEventArgs args)
{
// HACK: Ensure scrollbars are shown as needed before calculating sizes
VerticalScrollBar.AutoShow = true;
HorizontalScrollBar.AutoShow = true;
UpdateSizes ();
base.OnSubViewLayout (args);
}
#pragma warning disable TGUI001
/// <summary>
/// Propagates the buttons in the Padding Accepting events to the Dialog's Accepting event.
/// </summary>
private void OnDialogButtonOnAccepting (object? s, CommandEventArgs e)
{
if (e.Handled || !IsRunning)
{
return;
}
e.Handled = RaiseAccepting (e.Context) is true;
if (e.Handled)
{
return;
}
// Only RequestStop if we are running (modal)
if (!IsRunning)
{
return;
}
e.Handled = true;
RequestStop ();
}
#pragma warning restore
/// <summary>
/// Overrides the default Accepting behavior to handle Dialog Button presses.
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
protected override bool OnAccepting (CommandEventArgs args)
{
if (!args.Context.TryGetSource (out View? sourceView) || !Buttons.Contains (sourceView))
{
return false;
}
if (Buttons.FirstOrDefault (v => v is Button { IsDefault: true }) == sourceView)
{
// Default button pressed
return false;
}
// Any other Dialog Button pressed
if (IsRunning)
{
RequestStop ();
}
return true;
}
private void UpdateSizes ()
{
if (SubViews.Count == 0)
{
// This is primarily to support MessageBox where there are no subviews but
// Text is used.
return;
}
int subViewsWidth = _minimumSubViewsSize.Width;
if (!Width.Has<DimAuto> (out _))
{
subViewsWidth = Math.Max (subViewsWidth, Viewport.Width);
}
int subViewsHeight = _minimumSubViewsSize.Height;
if (!Height.Has<DimAuto> (out _))
{
subViewsHeight = Math.Max (subViewsHeight, Viewport.Height);
}
SetContentSize (new Size (Math.Max (_minimumButtonsSize.Width, subViewsWidth), Math.Max (_minimumButtonsSize.Height, subViewsHeight)));
}
/// <summary>
/// INTERNAL: Gets the minimum width required for the <see cref="Dialog{TResult}"/>. This takes into account
/// the width required for the title, the buttons, and any subviews.
/// </summary>
/// <returns></returns>
private int GetMinimumDialogWidth ()
{
int minSize = Math.Max (Math.Max (_minimumSubViewsSize.Width,
// Ensure space for title + borders
Title.GetColumns () + 4),
_minimumButtonsSize.Width);
return minSize;
}
/// <summary>
/// INTERNAL: Gets the minimum height required for the <see cref="Dialog{TResult}"/>. This takes into account
/// the height required for the buttons.
/// </summary>
/// <returns></returns>
private int GetMinimumDialogHeight ()
{
int minSize = Math.Max (_minimumSubViewsSize.Height, _minimumButtonsSize.Height - Border!.Thickness.Vertical - Margin!.Thickness.Vertical);
return minSize;
}
private readonly List<Button> _buttons = [];
private Size _minimumButtonsSize;
/// <summary>
/// Adds a <see cref="Button"/> to the bottom of the dialog and to the <see cref="Buttons"/> collection.
/// </summary>
/// <param name="dialogButton">The Dialog button to add. Its lifetime and layout will be managed by the Dialog.</param>
/// <remarks>
/// <para>
/// Buttons are positioned according to <see cref="ButtonAlignment"/> and <see cref="ButtonAlignmentModes"/>.
/// The last button added becomes the default (<see cref="Button.IsDefault"/> = <see langword="true"/>).
/// </para>
/// <para>
/// When a button is pressed, the dialog's <see cref="View.Accepting"/> event is raised.
/// </para>
/// </remarks>
public void AddButton (Button dialogButton)
{
// Use a distinct GroupId so users can use Pos.Align for other views in the Dialog
dialogButton.X = Pos.Align (ButtonAlignment, ButtonAlignmentModes, GetHashCode ());
dialogButton.Y = 1;
_buttons.Add (dialogButton);
foreach (Button button in _buttons)
{
button.IsDefault = false;
button.Accepting -= OnDialogButtonOnAccepting;
button.Accepting += OnDialogButtonOnAccepting;
}
dialogButton.IsDefault = true;
_buttonContainer?.Add (dialogButton);
Padding!.Thickness = Padding!.Thickness with { Bottom = _buttonContainer!.GetHeightRequiredForSubViews () };
_minimumButtonsSize = new Size (_buttonContainer?.GetWidthRequiredForSubViews () ?? 0, _buttonContainer?.GetHeightRequiredForSubViews () ?? 0);
}
/// <summary>
/// Determines how buttons are aligned horizontally at the bottom of the dialog.
/// </summary>
/// <remarks>
/// Default is <see cref="Dialog.DefaultButtonAlignment"/> (typically <see cref="Alignment.End"/>).
/// </remarks>
public Alignment ButtonAlignment
{
get;
set
{
if (field == value)
{
return;
}
field = value;
foreach (Button dialogButton in _buttons)
{
dialogButton.X = Pos.Align (ButtonAlignment, ButtonAlignmentModes, GetHashCode ());
}
}
}
/// <summary>
/// Controls button spacing and alignment behavior.
/// </summary>
/// <remarks>
/// Default is <see cref="Dialog.DefaultButtonAlignmentModes"/> (typically
/// <see cref="AlignmentModes.StartToEnd"/> | <see cref="AlignmentModes.AddSpaceBetweenItems"/>).
/// </remarks>
public AlignmentModes ButtonAlignmentModes
{
get;
set
{
if (field == value)
{
return;
}
field = value;
foreach (Button dialogButton in _buttons)
{
dialogButton.X = Pos.Align (ButtonAlignment, ButtonAlignmentModes, GetHashCode ());
}
UpdateSizes ();
}
}
// BUGBUG: The setter of this property does not clear existing buttons, leading to potential duplicates.
// BUGBUG: Also, Buttons.Clear does not remove buttons from the _buttonContainer view (or even _buttons) because
// BUGBUG: the getter returns a copy of the internal list.
/// <summary>
/// Gets or sets the buttons displayed at the bottom of the dialog.
/// </summary>
/// <remarks>
/// Setting this property calls <see cref="AddButton"/> for each button in the array.
/// Getting returns a copy of the internal button list.
/// </remarks>
public Button [] Buttons
{
get => _buttons.ToArray ();
set
{
foreach (Button b in value)
{
AddButton (b);
}
}
}
/// <inheritdoc/>
protected override void OnIsRunningChanged (bool newIsModal)
{
base.OnIsRunningChanged (newIsModal);
if (newIsModal)
{
SetStyle ();
}
}
private void SetStyle ()
{
if (IsRunning)
{
Arrangement |= ViewArrangement.Movable | ViewArrangement.Resizable | ViewArrangement.Overlapped;
}
else
{
SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Base);
// strip out movable and resizable
Arrangement &= ~(ViewArrangement.Movable | ViewArrangement.Resizable);
}
}
// Dialogs are Modal and Focus is indicated by their Border. _drawingText ensures the
// Text of the dialog (e.g. for a MessageBox) is always drawn using the Normal Attribute
// instead of the Focus attribute.
private bool _drawingText;
/// <inheritdoc/>
protected override bool OnDrawingText ()
{
_drawingText = true;
return false;
}
/// <inheritdoc/>
protected override void OnDrewText () => _drawingText = false;
/// <inheritdoc/>
protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attribute currentAttribute)
{
if (!IsRunning)
{
return false;
}
if (!_drawingText || role is not VisualRole.Focus || Border?.Thickness == Thickness.Empty)
{
return false;
}
currentAttribute = GetScheme ().Normal;
return true;
}
bool IDesignable.EnableForDesign ()
{
Title = "Dialog Title";
AddButton (new Button { Id = "btnCancel", Title = Strings.btnCancel });
AddButton (new Button
{
Id = "btnOk", Title = Strings.btnOk
// Dialog will automatically set IsDefault to the last button added
});
// Add some example content to the dialog
Label infoLabel = new () { Id = "infoLabel", Text = "_Example:" };
TextField info = new ()
{
Id = "info",
X = Pos.Right (infoLabel) + 1,
Y = Pos.Top (infoLabel),
Text = "Type and press ENTER to accept.",
Width = 40
};
Add (infoLabel, info);
Accepting += (s, e) =>
{
if (e.Handled || !IsRunning)
{
return;
}
(s as View)?.App?.RequestStop ();
e.Handled = true;
};
return true;
}
}