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 tree = CreateTree (out Factory f, out Car car1, out Car car2); + tree.CheckboxMode = true; + tree.SelectedObject = f; + + List checkedObjects = []; + tree.CheckedChanged += (_, e) => + { + checkedObjects.Add (e.Object!); + }; + + tree.NewKeyDownEvent (Key.Space); + + Assert.False (tree.IsExpanded (f)); + Assert.Equal (CheckState.Checked, tree.GetCheckState (f)); + + // Propagation: f and all its children should be checked + Assert.Equal (CheckState.Checked, tree.GetCheckState (car1)); + Assert.Equal (CheckState.Checked, tree.GetCheckState (car2)); + + // Events fire for each node that changed + Assert.Contains (f, checkedObjects); + Assert.Contains (car1, checkedObjects); + Assert.Contains (car2, checkedObjects); + } + + // Copilot + [Fact] + public void CheckboxMode_Draws_Checkbox_Glyphs_And_Indeterminate_Parent () + { + IDriver driver = CreateTestDriver (); + TreeView tree = CreateTree (out Factory f, out Car car1, out _); + tree.Driver = driver; + tree.CheckboxMode = true; + tree.Frame = new Rectangle (0, 0, 20, 3); + tree.Expand (f); + + tree.SetChecked (car1, CheckState.Checked); + tree.Draw (); + + DriverAssert.AssertDriverContentsAre ($""" + └-{Glyphs.CheckStateNone} Factory + ├─{Glyphs.CheckStateChecked} + └─{Glyphs.CheckStateUnChecked} + """, + output, + driver); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_MouseClick_OnCheckbox_Toggles_Check () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 20, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + root.Children.Add (new TreeNode { Text = "Child1" }); + tree.AddObject (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // Layout (no border): └ + ☐ · R o o t + // Pos: 0 1 2 3 4 5 6 7 + // Checkbox is at screen position x=2 + + app.InjectSequence (InputInjectionExtensions.LeftButtonClick (new Point (2, 0))); + + Assert.False (tree.IsExpanded (root)); + Assert.Equal (CheckState.Checked, tree.GetCheckState (root)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void MouseClick_OnExpandSymbol_Expands_Node () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 20, Height = 5 }; + + TreeNode root = new () { Text = "Root" }; + root.Children.Add (new TreeNode { Text = "Child1" }); + tree.AddObject (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // Layout (no border): └ + R o o t + // Pos: 0 1 2 3 4 5 + // Expand symbol at position 1 + + app.InjectSequence (InputInjectionExtensions.LeftButtonClick (new Point (1, 0))); + + Assert.True (tree.IsExpanded (root)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_MouseClick_OnExpandSymbol_Expands_Not_Toggles_Check () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 20, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + root.Children.Add (new TreeNode { Text = "Child1" }); + tree.AddObject (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // Layout (no border): └ + ☐ · R o o t + // Pos: 0 1 2 3 4 5 6 7 + // Expand symbol at position 1, checkbox at position 2 + + app.InjectSequence (InputInjectionExtensions.LeftButtonClick (new Point (1, 0))); + + Assert.True (tree.IsExpanded (root)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_TriState_Parent_Reflects_Children () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 20, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + TreeNode child1 = new () { Text = "C1" }; + TreeNode child2 = new () { Text = "C2" }; + root.Children.Add (child1); + root.Children.Add (child2); + tree.AddObject (root); + tree.Expand (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + + // Initially all unchecked + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + + // Check one child → parent becomes indeterminate + tree.SetChecked (child1, CheckState.Checked); + Assert.Equal (CheckState.None, tree.GetCheckState (root)); + Assert.Equal (CheckState.Checked, tree.GetCheckState (child1)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child2)); + + // Check all children → parent becomes checked + tree.SetChecked (child2, CheckState.Checked); + Assert.Equal (CheckState.Checked, tree.GetCheckState (root)); + + // Uncheck one child → parent becomes indeterminate again + tree.SetChecked (child1, CheckState.UnChecked); + Assert.Equal (CheckState.None, tree.GetCheckState (root)); + + // Uncheck all children → parent becomes unchecked + tree.SetChecked (child2, CheckState.UnChecked); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_Toggling_Parent_Propagates_To_Children () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 20, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + TreeNode child1 = new () { Text = "C1" }; + TreeNode child2 = new () { Text = "C2" }; + root.Children.Add (child1); + root.Children.Add (child2); + tree.AddObject (root); + tree.Expand (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + + // Toggle parent ON → all children become checked + tree.SetChecked (root, CheckState.Checked); + Assert.Equal (CheckState.Checked, tree.GetCheckState (root)); + Assert.Equal (CheckState.Checked, tree.GetCheckState (child1)); + Assert.Equal (CheckState.Checked, tree.GetCheckState (child2)); + + // Toggle parent OFF → all children become unchecked + tree.SetChecked (root, CheckState.UnChecked); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child1)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child2)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_Toggle_Parent_When_All_Children_Checked_Unchecks_All () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 20, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + TreeNode child1 = new () { Text = "C1" }; + TreeNode child2 = new () { Text = "C2" }; + root.Children.Add (child1); + root.Children.Add (child2); + tree.AddObject (root); + tree.Expand (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // Check all children so parent derives as Checked + tree.SetChecked (child1, CheckState.Checked); + tree.SetChecked (child2, CheckState.Checked); + Assert.Equal (CheckState.Checked, tree.GetCheckState (root)); + + // Select root and press Space to toggle it OFF + tree.SelectedObject = root; + tree.NewKeyDownEvent (Key.Space); + + // Parent and all children should now be unchecked + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child1)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child2)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_MouseClick_Uncheck_Parent_When_Children_Checked () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 20, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + TreeNode child1 = new () { Text = "C1" }; + TreeNode child2 = new () { Text = "C2" }; + root.Children.Add (child1); + root.Children.Add (child2); + tree.AddObject (root); + tree.Expand (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // Check all children so parent derives as Checked + tree.SetChecked (child1, CheckState.Checked); + tree.SetChecked (child2, CheckState.Checked); + Assert.Equal (CheckState.Checked, tree.GetCheckState (root)); + + // Click on root's checkbox glyph (x=2 for expanded root with branch lines) + app.InjectSequence (InputInjectionExtensions.LeftButtonClick (new Point (2, 0))); + + // Parent and all children should now be unchecked + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child1)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child2)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_MouseClick_Children_Then_Uncheck_Parent () + { + // Reproduce exact user scenario: click child1 cb, click child2 cb, then click root cb + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 30, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + TreeNode child1 = new () { Text = "C1" }; + TreeNode child2 = new () { Text = "C2" }; + root.Children.Add (child1); + root.Children.Add (child2); + tree.AddObject (root); + tree.Expand (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // Layout with ShowBranchLines=true (default), CheckboxMode=true: + // GetLinePrefix yields: for each parent 2 elements (line + space), then 1 junction element. + // IsHitOnCheckbox = GetLinePrefix().Count() + GetExpandableSymbol().GetColumns() + // Root (depth=0): prefix count=1 (junction only), expand=1 col → checkbox at x=2 + // Children (depth=1): prefix count=3 (parent line+space + junction), expand=1 col → checkbox at x=4 + + // Click child1's checkbox at (4, 1) + app.InjectSequence (InputInjectionExtensions.LeftButtonClick (new Point (4, 1))); + Assert.Equal (CheckState.Checked, tree.GetCheckState (child1)); + Assert.Equal (CheckState.None, tree.GetCheckState (root)); // indeterminate + + // Click child2's checkbox at (4, 2) + app.InjectSequence (InputInjectionExtensions.LeftButtonClick (new Point (4, 2))); + Assert.Equal (CheckState.Checked, tree.GetCheckState (child2)); + Assert.Equal (CheckState.Checked, tree.GetCheckState (root)); // all children checked + + // Click root's checkbox at (2, 0) to uncheck everything + app.InjectSequence (InputInjectionExtensions.LeftButtonClick (new Point (2, 0))); + + // Parent and all children should now be unchecked + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child1)); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (child2)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_Collapsed_Parent_Shows_Indeterminate_When_One_Child_Checked () + { + // When only one child is checked and parent is collapsed, parent should show + // indeterminate (None), not Checked or UnChecked. + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 30, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + TreeNode child1 = new () { Text = "C1" }; + TreeNode child2 = new () { Text = "C2" }; + root.Children.Add (child1); + root.Children.Add (child2); + tree.AddObject (root); + tree.Expand (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // Check only child1 - parent should be indeterminate + tree.SetChecked (child1, CheckState.Checked); + Assert.Equal (CheckState.None, tree.GetCheckState (root)); + + // Collapse root - should STILL show indeterminate + tree.Collapse (root); + Assert.Equal (CheckState.None, tree.GetCheckState (root)); + + // Expand again - should still be indeterminate + tree.Expand (root); + Assert.Equal (CheckState.None, tree.GetCheckState (root)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_Collapsed_Parent_Shows_Checked_When_All_Children_Checked () + { + // When all children are checked and parent is collapsed, parent should show Checked. + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 30, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + TreeNode child1 = new () { Text = "C1" }; + TreeNode child2 = new () { Text = "C2" }; + root.Children.Add (child1); + root.Children.Add (child2); + tree.AddObject (root); + tree.Expand (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // Check both children + tree.SetChecked (child1, CheckState.Checked); + tree.SetChecked (child2, CheckState.Checked); + Assert.Equal (CheckState.Checked, tree.GetCheckState (root)); + + // Collapse root - should STILL show Checked + tree.Collapse (root); + Assert.Equal (CheckState.Checked, tree.GetCheckState (root)); + + top.Dispose (); + app.Dispose (); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_Collapsed_Parent_Shows_UnChecked_When_No_Children_Checked () + { + // When no children are checked and parent is collapsed, parent should show UnChecked. + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + TreeView tree = new () { Width = 30, Height = 5 }; + tree.CheckboxMode = true; + + TreeNode root = new () { Text = "Root" }; + TreeNode child1 = new () { Text = "C1" }; + TreeNode child2 = new () { Text = "C2" }; + root.Children.Add (child1); + root.Children.Add (child2); + tree.AddObject (root); + tree.Expand (root); + + Runnable top = new (); + top.Add (tree); + app.Begin (top); + app.LayoutAndDraw (); + + // No children checked - parent is UnChecked + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + + // Collapse root - should STILL show UnChecked + tree.Collapse (root); + Assert.Equal (CheckState.UnChecked, tree.GetCheckState (root)); + + top.Dispose (); + app.Dispose (); + } + [Fact] public void ContentWidth_BiggerAfterExpand () { @@ -896,5 +1373,57 @@ private TreeView CreateTree (out Factory factory1, out Car car1, out Car return tree; } + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_SetChecked_Handles_Cyclic_TreeBuilder () + { + // A tree builder that creates a cycle: A -> B -> A + object a = "A"; + object b = "B"; + + Dictionary graph = new () + { + { a, [b] }, + { b, [a] } + }; + + TreeView tree = new (new DelegateTreeBuilder ( + o => graph.TryGetValue (o, out object []? children) ? children : [], + _ => true)); + tree.CheckboxMode = true; + tree.AddObject (a); + + // Should not stack overflow - cycle protection should prevent infinite recursion + Exception? ex = Record.Exception (() => tree.SetChecked (a, CheckState.Checked)); + Assert.Null (ex); + } + + // Copilot - Opus 4.6 + [Fact] + public void CheckboxMode_GetCheckState_Handles_Cyclic_TreeBuilder () + { + // A tree builder that creates a cycle: A -> B -> A + object a = "A"; + object b = "B"; + + Dictionary graph = new () + { + { a, [b] }, + { b, [a] } + }; + + TreeView tree = new (new DelegateTreeBuilder ( + o => graph.TryGetValue (o, out object []? children) ? children : [], + _ => true)); + tree.CheckboxMode = true; + tree.AddObject (a); + + tree.SetChecked (a, CheckState.Checked); + + // Should not stack overflow when deriving state + Exception? ex = Record.Exception (() => tree.GetCheckState (a)); + Assert.Null (ex); + } + #endregion } diff --git a/docfx/docs/tableview.md b/docfx/docs/tableview.md index b0fc21ce40..629b1d6b13 100644 --- a/docfx/docs/tableview.md +++ b/docfx/docs/tableview.md @@ -309,6 +309,8 @@ tv.Table = src; Arrow Left/Right collapse/expand nodes when the tree column has focus. +> **Note:** `TreeTableSource` renders tree structure (branch lines, expand/collapse symbols) in column 0 but does **not** render checkboxes from `TreeView.CheckboxMode`. To add checkboxes to a tree-table, wrap the `TreeTableSource` with a `CheckBoxTableSourceWrapperByIndex` or `CheckBoxTableSourceWrapperByObject` (see [Checkbox Columns](#checkbox-columns) above). + --- ## Events diff --git a/docfx/docs/treeview.md b/docfx/docs/treeview.md index 27e8b73d1b..439d4382ab 100644 --- a/docfx/docs/treeview.md +++ b/docfx/docs/treeview.md @@ -36,6 +36,7 @@ Both share the same rendering, navigation, selection, and command behavior. - [Multi-Select](#multi-select) - [Letter-Based Navigation](#letter-based-navigation) - [Filtering](#filtering) +- [Checkbox Mode](#checkbox-mode) - [Dynamic Updates](#dynamic-updates) - [See Also](#see-also) @@ -187,7 +188,7 @@ TreeView integrates with the Terminal.Gui [command system](command.md). Input fl | Key | Command | Behavior | |-----|---------|----------| | **Enter** | `Command.Accept` | Raises `Accepting`/`Accepted` (CWP) | -| **Space** | `Command.Activate` | Raises `Activating`/`Activated`; toggles expand/collapse | +| **Space** | `Command.Activate` | Raises `Activating`/`Activated`; toggles expand/collapse (or toggles checkbox when `CheckboxMode` is enabled) | | **→** | `Command.Expand` | Expand selected node | | **Ctrl+→** | `Command.ExpandAll` | Expand node and all descendants | | **←** | `Command.Collapse` | Collapse selected node, or navigate to parent node | @@ -211,7 +212,7 @@ TreeView integrates with the Terminal.Gui [command system](command.md). Input fl | Input | Behavior | |-------|----------| -| **Single click** | Select the clicked node. If the click lands on the expand/collapse symbol (`+`/`-`), toggle expansion. | +| **Single click** | Select the clicked node. If the click lands on the expand/collapse symbol (`+`/`-`), toggle expansion. If `CheckboxMode` is enabled and the click lands on the checkbox glyph, toggle check state. | | **Double click** | Raises `Command.Accept` → fires `Accepting`/`Accepted` (CWP). Also toggles expand/collapse. | | **Wheel up/down** | Scroll viewport vertically | | **Wheel left/right** | Scroll viewport horizontally | @@ -390,6 +391,74 @@ When a filter is active, parent nodes leading to matches remain visible even if Set `Filter` to `null` to remove filtering. +## Checkbox Mode + +To enable built-in checkboxes, set `CheckboxMode = true`. Each node displays a checkbox glyph between the expand symbol and the text: + +```csharp +tree.CheckboxMode = true; +``` + +This produces: + +``` +├-☐ Root1 +│ ├─☐ Child1.1 +│ └─☐ Child1.2 +└-☐ Root2 +``` + +### Tri-State Behavior + +TreeView implements standard tri-state checkbox semantics: + +| Parent State | Meaning | +|-------------|---------| +| **Unchecked** | No descendants are checked | +| **Checked** | All descendants are checked | +| **Indeterminate** | Some (but not all) descendants are checked | + +- **Toggling a parent** propagates the new state to all descendants. +- **Toggling a leaf** updates ancestor states automatically via derivation. +- The indeterminate state is always derived — it cannot be set directly by the user. + +### Interacting with Checkboxes + +| Input | Behavior | +|-------|----------| +| **Space** | Toggle the check state of the selected node | +| **Click on checkbox glyph** | Toggle the check state of the clicked node | + +### Programmatic Access + +```csharp +// Get the effective check state (derived for parents) +CheckState state = tree.GetCheckState (node); + +// Set check state (propagates to descendants) +tree.SetChecked (node, CheckState.Checked); + +// Get all checked objects (includes parents derived as checked from children) +IEnumerable checkedObjects = tree.GetCheckedObjects (); +``` + +### CheckedChanged Event + +Fires for each node whose state changes (including descendants during propagation): + +```csharp +tree.CheckedChanged += (sender, e) => + { + // e.Object — the node whose state changed + // e.OldValue — previous CheckState + // e.NewValue — new CheckState + }; +``` + +### CheckboxMode vs. CheckBoxTableSourceWrapper + +When using `TreeTableSource` to display a tree inside a `TableView`, the checkbox glyphs from `CheckboxMode` are **not** rendered in the table column. To add checkboxes to a tree-table, wrap the `TreeTableSource` with a `CheckBoxTableSourceWrapperByIndex` or `CheckBoxTableSourceWrapperByObject` (see [TableView — Checkbox Columns](tableview.md#checkbox-columns)). + ## Dynamic Updates TreeView caches the expanded tree structure. After modifying nodes at runtime: