diff --git a/Terminal.Gui/Views/TreeView/Branch.cs b/Terminal.Gui/Views/TreeView/Branch.cs index 208a648f1d..83acdf7450 100644 --- a/Terminal.Gui/Views/TreeView/Branch.cs +++ b/Terminal.Gui/Views/TreeView/Branch.cs @@ -65,11 +65,10 @@ public virtual void FetchChildren () if (Depth >= tree.MaxDepth) { children = Enumerable.Empty (); - } - else { + } else { children = tree.TreeBuilder.GetChildren (this.Model) ?? Enumerable.Empty (); } - + this.ChildBranches = children.ToDictionary (k => k, val => new Branch (tree, this, val)); } @@ -95,6 +94,11 @@ public virtual int GetWidth (ConsoleDriver driver) /// public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, int availableWidth) { + var cells = new List (); + int? indexOfExpandCollapseSymbol = null; + int indexOfModelText; + + // true if the current line of the tree is the selected one and control has focus bool isSelected = tree.IsSelected (Model); @@ -110,15 +114,15 @@ public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, // if we have scrolled to the right then bits of the prefix will have dispeared off the screen int toSkip = tree.ScrollOffsetHorizontal; + var attr = symbolColor; - driver.SetAttribute (symbolColor); // Draw the line prefix (all parallel lanes or whitespace and an expand/collapse/leaf symbol) foreach (Rune r in prefix) { if (toSkip > 0) { toSkip--; } else { - driver.AddRune (r); + cells.Add (NewRuneCell (attr, r)); availableWidth -= r.GetColumns (); } } @@ -141,23 +145,31 @@ public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, color = new Attribute (color.Background, color.Foreground); } - driver.SetAttribute (color); + attr = color; } if (toSkip > 0) { toSkip--; } else { - driver.AddRune (expansion); + indexOfExpandCollapseSymbol = cells.Count; + cells.Add (NewRuneCell (attr, expansion)); availableWidth -= expansion.GetColumns (); } // horizontal scrolling has already skipped the prefix but now must also skip some of the line body 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; + if (toSkip > lineBody.Length) { lineBody = ""; } else { lineBody = lineBody.Substring (toSkip); } + } else { + indexOfModelText = cells.Count; } // If body of line is too long @@ -186,16 +198,47 @@ public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, } } - driver.SetAttribute (modelColor); - driver.AddStr (lineBody); + attr = modelColor; + cells.AddRange (lineBody.Select (r => NewRuneCell (attr, new Rune (r)))); if (availableWidth > 0) { - driver.SetAttribute (symbolColor); - driver.AddStr (new string (' ', availableWidth)); + attr = symbolColor; + cells.AddRange ( + Enumerable.Repeat ( + NewRuneCell (attr, new Rune (' ')), + availableWidth + )); } + + var e = new DrawTreeViewLineEventArgs { + Model = Model, + Y = y, + RuneCells = cells, + Tree = tree, + IndexOfExpandCollapseSymbol = indexOfExpandCollapseSymbol, + IndexOfModelText = indexOfModelText, + }; + tree.OnDrawLine (e); + + if (!e.Handled) { + foreach (var cell in cells) { + driver.SetAttribute (cell.ColorScheme.Normal); + driver.AddRune (cell.Rune); + } + } + driver.SetAttribute (colorScheme.Normal); } + private static RuneCell NewRuneCell (Attribute attr, Rune r) + { + return new RuneCell { + Rune = r, + ColorScheme = new ColorScheme (attr) + }; + } + + /// /// Gets all characters to render prior to the current branches line. This includes indentation /// whitespace and any tree branches (if enabled). diff --git a/Terminal.Gui/Views/TreeView/DrawTreeViewLineEventArgs.cs b/Terminal.Gui/Views/TreeView/DrawTreeViewLineEventArgs.cs new file mode 100644 index 0000000000..16b90f392e --- /dev/null +++ b/Terminal.Gui/Views/TreeView/DrawTreeViewLineEventArgs.cs @@ -0,0 +1,66 @@ +// This code is based on http://objectlistview.sourceforge.net (GPLv3 tree/list controls +// by phillip.piper@gmail.com). Phillip has explicitly granted permission for his design +// and code to be used in this library under the MIT license. + +using System.Collections.Generic; + +namespace Terminal.Gui { + /// + /// Event args for the event + /// + /// + public class DrawTreeViewLineEventArgs where T : class { + + /// + /// The object at this line in the tree + /// + public T Model { get; init; } + + /// + /// The that is performing the + /// rendering. + /// + public TreeView Tree { get; init; } + + /// + /// The line within tree view bounds that is being rendered + /// + public int Y { get; init; } + + /// + /// Set to true to cancel drawing (e.g. if you have already manually + /// drawn content). + /// + public bool Handled { get; set; } + + /// + /// The rune and color of each symbol that will be rendered. Note + /// that only is respected. You + /// can modify these to change what is rendered. + /// + /// + /// Changing the length of this collection may result in corrupt rendering + /// + public List RuneCells { get; init; } + + /// + /// The notional index in which contains the first + /// character of the text (i.e. + /// after all branch lines and expansion/collapse sybmols). + /// + /// + /// May be negative or outside of bounds of if the view + /// has been scrolled horizontally. + /// + + public int IndexOfModelText { get; init; } + + /// + /// If line contains a branch that can be expanded/collapsed then this is + /// the index in at which the symbol is (or null for + /// leaf elements). + /// + public int? IndexOfExpandCollapseSymbol { get; init; } + + } +} \ No newline at end of file diff --git a/Terminal.Gui/Views/TreeView/TreeView.cs b/Terminal.Gui/Views/TreeView/TreeView.cs index 318b0ffae7..ba63944118 100644 --- a/Terminal.Gui/Views/TreeView/TreeView.cs +++ b/Terminal.Gui/Views/TreeView/TreeView.cs @@ -169,6 +169,12 @@ public Key ObjectActivationKey { /// public event EventHandler> SelectionChanged; + /// + /// Called once for each visible row during rendering. Can be used + /// to make last minute changes to color or text rendered + /// + public event EventHandler> DrawLine; + /// /// The root objects in the tree, note that this collection is of root objects only. /// @@ -557,10 +563,9 @@ internal IReadOnlyCollection> BuildLineMap () List> toReturn = new List> (); foreach (var root in roots.Values) { - + var toAdd = AddToLineMap (root, false, out var isMatch); - if(isMatch) - { + if (isMatch) { toReturn.AddRange (toAdd); } } @@ -574,41 +579,38 @@ internal IReadOnlyCollection> BuildLineMap () private bool IsFilterMatch (Branch branch) { - return Filter?.IsMatch(branch.Model) ?? true; + return Filter?.IsMatch (branch.Model) ?? true; } - private IEnumerable> AddToLineMap (Branch currentBranch,bool parentMatches, out bool match) + private IEnumerable> AddToLineMap (Branch currentBranch, bool parentMatches, out bool match) { - bool weMatch = IsFilterMatch(currentBranch); + bool weMatch = IsFilterMatch (currentBranch); bool anyChildMatches = false; - - var toReturn = new List>(); - var children = new List>(); + + var toReturn = new List> (); + var children = new List> (); if (currentBranch.IsExpanded) { foreach (var subBranch in currentBranch.ChildBranches.Values) { foreach (var sub in AddToLineMap (subBranch, weMatch, out var childMatch)) { - - if(childMatch) - { - children.Add(sub); + + if (childMatch) { + children.Add (sub); anyChildMatches = true; } } } } - if(parentMatches || weMatch || anyChildMatches) - { + if (parentMatches || weMatch || anyChildMatches) { match = true; - toReturn.Add(currentBranch); - } - else{ + toReturn.Add (currentBranch); + } else { match = false; } - - toReturn.AddRange(children); + + toReturn.AddRange (children); return toReturn; } @@ -1421,8 +1423,17 @@ protected virtual void OnSelectionChanged (SelectionChangedEventArgs e) { SelectionChanged?.Invoke (this, e); } - } + /// + /// Raises the DrawLine event + /// + /// + internal void OnDrawLine (DrawTreeViewLineEventArgs e) + { + DrawLine?.Invoke (this, e); + } + + } class TreeSelection where T : class { public Branch Origin { get; } diff --git a/UICatalog/Scenarios/TreeViewFileSystem.cs b/UICatalog/Scenarios/TreeViewFileSystem.cs index ddae1dda1d..0f5cc497f8 100644 --- a/UICatalog/Scenarios/TreeViewFileSystem.cs +++ b/UICatalog/Scenarios/TreeViewFileSystem.cs @@ -84,6 +84,7 @@ public override void Setup () Width = Dim.Percent (50), Height = Dim.Fill (), }; + treeViewFiles.DrawLine += TreeViewFiles_DrawLine; _detailsFrame = new DetailsFrame (_iconProvider) { X = Pos.Right (treeViewFiles), @@ -106,7 +107,7 @@ public override void Setup () SetupScrollBar (); treeViewFiles.SetFocus (); - + UpdateIconCheckedness (); } @@ -140,6 +141,23 @@ private void TreeViewFiles_SelectionChanged (object sender, SelectionChangedEven ShowPropertiesOf (e.NewValue); } + private void TreeViewFiles_DrawLine (object sender, DrawTreeViewLineEventArgs e) + { + // Render directory icons in yellow + if (e.Model is IDirectoryInfo d) { + if (_iconProvider.UseNerdIcons || _iconProvider.UseUnicodeCharacters) { + if (e.IndexOfModelText > 0 && e.IndexOfModelText < e.RuneCells.Count) { + var cell = e.RuneCells [e.IndexOfModelText]; + cell.ColorScheme = new ColorScheme ( + new Terminal.Gui.Attribute ( + Color.BrightYellow, + cell.ColorScheme.Normal.Background) + ); + } + } + } + } + private void TreeViewFiles_KeyPress (object sender, KeyEventEventArgs obj) { if (obj.KeyEvent.Key == (Key.R | Key.CtrlMask)) { @@ -195,7 +213,7 @@ class DetailsFrame : FrameView { private IFileSystemInfo fileInfo; private FileSystemIconProvider _iconProvider; - public DetailsFrame (FileSystemIconProvider iconProvider) + public DetailsFrame (FileSystemIconProvider iconProvider) { Title = "Details"; Visible = true; @@ -209,7 +227,7 @@ public IFileSystemInfo FileInfo { System.Text.StringBuilder sb = null; if (fileInfo is IFileInfo f) { - Title = $"{_iconProvider.GetIconWithOptionalSpace(f)}{f.Name}".Trim(); + Title = $"{_iconProvider.GetIconWithOptionalSpace (f)}{f.Name}".Trim (); sb = new System.Text.StringBuilder (); sb.AppendLine ($"Path:\n {f.FullName}\n"); sb.AppendLine ($"Size:\n {f.Length:N0} bytes\n"); @@ -218,7 +236,7 @@ public IFileSystemInfo FileInfo { } if (fileInfo is IDirectoryInfo dir) { - Title = $"{_iconProvider.GetIconWithOptionalSpace(dir)}{dir.Name}".Trim(); + Title = $"{_iconProvider.GetIconWithOptionalSpace (dir)}{dir.Name}".Trim (); sb = new System.Text.StringBuilder (); sb.AppendLine ($"Path:\n {dir?.FullName}\n"); sb.AppendLine ($"Modified:\n {dir.LastWriteTime}\n"); @@ -241,7 +259,7 @@ private void SetupScrollBar () var scrollBar = new ScrollBarView (treeViewFiles, true); - scrollBar.ChangedPosition += (s,e) => { + scrollBar.ChangedPosition += (s, e) => { treeViewFiles.ScrollOffsetVertical = scrollBar.Position; if (treeViewFiles.ScrollOffsetVertical != scrollBar.Position) { scrollBar.Position = treeViewFiles.ScrollOffsetVertical; @@ -249,7 +267,7 @@ private void SetupScrollBar () treeViewFiles.SetNeedsDisplay (); }; - scrollBar.OtherScrollBarView.ChangedPosition += (s,e) => { + scrollBar.OtherScrollBarView.ChangedPosition += (s, e) => { treeViewFiles.ScrollOffsetHorizontal = scrollBar.OtherScrollBarView.Position; if (treeViewFiles.ScrollOffsetHorizontal != scrollBar.OtherScrollBarView.Position) { scrollBar.OtherScrollBarView.Position = treeViewFiles.ScrollOffsetHorizontal; @@ -257,7 +275,7 @@ private void SetupScrollBar () treeViewFiles.SetNeedsDisplay (); }; - treeViewFiles.DrawContent += (s,e) => { + treeViewFiles.DrawContent += (s, e) => { scrollBar.Size = treeViewFiles.ContentHeight; scrollBar.Position = treeViewFiles.ScrollOffsetVertical; scrollBar.OtherScrollBarView.Size = treeViewFiles.GetContentWidth (true); @@ -269,20 +287,20 @@ private void SetupScrollBar () private void SetupFileTree () { // setup how to build tree - var fs = new FileSystem(); - var rootDirs = DriveInfo.GetDrives ().Select (d=>fs.DirectoryInfo.New(d.RootDirectory.FullName)); + var fs = new FileSystem (); + var rootDirs = DriveInfo.GetDrives ().Select (d => fs.DirectoryInfo.New (d.RootDirectory.FullName)); treeViewFiles.TreeBuilder = new FileSystemTreeBuilder (); treeViewFiles.AddObjects (rootDirs); // Determines how to represent objects as strings on the screen treeViewFiles.AspectGetter = AspectGetter; - + _iconProvider.IsOpenGetter = treeViewFiles.IsExpanded; } private string AspectGetter (IFileSystemInfo f) { - return (_iconProvider.GetIconWithOptionalSpace(f) + f.Name).Trim(); + return (_iconProvider.GetIconWithOptionalSpace (f) + f.Name).Trim (); } private void ShowLines () diff --git a/UnitTests/Views/TreeViewTests.cs b/UnitTests/Views/TreeViewTests.cs index 0644fac65c..cf6f5e2122 100644 --- a/UnitTests/Views/TreeViewTests.cs +++ b/UnitTests/Views/TreeViewTests.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using Xunit; using Xunit.Abstractions; @@ -939,7 +940,7 @@ public void TestBottomlessTreeView_MaxDepth_5 () Assert.False (tv.CanExpand ("6")); Assert.False (tv.IsExpanded ("6")); - tv.Collapse("6"); + tv.Collapse ("6"); Assert.False (tv.CanExpand ("6")); Assert.False (tv.IsExpanded ("6")); @@ -992,6 +993,174 @@ public void TestBottomlessTreeView_MaxDepth_3 () └-2 └-3 └─4 +", output); + } + [Fact, AutoInitShutdown] + public void TestTreeView_DrawLineEvent () + { + var tv = new TreeView { Width = 20, Height = 10 }; + + var eventArgs = new List> (); + + tv.DrawLine += (s, e) => { + eventArgs.Add (e); + }; + + var n1 = new TreeNode ("root one"); + var n1_1 = new TreeNode ("leaf 1"); + var n1_2 = new TreeNode ("leaf 2"); + n1.Children.Add (n1_1); + n1.Children.Add (n1_2); + + var n2 = new TreeNode ("root two"); + tv.AddObject (n1); + tv.AddObject (n2); + tv.Expand (n1); + + tv.ColorScheme = new ColorScheme (); + tv.LayoutSubviews (); + tv.Draw (); + + // Normal drawing of the tree view + TestHelpers.AssertDriverContentsAre ( +@" +├-root one +│ ├─leaf 1 +│ └─leaf 2 +└─root two +", output); + Assert.Equal (4, eventArgs.Count ()); + + Assert.Equal (0, eventArgs [0].Y); + Assert.Equal (1, eventArgs [1].Y); + Assert.Equal (2, eventArgs [2].Y); + Assert.Equal (3, eventArgs [3].Y); + + Assert.All (eventArgs, ea => Assert.Equal (ea.Tree, tv)); + Assert.All (eventArgs, ea => Assert.False (ea.Handled)); + + Assert.Equal ("├-root one", eventArgs [0].RuneCells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ()); + Assert.Equal ("│ ├─leaf 1", eventArgs [1].RuneCells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ()); + Assert.Equal ("│ └─leaf 2", eventArgs [2].RuneCells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ()); + Assert.Equal ("└─root two", eventArgs [3].RuneCells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ()); + + Assert.Equal (1, eventArgs [0].IndexOfExpandCollapseSymbol); + Assert.Equal (3, eventArgs [1].IndexOfExpandCollapseSymbol); + Assert.Equal (3, eventArgs [2].IndexOfExpandCollapseSymbol); + Assert.Equal (1, eventArgs [3].IndexOfExpandCollapseSymbol); + + Assert.Equal (2, eventArgs [0].IndexOfModelText); + Assert.Equal (4, eventArgs [1].IndexOfModelText); + Assert.Equal (4, eventArgs [2].IndexOfModelText); + Assert.Equal (2, eventArgs [3].IndexOfModelText); + + + Assert.Equal ("root one", eventArgs [0].Model.Text); + Assert.Equal ("leaf 1", eventArgs [1].Model.Text); + Assert.Equal ("leaf 2", eventArgs [2].Model.Text); + Assert.Equal ("root two", eventArgs [3].Model.Text); + } + + [Fact, AutoInitShutdown] + public void TestTreeView_DrawLineEvent_WithScrolling () + { + var tv = new TreeView { Width = 20, Height = 10 }; + + var eventArgs = new List> (); + + tv.DrawLine += (s, e) => { + eventArgs.Add (e); + }; + + tv.ScrollOffsetHorizontal = 3; + tv.ScrollOffsetVertical = 1; + + var n1 = new TreeNode ("root one"); + var n1_1 = new TreeNode ("leaf 1"); + var n1_2 = new TreeNode ("leaf 2"); + n1.Children.Add (n1_1); + n1.Children.Add (n1_2); + + var n2 = new TreeNode ("root two"); + tv.AddObject (n1); + tv.AddObject (n2); + tv.Expand (n1); + + tv.ColorScheme = new ColorScheme (); + tv.LayoutSubviews (); + tv.Draw (); + + // Normal drawing of the tree view + TestHelpers.AssertDriverContentsAre ( +@" +─leaf 1 +─leaf 2 +oot two +", output); + Assert.Equal (3, eventArgs.Count ()); + + Assert.Equal (0, eventArgs [0].Y); + Assert.Equal (1, eventArgs [1].Y); + Assert.Equal (2, eventArgs [2].Y); + + Assert.All (eventArgs, ea => Assert.Equal (ea.Tree, tv)); + Assert.All (eventArgs, ea => Assert.False (ea.Handled)); + + Assert.Equal ("─leaf 1", eventArgs [0].RuneCells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ()); + Assert.Equal ("─leaf 2", eventArgs [1].RuneCells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ()); + Assert.Equal ("oot two", eventArgs [2].RuneCells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ()); + + Assert.Equal (0, eventArgs [0].IndexOfExpandCollapseSymbol); + Assert.Equal (0, eventArgs [1].IndexOfExpandCollapseSymbol); + Assert.Null (eventArgs [2].IndexOfExpandCollapseSymbol); + + Assert.Equal (1, eventArgs [0].IndexOfModelText); + Assert.Equal (1, eventArgs [1].IndexOfModelText); + Assert.Equal (-1, eventArgs [2].IndexOfModelText); + + Assert.Equal ("leaf 1", eventArgs [0].Model.Text); + Assert.Equal ("leaf 2", eventArgs [1].Model.Text); + Assert.Equal ("root two", eventArgs [2].Model.Text); + } + + [Fact, AutoInitShutdown] + public void TestTreeView_DrawLineEvent_Handled () + { + var tv = new TreeView { Width = 20, Height = 10 }; + + tv.DrawLine += (s, e) => { + if(e.Model.Text.Equals("leaf 1")) { + e.Handled = true; + + for (int i = 0; i < 10; i++) { + + e.Tree.AddRune (i,e.Y,new System.Text.Rune('F')); + } + } + }; + + var n1 = new TreeNode ("root one"); + var n1_1 = new TreeNode ("leaf 1"); + var n1_2 = new TreeNode ("leaf 2"); + n1.Children.Add (n1_1); + n1.Children.Add (n1_2); + + var n2 = new TreeNode ("root two"); + tv.AddObject (n1); + tv.AddObject (n2); + tv.Expand (n1); + + tv.ColorScheme = new ColorScheme (); + tv.LayoutSubviews (); + tv.Draw (); + + // Normal drawing of the tree view + TestHelpers.AssertDriverContentsAre ( +@" +├-root one +FFFFFFFFFF +│ └─leaf 2 +└─root two ", output); }