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.