From 13af2b16622baedcf7d6c626a70802007b91d7a1 Mon Sep 17 00:00:00 2001 From: Thomas Nind <31306100+tznind@users.noreply.github.com> Date: Wed, 1 Jun 2022 21:33:20 +0100 Subject: [PATCH] Support for flexible column widths in TableView (#1760) * Support for flexible column widths in TableView * Fixed not respecting min width of MinAcceptableWidth an added UICatalog support * Added menu options for SmoothHorizontalScrolling and setting all MinAcceptableWidth to 1 * spelling fix --- Terminal.Gui/Views/TableView.cs | 59 +++++++++++-- UICatalog/Scenarios/TableEditor.cs | 102 +++++++++++++++++++++- UnitTests/TableViewTests.cs | 135 +++++++++++++++++++++++++++++ docfx/articles/tableview.md | 12 +++ 4 files changed, 301 insertions(+), 7 deletions(-) diff --git a/Terminal.Gui/Views/TableView.cs b/Terminal.Gui/Views/TableView.cs index 07c563e11..ac5f3ba3f 100644 --- a/Terminal.Gui/Views/TableView.cs +++ b/Terminal.Gui/Views/TableView.cs @@ -68,6 +68,12 @@ namespace Terminal.Gui { /// public const int DefaultMaxCellWidth = 100; + + /// + /// The default minimum cell width for + /// + public const int DefaultMinAcceptableWidth = 100; + /// /// The data table to render in the view. Setting this property automatically updates and redraws the control. /// @@ -1214,11 +1220,40 @@ namespace Terminal.Gui { int colWidth; // is there enough space for this column (and it's data)? - usedSpace += colWidth = CalculateMaxCellWidth (col, rowsToRender, colStyle) + padding; + colWidth = CalculateMaxCellWidth (col, rowsToRender, colStyle) + padding; - // no (don't render it) unless its the only column we are render (that must be one massively wide column!) - if (!first && usedSpace > availableHorizontalSpace) - yield break; + // there is not enough space for this columns + // visible content + if (usedSpace + colWidth > availableHorizontalSpace) + { + bool showColumn = false; + + // if this column accepts flexible width rendering and + // is therefore happy rendering into less space + if ( colStyle != null && colStyle.MinAcceptableWidth > 0 && + // is there enough space to meet the MinAcceptableWidth + (availableHorizontalSpace - usedSpace) >= colStyle.MinAcceptableWidth) + { + // show column and use use whatever space is + // left for rendering it + showColumn = true; + colWidth = availableHorizontalSpace - usedSpace; + } + + // If its the only column we are able to render then + // accept it anyway (that must be one massively wide column!) + if (first) + { + showColumn = true; + } + + // no special exceptions and we are out of space + // so stop accepting new columns for the render area + if(!showColumn) + break; + } + + usedSpace += colWidth; // there is space yield return new ColumnToRender (col, startingIdxForCurrentHeader, @@ -1351,10 +1386,24 @@ namespace Terminal.Gui { public int MaxWidth { get; set; } = TableView.DefaultMaxCellWidth; /// - /// Set the minimum width of the column in characters. This value will be ignored if more than the tables or the + /// Set the minimum width of the column in characters. Setting this will ensure that + /// even when a column has short content/header it still fills a given width of the control. + /// + /// This value will be ignored if more than the tables + /// or the + /// + /// + /// For setting a flexible column width (down to a lower limit) use + /// instead + /// /// public int MinWidth { get; set; } + /// + /// Enables flexible sizing of this column based on available screen space to render into. + /// + public int MinAcceptableWidth { get; set; } = DefaultMinAcceptableWidth; + /// /// Returns the alignment for the cell based on and / /// diff --git a/UICatalog/Scenarios/TableEditor.cs b/UICatalog/Scenarios/TableEditor.cs index 8afd6aa54..b0fe9f63f 100644 --- a/UICatalog/Scenarios/TableEditor.cs +++ b/UICatalog/Scenarios/TableEditor.cs @@ -4,6 +4,7 @@ using System.Data; using Terminal.Gui; using System.Linq; using System.Globalization; +using static Terminal.Gui.TableView; namespace UICatalog.Scenarios { @@ -24,6 +25,7 @@ namespace UICatalog.Scenarios { private MenuItem miCellLines; private MenuItem miFullRowSelect; private MenuItem miExpandLastColumn; + private MenuItem miSmoothScrolling; private MenuItem miAlternatingColors; private MenuItem miCursor; @@ -62,14 +64,23 @@ namespace UICatalog.Scenarios { miFullRowSelect =new MenuItem ("_FullRowSelect", "", () => ToggleFullRowSelect()){Checked = tableView.FullRowSelect, CheckType = MenuItemCheckStyle.Checked }, miCellLines =new MenuItem ("_CellLines", "", () => ToggleCellLines()){Checked = tableView.Style.ShowVerticalCellLines, CheckType = MenuItemCheckStyle.Checked }, miExpandLastColumn = new MenuItem ("_ExpandLastColumn", "", () => ToggleExpandLastColumn()){Checked = tableView.Style.ExpandLastColumn, CheckType = MenuItemCheckStyle.Checked }, + miSmoothScrolling = new MenuItem ("_SmoothHorizontalScrolling", "", () => ToggleSmoothScrolling()){Checked = tableView.Style.SmoothHorizontalScrolling, CheckType = MenuItemCheckStyle.Checked }, new MenuItem ("_AllLines", "", () => ToggleAllCellLines()), new MenuItem ("_NoLines", "", () => ToggleNoCellLines()), miAlternatingColors = new MenuItem ("Alternating Colors", "", () => ToggleAlternatingColors()){CheckType = MenuItemCheckStyle.Checked}, miCursor = new MenuItem ("Invert Selected Cell First Character", "", () => ToggleInvertSelectedCellFirstCharacter()){Checked = tableView.Style.InvertSelectedCellFirstCharacter,CheckType = MenuItemCheckStyle.Checked}, new MenuItem ("_ClearColumnStyles", "", () => ClearColumnStyles()), }), + new MenuBarItem ("_Column", new MenuItem [] { + new MenuItem ("_Set Max Width", "", SetMaxWidth), + new MenuItem ("_Set Min Width", "", SetMinWidth), + new MenuItem ("_Set MinAcceptableWidth", "",SetMinAcceptableWidth), + new MenuItem ("_Set All MinAcceptableWidth=1", "",SetMinAcceptableWidthToOne), + }), }); - Top.Add (menu); + + + Top.Add (menu); var statusBar = new StatusBar (new StatusItem [] { new StatusItem(Key.F2, "~F2~ OpenExample", () => OpenExample(true)), @@ -92,7 +103,7 @@ namespace UICatalog.Scenarios { Win.Add(selectedCellLabel); - tableView.SelectedCellChanged += (e)=>{selectedCellLabel.Text = $"{tableView.SelectedRow},{tableView.SelectedColumn}";}; + tableView.SelectedCellChanged += (e) => { selectedCellLabel.Text = $"{tableView.SelectedRow},{tableView.SelectedColumn}"; }; tableView.CellActivated += EditCurrentCell; tableView.KeyPress += TableViewKeyPress; @@ -121,6 +132,85 @@ namespace UICatalog.Scenarios { }; } + + private DataColumn GetColumn () + { + if (tableView.Table == null) + return null; + + if (tableView.SelectedColumn < 0 || tableView.SelectedColumn > tableView.Table.Columns.Count) + return null; + + return tableView.Table.Columns [tableView.SelectedColumn]; + } + + private void SetMinAcceptableWidthToOne () + { + foreach (DataColumn c in tableView.Table.Columns) + { + var style = tableView.Style.GetOrCreateColumnStyle (c); + style.MinAcceptableWidth = 1; + } + } + private void SetMinAcceptableWidth () + { + var col = GetColumn (); + RunColumnWidthDialog (col, "MinAcceptableWidth", (s,v)=>s.MinAcceptableWidth = v,(s)=>s.MinAcceptableWidth); + } + + private void SetMinWidth () + { + var col = GetColumn (); + RunColumnWidthDialog (col, "MinWidth", (s, v) => s.MinWidth = v, (s) => s.MinWidth); + } + + private void SetMaxWidth () + { + var col = GetColumn (); + RunColumnWidthDialog (col, "MaxWidth", (s, v) => s.MaxWidth = v, (s) => s.MaxWidth); + } + + private void RunColumnWidthDialog (DataColumn col, string prompt, Action setter,Func getter) + { + var accepted = false; + var ok = new Button ("Ok", is_default: true); + ok.Clicked += () => { accepted = true; Application.RequestStop (); }; + var cancel = new Button ("Cancel"); + cancel.Clicked += () => { Application.RequestStop (); }; + var d = new Dialog (prompt, 60, 20, ok, cancel); + + var style = tableView.Style.GetOrCreateColumnStyle (col); + + var lbl = new Label () { + X = 0, + Y = 1, + Text = col.ColumnName + }; + + var tf = new TextField () { + Text = getter(style).ToString (), + X = 0, + Y = 2, + Width = Dim.Fill () + }; + + d.Add (lbl, tf); + tf.SetFocus (); + + Application.Run (d); + + if (accepted) { + + try { + setter (style, int.Parse (tf.Text.ToString())); + } catch (Exception ex) { + MessageBox.ErrorQuery (60, 20, "Failed to set", ex.Message, "Ok"); + } + + tableView.Update (); + } + } + private void SetupScrollBar () { var _scrollBar = new ScrollBarView (tableView, true); @@ -228,6 +318,14 @@ namespace UICatalog.Scenarios { tableView.Update(); + } + private void ToggleSmoothScrolling() + { + miSmoothScrolling.Checked = !miSmoothScrolling.Checked; + tableView.Style.SmoothHorizontalScrolling = miSmoothScrolling.Checked; + + tableView.Update (); + } private void ToggleCellLines() { diff --git a/UnitTests/TableViewTests.cs b/UnitTests/TableViewTests.cs index a31985994..bc5adb11c 100644 --- a/UnitTests/TableViewTests.cs +++ b/UnitTests/TableViewTests.cs @@ -778,6 +778,141 @@ namespace Terminal.Gui.Views { Application.Shutdown (); } + [Fact] + public void LongColumnTest () + { + GraphViewTests.InitFakeDriver (); + + var tableView = new TableView (); + tableView.ColorScheme = Colors.TopLevel; + + // 25 characters can be printed into table + tableView.Bounds = new Rect (0, 0, 25, 5); + tableView.Style.ShowHorizontalHeaderUnderline = true; + tableView.Style.ShowHorizontalHeaderOverline = false; + tableView.Style.AlwaysShowHeaders = true; + tableView.Style.SmoothHorizontalScrolling = true; + + var dt = new DataTable (); + dt.Columns.Add ("A"); + dt.Columns.Add ("B"); + dt.Columns.Add ("Very Long Column"); + + dt.Rows.Add (1, 2, new string('a',500)); + dt.Rows.Add (1, 2, "aaa"); + + tableView.Table = dt; + + tableView.Redraw (tableView.Bounds); + + // default behaviour of TableView is not to render + // columns unless there is sufficient space + string expected = + @" +│A│B │ +├─┼─────────────────────► +│1│2 │ +│1│2 │ +"; + + GraphViewTests.AssertDriverContentsAre (expected, output); + + // get a style for the long column + var style = tableView.Style.GetOrCreateColumnStyle(dt.Columns[2]); + + // one way the API user can fix this for long columns + // is to specify a max width for the column + style.MaxWidth = 10; + + tableView.Redraw (tableView.Bounds); + expected = + @" +│A│B│Very Long │ +├─┼─┼───────────────────┤ +│1│2│aaaaaaaaaa │ +│1│2│aaa │ +"; + GraphViewTests.AssertDriverContentsAre (expected, output); + + // revert the style change + style.MaxWidth = TableView.DefaultMaxCellWidth; + + // another way API user can fix problem is to implement + // RepresentationGetter and apply max length there + + style.RepresentationGetter = (s)=>{ + return s.ToString().Length < 15 ? s.ToString() : s.ToString().Substring(0,13)+"..."; + }; + + tableView.Redraw (tableView.Bounds); + expected = + @" +│A│B│Very Long Column │ +├─┼─┼───────────────────┤ +│1│2│aaaaaaaaaaaaa... │ +│1│2│aaa │ +"; + GraphViewTests.AssertDriverContentsAre (expected, output); + + // revert style change + style.RepresentationGetter = null; + + // Both of the above methods rely on having a fixed + // size limit for the column. These are awkward if a + // table is resizeable e.g. Dim.Fill(). Ideally we want + // to render in any space available and truncate the content + // of the column dynamically so it fills the free space at + // the end of the table. + + // We can now specify that the column can be any length + // (Up to MaxWidth) but the renderer can accept using + // less space down to this limit + style.MinAcceptableWidth = 5; + + tableView.Redraw (tableView.Bounds); + expected = + @" +│A│B│Very Long Column │ +├─┼─┼───────────────────┤ +│1│2│aaaaaaaaaaaaaaaaaaa│ +│1│2│aaa │ +"; + GraphViewTests.AssertDriverContentsAre (expected, output); + + // Now test making the width too small for the MinAcceptableWidth + // the Column won't fit so should not be rendered + Application.Shutdown (); + GraphViewTests.InitFakeDriver (); + + tableView.Bounds = new Rect(0,0,9,5); + tableView.Redraw (tableView.Bounds); + expected = +@" +│A│B │ +├─┼─────► +│1│2 │ +│1│2 │ + +"; + GraphViewTests.AssertDriverContentsAre (expected, output); + + // setting width to 10 leaves just enough space for the column to + // meet MinAcceptableWidth of 5. Column width includes terminator line + // symbol (e.g. ┤ or │) + tableView.Bounds = new Rect (0, 0, 10, 5); + tableView.Redraw (tableView.Bounds); + expected = +@" +│A│B│Very│ +├─┼─┼────┤ +│1│2│aaaa│ +│1│2│aaa │ +"; + GraphViewTests.AssertDriverContentsAre (expected, output); + + Application.Shutdown (); + } + [Fact] public void ScrollIndicators () diff --git a/docfx/articles/tableview.md b/docfx/articles/tableview.md index 17799df95..8f1ebf0ea 100644 --- a/docfx/articles/tableview.md +++ b/docfx/articles/tableview.md @@ -54,3 +54,15 @@ tableView = new TableView () { tableView.Table = yourDataTable; ``` + +## Table Rendering +TableView supports any size of table (limited only by the RAM requirements of `System.DataTable`). You can have +thousands of columns and/or millions of rows if you want. Horizontal and vertical scrolling can be done using +the mouse or keyboard. + +TableView uses `ColumnOffset` and `RowOffset` to determine the first visible cell of the `System.DataTable`. +Rendering then continues until the avaialble console space is exhausted. Updating the `ColumnOffset` and +`RowOffset` changes which part of the table is rendered (scrolls the viewport). + +This approach ensures that no matter how big the table, only a small number of columns/rows need to be +evaluated for rendering.