diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/TreeViewEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/TreeViewEditor.cs
index 9b6838a79c..2b119ec094 100644
--- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/TreeViewEditor.cs
+++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/TreeViewEditor.cs
@@ -9,6 +9,7 @@ namespace UICatalog.Scenarios;
public sealed class TreeViewEditor : EditorBase
{
private CheckBox? _cbMultiSelect;
+ private CheckBox? _cbCheckboxMode;
private CheckBox? _cbShowBranchLines;
private CheckBox? _cbColorExpandSymbol;
private CheckBox? _cbInvertExpandSymbolColors;
@@ -66,6 +67,7 @@ private void SyncFromView ()
}
_cbMultiSelect!.Value = treeView.MultiSelect ? CheckState.Checked : CheckState.UnChecked;
+ _cbCheckboxMode!.Value = treeView.CheckboxMode ? CheckState.Checked : CheckState.UnChecked;
_cbShowBranchLines!.Value = treeView.Style.ShowBranchLines ? CheckState.Checked : CheckState.UnChecked;
_cbColorExpandSymbol!.Value = treeView.Style.ColorExpandSymbol ? CheckState.Checked : CheckState.UnChecked;
_cbInvertExpandSymbolColors!.Value = treeView.Style.InvertExpandSymbolColors ? CheckState.Checked : CheckState.UnChecked;
@@ -81,10 +83,13 @@ private void TreeViewEditor_Initialized (object? s, EventArgs e)
_cbMultiSelect = new CheckBox { Title = "MultiSelect", CanFocus = true };
_cbMultiSelect.ValueChanging += (_, args) => SetBool (args, (tv, v) => tv.MultiSelect = v);
+ _cbCheckboxMode = new CheckBox { X = Pos.Right (_cbMultiSelect) + 1, Title = "CheckboxMode", CanFocus = true };
+ _cbCheckboxMode.ValueChanging += (_, args) => SetBool (args, (tv, v) => tv.CheckboxMode = v);
+
_cbShowBranchLines = new CheckBox { Y = Pos.Bottom (_cbMultiSelect), Title = "ShowBranchLines", CanFocus = true };
_cbShowBranchLines.ValueChanging += (_, args) => SetStyleBool (args, (style, v) => style.ShowBranchLines = v);
- _cbColorExpandSymbol = new CheckBox { X = Pos.Right (_cbMultiSelect) + 1, Title = "ColorExpandSymbol", CanFocus = true };
+ _cbColorExpandSymbol = new CheckBox { X = Pos.Right (_cbCheckboxMode) + 1, Title = "ColorExpandSymbol", CanFocus = true };
_cbColorExpandSymbol.ValueChanging += (_, args) => SetStyleBool (args, (style, v) => style.ColorExpandSymbol = v);
_cbInvertExpandSymbolColors = new CheckBox
@@ -189,6 +194,7 @@ private void TreeViewEditor_Initialized (object? s, EventArgs e)
};
Add (_cbMultiSelect,
+ _cbCheckboxMode,
_cbColorExpandSymbol,
_cbShowBranchLines,
_cbInvertExpandSymbolColors,
diff --git a/Terminal.Gui/Views/TreeView/Branch.cs b/Terminal.Gui/Views/TreeView/Branch.cs
index 95fc64d400..9d167e4a16 100644
--- a/Terminal.Gui/Views/TreeView/Branch.cs
+++ b/Terminal.Gui/Views/TreeView/Branch.cs
@@ -95,6 +95,11 @@ public virtual void Draw (int y, int availableWidth)
string expansion = GetExpandableSymbol ();
string lineBody = _tree.AspectGetter (Model);
+ if (_tree.CheckboxMode)
+ {
+ lineBody = $"{_tree.GetCheckGlyph (Model)} {lineBody}";
+ }
+
_tree.Move (0, y);
// if we have scrolled to the right then bits of the prefix will have disappeared off the screen
@@ -160,8 +165,10 @@ public virtual void Draw (int y, int availableWidth)
if (toSkip > 0)
{
// For the event record a negative location for where model text starts since it
- // is pushed off to the left because of scrolling
- indexOfModelText = -toSkip;
+ // is pushed off to the left because of scrolling.
+ // Account for checkbox cells (glyph + space) prepended to lineBody.
+ int checkboxOffset = _tree.CheckboxMode ? 2 : 0;
+ indexOfModelText = -(toSkip - checkboxOffset);
if (toSkip > lineBody.Length)
{
@@ -174,7 +181,7 @@ public virtual void Draw (int y, int availableWidth)
}
else
{
- indexOfModelText = cells.Count;
+ indexOfModelText = cells.Count + (_tree.CheckboxMode ? 2 : 0);
}
// If body of line is too long
@@ -296,7 +303,10 @@ public string GetExpandableSymbol ()
///
///
public virtual int GetWidth () =>
- GetLinePrefix ().Sum (r => r.GetColumns ()) + GetExpandableSymbol ().GetColumns () + _tree.AspectGetter (Model).GetColumns ();
+ GetLinePrefix ().Sum (r => r.GetColumns ())
+ + GetExpandableSymbol ().GetColumns ()
+ + (_tree.CheckboxMode ? 2 : 0)
+ + _tree.AspectGetter (Model).GetColumns ();
/// Refreshes cached knowledge in this branch e.g. what children an object has.
/// True to also refresh all branches (starting with the root).
@@ -462,6 +472,11 @@ internal bool IsHitOnExpandableSymbol (int x)
return false;
}
+ /// Returns true if the given x offset on the branch line is the checkbox glyph.
+ /// The x offset on the branch line.
+ /// if checkbox mode is enabled and hits the checkbox glyph.
+ internal bool IsHitOnCheckbox (int x) => _tree.CheckboxMode && x == GetLinePrefix ().Count () + GetExpandableSymbol ().GetColumns ();
+
/// Calls on the current branch and all expanded children.
internal void Rebuild ()
{
diff --git a/Terminal.Gui/Views/TreeView/CheckedChangedEventArgs.cs b/Terminal.Gui/Views/TreeView/CheckedChangedEventArgs.cs
new file mode 100644
index 0000000000..e64fca3a93
--- /dev/null
+++ b/Terminal.Gui/Views/TreeView/CheckedChangedEventArgs.cs
@@ -0,0 +1,18 @@
+namespace Terminal.Gui.Views;
+
+/// Describes a tree node check state change.
+/// The type of object represented by nodes in the tree.
+public class CheckedChangedEventArgs (TreeView tree, T @object, CheckState oldValue, CheckState newValue) : EventArgs where T : class
+{
+ /// The tree whose node check state changed.
+ public TreeView Tree { get; } = tree;
+
+ /// The object whose check state changed.
+ public T Object { get; } = @object;
+
+ /// The previous check state.
+ public CheckState OldValue { get; } = oldValue;
+
+ /// The new check state.
+ public CheckState NewValue { get; } = newValue;
+}
diff --git a/Terminal.Gui/Views/TreeView/ITreeView.cs b/Terminal.Gui/Views/TreeView/ITreeView.cs
index 9d4706f9e8..044bec4922 100644
--- a/Terminal.Gui/Views/TreeView/ITreeView.cs
+++ b/Terminal.Gui/Views/TreeView/ITreeView.cs
@@ -12,6 +12,9 @@ public interface ITreeView
/// Gets or sets whether the user can navigate the tree using letter keys.
bool AllowLetterBasedNavigation { get; set; }
+ /// Gets or sets whether built-in checkbox rendering and toggling is enabled for tree nodes.
+ bool CheckboxMode { get; set; }
+
/// Gets or sets the maximum depth to which the tree will expand.
int MaxDepth { get; set; }
diff --git a/Terminal.Gui/Views/TreeView/TreeView.Navigation.cs b/Terminal.Gui/Views/TreeView/TreeView.Navigation.cs
index e68daf8ecd..62cd9b5d0d 100644
--- a/Terminal.Gui/Views/TreeView/TreeView.Navigation.cs
+++ b/Terminal.Gui/Views/TreeView/TreeView.Navigation.cs
@@ -350,6 +350,48 @@ public IEnumerable GetAllSelectedObjects ()
}
}
+ /// Returns all objects whose effective check state is .
+ /// Checked objects in tree order, followed by any checked objects that are not currently in the tree.
+ public IEnumerable GetCheckedObjects ()
+ {
+ HashSet yielded = [];
+
+ foreach (T model in EnumerateKnownObjects ())
+ {
+ if (GetCheckState (model) == CheckState.Checked && yielded.Add (model))
+ {
+ yield return model;
+ }
+ }
+
+ foreach (T model in _checkedStates.Keys)
+ {
+ if (GetCheckState (model) == CheckState.Checked && yielded.Add (model))
+ {
+ yield return model;
+ }
+ }
+ }
+
+ /// Gets the effective check state of , derived from children if applicable.
+ /// The node object.
+ /// The effective check state.
+ public CheckState GetCheckState (T? o) => o is null ? CheckState.UnChecked : GetEffectiveCheckState (o, []);
+
+ /// Sets the explicit check state of and propagates to all descendants.
+ /// The node object.
+ /// The new check state.
+ public void SetChecked (T? o, CheckState state)
+ {
+ if (o is null)
+ {
+ return;
+ }
+
+ SetCheckedRecursive (o, state);
+ SetNeedsDraw ();
+ }
+
///
/// Returns the currently expanded children of the passed object. Returns an empty collection if the branch is not
/// exposed or not expanded.
@@ -640,6 +682,14 @@ protected virtual void Space ()
{
return;
}
+
+ if (CheckboxMode)
+ {
+ ToggleChecked (SelectedObject);
+
+ return;
+ }
+
Toggle (SelectedObject);
}
@@ -648,4 +698,197 @@ protected virtual void Space ()
// TODO: Refactor to use CWP
protected virtual void OnSelectionChanged (SelectionChangedEventArgs e) => SelectionChanged?.Invoke (this, e);
+
+ private IEnumerable EnumerateKnownObjects ()
+ {
+ if (Roots is null)
+ {
+ yield break;
+ }
+
+ HashSet seen = [];
+
+ foreach (T root in Roots.Keys)
+ {
+ foreach (T model in EnumerateKnownObjects (root, seen, 0))
+ {
+ yield return model;
+ }
+ }
+ }
+
+ private IEnumerable EnumerateKnownObjects (T model, HashSet seen, int depth)
+ {
+ if (!seen.Add (model))
+ {
+ yield break;
+ }
+
+ yield return model;
+
+ if (depth >= MaxDepth || TreeBuilder is null)
+ {
+ yield break;
+ }
+
+ foreach (T child in TreeBuilder.GetChildren (model))
+ {
+ foreach (T descendant in EnumerateKnownObjects (child, seen, depth + 1))
+ {
+ yield return descendant;
+ }
+ }
+ }
+
+ internal Rune GetCheckGlyph (T model) =>
+ GetCheckState (model) switch
+ {
+ CheckState.Checked => Glyphs.CheckStateChecked,
+ CheckState.UnChecked => Glyphs.CheckStateUnChecked,
+ CheckState.None => Glyphs.CheckStateNone,
+ _ => throw new ArgumentOutOfRangeException ()
+ };
+
+ private CheckState GetEffectiveCheckState (T model, HashSet visited)
+ {
+ if (!visited.Add (model))
+ {
+ return _checkedStates.GetValueOrDefault (model, CheckState.UnChecked);
+ }
+
+ CheckState explicitState = _checkedStates.GetValueOrDefault (model, CheckState.UnChecked);
+
+ if (TreeBuilder is null)
+ {
+ return explicitState;
+ }
+
+ // Derive state from known children (ChildBranches). These are preserved even after
+ // collapse, so we can derive correct tri-state without calling TreeBuilder.GetChildren.
+ Branch? branch = ObjectToBranch (model);
+
+ if (branch is not { ChildBranches: { Count: > 0 } childBranches })
+ {
+ return explicitState;
+ }
+
+ CheckState [] childStates = childBranches
+ .Select (child => GetEffectiveCheckState (child.Model, visited))
+ .ToArray ();
+
+ if (childStates.All (state => state is CheckState.Checked))
+ {
+ return CheckState.Checked;
+ }
+
+ if (childStates.All (state => state is CheckState.UnChecked))
+ {
+ return CheckState.UnChecked;
+ }
+
+ // Mixed states among children - indeterminate
+ return CheckState.None;
+ }
+
+ private void SetCheckedRecursive (T model, CheckState state)
+ {
+ SetCheckedRecursive (model, state, []);
+ }
+
+ private void SetCheckedRecursive (T model, CheckState state, HashSet visited)
+ {
+ if (!visited.Add (model))
+ {
+ return;
+ }
+
+ SetCheckedCore (model, state);
+
+ if (TreeBuilder is null)
+ {
+ return;
+ }
+
+ foreach (T child in TreeBuilder.GetChildren (model))
+ {
+ SetCheckedRecursive (child, state, visited);
+ }
+ }
+
+ private void SetCheckedCore (T model, CheckState state)
+ {
+ CheckState oldEffective = GetCheckState (model);
+
+ if (oldEffective == state)
+ {
+ return;
+ }
+
+ _checkedStates [model] = state;
+ CheckedChanged?.Invoke (this, new CheckedChangedEventArgs (this, model, oldEffective, state));
+ }
+
+ private void ToggleChecked (T model)
+ {
+ CheckState current = GetCheckState (model);
+
+ // If effective state is not UnChecked, toggle to UnChecked
+ if (current != CheckState.UnChecked)
+ {
+ SetChecked (model, CheckState.UnChecked);
+
+ return;
+ }
+
+ // Effective state is UnChecked - but for collapsed nodes, descendants might
+ // still be explicitly checked in the dictionary. Check for that case.
+ if (HasCheckedDescendantsInState (model))
+ {
+ SetChecked (model, CheckState.UnChecked);
+
+ return;
+ }
+
+ SetChecked (model, CheckState.Checked);
+ }
+
+ private bool HasCheckedDescendantsInState (T model)
+ {
+ if (TreeBuilder is null)
+ {
+ return false;
+ }
+
+ HashSet visited = [];
+
+ return HasCheckedDescendantsRecursive (model, visited);
+ }
+
+ private bool HasCheckedDescendantsRecursive (T model, HashSet visited)
+ {
+ if (!visited.Add (model))
+ {
+ return false;
+ }
+
+ if (TreeBuilder is null)
+ {
+ return false;
+ }
+
+ foreach (T child in TreeBuilder.GetChildren (model))
+ {
+ if (_checkedStates.TryGetValue (child, out CheckState state) && state == CheckState.Checked)
+ {
+ return true;
+ }
+
+ if (HasCheckedDescendantsRecursive (child, visited))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
}
diff --git a/Terminal.Gui/Views/TreeView/TreeViewT.cs b/Terminal.Gui/Views/TreeView/TreeViewT.cs
index 2d745289a8..2a6fafa741 100644
--- a/Terminal.Gui/Views/TreeView/TreeViewT.cs
+++ b/Terminal.Gui/Views/TreeView/TreeViewT.cs
@@ -296,6 +296,26 @@ public TreeView ()
///
public ITreeViewFilter? Filter { get; set; } = null;
+ /// Enables built-in checkbox rendering and toggling for tree nodes.
+ public bool CheckboxMode
+ {
+ get;
+ set
+ {
+ if (field == value)
+ {
+ return;
+ }
+
+ field = value;
+ UpdateContentSize ();
+ SetNeedsDraw ();
+ }
+ }
+
+ /// Raised when a node check state changes.
+ public event EventHandler>? CheckedChanged;
+
/// True makes a letter key press navigate to the next visible branch that begins with that letter/digit.
public bool AllowLetterBasedNavigation { get; set; } = true;
@@ -393,11 +413,15 @@ public T? SelectedObject
/// Secondary selected regions of tree when is true.
private readonly Stack> _multiSelectedRegions = new ();
+ /// Explicit check states for nodes in checkbox mode.
+ private readonly Dictionary _checkedStates = new ();
+
/// Removes all objects from the tree and clears .
public void ClearObjects ()
{
SelectedObject = null;
_multiSelectedRegions.Clear ();
+ _checkedStates.Clear ();
Roots = new Dictionary> ();
InvalidateLineMap ();
SetNeedsDraw ();
@@ -408,18 +432,9 @@ protected override void OnActivated (ICommandContext? ctx)
{
base.OnActivated (ctx);
- T? o = SelectedObject;
-
- if (o is null)
- {
- return;
- }
-
- var isExpandToggleAttempt = true;
-
+ // Mouse activation: use hit-test to find the clicked branch directly
if (ctx?.Binding is MouseBinding { MouseEvent: { } } mouseBinding)
{
- // The line they clicked on a branch
Branch? clickedBranch = HitTest (mouseBinding.MouseEvent.Position!.Value.Y);
if (clickedBranch is null)
@@ -429,13 +444,37 @@ protected override void OnActivated (ICommandContext? ctx)
SelectedObject = clickedBranch.Model;
- isExpandToggleAttempt = clickedBranch.IsHitOnExpandableSymbol (mouseBinding.MouseEvent.Position!.Value.X);
+ if (clickedBranch.IsHitOnCheckbox (mouseBinding.MouseEvent.Position!.Value.X))
+ {
+ ToggleChecked (clickedBranch.Model);
+
+ return;
+ }
+
+ if (clickedBranch.IsHitOnExpandableSymbol (mouseBinding.MouseEvent.Position!.Value.X))
+ {
+ Toggle (clickedBranch.Model);
+ }
+
+ return;
}
- if (isExpandToggleAttempt)
+ // Keyboard activation: operate on currently selected object
+ T? o = SelectedObject;
+
+ if (o is null)
{
- Toggle (SelectedObject);
+ return;
}
+
+ if (CheckboxMode)
+ {
+ ToggleChecked (o);
+
+ return;
+ }
+
+ Toggle (o);
}
///
@@ -561,6 +600,11 @@ public void Remove (T o)
return;
}
+ foreach (T model in EnumerateKnownObjects (o, [], 0))
+ {
+ _checkedStates.Remove (model);
+ }
+
Roots.Remove (o);
InvalidateLineMap ();
SetNeedsDraw ();
diff --git a/Tests/UnitTestsParallelizable/Views/TreeViewTests.cs b/Tests/UnitTestsParallelizable/Views/TreeViewTests.cs
index 1e92fd9fb5..3c5056f0f7 100644
--- a/Tests/UnitTestsParallelizable/Views/TreeViewTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TreeViewTests.cs
@@ -217,6 +217,483 @@ public void Command_Toggle_ExpandCollapse ()
tree.Dispose ();
}
+ // Copilot
+ [Fact]
+ public void CheckboxMode_Space_Toggles_Checked_State_Not_Expansion ()
+ {
+ TreeView