Просмотр исходного кода

Fixes #2587 - Add CheckBoxTableSourceWrapper for TableView checkboxes (#2589)

* Add CheckBoxTableSourceWrapper

* Fix column offsets when there are checkboxes column

* Fix index

* Add CellToggledEventArgs and handle in CheckBoxTableSourceWrapper

* Add xmldoc for CheckBoxTableSourceWrapper

* Add tests and default keybinding for toggle to CheckBoxTableSourceWrapper

* Add unit tests for TableView checkboxes

* Split CheckBoxTableSource to two subclasses, one by index the other by object

* Add more tests for CheckBoxTableSourceWrapperByObject

* Refactor for readability

* Add UseRadioButtons

* Add test for radio buttons in table view

* Fix xmldoc

* Fix regression during radio refactoring

* Fix build errors for new glyph and draw method names

---------

Co-authored-by: Tig <[email protected]>
Thomas Nind 2 лет назад
Родитель
Сommit
6cd79ada3a

+ 45 - 0
Terminal.Gui/Views/TableView/CellToggledEventArgs.cs

@@ -0,0 +1,45 @@
+using System;
+
+namespace Terminal.Gui {
+	/// <summary>
+	/// Event args for the <see cref="TableView.CellToggled"/> event.
+	/// </summary>
+	public class CellToggledEventArgs : EventArgs
+	{
+		/// <summary>
+		/// The current table to which the new indexes refer.  May be null e.g. if selection change is the result of clearing the table from the view
+		/// </summary>
+		/// <value></value>
+		public ITableSource Table { get; }
+
+		/// <summary>
+		/// The column index of the <see cref="Table"/> cell that is being toggled
+		/// </summary>
+		/// <value></value>
+		public int Col { get; }
+
+		/// <summary>
+		/// The row index of the <see cref="Table"/> cell that is being toggled
+		/// </summary>
+		/// <value></value>
+		public int Row { get; }
+
+		/// <summary>
+		/// Gets or sets whether to cancel the processing of this event
+		/// </summary>
+		public bool Cancel { get; set; }
+
+		/// <summary>
+		/// Creates a new instance of arguments describing a cell being toggled in <see cref="TableView"/>
+		/// </summary>
+		/// <param name="t"></param>
+		/// <param name="col"></param>
+		/// <param name="row"></param>
+		public CellToggledEventArgs (ITableSource t, int col, int row)
+		{
+			Table = t;
+			Col = col;
+			Row = row;
+		}
+	}
+}

+ 198 - 0
Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapper.cs

