瀏覽代碼

Fixes #2683 - Adds an ITableSource which wraps a TreeView<T> (#2685)

* WIP: Add TreeTableSource

* Improve expand/collapse

* Render branch/tree properly

* Simplify TreeTableSource to only allow one TreeView

* Add TestTreeTableSource_BasicExpanding test

* Add test combining checkbox and tree together

* Move tree example into main TableEditor scenario
(deleting TreeTableExample.cs)

* Mouse support for expanding/collapsing branches

* Make TreeTableSource work with CheckBoxTableSourceWrapperByObject<T>

* Add tests for mouse expand/collapse

* Improve quality of TableEditor scenario

* Fix mouse expanding not refreshing screen

* Fixed null reference when clicking in header lines

* Add null checks to scenario now it can show trees as well as data tables

* Switch to underscore prefix on private members

* Remove accidentally committed file

* Add setup/teardown to explicitly set driver checked/unchecked glyphs

---------

Co-authored-by: Tig <[email protected]>
Thomas Nind 2 年之前
父節點
當前提交
cd6cfd7f62

+ 7 - 7
Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapperByObject.cs

@@ -7,7 +7,7 @@ namespace Terminal.Gui {
 	/// by a property on row objects.
 	/// </summary>
 	public class CheckBoxTableSourceWrapperByObject<T> : CheckBoxTableSourceWrapperBase {
-		private readonly EnumerableTableSource<T> toWrap;
+		private readonly IEnumerableTableSource<T> toWrap;
 		readonly Func<T, bool> getter;
 		readonly Action<T, bool> setter;
 
@@ -20,7 +20,7 @@ namespace Terminal.Gui {
 		/// <param name="setter">Delegate method for setting new checked states on your objects of type <typeparamref name="T"/>.</param>
 		public CheckBoxTableSourceWrapperByObject (
 			TableView tableView,
-			EnumerableTableSource<T> toWrap,
+			IEnumerableTableSource<T> toWrap,
 			Func<T,bool> getter,
 			Action<T,bool> setter) : base (tableView, toWrap)
 		{
@@ -32,7 +32,7 @@ namespace Terminal.Gui {
 		/// <inheritdoc/>
 		protected override bool IsChecked (int row)
 		{
-			return getter (toWrap.Data.ElementAt (row));
+			return getter (toWrap.GetObjectOnRow (row));
 		}
 
 		/// <inheritdoc/>
@@ -44,7 +44,7 @@ namespace Terminal.Gui {
 		/// <inheritdoc/>
 		protected override void ToggleRow (int row)
 		{
-			var d = toWrap.Data.ElementAt (row);
+			var d = toWrap.GetObjectOnRow (row);
 			setter (d, !getter(d));
 		}
 		
@@ -55,12 +55,12 @@ namespace Terminal.Gui {
 			if (range.All (IsChecked)) {
 				// select none
 				foreach(var r in range) {
-					setter (toWrap.Data.ElementAt (r), false);
+					setter (toWrap.GetObjectOnRow (r), false);
 				}
 			} else {
 				// otherwise tick all
 				foreach (var r in range) {
-					setter (toWrap.Data.ElementAt (r), true);
+					setter (toWrap.GetObjectOnRow  (r), true);
 				}
 			}
 		}
@@ -68,7 +68,7 @@ namespace Terminal.Gui {
 		/// <inheritdoc/>
 		protected override void ClearAllToggles ()
 		{
-			foreach (var e in toWrap.Data) {
+			foreach (var e in toWrap.GetAllObjects()) {
 				setter (e, false);
 			}
 		}

+ 13 - 2
Terminal.Gui/Views/TableView/EnumerableTableSource.cs

@@ -3,12 +3,11 @@ using System.Collections.Generic;
 using System.Linq;
 
 namespace Terminal.Gui {
-
 	/// <summary>
 	/// <see cref="ITableSource"/> implementation that wraps arbitrary data.
 	/// </summary>
 	/// <typeparam name="T"></typeparam>
-	public class EnumerableTableSource<T> : ITableSource {
+	public class EnumerableTableSource<T> : IEnumerableTableSource<T> {
 		private T [] data;
 		private string [] cols;
 		private Dictionary<string, Func<T, object>> lamdas;
@@ -55,5 +54,17 @@ namespace Terminal.Gui {
 		/// Gets the object collection hosted by this wrapper.
 		/// </summary>
 		public IReadOnlyCollection<T> Data => this.data.AsReadOnly();
+
+		/// <inheritdoc/>
+		public IEnumerable<T> GetAllObjects ()
+		{
+			return Data;
+		}
+
+		/// <inheritdoc/>
+		public T GetObjectOnRow (int row)
+		{
+			return Data.ElementAt(row);
+		}
 	}
 }

+ 21 - 0
Terminal.Gui/Views/TableView/IEnumerableTableSource.cs

@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+
+namespace Terminal.Gui {
+
+    /// <summary>
+    /// Interface for all <see cref="ITableSource"/> which present
+    /// an object per row (of type <typeparamref name="T"/>).
+    /// </summary>
+	public interface IEnumerableTableSource<T> : ITableSource
+	{
+        /// <summary>
+        /// Return the object on the given row.
+        /// </summary>
+		T GetObjectOnRow(int row);
+
+        /// <summary>
+        /// Return all objects in the table.
+        /// </summary>
+		IEnumerable<T> GetAllObjects();
+	}
+}

+ 17 - 3
Terminal.Gui/Views/TableView/TableView.cs

@@ -1288,19 +1288,31 @@ namespace Terminal.Gui {
 		/// <returns>Cell clicked or null.</returns>
 		public Point? ScreenToCell (int clientX, int clientY)
 		{
-			return ScreenToCell (clientX, clientY, out _);
+			return ScreenToCell (clientX, clientY, out _, out _);
 		}
-
 		/// <inheritdoc cref="ScreenToCell(int, int)"/>
 		/// <param name="clientX">X offset from the top left of the control.</param>
 		/// <param name="clientY">Y offset from the top left of the control.</param>
 		/// <param name="headerIfAny">If the click is in a header this is the column clicked.</param>
 		public Point? ScreenToCell (int clientX, int clientY, out int? headerIfAny)
+		{
+			return ScreenToCell (clientX, clientY, out headerIfAny, out _);
+		}
+
+		/// <inheritdoc cref="ScreenToCell(int, int)"/>
+		/// <param name="clientX">X offset from the top left of the control.</param>
+		/// <param name="clientY">Y offset from the top left of the control.</param>
+		/// <param name="headerIfAny">If the click is in a header this is the column clicked.</param>
+		/// <param name="offsetX">The horizontal offset of the click within the returned cell.</param>
+		public Point? ScreenToCell (int clientX, int clientY, out int? headerIfAny, out int? offsetX)
 		{
 			headerIfAny = null;
+			offsetX = null;
 
-			if (TableIsNullOrInvisible ())
+			if (TableIsNullOrInvisible ()) {
 				return null;
+			}
+				
 
 			var viewPort = CalculateViewport (Bounds);
 
@@ -1311,6 +1323,7 @@ namespace Terminal.Gui {
 			// Click is on the header section of rendered UI
 			if (clientY < headerHeight) {
 				headerIfAny = col?.Column;
+				offsetX = col != null ? clientX - col.X : null;
 				return null;
 			}
 
@@ -1324,6 +1337,7 @@ namespace Terminal.Gui {
 
 			if (col != null && rowIdx >= 0) {
 
+				offsetX = clientX - col.X;
 				return new Point (col.Column, rowIdx);
 			}
 

+ 203 - 0
Terminal.Gui/Views/TableView/TreeTableSource.cs

@@ -0,0 +1,203 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+using System.Text;
+
+namespace Terminal.Gui;
+
+/// <summary>
+/// An <see cref="ITableSource"/> with expandable rows.
+/// </summary>
+/// <typeparam name="T"></typeparam>
+public class TreeTableSource<T> : IEnumerableTableSource<T>, IDisposable where T : class {
+	
+	private TreeView<T> _tree;
+	private string [] _cols;
+	private Dictionary<string, Func<T, object>> _lamdas;
+	private TableView _tableView;
+
+	/// <summary>
+	/// Creates a new instance of <see cref="TreeTableSource{T}"/> presenting the given
+	/// <paramref name="tree"/>. This source should only be used with <paramref name="table"/>.
+	/// </summary>
+	/// <param name="table">The table this source will provide data for.</param>
+	/// <param name="firstColumnName">Column name to use for the first column of the table (where
+	/// the tree branches/leaves will be rendered.</param>
+	/// <param name="tree">The tree data to render. This should be a new view and not used
+	/// elsewhere (e.g. via <see cref="View.Add(View)"/>).</param>
+	/// <param name="subsequentColumns">
+	/// Getter methods for each additional property you want to present in the table. For example:
+	/// <code>
+	/// new () {
+	///    { "Colname1", (t)=>t.SomeField},
+	///    { "Colname2", (t)=>t.SomeOtherField}
+	///}
+	/// </code></param>
+	public TreeTableSource (TableView table, string firstColumnName, TreeView<T> tree, Dictionary<string, Func<T, object>> subsequentColumns)
+	{
+		_tableView = table;
+		_tree = tree;
+		_tableView.KeyPress += Table_KeyPress;
+		_tableView.MouseClick += Table_MouseClick;
+
+		var colList = subsequentColumns.Keys.ToList ();
+		colList.Insert (0, firstColumnName);
+
+		_cols = colList.ToArray ();
+
+
+		_lamdas = subsequentColumns;
+	}
+
+
+	/// <inheritdoc/>
+	public object this [int row, int col] =>
+		col == 0 ? GetColumnZeroRepresentationFromTree (row) :
+		_lamdas [ColumnNames [col]] (RowToObject (row));
+
+	/// <inheritdoc/>
+	public int Rows => _tree.BuildLineMap ().Count;
+
+	/// <inheritdoc/>
+	public int Columns => _lamdas.Count + 1;
+
+	/// <inheritdoc/>
+	public string [] ColumnNames => _cols;
+
+	/// <inheritdoc/>
+	public void Dispose ()
+	{
+		_tree.Dispose ();
+	}
+
+	/// <summary>
+	/// Returns the tree model object rendering on the given <paramref name="row"/>
+	/// of the table.
+	/// </summary>
+	/// <param name="row">Row in table.</param>
+	/// <returns></returns>
+	public T RowToObject (int row)
+	{
+		return _tree.BuildLineMap ().ElementAt (row).Model;
+	}
+
+
+	private string GetColumnZeroRepresentationFromTree (int row)
+	{
+		var branch = RowToBranch (row);
+
+		// Everything on line before the expansion run and branch text
+		Rune [] prefix = branch.GetLinePrefix (Application.Driver).ToArray ();
+		Rune expansion = branch.GetExpandableSymbol (Application.Driver);
+		string lineBody = _tree.AspectGetter (branch.Model) ?? "";
+
+		var sb = new StringBuilder ();
+
+		foreach (var p in prefix) {
+			sb.Append (p);
+		}
+
+		sb.Append (expansion);
+		sb.Append (lineBody);
+
+		return sb.ToString ();
+	}
+
+	private void Table_KeyPress (object sender, KeyEventEventArgs e)
+	{
+		if (!IsInTreeColumn (_tableView.SelectedColumn, true)) {
+			return;
+		}
+
+		var obj = _tree.GetObjectOnRow (_tableView.SelectedRow);
+
+		if (obj == null) {
+			return;
+		}
+
+		if (e.KeyEvent.Key == Key.CursorLeft) {
+			if (_tree.IsExpanded (obj)) {
+				_tree.Collapse (obj);
+				e.Handled = true;
+			}
+		}
+		if (e.KeyEvent.Key == Key.CursorRight) {
+			if (_tree.CanExpand (obj) && !_tree.IsExpanded (obj)) {
+				_tree.Expand (obj);
+				e.Handled = true;
+			}
+		}
+
+		if (e.Handled) {
+			_tree.InvalidateLineMap ();
+			_tableView.SetNeedsDisplay ();
+		}
+	}
+
+	private void Table_MouseClick (object sender, MouseEventEventArgs e)
+	{
+		var hit = _tableView.ScreenToCell (e.MouseEvent.X, e.MouseEvent.Y, out var headerIfAny, out var offsetX);
+
+		if (hit == null || headerIfAny != null || !IsInTreeColumn (hit.Value.X, false) || offsetX == null) {
+			return;
+		}
+
+		var branch = RowToBranch (hit.Value.Y);
+
+		if (branch.IsHitOnExpandableSymbol (Application.Driver, offsetX.Value)) {
+
+			var m = branch.Model;
+
+			if (_tree.CanExpand (m) && !_tree.IsExpanded (m)) {
+				_tree.Expand (m);
+
+				e.Handled = true;
+			} else if (_tree.IsExpanded (m)) {
+				_tree.Collapse (m);
+				e.Handled = true;
+			}
+		}
+
+		if (e.Handled) {
+			_tree.InvalidateLineMap ();
+			_tableView.SetNeedsDisplay ();
+		}
+	}
+
+	private Branch<T> RowToBranch (int row)
+	{
+		return _tree.BuildLineMap ().ElementAt (row);
+	}
+
+	private bool IsInTreeColumn (int column, bool isKeyboard)
+	{
+		var colNames = _tableView.Table.ColumnNames;
+
+		if (column < 0 || column >= colNames.Length) {
+			return false;
+		}
+
+		// if full row is selected then it is hard to tell which sub cell in the tree
+		// has focus so we should typically just always respond with expand/collapse
+		if (_tableView.FullRowSelect && isKeyboard) {
+			return true;
+		}
+
+		// we cannot just check that SelectedColumn is 0 because source may
+		// be wrapped e.g. with a CheckBoxTableSourceWrapperBase
+		return colNames [column] == _cols [0];
+	}
+
+	/// <inheritdoc/>
+	public T GetObjectOnRow (int row)
+	{
+		return RowToObject (row);
+	}
+
+	/// <inheritdoc/>
+	public IEnumerable<T> GetAllObjects ()
+	{
+		return _tree.BuildLineMap ().Select (b => b.Model);
+	}
+}

+ 2 - 2
Terminal.Gui/Views/TreeView/Branch.cs

@@ -4,7 +4,7 @@ using System.Linq;
 using System.Text;
 
 namespace Terminal.Gui {
-	class Branch<T> where T : class {
+	internal class Branch<T> where T : class {
 		/// <summary>
 		/// True if the branch is expanded to reveal child branches.
 		/// </summary>
@@ -202,7 +202,7 @@ namespace Terminal.Gui {
 		/// </summary>
 		/// <param name="driver"></param>
 		/// <returns></returns>
-		private IEnumerable<Rune> GetLinePrefix (ConsoleDriver driver)
+		internal IEnumerable<Rune> GetLinePrefix (ConsoleDriver driver)
 		{
 			// If not showing line branches or this is a root object.
 			if (!tree.Style.ShowBranchLines) {

+ 1 - 1
Terminal.Gui/Views/TreeView/TreeView.cs

@@ -548,7 +548,7 @@ namespace Terminal.Gui {
 		/// <remarks>Index 0 of the returned array is the first item that should be visible in the
 		/// top of the control, index 1 is the next etc.</remarks>
 		/// <returns></returns>
-		private IReadOnlyCollection<Branch<T>> BuildLineMap ()
+		internal IReadOnlyCollection<Branch<T>> BuildLineMap ()
 		{
 			if (cachedLineMap != null) {
 				return cachedLineMap;

+ 160 - 36
UICatalog/Scenarios/TableEditor.cs

@@ -6,6 +6,8 @@ using System.Linq;
 using System.Globalization;
 using static Terminal.Gui.TableView;
 using System.Text;
+using System.IO;
+using System.Text.RegularExpressions;
 
 namespace UICatalog.Scenarios {
 
@@ -18,7 +20,7 @@ namespace UICatalog.Scenarios {
 	public class TableEditor : Scenario {
 		TableView tableView;
 		DataTable currentTable;
-    
+
 		private MenuItem _miShowHeaders;
 		private MenuItem _miAlwaysShowHeaders;
 		private MenuItem _miHeaderOverline;
@@ -36,10 +38,14 @@ namespace UICatalog.Scenarios {
 		private MenuItem _miCheckboxes;
 		private MenuItem _miRadioboxes;
 
+		private List<IDisposable> toDispose = new List<IDisposable> ();
+
 		ColorScheme redColorScheme;
 		ColorScheme redColorSchemeAlt;
 		ColorScheme alternatingColorScheme;
 
+		HashSet<FileSystemInfo> _checkedFileSystemInfos = new HashSet<FileSystemInfo> ();
+
 		public override void Setup ()
 		{
 			Win.Title = this.GetName ();
@@ -59,6 +65,7 @@ namespace UICatalog.Scenarios {
 					new MenuItem ("_OpenBigExample", "", () => OpenExample(true)),
 					new MenuItem ("_OpenSmallExample", "", () => OpenExample(false)),
 					new MenuItem ("OpenCharacter_Map","",()=>OpenUnicodeMap()),
+					new MenuItem ("OpenTreeExample","",()=>OpenTreeExample()),
 					new MenuItem ("_CloseExample", "", () => CloseExample()),
 					new MenuItem ("_Quit", "", () => Quit()),
 				}),
@@ -144,7 +151,11 @@ namespace UICatalog.Scenarios {
 			};
 
 			// if user clicks the mouse in TableView
-			tableView.MouseClick += (s,e) => {
+			tableView.MouseClick += (s, e) => {
+
+				if(currentTable == null) {
+					return;
+				}
 
 				tableView.ScreenToCell (e.MouseEvent.X, e.MouseEvent.Y, out int? clickedCol);
 
@@ -180,13 +191,17 @@ namespace UICatalog.Scenarios {
 			if (HasCheckboxes () && clickedCol == 0) {
 				return;
 			}
-				
+
 
 			SortColumn (clickedCol, sort, isAsc);
 		}
 
 		private void SortColumn (int clickedCol, string sort, bool isAsc)
 		{
+			if(currentTable == null) {
+				return;
+			}
+
 			// set a sort order
 			currentTable.DefaultView.Sort = sort;
 
@@ -223,7 +238,7 @@ namespace UICatalog.Scenarios {
 		{
 			// work out new sort order
 			var sort = currentTable.DefaultView.Sort;
-			var colName = tableView.Table.ColumnNames[clickedCol];
+			var colName = tableView.Table.ColumnNames [clickedCol];
 
 			if (sort?.EndsWith ("ASC") ?? false) {
 				sort = $"{colName} DESC";
@@ -238,12 +253,12 @@ namespace UICatalog.Scenarios {
 
 		private void ShowHeaderContextMenu (int clickedCol, MouseEventEventArgs e)
 		{
-			if(HasCheckboxes() && clickedCol == 0) {
+			if (HasCheckboxes () && clickedCol == 0) {
 				return;
 			}
 
 			var sort = GetProposedNewSortOrder (clickedCol, out var isAsc);
-			var colName = tableView.Table.ColumnNames[clickedCol];
+			var colName = tableView.Table.ColumnNames [clickedCol];
 
 			var contextMenu = new ContextMenu (e.MouseEvent.X + 1, e.MouseEvent.Y + 1,
 				new MenuBarItem (new MenuItem [] {
@@ -275,8 +290,8 @@ namespace UICatalog.Scenarios {
 
 		private void SetMinAcceptableWidthToOne ()
 		{
-			foreach (DataColumn c in currentTable.Columns) {
-				var style = tableView.Style.GetOrCreateColumnStyle (c.Ordinal);
+			for(int i =0;i<tableView.Table.Columns;i++) {
+				var style = tableView.Style.GetOrCreateColumnStyle (i);
 				style.MinAcceptableWidth = 1;
 			}
 		}
@@ -300,15 +315,15 @@ namespace UICatalog.Scenarios {
 
 		private void RunColumnWidthDialog (int? col, string prompt, Action<ColumnStyle, int> setter, Func<ColumnStyle, int> getter)
 		{
-			if(col == null) {
+			if (col == null) {
 				return;
 			}
 
 			var accepted = false;
 			var ok = new Button ("Ok", is_default: true);
-			ok.Clicked += (s,e) => { accepted = true; Application.RequestStop (); };
+			ok.Clicked += (s, e) => { accepted = true; Application.RequestStop (); };
 			var cancel = new Button ("Cancel");
-			cancel.Clicked += (s,e) => { Application.RequestStop (); };
+			cancel.Clicked += (s, e) => { Application.RequestStop (); };
 			var d = new Dialog (ok, cancel) { Title = prompt };
 
 			var style = tableView.Style.GetOrCreateColumnStyle (col.Value);
@@ -316,7 +331,7 @@ namespace UICatalog.Scenarios {
 			var lbl = new Label () {
 				X = 0,
 				Y = 1,
-				Text = tableView.Table.ColumnNames[col.Value]
+				Text = tableView.Table.ColumnNames [col.Value]
 			};
 
 			var tf = new TextField () {
@@ -347,7 +362,7 @@ namespace UICatalog.Scenarios {
 		{
 			var scrollBar = new ScrollBarView (tableView, true);
 
-			scrollBar.ChangedPosition += (s,e) => {
+			scrollBar.ChangedPosition += (s, e) => {
 				tableView.RowOffset = scrollBar.Position;
 				if (tableView.RowOffset != scrollBar.Position) {
 					scrollBar.Position = tableView.RowOffset;
@@ -363,7 +378,7 @@ namespace UICatalog.Scenarios {
 				tableView.SetNeedsDisplay ();
 			};*/
 
-			tableView.DrawContent += (s,e) => {
+			tableView.DrawContent += (s, e) => {
 				scrollBar.Size = tableView.Table?.Rows ?? 0;
 				scrollBar.Position = tableView.RowOffset;
 				//scrollBar.OtherScrollBarView.Size = tableView.Maxlength - 1;
@@ -375,6 +390,10 @@ namespace UICatalog.Scenarios {
 
 		private void TableViewKeyPress (object sender, KeyEventEventArgs e)
 		{
+			if(currentTable == null) {
+				return;
+			}
+
 			if (e.KeyEvent.Key == Key.DeleteChar) {
 
 				if (tableView.FullRowSelect) {
@@ -433,7 +452,7 @@ namespace UICatalog.Scenarios {
 			tableView.Style.ShowHorizontalHeaderUnderline = (bool)_miHeaderUnderline.Checked;
 			tableView.Update ();
 		}
-		private void ToggleBottomline()
+		private void ToggleBottomline ()
 		{
 			_miBottomline.Checked = !_miBottomline.Checked;
 			tableView.Style.ShowHorizontalBottomline = (bool)_miBottomline.Checked;
@@ -463,7 +482,7 @@ namespace UICatalog.Scenarios {
 
 		private void ToggleCheckboxes (bool radio)
 		{
-			if (tableView.Table is CheckBoxTableSourceWrapperByIndex wrapper) {
+			if (tableView.Table is CheckBoxTableSourceWrapperBase wrapper) {
 
 				// unwrap it to remove check boxes
 				tableView.Table = wrapper.Wrapping;
@@ -472,32 +491,51 @@ namespace UICatalog.Scenarios {
 				_miRadioboxes.Checked = false;
 
 				// if toggling off checkboxes/radio
-				if(wrapper.UseRadioButtons == radio) {
+				if (wrapper.UseRadioButtons == radio) {
 					return;
 				}
 			}
-			
+
+			ITableSource source;
 			// Either toggling on checkboxes/radio or switching from radio to checkboxes (or vice versa)
-			
-			var source = new CheckBoxTableSourceWrapperByIndex (tableView, tableView.Table) {
-				UseRadioButtons = radio
-			};
+			if (tableView.Table is TreeTableSource<FileSystemInfo> treeSource) {
+				source = new CheckBoxTableSourceWrapperByObject<FileSystemInfo> (tableView, treeSource,
+					this._checkedFileSystemInfos.Contains,
+					this.CheckOrUncheckFile
+				) {
+					UseRadioButtons = radio
+				};
+			} else {
+				source = new CheckBoxTableSourceWrapperByIndex (tableView, tableView.Table) {
+					UseRadioButtons = radio
+				};
+			}
+
 			tableView.Table = source;
 
 
 			if (radio) {
 				_miRadioboxes.Checked = true;
 				_miCheckboxes.Checked = false;
-			}
-			else {
+			} else {
 
 				_miRadioboxes.Checked = false;
 				_miCheckboxes.Checked = true;
 			}
-			
+
+		}
+
+		private void CheckOrUncheckFile (FileSystemInfo info, bool check)
+		{
+			if (check) {
+				_checkedFileSystemInfos.Add (info);
+			} else {
+
+				_checkedFileSystemInfos.Remove (info);
+			}
 		}
 
-		private void ToggleAlwaysUseNormalColorForVerticalCellLines()
+		private void ToggleAlwaysUseNormalColorForVerticalCellLines ()
 		{
 			_miAlwaysUseNormalColorForVerticalCellLines.Checked = !_miAlwaysUseNormalColorForVerticalCellLines.Checked;
 			tableView.Style.AlwaysUseNormalColorForVerticalCellLines = (bool)_miAlwaysUseNormalColorForVerticalCellLines.Checked;
@@ -579,21 +617,107 @@ namespace UICatalog.Scenarios {
 
 		private void OpenExample (bool big)
 		{
-			SetTable(BuildDemoDataTable (big ? 30 : 5, big ? 1000 : 5));
+			SetTable (BuildDemoDataTable (big ? 30 : 5, big ? 1000 : 5));
 			SetDemoTableStyles ();
 		}
 
 		private void SetTable (DataTable dataTable)
 		{
-			tableView.Table = new DataTableSource(currentTable = dataTable);
+			tableView.Table = new DataTableSource (currentTable = dataTable);
 		}
 
 		private void OpenUnicodeMap ()
 		{
-			SetTable(BuildUnicodeMap ());
+			SetTable (BuildUnicodeMap ());
 			tableView.Update ();
 		}
 
+
+		private IEnumerable<FileSystemInfo> GetChildren (FileSystemInfo arg)
+		{
+			try {
+				return arg is DirectoryInfo d ?
+					d.GetFileSystemInfos () :
+					Enumerable.Empty<FileSystemInfo> ();
+			} catch (Exception) {
+				// Permission denied etc
+				return Enumerable.Empty<FileSystemInfo> ();
+			}
+
+		}
+
+		protected override void Dispose (bool disposing)
+		{
+			base.Dispose (disposing);
+
+			foreach (var d in toDispose) {
+				d.Dispose ();
+			}
+
+		}
+
+		private void OpenTreeExample ()
+		{
+			tableView.Style.ColumnStyles.Clear ();
+
+			var tree = new TreeView<FileSystemInfo> {
+				AspectGetter = (f) => f.Name,
+				TreeBuilder = new DelegateTreeBuilder<FileSystemInfo> (GetChildren)
+			};
+
+
+			var source = new TreeTableSource<FileSystemInfo> (tableView, "Name", tree, new (){
+				{"Extension", f=>f.Extension},
+				{"CreationTime", f=>f.CreationTime},
+				{"FileSize", GetHumanReadableFileSize}
+
+			    });
+
+			var seen = new HashSet<string> ();
+			try {
+				foreach (var path in Environment.GetLogicalDrives ()) {
+					tree.AddObject (new DirectoryInfo (path));
+				}
+			} catch (Exception e) {
+				MessageBox.ErrorQuery ("Could not find local drives", e.Message, "Ok");
+			}
+
+			tableView.Table = source;
+
+			toDispose.Add (tree);
+		}
+
+		private string GetHumanReadableFileSize (FileSystemInfo fsi)
+		{
+			if (fsi is not FileInfo fi) {
+				return null;
+			}
+
+			long value = fi.Length;
+			var culture = CultureInfo.CurrentUICulture;
+
+			return GetHumanReadableFileSize (value, culture);
+		}
+
+		private string GetHumanReadableFileSize (long value, CultureInfo culture)
+		{
+			const long ByteConversion = 1024;
+			string [] SizeSuffixes = { "bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" };
+
+			if (value < 0) {
+				return "-" + GetHumanReadableFileSize (-value, culture);
+			}
+
+			if (value == 0) {
+				return "0.0 bytes";
+			}
+
+			int mag = (int)Math.Log (value, ByteConversion);
+			double adjustedSize = value / Math.Pow (1000, mag);
+			return string.Format (culture.NumberFormat, "{0:n2} {1}", adjustedSize, SizeSuffixes [mag]);
+		}
+
+
 		private DataTable BuildUnicodeMap ()
 		{
 			var dt = new DataTable ();
@@ -791,7 +915,7 @@ namespace UICatalog.Scenarios {
 		};
 		private void SetDemoTableStyles ()
 		{
-			tableView.Style.ColumnStyles.Clear();
+			tableView.Style.ColumnStyles.Clear ();
 
 			var alignMid = new ColumnStyle () {
 				Alignment = TextAlignment.Centered
@@ -836,15 +960,15 @@ namespace UICatalog.Scenarios {
 
 		private void OpenSimple (bool big)
 		{
-			SetTable(BuildSimpleDataTable (big ? 30 : 5, big ? 1000 : 5));
+			SetTable (BuildSimpleDataTable (big ? 30 : 5, big ? 1000 : 5));
 		}
 
 		private void EditCurrentCell (object sender, CellActivatedEventArgs e)
 		{
-			if (e.Table == null)
+			if (e.Table as DataTableSource == null || currentTable == null)
 				return;
 
-			var tableCol = ToTableCol(e.Col);
+			var tableCol = ToTableCol (e.Col);
 			if (tableCol < 0) {
 				return;
 			}
@@ -857,15 +981,15 @@ namespace UICatalog.Scenarios {
 			bool okPressed = false;
 
 			var ok = new Button ("Ok", is_default: true);
-			ok.Clicked += (s,e) => { okPressed = true; Application.RequestStop (); };
+			ok.Clicked += (s, e) => { okPressed = true; Application.RequestStop (); };
 			var cancel = new Button ("Cancel");
-			cancel.Clicked += (s,e) => { Application.RequestStop (); };
+			cancel.Clicked += (s, e) => { Application.RequestStop (); };
 			var d = new Dialog (ok, cancel) { Title = title };
 
 			var lbl = new Label () {
 				X = 0,
 				Y = 1,
-				Text = tableView.Table.ColumnNames[e.Col]
+				Text = tableView.Table.ColumnNames [e.Col]
 			};
 
 			var tf = new TextField () {

+ 301 - 0
UnitTests/Views/TreeTableSourceTests.cs

@@ -0,0 +1,301 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+using System.Text;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Terminal.Gui.ViewsTests;
+
+public class TreeTableSourceTests: IDisposable {
+
+	readonly ITestOutputHelper _output;
+	private readonly Rune _origChecked;
+	private readonly Rune _origUnchecked;
+	public TreeTableSourceTests (ITestOutputHelper output)
+	{
+		_output = output;
+
+		_origChecked = ConfigurationManager.Glyphs.Checked;
+		_origUnchecked = ConfigurationManager.Glyphs.UnChecked;
+		ConfigurationManager.Glyphs.Checked = new Rune ('☑');
+		ConfigurationManager.Glyphs.UnChecked = new Rune ('☐');
+	}
+
+	[Fact, AutoInitShutdown]
+	public void TestTreeTableSource_BasicExpanding_WithKeyboard ()
+	{
+		var tv = GetTreeTable (out _);
+
+		tv.Style.GetOrCreateColumnStyle (1).MinAcceptableWidth = 1;
+
+		tv.Draw ();
+
+		string expected =
+			@"
+│Name          │Description            │
+├──────────────┼───────────────────────┤
+│├+Lost Highway│Exciting night road    │
+│└+Route 66    │Great race course      │";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+		Assert.Equal(2, tv.Table.Rows);
+
+		// top left is selected cell
+		Assert.Equal (0, tv.SelectedRow);
+		Assert.Equal(0, tv.SelectedColumn);
+
+		// when pressing right we should expand the top route
+		Application.Top.ProcessHotKey (new KeyEvent (Key.CursorRight, new KeyModifiers ()));
+
+
+		tv.Draw ();
+
+		expected =
+			@"
+│Name             │Description         │
+├─────────────────┼────────────────────┤
+│├-Lost Highway   │Exciting night road │
+││ ├─Ford Trans-Am│Talking thunderbird │
+││ └─DeLorean     │Time travelling car │
+│└+Route 66       │Great race course   │
+";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+		// when pressing left we should collapse the top route again
+		Application.Top.ProcessHotKey (new KeyEvent (Key.CursorLeft, new KeyModifiers ()));
+
+
+		tv.Draw ();
+
+		expected =
+			@"
+│Name          │Description            │
+├──────────────┼───────────────────────┤
+│├+Lost Highway│Exciting night road    │
+│└+Route 66    │Great race course      │
+";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+	}
+
+	[Fact, AutoInitShutdown]
+	public void TestTreeTableSource_BasicExpanding_WithMouse ()
+	{
+		var tv = GetTreeTable (out _);
+
+		tv.Style.GetOrCreateColumnStyle (1).MinAcceptableWidth = 1;
+
+		tv.Draw ();
+
+		string expected =
+			@"
+│Name          │Description            │
+├──────────────┼───────────────────────┤
+│├+Lost Highway│Exciting night road    │
+│└+Route 66    │Great race course      │";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+		Assert.Equal (2, tv.Table.Rows);
+
+		// top left is selected cell
+		Assert.Equal (0, tv.SelectedRow);
+		Assert.Equal (0, tv.SelectedColumn);
+
+		Assert.True(tv.OnMouseEvent (new MouseEvent () { X = 2,Y=2,Flags = MouseFlags.Button1Clicked}));
+			
+		tv.Draw ();
+
+		expected =
+			@"
+│Name             │Description         │
+├─────────────────┼────────────────────┤
+│├-Lost Highway   │Exciting night road │
+││ ├─Ford Trans-Am│Talking thunderbird │
+││ └─DeLorean     │Time travelling car │
+│└+Route 66       │Great race course   │
+";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+		// Clicking to the right/left of the expand/collapse does nothing
+		tv.OnMouseEvent (new MouseEvent () { X = 3, Y = 2, Flags = MouseFlags.Button1Clicked });
+		tv.Draw ();
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+		tv.OnMouseEvent (new MouseEvent () { X = 1, Y = 2, Flags = MouseFlags.Button1Clicked });
+		tv.Draw ();
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+		// Clicking on the + again should collapse
+		tv.OnMouseEvent (new MouseEvent () { X = 2, Y = 2, Flags = MouseFlags.Button1Clicked });
+		tv.Draw ();
+		
+		expected =
+			@"
+│Name          │Description            │
+├──────────────┼───────────────────────┤
+│├+Lost Highway│Exciting night road    │
+│└+Route 66    │Great race course      │";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+	}
+
+	[Fact, AutoInitShutdown]
+	public void TestTreeTableSource_CombinedWithCheckboxes ()
+	{
+		var tv = GetTreeTable (out var treeSource);
+
+		CheckBoxTableSourceWrapperByIndex checkSource;
+		tv.Table = checkSource = new CheckBoxTableSourceWrapperByIndex (tv, tv.Table);
+		tv.Style.GetOrCreateColumnStyle (2).MinAcceptableWidth = 1;
+
+		tv.Draw ();
+
+		string expected =
+			@"
+    │ │Name          │Description          │
+├─┼──────────────┼─────────────────────┤
+│☐│├+Lost Highway│Exciting night road  │
+│☐│└+Route 66    │Great race course    │
+";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+		Assert.Equal (2, tv.Table.Rows);
+
+		// top left is selected cell
+		Assert.Equal (0, tv.SelectedRow);
+		Assert.Equal (0, tv.SelectedColumn);
+
+		// when pressing right we move to tree column
+		tv.ProcessKey(new KeyEvent (Key.CursorRight, new KeyModifiers ()));
+
+		// now we are in tree column
+		Assert.Equal (0, tv.SelectedRow);
+		Assert.Equal (1, tv.SelectedColumn);
+
+		Application.Top.ProcessHotKey (new KeyEvent (Key.CursorRight, new KeyModifiers ()));
+
+		tv.Draw ();
+
+		expected =
+			@"
+
+│ │Name             │Description       │
+├─┼─────────────────┼──────────────────┤
+│☐│├-Lost Highway   │Exciting night roa│
+│☐││ ├─Ford Trans-Am│Talking thunderbir│
+│☐││ └─DeLorean     │Time travelling ca│
+│☐│└+Route 66       │Great race course │
+";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+		tv.ProcessKey(new KeyEvent(Key.CursorDown,new KeyModifiers ()));
+		tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+		tv.Draw ();
+
+		expected =
+			@"
+
+│ │Name             │Description       │
+├─┼─────────────────┼──────────────────┤
+│☐│├-Lost Highway   │Exciting night roa│
+│☑││ ├─Ford Trans-Am│Talking thunderbir│
+│☐││ └─DeLorean     │Time travelling ca│
+│☐│└+Route 66       │Great race course │
+";
+
+		TestHelpers.AssertDriverContentsAre (expected, _output);
+
+		var selectedObjects = checkSource.CheckedRows.Select (treeSource.GetObjectOnRow).ToArray();
+		var selected = Assert.Single(selectedObjects);
+
+		Assert.Equal ("Ford Trans-Am",selected.Name);
+		Assert.Equal ("Talking thunderbird car", selected.Description);
+
+	}
+
+	interface IDescribedThing {
+		string Name { get; }
+		string Description { get; }
+	}
+
+	class Road : IDescribedThing {
+		public string Name { get; set; }
+		public string Description { get; set; }
+
+		public List<Car> Traffic { get; set; }
+	}
+
+	class Car : IDescribedThing {
+		public string Name { get; set; }
+		public string Description { get; set; }
+	}
+
+
+	private TableView GetTreeTable (out TreeView<IDescribedThing> tree)
+	{
+		var tableView = new TableView ();
+		tableView.ColorScheme = Colors.TopLevel;
+		tableView.ColorScheme = Colors.TopLevel;
+		tableView.Bounds = new Rect (0, 0, 40, 6);
+
+		tableView.Style.ShowHorizontalHeaderUnderline = true;
+		tableView.Style.ShowHorizontalHeaderOverline = false;
+		tableView.Style.AlwaysShowHeaders = true;
+		tableView.Style.SmoothHorizontalScrolling = true;
+
+		tree = new TreeView<IDescribedThing> ();
+		tree.AspectGetter = (d) => d.Name;
+
+		tree.TreeBuilder = new DelegateTreeBuilder<IDescribedThing> (
+			(d) => d is Road r ? r.Traffic : Enumerable.Empty<IDescribedThing> ()
+			);
+
+		tree.AddObject (new Road {
+			Name = "Lost Highway",
+			Description = "Exciting night road",
+			Traffic = new List<Car> {
+				new Car { Name = "Ford Trans-Am", Description = "Talking thunderbird car"},
+				new Car { Name = "DeLorean", Description = "Time travelling car"}
+			}
+		});
+
+		tree.AddObject (new Road {
+			Name = "Route 66",
+			Description = "Great race course",
+			Traffic = new List<Car> {
+				new Car { Name = "Pink Compact", Description = "Penelope Pitstop's car"},
+				new Car { Name = "Mean Machine", Description = "Dick Dastardly's car"}
+			}
+		});
+
+		tableView.Table = new TreeTableSource<IDescribedThing> (tableView,"Name",tree,
+			new () {
+				{"Description",(d)=>d.Description }
+			});
+
+		tableView.BeginInit ();
+		tableView.EndInit ();
+		tableView.LayoutSubviews ();
+
+		Application.Top.Add (tableView);
+		Application.Top.EnsureFocus ();
+		Assert.Equal (tableView, Application.Top.MostFocused);
+
+		return tableView;
+	}
+
+	public void Dispose ()
+	{
+
+		ConfigurationManager.Glyphs.Checked = _origChecked;
+		ConfigurationManager.Glyphs.UnChecked = _origUnchecked;
+	}
+}