From cd6cfd7f628a7084af2ceb1277ed995b78ce3356 Mon Sep 17 00:00:00 2001 From: Thomas Nind <31306100+tznind@users.noreply.github.com> Date: Wed, 5 Jul 2023 23:45:42 +0100 Subject: [PATCH] Fixes #2683 - Adds an ITableSource which wraps a TreeView (#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 * 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 --- .../CheckBoxTableSourceWrapperByObject.cs | 14 +- .../Views/TableView/EnumerableTableSource.cs | 15 +- .../Views/TableView/IEnumerableTableSource.cs | 21 ++ Terminal.Gui/Views/TableView/TableView.cs | 20 +- .../Views/TableView/TreeTableSource.cs | 203 ++++++++++++ Terminal.Gui/Views/TreeView/Branch.cs | 4 +- Terminal.Gui/Views/TreeView/TreeView.cs | 2 +- UICatalog/Scenarios/TableEditor.cs | 196 +++++++++--- UnitTests/Views/TreeTableSourceTests.cs | 301 ++++++++++++++++++ 9 files changed, 725 insertions(+), 51 deletions(-) create mode 100644 Terminal.Gui/Views/TableView/IEnumerableTableSource.cs create mode 100644 Terminal.Gui/Views/TableView/TreeTableSource.cs create mode 100644 UnitTests/Views/TreeTableSourceTests.cs diff --git a/Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapperByObject.cs b/Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapperByObject.cs index 04d0cd8a4f..b6d8116396 100644 --- a/Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapperByObject.cs +++ b/Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapperByObject.cs @@ -7,7 +7,7 @@ namespace Terminal.Gui { /// by a property on row objects. /// public class CheckBoxTableSourceWrapperByObject : CheckBoxTableSourceWrapperBase { - private readonly EnumerableTableSource toWrap; + private readonly IEnumerableTableSource toWrap; readonly Func getter; readonly Action setter; @@ -20,7 +20,7 @@ public class CheckBoxTableSourceWrapperByObject : CheckBoxTableSourceWrapperB /// Delegate method for setting new checked states on your objects of type . public CheckBoxTableSourceWrapperByObject ( TableView tableView, - EnumerableTableSource toWrap, + IEnumerableTableSource toWrap, Func getter, Action setter) : base (tableView, toWrap) { @@ -32,7 +32,7 @@ public CheckBoxTableSourceWrapperByObject ( /// protected override bool IsChecked (int row) { - return getter (toWrap.Data.ElementAt (row)); + return getter (toWrap.GetObjectOnRow (row)); } /// @@ -44,7 +44,7 @@ protected override void ToggleAllRows () /// 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 @@ protected override void ToggleRows (int [] range) 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 @@ protected override void ToggleRows (int [] range) /// protected override void ClearAllToggles () { - foreach (var e in toWrap.Data) { + foreach (var e in toWrap.GetAllObjects()) { setter (e, false); } } diff --git a/Terminal.Gui/Views/TableView/EnumerableTableSource.cs b/Terminal.Gui/Views/TableView/EnumerableTableSource.cs index b5c84d0436..266c2276a0 100644 --- a/Terminal.Gui/Views/TableView/EnumerableTableSource.cs +++ b/Terminal.Gui/Views/TableView/EnumerableTableSource.cs @@ -3,12 +3,11 @@ using System.Linq; namespace Terminal.Gui { - /// /// implementation that wraps arbitrary data. /// /// - public class EnumerableTableSource : ITableSource { + public class EnumerableTableSource : IEnumerableTableSource { private T [] data; private string [] cols; private Dictionary> lamdas; @@ -55,5 +54,17 @@ public EnumerableTableSource (IEnumerable data, Dictionary public IReadOnlyCollection Data => this.data.AsReadOnly(); + + /// + public IEnumerable GetAllObjects () + { + return Data; + } + + /// + public T GetObjectOnRow (int row) + { + return Data.ElementAt(row); + } } } diff --git a/Terminal.Gui/Views/TableView/IEnumerableTableSource.cs b/Terminal.Gui/Views/TableView/IEnumerableTableSource.cs new file mode 100644 index 0000000000..887f96257a --- /dev/null +++ b/Terminal.Gui/Views/TableView/IEnumerableTableSource.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace Terminal.Gui { + + /// + /// Interface for all which present + /// an object per row (of type ). + /// + public interface IEnumerableTableSource : ITableSource + { + /// + /// Return the object on the given row. + /// + T GetObjectOnRow(int row); + + /// + /// Return all objects in the table. + /// + IEnumerable GetAllObjects(); + } +} diff --git a/Terminal.Gui/Views/TableView/TableView.cs b/Terminal.Gui/Views/TableView/TableView.cs index c26f4b7e6e..ae558b9ca2 100644 --- a/Terminal.Gui/Views/TableView/TableView.cs +++ b/Terminal.Gui/Views/TableView/TableView.cs @@ -1288,19 +1288,31 @@ private bool HasControlOrAlt (MouseEvent me) /// Cell clicked or null. public Point? ScreenToCell (int clientX, int clientY) { - return ScreenToCell (clientX, clientY, out _); + return ScreenToCell (clientX, clientY, out _, out _); } - /// /// X offset from the top left of the control. /// Y offset from the top left of the control. /// If the click is in a header this is the column clicked. public Point? ScreenToCell (int clientX, int clientY, out int? headerIfAny) + { + return ScreenToCell (clientX, clientY, out headerIfAny, out _); + } + + /// + /// X offset from the top left of the control. + /// Y offset from the top left of the control. + /// If the click is in a header this is the column clicked. + /// The horizontal offset of the click within the returned cell. + 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 @@ private bool HasControlOrAlt (MouseEvent me) // 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 @@ private bool HasControlOrAlt (MouseEvent me) if (col != null && rowIdx >= 0) { + offsetX = clientX - col.X; return new Point (col.Column, rowIdx); } diff --git a/Terminal.Gui/Views/TableView/TreeTableSource.cs b/Terminal.Gui/Views/TableView/TreeTableSource.cs new file mode 100644 index 0000000000..d14b1eec25 --- /dev/null +++ b/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; + +/// +/// An with expandable rows. +/// +/// +public class TreeTableSource : IEnumerableTableSource, IDisposable where T : class { + + private TreeView _tree; + private string [] _cols; + private Dictionary> _lamdas; + private TableView _tableView; + + /// + /// Creates a new instance of presenting the given + /// . This source should only be used with . + /// + /// The table this source will provide data for. + /// Column name to use for the first column of the table (where + /// the tree branches/leaves will be rendered. + /// The tree data to render. This should be a new view and not used + /// elsewhere (e.g. via ). + /// + /// Getter methods for each additional property you want to present in the table. For example: + /// + /// new () { + /// { "Colname1", (t)=>t.SomeField}, + /// { "Colname2", (t)=>t.SomeOtherField} + ///} + /// + public TreeTableSource (TableView table, string firstColumnName, TreeView tree, Dictionary> 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; + } + + + /// + public object this [int row, int col] => + col == 0 ? GetColumnZeroRepresentationFromTree (row) : + _lamdas [ColumnNames [col]] (RowToObject (row)); + + /// + public int Rows => _tree.BuildLineMap ().Count; + + /// + public int Columns => _lamdas.Count + 1; + + /// + public string [] ColumnNames => _cols; + + /// + public void Dispose () + { + _tree.Dispose (); + } + + /// + /// Returns the tree model object rendering on the given + /// of the table. + /// + /// Row in table. + /// + 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 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]; + } + + /// + public T GetObjectOnRow (int row) + { + return RowToObject (row); + } + + /// + public IEnumerable GetAllObjects () + { + return _tree.BuildLineMap ().Select (b => b.Model); + } +} \ No newline at end of file diff --git a/Terminal.Gui/Views/TreeView/Branch.cs b/Terminal.Gui/Views/TreeView/Branch.cs index aeb556288d..208a648f1d 100644 --- a/Terminal.Gui/Views/TreeView/Branch.cs +++ b/Terminal.Gui/Views/TreeView/Branch.cs @@ -4,7 +4,7 @@ using System.Text; namespace Terminal.Gui { - class Branch where T : class { + internal class Branch where T : class { /// /// True if the branch is expanded to reveal child branches. /// @@ -202,7 +202,7 @@ public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, /// /// /// - private IEnumerable GetLinePrefix (ConsoleDriver driver) + internal IEnumerable GetLinePrefix (ConsoleDriver driver) { // If not showing line branches or this is a root object. if (!tree.Style.ShowBranchLines) { diff --git a/Terminal.Gui/Views/TreeView/TreeView.cs b/Terminal.Gui/Views/TreeView/TreeView.cs index 14645539d8..318b0ffae7 100644 --- a/Terminal.Gui/Views/TreeView/TreeView.cs +++ b/Terminal.Gui/Views/TreeView/TreeView.cs @@ -548,7 +548,7 @@ public int GetContentWidth (bool visible) /// 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. /// - private IReadOnlyCollection> BuildLineMap () + internal IReadOnlyCollection> BuildLineMap () { if (cachedLineMap != null) { return cachedLineMap; diff --git a/UICatalog/Scenarios/TableEditor.cs b/UICatalog/Scenarios/TableEditor.cs index 19b55eb7bd..b392a72f73 100644 --- a/UICatalog/Scenarios/TableEditor.cs +++ b/UICatalog/Scenarios/TableEditor.cs @@ -6,6 +6,8 @@ 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 @@ public class TableEditor : Scenario { private MenuItem _miCheckboxes; private MenuItem _miRadioboxes; + private List toDispose = new List (); + ColorScheme redColorScheme; ColorScheme redColorSchemeAlt; ColorScheme alternatingColorScheme; + HashSet _checkedFileSystemInfos = new HashSet (); + public override void Setup () { Win.Title = this.GetName (); @@ -59,6 +65,7 @@ public override void Setup () 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 @@ public override void Setup () }; // 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 @@ private void SortColumn (int clickedCol) 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 @@ private string GetProposedNewSortOrder (int clickedCol, out bool isAsc) { // 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 @@ private string GetProposedNewSortOrder (int clickedCol, out bool isAsc) 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 @@ private void HideColumn (int clickedCol) private void SetMinAcceptableWidthToOne () { - foreach (DataColumn c in currentTable.Columns) { - var style = tableView.Style.GetOrCreateColumnStyle (c.Ordinal); + for(int i =0;i setter, Func 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 @@ private void RunColumnWidthDialog (int? col, string prompt, Action { + scrollBar.ChangedPosition += (s, e) => { tableView.RowOffset = scrollBar.Position; if (tableView.RowOffset != scrollBar.Position) { scrollBar.Position = tableView.RowOffset; @@ -363,7 +378,7 @@ private void SetupScrollBar () 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 @@ private void SetupScrollBar () private void TableViewKeyPress (object sender, KeyEventEventArgs e) { + if(currentTable == null) { + return; + } + if (e.KeyEvent.Key == Key.DeleteChar) { if (tableView.FullRowSelect) { @@ -433,7 +452,7 @@ private void ToggleUnderline () 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 @@ private void ToggleExpandLastColumn () 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 @@ private void ToggleCheckboxes (bool radio) _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 treeSource) { + source = new CheckBoxTableSourceWrapperByObject (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 @@ private void Quit () 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 GetChildren (FileSystemInfo arg) + { + try { + return arg is DirectoryInfo d ? + d.GetFileSystemInfos () : + Enumerable.Empty (); + } catch (Exception) { + // Permission denied etc + return Enumerable.Empty (); + } + + } + + 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 { + AspectGetter = (f) => f.Name, + TreeBuilder = new DelegateTreeBuilder (GetChildren) + }; + + + var source = new TreeTableSource (tableView, "Name", tree, new (){ + {"Extension", f=>f.Extension}, + {"CreationTime", f=>f.CreationTime}, + {"FileSize", GetHumanReadableFileSize} + + }); + + var seen = new HashSet (); + 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 @@ public UnicodeRange (uint start, uint end, string category) }; private void SetDemoTableStyles () { - tableView.Style.ColumnStyles.Clear(); + tableView.Style.ColumnStyles.Clear (); var alignMid = new ColumnStyle () { Alignment = TextAlignment.Centered @@ -836,15 +960,15 @@ private void SetDemoTableStyles () 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 @@ private void EditCurrentCell (object sender, CellActivatedEventArgs e) 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 () { diff --git a/UnitTests/Views/TreeTableSourceTests.cs b/UnitTests/Views/TreeTableSourceTests.cs new file mode 100644 index 0000000000..5bda847220 --- /dev/null +++ b/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 Traffic { get; set; } + } + + class Car : IDescribedThing { + public string Name { get; set; } + public string Description { get; set; } + } + + + private TableView GetTreeTable (out TreeView 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 (); + tree.AspectGetter = (d) => d.Name; + + tree.TreeBuilder = new DelegateTreeBuilder ( + (d) => d is Road r ? r.Traffic : Enumerable.Empty () + ); + + tree.AddObject (new Road { + Name = "Lost Highway", + Description = "Exciting night road", + Traffic = new List { + 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 { + new Car { Name = "Pink Compact", Description = "Penelope Pitstop's car"}, + new Car { Name = "Mean Machine", Description = "Dick Dastardly's car"} + } + }); + + tableView.Table = new TreeTableSource (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; + } +}