@@ -0,0 +1,198 @@
+using System;
+using System.Data;
+using System.Linq;
+
+namespace Terminal.Gui {
+
+	/// <summary>
+	/// <see cref="ITableSource"/> for a <see cref="TableView"/> which adds a
+	/// checkbox column as an additional column in the table.
+	/// </summary>
+	/// <remarks>This class wraps another <see cref="ITableSource"/> and dynamically
+	/// serves its rows/cols plus an extra column. Data in the wrapped source can be
+	/// dynamic (change over time).</remarks>
+	public abstract class CheckBoxTableSourceWrapperBase : ITableSource {
+
+		private readonly TableView tableView;
+
+		/// <summary>
+		/// Creates a new instance of the class presenting the data in <paramref name="toWrap"/>
+		/// plus an additional checkbox column.
+		/// </summary>
+		/// <param name="tableView">The <see cref="TableView"/> this source will be used with.
+		/// This is required for event registration.</param>
+		/// <param name="toWrap">The original data source of the <see cref="TableView"/> that you
+		/// want to add checkboxes to.</param>
+		public CheckBoxTableSourceWrapperBase (TableView tableView, ITableSource toWrap)
+		{
+			this.Wrapping = toWrap;
+			this.tableView = tableView;
+
+			tableView.AddKeyBinding (Key.Space, Command.ToggleChecked);
+
+			tableView.MouseClick += TableView_MouseClick;
+			tableView.CellToggled += TableView_CellToggled;
+		}
+
+
+		/// <summary>
+		/// Gets or sets the character to use for checked entries. Defaults to <see cref="GlyphDefinitions.Checked"/>
+		/// </summary>
+		public Rune CheckedRune { get; set; } = CM.Glyphs.Checked;
+
+		/// <summary>
+		/// Gets or sets the character to use for UnChecked entries. Defaults to <see cref="GlyphDefinitions.UnChecked"/>
+		/// </summary>
+		public Rune UnCheckedRune { get; set; } = CM.Glyphs.UnChecked;
+
+		/// <summary>
+		/// Gets or sets whether to only allow a single row to be toggled at once (Radio button).
+		/// </summary>
+		public bool UseRadioButtons { get; set; }
+
+		/// <summary>
+		/// Gets or sets the character to use for checked entry when <see cref="UseRadioButtons"/> is true.
+		/// Defaults to <see cref="GlyphDefinitions.Selected"/>
+		/// </summary>
+		public Rune RadioCheckedRune { get; set; } = CM.Glyphs.Selected;
+
+		/// <summary>
+		/// Gets or sets the character to use for unchecked entries when <see cref="UseRadioButtons"/> is true.
+		/// Defaults to <see cref="GlyphDefinitions.UnSelected"/>
+		/// </summary>
+		public Rune RadioUnCheckedRune { get; set; } = CM.Glyphs.UnSelected;
+
+		/// <summary>
+		/// Gets the <see cref="ITableSource"/> that this instance is wrapping.
+		/// </summary>
+		public ITableSource Wrapping { get; }
+
+
+		/// <inheritdoc/>
+		public object this [int row, int col] {
+			get {
+				if (col == 0) {
+					if(UseRadioButtons) {
+						return IsChecked (row) ? RadioCheckedRune : RadioUnCheckedRune;
+					}
+
+					return IsChecked(row) ? CheckedRune : UnCheckedRune;
+				}
+
+				return Wrapping [row, col - 1];
+			}
+		}
+
+
+		/// <inheritdoc/>
+		public int Rows => Wrapping.Rows;
+
+		/// <inheritdoc/>
+		public int Columns => Wrapping.Columns + 1;
+
+		/// <inheritdoc/>
+		public string [] ColumnNames {
+			get {
+				var toReturn = Wrapping.ColumnNames.ToList ();
+				toReturn.Insert (0, " ");
+				return toReturn.ToArray ();
+			}
+		}
+
+		private void TableView_MouseClick (object sender, MouseEventEventArgs e)
+		{
+			// we only care about clicks (not movements)
+			if(!e.MouseEvent.Flags.HasFlag(MouseFlags.Button1Clicked)) {
+				return;
+			}
+
+			var hit = tableView.ScreenToCell (e.MouseEvent.X,e.MouseEvent.Y, out int? headerIfAny);
+
+			if(headerIfAny.HasValue && headerIfAny.Value == 0) {
+
+				// clicking in header with radio buttons does nothing
+				if(UseRadioButtons) {
+					return;
+				}
+
+				// otherwise it ticks all rows
+				ToggleAllRows ();
+				e.Handled = true;
+				tableView.SetNeedsDisplay ();
+			}
+			else
+			if(hit.HasValue && hit.Value.X == 0) {
+
+				if(UseRadioButtons) {
+
+					ClearAllToggles ();
+					ToggleRow (hit.Value.Y);
+				} else {
+					ToggleRow (hit.Value.Y);
+				}
+
+				e.Handled = true;
+				tableView.SetNeedsDisplay ();
+			}
+		}
+
+		private void TableView_CellToggled (object sender, CellToggledEventArgs e)
+		{
+			// Suppress default toggle behavior when using checkboxes
+			// and instead handle ourselves
+			var range = tableView.GetAllSelectedCells ().Select (c => c.Y).Distinct ().ToArray();
+
+			if(UseRadioButtons) {
+				
+				// multi selection makes it unclear what to toggle in this situation
+				if(range.Length != 1) {
+					e.Cancel = true;
+					return;
+				}
+
+				ClearAllToggles ();
+				ToggleRow (range.Single ());
+			}
+			else {
+				ToggleRows (range);
+			}
+
+			e.Cancel = true;
+			tableView.SetNeedsDisplay ();
+		}
+
+		/// <summary>
+		/// Returns true if <paramref name="row"/> is checked.
+		/// </summary>
+		/// <param name="row"></param>
+		/// <returns></returns>
+		protected abstract bool IsChecked (int row);
+
+		/// <summary>
+		/// Flips the checked state for a collection of rows. If
+		/// some (but not all) are selected they should flip to all
+		/// selected.
+		/// </summary>
+		/// <param name="range"></param>
+		protected abstract void ToggleRows (int [] range);
+
+		/// <summary>
+		/// Flips the checked state of the given <paramref name="row"/>/
+		/// </summary>
+		/// <param name="row"></param>
+		protected abstract void ToggleRow (int row);
+
+		/// <summary>
+		/// Called when the 'toggled all' action is performed.
+		/// This should change state from 'some selected' to
+		/// 'all selected' or clear selection if all area already
+		/// selected.
+		/// </summary>
+		protected abstract void ToggleAllRows ();
+
+		/// <summary>
+		/// Clears the toggled state of all rows.
+		/// </summary>
+		protected abstract void ClearAllToggles ();
+	}
+}

