Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -189,6 +194,7 @@ private void TreeViewEditor_Initialized (object? s, EventArgs e)
};

Add (_cbMultiSelect,
_cbCheckboxMode,
_cbColorExpandSymbol,
_cbShowBranchLines,
_cbInvertExpandSymbolColors,
Expand Down
23 changes: 19 additions & 4 deletions Terminal.Gui/Views/TreeView/Branch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
{
Expand All @@ -174,7 +181,7 @@ public virtual void Draw (int y, int availableWidth)
}
else
{
indexOfModelText = cells.Count;
indexOfModelText = cells.Count + (_tree.CheckboxMode ? 2 : 0);
Comment thread
tig marked this conversation as resolved.
}

// If body of line is too long
Expand Down Expand Up @@ -296,7 +303,10 @@ public string GetExpandableSymbol ()
/// </summary>
/// <returns></returns>
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 ();

/// <summary>Refreshes cached knowledge in this branch e.g. what children an object has.</summary>
/// <param name="startAtTop">True to also refresh all <see cref="Parent"/> branches (starting with the root).</param>
Expand Down Expand Up @@ -462,6 +472,11 @@ internal bool IsHitOnExpandableSymbol (int x)
return false;
}

/// <summary>Returns true if the given x offset on the branch line is the checkbox glyph.</summary>
/// <param name="x">The x offset on the branch line.</param>
/// <returns><see langword="true"/> if checkbox mode is enabled and <paramref name="x"/> hits the checkbox glyph.</returns>
internal bool IsHitOnCheckbox (int x) => _tree.CheckboxMode && x == GetLinePrefix ().Count () + GetExpandableSymbol ().GetColumns ();

/// <summary>Calls <see cref="Refresh(bool)"/> on the current branch and all expanded children.</summary>
internal void Rebuild ()
{
Expand Down
18 changes: 18 additions & 0 deletions Terminal.Gui/Views/TreeView/CheckedChangedEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Terminal.Gui.Views;

/// <summary>Describes a tree node check state change.</summary>
/// <typeparam name="T">The type of object represented by nodes in the tree.</typeparam>
public class CheckedChangedEventArgs<T> (TreeView<T> tree, T @object, CheckState oldValue, CheckState newValue) : EventArgs where T : class
{
/// <summary>The tree whose node check state changed.</summary>
public TreeView<T> Tree { get; } = tree;

/// <summary>The object whose check state changed.</summary>
public T Object { get; } = @object;

/// <summary>The previous check state.</summary>
public CheckState OldValue { get; } = oldValue;

/// <summary>The new check state.</summary>
public CheckState NewValue { get; } = newValue;
}
3 changes: 3 additions & 0 deletions Terminal.Gui/Views/TreeView/ITreeView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public interface ITreeView
/// <summary>Gets or sets whether the user can navigate the tree using letter keys.</summary>
bool AllowLetterBasedNavigation { get; set; }

/// <summary>Gets or sets whether built-in checkbox rendering and toggling is enabled for tree nodes.</summary>
bool CheckboxMode { get; set; }

/// <summary>Gets or sets the maximum depth to which the tree will expand.</summary>
int MaxDepth { get; set; }

Expand Down
243 changes: 243 additions & 0 deletions Terminal.Gui/Views/TreeView/TreeView.Navigation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,48 @@ public IEnumerable<T> GetAllSelectedObjects ()
}
}

/// <summary>Returns all objects whose effective check state is <see cref="CheckState.Checked"/>.</summary>
/// <returns>Checked objects in tree order, followed by any checked objects that are not currently in the tree.</returns>
public IEnumerable<T> GetCheckedObjects ()
{
HashSet<T> 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;
}
}
}

/// <summary>Gets the effective check state of <paramref name="o"/>, derived from children if applicable.</summary>
/// <param name="o">The node object.</param>
/// <returns>The effective check state.</returns>
public CheckState GetCheckState (T? o) => o is null ? CheckState.UnChecked : GetEffectiveCheckState (o, []);

/// <summary>Sets the explicit check state of <paramref name="o"/> and propagates to all descendants.</summary>
/// <param name="o">The node object.</param>
/// <param name="state">The new check state.</param>
public void SetChecked (T? o, CheckState state)
{
if (o is null)
{
return;
}

SetCheckedRecursive (o, state);
SetNeedsDraw ();
}

/// <summary>
/// Returns the currently expanded children of the passed object. Returns an empty collection if the branch is not
/// exposed or not expanded.
Expand Down Expand Up @@ -640,6 +682,14 @@ protected virtual void Space ()
{
return;
}

if (CheckboxMode)
{
ToggleChecked (SelectedObject);

return;
}

Toggle (SelectedObject);
}

Expand All @@ -648,4 +698,197 @@ protected virtual void Space ()

// TODO: Refactor to use CWP
protected virtual void OnSelectionChanged (SelectionChangedEventArgs<T> e) => SelectionChanged?.Invoke (this, e);

private IEnumerable<T> EnumerateKnownObjects ()
{
if (Roots is null)
{
yield break;
}

HashSet<T> seen = [];

foreach (T root in Roots.Keys)
{
foreach (T model in EnumerateKnownObjects (root, seen, 0))
{
yield return model;
}
}
}

private IEnumerable<T> EnumerateKnownObjects (T model, HashSet<T> 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<T> 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<T>? 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;
Comment thread
tig marked this conversation as resolved.
}

private void SetCheckedRecursive (T model, CheckState state)
{
SetCheckedRecursive (model, state, []);
}

private void SetCheckedRecursive (T model, CheckState state, HashSet<T> 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<T> (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<T> visited = [];

return HasCheckedDescendantsRecursive (model, visited);
}

private bool HasCheckedDescendantsRecursive (T model, HashSet<T> 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;
}
}
Loading
Loading