+ 73 - 0
Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapperByIndex.cs

@@ -0,0 +1,73 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Terminal.Gui {
+	/// <summary>
+	/// Implementation of <see cref="CheckBoxTableSourceWrapperBase"/> which records toggled rows
+	/// by their row number.
+	/// </summary>
+	public class CheckBoxTableSourceWrapperByIndex : CheckBoxTableSourceWrapperBase {
+
+		/// <inheritdoc/>
+		public CheckBoxTableSourceWrapperByIndex (TableView tableView, ITableSource toWrap) : base (tableView, toWrap)
+		{
+		}
+
+		/// <summary>
+		/// Gets the collection of all the checked rows in the
+		/// <see cref="CheckBoxTableSourceWrapperBase.Wrapping"/> <see cref="ITableSource"/>.
+		/// </summary>
+		public HashSet<int> CheckedRows { get; private set; } = new HashSet<int> ();
+
+		/// <inheritdoc/>
+		protected override bool IsChecked (int row)
+		{
+			return CheckedRows.Contains (row);
+		}
+
+		/// <inheritdoc/>
+		protected override void ToggleRows (int [] range)
+		{
+			// if all are ticked untick them
+			if (range.All (CheckedRows.Contains)) {
+				// select none
+				foreach (var r in range) {
+					CheckedRows.Remove (r);
+				}
+			} else {
+				// otherwise tick all
+				foreach (var r in range) {
+					CheckedRows.Add (r);
+				}
+			}
+		}
+
+		/// <inheritdoc/>
+		protected override void ToggleRow (int row)
+		{
+			if (CheckedRows.Contains (row)) {
+				CheckedRows.Remove (row);
+			} else {
+				CheckedRows.Add (row);
+			}
+		}
+
+		/// <inheritdoc/>
+		protected override void ToggleAllRows ()
+		{
+			if (CheckedRows.Count == Rows) {
+				// select none
+				ClearAllToggles ();
+			} else {
+				// select all
+				CheckedRows = new HashSet<int> (Enumerable.Range (0, Rows));
+			}
+		}
+
+		/// <inheritdoc/>
+		protected override void ClearAllToggles ()
+		{
+			CheckedRows.Clear ();
+		}
+	}
+}

+ 76 - 0
Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapperByObject.cs

@@ -0,0 +1,76 @@
+using System;
+using System.Linq;
+
+namespace Terminal.Gui {
+	/// <summary>
+	/// Implementation of <see cref="CheckBoxTableSourceWrapperBase"/> which records toggled rows
+	/// by a property on row objects.
+	/// </summary>
+	public class CheckBoxTableSourceWrapperByObject<T> : CheckBoxTableSourceWrapperBase {
+		private readonly EnumerableTableSource<T> toWrap;
+		readonly Func<T, bool> getter;
+		readonly Action<T, bool> setter;
+
+		/// <summary>
+		/// Creates a new instance of the class wrapping the collection <see cref="toWrap"/>.
+		/// </summary>
+		/// <param name="tableView">The table you will use the source with.</param>
+		/// <param name="toWrap">The collection of objects you will record checked state for</param>
+		/// <param name="getter">Delegate method for retrieving checked state from your objects of type <typeparamref name="T"/>.</param>
+		/// <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,
+			Func<T,bool> getter,
+			Action<T,bool> setter) : base (tableView, toWrap)
+		{
+			this.toWrap = toWrap;
+			this.getter = getter;
+			this.setter = setter;
+		}
+
+		/// <inheritdoc/>
+		protected override bool IsChecked (int row)
+		{
+			return getter (toWrap.Data.ElementAt (row));
+		}
+
+		/// <inheritdoc/>
+		protected override void ToggleAllRows ()
+		{
+			ToggleRows (Enumerable.Range (0, toWrap.Rows).ToArray());
+		}
+
+		/// <inheritdoc/>
+		protected override void ToggleRow (int row)
+		{
+			var d = toWrap.Data.ElementAt (row);
+			setter (d, !getter(d));
+		}
+		
+		/// <inheritdoc/>
+		protected override void ToggleRows (int [] range)
+		{
+			// if all are ticked untick them
+			if (range.All (IsChecked)) {
+				// select none
+				foreach(var r in range) {
+					setter (toWrap.Data.ElementAt (r), false);
+				}
+			} else {
+				// otherwise tick all
+				foreach (var r in range) {
+					setter (toWrap.Data.ElementAt (r), true);
+				}
+			}
+		}
+
+		/// <inheritdoc/>
+		protected override void ClearAllToggles ()
+		{
+			foreach (var e in toWrap.Data) {
+				setter (e, false);
+			}
+		}
+	}
+}

+ 5 - 0
Terminal.Gui/Views/TableView/EnumerableTableSource.cs

@@ -50,5 +50,10 @@ namespace Terminal.Gui {
 
 		/// <inheritdoc/>
 		public string [] ColumnNames => cols;
+
+		/// <summary>
+		/// Gets the object collection hosted by this wrapper.
+		/// </summary>
+		public IReadOnlyCollection<T> Data => this.data.AsReadOnly();
 	}
 }

+ 20 - 0
Terminal.Gui/Views/TableView/TableView.cs

@@ -151,6 +151,11 @@ namespace Terminal.Gui {
 		/// </summary>
 		public event EventHandler<CellActivatedEventArgs> CellActivated;
 
+		/// <summary>
+		/// This event is raised when a cell is toggled (see <see cref="Command.ToggleChecked"/>
+		/// </summary>
+		public event EventHandler<CellToggledEventArgs> CellToggled;
+
 		/// <summary>
 		/// The key which when pressed should trigger <see cref="CellActivated"/> event.  Defaults to Enter.
 		/// </summary>
@@ -1046,6 +1051,13 @@ namespace Terminal.Gui {
 
 		private void ToggleCurrentCellSelection ()
 		{
+
+			var e = new CellToggledEventArgs (Table, selectedColumn, selectedRow);
+			OnCellToggled (e);
+			if (e.Cancel) {
+				return;
+			}
+
 			if (!MultiSelect) {
 				return;
 			}
@@ -1578,6 +1590,14 @@ namespace Terminal.Gui {
 		{
 			CellActivated?.Invoke (this, args);
 		}
+		/// <summary>
+		/// Invokes the <see cref="CellToggled"/> event
+		/// </summary>
+		/// <param name="args"></param>
+		protected virtual void OnCellToggled(CellToggledEventArgs args)
+		{
+			CellToggled?.Invoke (this, args);
+		}
 
 		/// <summary>
 		/// Calculates which columns should be rendered given the <paramref name="bounds"/> in which to display and the <see cref="ColumnOffset"/>

+ 85 - 10
UICatalog/Scenarios/TableEditor.cs

@@ -17,6 +17,7 @@ namespace UICatalog.Scenarios {
 	public class TableEditor : Scenario {
 		TableView tableView;
 		DataTable currentTable;
+    
 		private MenuItem _miShowHeaders;
 		private MenuItem _miAlwaysShowHeaders;
 		private MenuItem _miHeaderOverline;
@@ -31,6 +32,8 @@ namespace UICatalog.Scenarios {
 		private MenuItem _miAlternatingColors;
 		private MenuItem _miCursor;
 		private MenuItem _miBottomline;
+		private MenuItem _miCheckboxes;
+		private MenuItem _miRadioboxes;
 
 		ColorScheme redColorScheme;
 		ColorScheme redColorSchemeAlt;
@@ -73,6 +76,8 @@ namespace UICatalog.Scenarios {
 					_miSmoothScrolling = new MenuItem ("_SmoothHorizontalScrolling", "", () => ToggleSmoothScrolling()){Checked = tableView.Style.SmoothHorizontalScrolling, CheckType = MenuItemCheckStyle.Checked },
 					new MenuItem ("_AllLines", "", () => ToggleAllCellLines()),
 					new MenuItem ("_NoLines", "", () => ToggleNoCellLines()),
+					_miCheckboxes = new MenuItem ("_Checkboxes", "", () => ToggleCheckboxes(false)){Checked = false, CheckType = MenuItemCheckStyle.Checked },
+					_miRadioboxes = new MenuItem ("_Radioboxes", "", () => ToggleCheckboxes(true)){Checked = false, CheckType = MenuItemCheckStyle.Checked },
 					_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()),
@@ -170,6 +175,12 @@ namespace UICatalog.Scenarios {
 		{
 			var sort = GetProposedNewSortOrder (clickedCol, out var isAsc);
 
+			// don't try to sort on the toggled column
+			if (HasCheckboxes () && clickedCol == 0) {
+				return;
+			}
+				
+
 			SortColumn (clickedCol, sort, isAsc);
 		}
 
@@ -211,7 +222,7 @@ namespace UICatalog.Scenarios {
 		{
 			// work out new sort order
 			var sort = currentTable.DefaultView.Sort;
-			var colName = currentTable.Columns[clickedCol];
+			var colName = tableView.Table.ColumnNames[clickedCol];
 
 			if (sort?.EndsWith ("ASC") ?? false) {
 				sort = $"{colName} DESC";
@@ -226,6 +237,10 @@ namespace UICatalog.Scenarios {
 
 		private void ShowHeaderContextMenu (int clickedCol, MouseEventEventArgs e)
 		{
+			if(HasCheckboxes() && clickedCol == 0) {
+				return;
+			}
+
 			var sort = GetProposedNewSortOrder (clickedCol, out var isAsc);
 			var colName = tableView.Table.ColumnNames[clickedCol];
 
@@ -246,7 +261,7 @@ namespace UICatalog.Scenarios {
 			tableView.Update ();
 		}
 
-		private DataColumn GetColumn ()
+		private int? GetColumn ()
 		{
 			if (tableView.Table == null)
 				return null;
@@ -254,7 +269,7 @@ namespace UICatalog.Scenarios {
 			if (tableView.SelectedColumn < 0 || tableView.SelectedColumn > tableView.Table.Columns)
 				return null;
 
-			return currentTable.Columns [tableView.SelectedColumn];
+			return tableView.SelectedColumn;
 		}
 
 		private void SetMinAcceptableWidthToOne ()
@@ -282,8 +297,12 @@ namespace UICatalog.Scenarios {
 			RunColumnWidthDialog (col, "MaxWidth", (s, v) => s.MaxWidth = v, (s) => s.MaxWidth);
 		}
 
-		private void RunColumnWidthDialog (DataColumn col, string prompt, Action<ColumnStyle, int> setter, Func<ColumnStyle, int> getter)
+		private void RunColumnWidthDialog (int? col, string prompt, Action<ColumnStyle, int> setter, Func<ColumnStyle, int> getter)
 		{
+			if(col == null) {
+				return;
+			}
+
 			var accepted = false;
 			var ok = new Button ("Ok", is_default: true);
 			ok.Clicked += (s,e) => { accepted = true; Application.RequestStop (); };
@@ -291,12 +310,12 @@ namespace UICatalog.Scenarios {
 			cancel.Clicked += (s,e) => { Application.RequestStop (); };
 			var d = new Dialog (ok, cancel) { Title = prompt };
 
-			var style = tableView.Style.GetOrCreateColumnStyle (col.Ordinal);
+			var style = tableView.Style.GetOrCreateColumnStyle (col.Value);
 
 			var lbl = new Label () {
 				X = 0,
 				Y = 1,
-				Text = col.ColumnName
+				Text = tableView.Table.ColumnNames[col.Value]
 			};
 
 			var tf = new TextField () {
@@ -441,6 +460,42 @@ namespace UICatalog.Scenarios {
 
 		}
 
+		private void ToggleCheckboxes (bool radio)
+		{
+			if (tableView.Table is CheckBoxTableSourceWrapperByIndex wrapper) {
+
+				// unwrap it to remove check boxes
+				tableView.Table = wrapper.Wrapping;
+
+				_miCheckboxes.Checked = false;
+				_miRadioboxes.Checked = false;
+
+				// if toggling off checkboxes/radio
+				if(wrapper.UseRadioButtons == radio) {
+					return;
+				}
+			}
+			
+			// Either toggling on checkboxes/radio or switching from radio to checkboxes (or vice versa)
+			
+			var source = new CheckBoxTableSourceWrapperByIndex (tableView, tableView.Table) {
+				UseRadioButtons = radio
+			};
+			tableView.Table = source;
+
+
+			if (radio) {
+				_miRadioboxes.Checked = true;
+				_miCheckboxes.Checked = false;
+			}
+			else {
+
+				_miRadioboxes.Checked = false;
+				_miCheckboxes.Checked = true;
+			}
+			
+		}
+
 		private void ToggleAlwaysUseNormalColorForVerticalCellLines()
 		{
 			_miAlwaysUseNormalColorForVerticalCellLines.Checked = !_miAlwaysUseNormalColorForVerticalCellLines.Checked;
@@ -787,11 +842,17 @@ namespace UICatalog.Scenarios {
 		{
 			if (e.Table == null)
 				return;
-			var o = currentTable.Rows [e.Row] [e.Col];
+
+			var tableCol = ToTableCol(e.Col);
+			if (tableCol < 0) {
+				return;
+			}
+
+			var o = currentTable.Rows [e.Row] [tableCol];
 
 			var title = o is uint u ? GetUnicodeCategory (u) + $"(0x{o:X4})" : "Enter new value";
 
-			var oldValue = currentTable.Rows [e.Row] [e.Col].ToString ();
+			var oldValue = currentTable.Rows [e.Row] [tableCol].ToString ();
 			bool okPressed = false;
 
 			var ok = new Button ("Ok", is_default: true);
@@ -803,7 +864,7 @@ namespace UICatalog.Scenarios {
 			var lbl = new Label () {
 				X = 0,
 				Y = 1,
-				Text = currentTable.Columns [e.Col].ColumnName
+				Text = tableView.Table.ColumnNames[e.Col]
 			};
 
 			var tf = new TextField () {
@@ -821,7 +882,7 @@ namespace UICatalog.Scenarios {
 			if (okPressed) {
 
 				try {
-					currentTable.Rows [e.Row] [e.Col] = string.IsNullOrWhiteSpace (tf.Text.ToString ()) ? DBNull.Value : (object)tf.Text;
+					currentTable.Rows [e.Row] [tableCol] = string.IsNullOrWhiteSpace (tf.Text.ToString ()) ? DBNull.Value : (object)tf.Text;
 				} catch (Exception ex) {
 					MessageBox.ErrorQuery (60, 20, "Failed to set text", ex.Message, "Ok");
 				}
@@ -830,6 +891,20 @@ namespace UICatalog.Scenarios {
 			}
 		}
 
+		private int ToTableCol (int col)
+		{
+			if (HasCheckboxes ()) {
+				return col - 1;
+			}
+
+			return col;
+		}
+
+		private bool HasCheckboxes ()
+		{
+			return tableView.Table is CheckBoxTableSourceWrapperBase;
+		}
+
 		private string GetUnicodeCategory (uint u)
 		{
 			return Ranges.FirstOrDefault (r => u >= r.Start && u <= r.End)?.Category ?? "Unknown";

+ 478 - 0
UnitTests/Views/TableViewTests.cs

@@ -2288,6 +2288,446 @@ namespace Terminal.Gui.ViewsTests {
 
 			TestHelpers.AssertDriverColorsAre (expected, normal, focus);
 		}
+
+		[Fact, AutoInitShutdown]
+		public void TestTableViewCheckboxes_Simple()
+		{
+
+			var tv = GetTwoRowSixColumnTable (out var dt);
+			dt.Rows.Add (1, 2, 3, 4, 5, 6);
+			tv.LayoutSubviews ();
+
+			var wrapper = new CheckBoxTableSourceWrapperByIndex (tv, tv.Table);
+			tv.Table = wrapper;
+
+
+			tv.Draw ();
+
+			string expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│╴│1│2│
+│╴│1│2│
+│╴│1│2│";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+			Assert.Empty (wrapper.CheckedRows);
+
+			//toggle the top cell
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			Assert.Single (wrapper.CheckedRows, 0);
+
+			tv.Draw();
+
+			expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│√│1│2│
+│╴│1│2│
+│╴│1│2│";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+			tv.ProcessKey (new KeyEvent (Key.CursorDown, new KeyModifiers ()));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+
+			Assert.Contains (0,wrapper.CheckedRows);
+			Assert.Contains (1,wrapper.CheckedRows);
+			Assert.Equal (2, wrapper.CheckedRows.Count);
+
+
+			tv.Draw();
+
+			expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│√│1│2│
+│√│1│2│
+│╴│1│2│";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+			// untoggle top one
+			tv.ProcessKey (new KeyEvent (Key.CursorUp, new KeyModifiers ()));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			Assert.Single (wrapper.CheckedRows, 1);
+
+			tv.Draw();
+
+			expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│╴│1│2│
+│√│1│2│
+│╴│1│2│";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+		}
+
+		[Fact, AutoInitShutdown]
+		public void TestTableViewCheckboxes_SelectAllToggle ()
+		{
+
+			var tv = GetTwoRowSixColumnTable (out var dt);
+			dt.Rows.Add (1, 2, 3, 4, 5, 6);
+			tv.LayoutSubviews ();
+
+			var wrapper = new CheckBoxTableSourceWrapperByIndex (tv, tv.Table);
+			tv.Table = wrapper;
+
+			//toggle all cells
+			tv.ProcessKey (new KeyEvent (Key.A | Key.CtrlMask, new KeyModifiers { Ctrl = true }));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			tv.Draw();
+
+			string expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│√│1│2│
+│√│1│2│
+│√│1│2│";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+			Assert.Contains (0, wrapper.CheckedRows);
+			Assert.Contains (1, wrapper.CheckedRows);
+			Assert.Contains (2, wrapper.CheckedRows);
+			Assert.Equal (3, wrapper.CheckedRows.Count);
+
+			// Untoggle all again
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			tv.Draw();
+
+			expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│╴│1│2│
+│╴│1│2│
+│╴│1│2│";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+			Assert.Empty (wrapper.CheckedRows);
+		}
+
+		[Fact, AutoInitShutdown]
+		public void TestTableViewCheckboxes_MultiSelectIsUnion_WhenToggling ()
+		{
+			var tv = GetTwoRowSixColumnTable (out var dt);
+			dt.Rows.Add (1, 2, 3, 4, 5, 6);
+			tv.LayoutSubviews ();
+
+			var wrapper = new CheckBoxTableSourceWrapperByIndex (tv, tv.Table);
+			tv.Table = wrapper;
+			wrapper.CheckedRows.Add (0);
+			wrapper.CheckedRows.Add (2);
+
+			tv.Draw();
+
+			string expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│√│1│2│
+│╴│1│2│
+│√│1│2│";
+			//toggle top two at once
+			tv.ProcessKey (new KeyEvent (Key.CursorDown | Key.ShiftMask, new KeyModifiers { Shift = true }));
+			Assert.True (tv.IsSelected (0, 0));
+			Assert.True (tv.IsSelected (0, 1));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			// Because at least 1 of the rows is not yet ticked we toggle them all to ticked
+			TestHelpers.AssertDriverContentsAre (expected, output);
+			Assert.Contains (0, wrapper.CheckedRows);
+			Assert.Contains (1, wrapper.CheckedRows);
+			Assert.Contains (2, wrapper.CheckedRows);
+			Assert.Equal (3, wrapper.CheckedRows.Count);
+
+			tv.Draw();
+
+			expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│√│1│2│
+│√│1│2│
+│√│1│2│";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+			// Untoggle the top 2
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			tv.Draw();
+
+			expected =
+				@"
+│ │A│B│
+├─┼─┼─►
+│╴│1│2│
+│╴│1│2│
+│√│1│2│";
+			TestHelpers.AssertDriverContentsAre (expected, output);
+			Assert.Single (wrapper.CheckedRows, 2);
+		}
+
+
+		[Fact, AutoInitShutdown]
+		public void TestTableViewCheckboxes_ByObject ()
+		{
+			var tv = GetPetTable (out var source);
+			tv.LayoutSubviews ();
+			var pets = source.Data;
+
+			var wrapper = new CheckBoxTableSourceWrapperByObject<PickablePet>(
+				tv,
+				source,
+				(p)=>p.IsPicked,
+				(p,b)=>p.IsPicked = b);
+
+			tv.Table = wrapper;
+
+			tv.Draw();
+
+			string expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│╴│Tammy  │Cat          │
+│╴│Tibbles│Cat          │
+│╴│Ripper │Dog          │";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+			Assert.Empty (pets.Where(p=>p.IsPicked));
+
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+			
+			Assert.True (pets.First ().IsPicked);
+
+			tv.Draw();
+
+			expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│√│Tammy  │Cat          │
+│╴│Tibbles│Cat          │
+│╴│Ripper │Dog          │";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+
+			tv.ProcessKey (new KeyEvent (Key.CursorDown, new KeyModifiers ()));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			Assert.True (pets.ElementAt(0).IsPicked);
+			Assert.True (pets.ElementAt (1).IsPicked);
+			Assert.False (pets.ElementAt (2).IsPicked);
+
+			tv.Draw();
+
+			expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│√│Tammy  │Cat          │
+│√│Tibbles│Cat          │
+│╴│Ripper │Dog          │";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+
+			tv.ProcessKey (new KeyEvent (Key.CursorUp, new KeyModifiers ()));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+
+			Assert.False (pets.ElementAt (0).IsPicked);
+			Assert.True (pets.ElementAt (1).IsPicked);
+			Assert.False (pets.ElementAt (2).IsPicked);
+
+			tv.Draw();
+
+			expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│╴│Tammy  │Cat          │
+│√│Tibbles│Cat          │
+│╴│Ripper │Dog          │";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+		}
+
+		[Fact, AutoInitShutdown]
+		public void TestTableViewCheckboxes_SelectAllToggle_ByObject ()
+		{
+
+			var tv = GetPetTable (out var source);
+			tv.LayoutSubviews ();
+			var pets = source.Data;
+
+			var wrapper = new CheckBoxTableSourceWrapperByObject<PickablePet> (
+				tv,
+				source,
+				(p) => p.IsPicked,
+				(p, b) => p.IsPicked = b);
+
+			tv.Table = wrapper;
+
+
+			Assert.DoesNotContain (pets, p => p.IsPicked);
+
+			//toggle all cells
+			tv.ProcessKey (new KeyEvent (Key.A | Key.CtrlMask, new KeyModifiers { Ctrl = true }));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			Assert.True (pets.All (p => p.IsPicked));
+
+			tv.Draw();
+
+			string expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│√│Tammy  │Cat          │
+│√│Tibbles│Cat          │
+│√│Ripper │Dog          │";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			Assert.Empty (pets.Where (p => p.IsPicked));
+
+			tv.Draw();
+
+			expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│╴│Tammy  │Cat          │
+│╴│Tibbles│Cat          │
+│╴│Ripper │Dog          │
+";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+		}
+
+		[Fact, AutoInitShutdown]
+		public void TestTableViewRadioBoxes_Simple_ByObject ()
+		{
+
+			var tv = GetPetTable (out var source);
+			tv.LayoutSubviews ();
+			var pets = source.Data;
+
+			var wrapper = new CheckBoxTableSourceWrapperByObject<PickablePet> (
+				tv,
+				source,
+				(p) => p.IsPicked,
+				(p, b) => p.IsPicked = b);
+
+			wrapper.UseRadioButtons = true;
+
+			tv.Table = wrapper;
+			tv.Draw();
+
+			string expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│◌│Tammy  │Cat          │
+│◌│Tibbles│Cat          │
+│◌│Ripper │Dog          │
+";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+			Assert.Empty (pets.Where (p => p.IsPicked));
+
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			Assert.True (pets.First ().IsPicked);
+
+			tv.Draw();
+
+			expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│●│Tammy  │Cat          │
+│◌│Tibbles│Cat          │
+│◌│Ripper │Dog          │";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+
+			tv.ProcessKey (new KeyEvent (Key.CursorDown, new KeyModifiers ()));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+			Assert.False (pets.ElementAt (0).IsPicked);
+			Assert.True (pets.ElementAt (1).IsPicked);
+			Assert.False (pets.ElementAt (2).IsPicked);
+
+			tv.Draw();
+
+			expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│◌│Tammy  │Cat          │
+│●│Tibbles│Cat          │
+│◌│Ripper │Dog          │";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+
+
+			tv.ProcessKey (new KeyEvent (Key.CursorUp, new KeyModifiers ()));
+			tv.ProcessKey (new KeyEvent (Key.Space, new KeyModifiers ()));
+
+
+			Assert.True (pets.ElementAt (0).IsPicked);
+			Assert.False (pets.ElementAt (1).IsPicked);
+			Assert.False (pets.ElementAt (2).IsPicked);
+
+			tv.Draw();
+
+			expected =
+				@"
+┌─┬───────┬─────────────┐
+│ │Name   │Kind         │
+├─┼───────┼─────────────┤
+│●│Tammy  │Cat          │
+│◌│Tibbles│Cat          │
+│◌│Ripper │Dog          │";
+
+			TestHelpers.AssertDriverContentsAre (expected, output);
+		}
+
 		[Fact, AutoInitShutdown]
 		public void TestFullRowSelect_SelectionColorDoesNotStop_WhenShowVerticalCellLinesIsFalse ()
 		{
@@ -2763,5 +3203,43 @@ A B C
 			tableView.Table = new DataTableSource (dt);
 			return tableView;
 		}
+
+
+		private class PickablePet {
+			public bool IsPicked { get; set; }
+			public string Name{ get; set; }
+			public string Kind { get; set; }
+
+			public PickablePet (bool isPicked, string name, string kind)
+			{
+				IsPicked = isPicked;
+				Name = name;
+				Kind = kind;
+			}
+		}
+
+		private TableView GetPetTable (out EnumerableTableSource<PickablePet> source)
+		{
+			var tv = new TableView ();
+			tv.ColorScheme = Colors.TopLevel;
+			tv.Bounds = new Rect (0, 0, 25, 6);
+
+			var pets = new List<PickablePet> {
+				new PickablePet(false,"Tammy","Cat"),
+				new PickablePet(false,"Tibbles","Cat"),
+				new PickablePet(false,"Ripper","Dog")};
+
+			tv.Table = source = new EnumerableTableSource<PickablePet> (
+				pets,
+				new () {
+					{ "Name", (p) => p.Name},
+					{ "Kind", (p) => p.Kind},
+				});
+
+			tv.LayoutSubviews ();
+
+			return tv;
+		}
+
 	}
 }