diff --git a/Examples/UICatalog/Resources/config.json b/Examples/UICatalog/Resources/config.json index 4f044d1ae6..afeb11edc6 100644 --- a/Examples/UICatalog/Resources/config.json +++ b/Examples/UICatalog/Resources/config.json @@ -1,11 +1,5 @@ { "$schema": "https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json", - "FileDialog.MaxSearchResults": 10000, - "FileDialogStyle.DefaultUseColors": false, - "FileDialogStyle.DefaultUseUnicodeCharacters": false, - "AppSettings": { - "UICatalog.StatusBar": true - }, "Themes": [ { "Hot Dog Stand": { diff --git a/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs b/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs index 97c13654f8..2bb4349d66 100644 --- a/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs +++ b/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Globalization; using System.Text; @@ -69,7 +69,10 @@ public override void Main () Menus = [ new MenuBarItem (Strings.menuFile, - new MenuItem [] { new (Strings.cmdQuit, $"{Application.GetDefaultKey (Command.Quit)}", () => _charMap?.App?.RequestStop ()) }), + new MenuItem [] + { + new (Strings.cmdQuit, $"{Application.GetDefaultKey (Command.Quit)}", () => _charMap?.App?.RequestStop ()) + }), new MenuBarItem ("_Options", [CreateMenuShowWidth (), CreateMenuUnicodeCategorySelector ()]) ] }; @@ -135,18 +138,19 @@ public override void Main () return; } EnumerableTableSource table = (EnumerableTableSource)_categoryList.Table!; - string prevSelection = table.Data.ElementAt (_categoryList.SelectedRow).Category; + string prevSelection = table.Data.ElementAt (_categoryList.Value?.Cursor.Y ?? 0).Category; isDescending = !isDescending; _categoryList.Table = CreateCategoryTable (clickedCol.Value, isDescending); table = (EnumerableTableSource)_categoryList.Table!; - _categoryList.SelectedRow = - table.Data.Select ((item, index) => new { item, index }) - .FirstOrDefault (x => x.item.Category == prevSelection) - ?.index - ?? -1; + _categoryList.SetSelection (0, + table.Data.Select ((item, index) => new { item, index }) + .FirstOrDefault (x => x.item.Category == prevSelection) + ?.index + ?? 0, + false); }; int longestName = UnicodeRange.Ranges.Max (r => r.Category.GetColumns ()); @@ -157,12 +161,17 @@ public override void Main () _categoryList.Width = _categoryList.Style.ColumnStyles.Sum (c => c.Value.MinWidth) + 4; - _categoryList.SelectedCellChanged += (_, args) => - { - EnumerableTableSource table = (EnumerableTableSource)_categoryList.Table!; - _charMap.StartCodePoint = table.Data.ToArray () [args.NewRow].Start; - jumpEdit.Text = $"U+{_charMap.SelectedCodePoint:x5}"; - }; + _categoryList.ValueChanged += (_, args) => + { + if (args.NewValue is null) + { + return; + } + + EnumerableTableSource table = (EnumerableTableSource)_categoryList.Table!; + _charMap.StartCodePoint = table.Data.ToArray () [args.NewValue.Cursor.Y].Start; + jumpEdit.Text = $"U+{_charMap.SelectedCodePoint:x5}"; + }; top.Add (menu, _charMap, jumpLabel, jumpEdit, _errorLabel, _categoryList); @@ -239,11 +248,13 @@ void JumpEditOnAccept (object? sender, CommandEventArgs e) EnumerableTableSource table = (EnumerableTableSource)_categoryList!.Table!; - _categoryList.SelectedRow = table.Data.Select ((item, index) => new { item, index }) + _categoryList.SetSelection (0, + table.Data.Select ((item, index) => new { item, index }) .FirstOrDefault (x => x.item.Start <= result && x.item.End >= result) ?.index - ?? -1; - _categoryList.EnsureSelectedCellIsVisible (); + ?? 0, + false); + _categoryList.EnsureCursorIsVisible (); // Ensure the typed glyph is selected _charMap.SelectedCodePoint = (int)result; diff --git a/Examples/UICatalog/Scenarios/CsvEditor.cs b/Examples/UICatalog/Scenarios/CsvEditor.cs index d245bf4edc..21b03c7b97 100644 --- a/Examples/UICatalog/Scenarios/CsvEditor.cs +++ b/Examples/UICatalog/Scenarios/CsvEditor.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Data; using System.Globalization; @@ -33,22 +33,23 @@ public override void Main () app.Init (); _app = app; - using Window appWindow = new () { Title = GetName () }; + using Window appWindow = new (); + appWindow.Title = GetName (); // MenuBar MenuBar menu = new (); - _tableView = new () { X = 0, Y = Pos.Bottom (menu), Width = Dim.Fill (), Height = Dim.Fill (1) }; + _tableView = new TableView { X = 0, Y = Pos.Bottom (menu), Width = Dim.Fill (), Height = Dim.Fill (1) }; - _selectedCellTextField = new () { Text = "0,0", Width = 10, Height = 1 }; + _selectedCellTextField = new TextField { Text = "0,0", Width = 10, Height = 1 }; _selectedCellTextField.TextChanged += SelectedCellLabel_TextChanged; // StatusBar StatusBar statusBar = new ([ - new (Application.GetDefaultKey (Command.Quit), "Quit", Quit, "Quit!"), - new (Key.O.WithCtrl, "Open", Open, "Open a file."), - new (Key.S.WithCtrl, "Save", Save, "Save current."), - new () + new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", Quit, "Quit!"), + new Shortcut (Key.O.WithCtrl, "Open", Open, "Open a file."), + new Shortcut (Key.S.WithCtrl, "Save", Save, "Save current."), + new Shortcut { HelpText = "Cell:", CommandView = _selectedCellTextField, @@ -58,13 +59,13 @@ public override void Main () ]) { AlignmentModes = AlignmentModes.IgnoreFirstOrLast }; // Setup menu checkboxes for alignment - _miLeftCheckBox = new () { Title = "_Align Left" }; + _miLeftCheckBox = new CheckBox { Title = "_Align Left" }; _miLeftCheckBox.ValueChanged += (_, _) => Align (Alignment.Start); - _miRightCheckBox = new () { Title = "_Align Right" }; + _miRightCheckBox = new CheckBox { Title = "_Align Right" }; _miRightCheckBox.ValueChanged += (_, _) => Align (Alignment.End); - _miCenteredCheckBox = new () { Title = "_Align Centered" }; + _miCenteredCheckBox = new CheckBox { Title = "_Align Centered" }; _miCenteredCheckBox.ValueChanged += (_, _) => Align (Alignment.Center); MenuBarItem fileMenu = new (Strings.menuFile, @@ -98,8 +99,8 @@ public override void Main () appWindow.Add (menu, _tableView, statusBar); - _tableView.SelectedCellChanged += OnSelectedCellChanged; - _tableView.CellActivated += EditCurrentCell; + _tableView.ValueChanged += OnValueChanged; + _tableView.Accepted += EditCurrentCell; _tableView.KeyDown += TableViewKeyPress; app.Run (appWindow); @@ -112,46 +113,47 @@ private void AddColumn () return; } - if (GetText ("Enter column name", "Name:", "", out string colName)) + if (!GetText ("Enter column name", "Name:", "", out string colName)) { - DataColumn col = new (colName); - - int newColIdx = Math.Min (Math.Max (0, _tableView.SelectedColumn + 1), _tableView.Table!.Columns); + return; + } + DataColumn col = new (colName); - int? result = MessageBox.Query (_tableView.App!, "Column Type", "Pick a data type for the column", "Date", "Integer", "Double", "Text", "Cancel"); + int newColIdx = Math.Min (Math.Max (0, (_tableView.Value?.Cursor.X ?? 0) + 1), _tableView.Table!.Columns); - if (result is null || result >= 4) - { - return; - } + int? result = MessageBox.Query (_tableView.App!, "Column Type", "Pick a data type for the column", "Date", "Integer", "Double", "Text", "Cancel"); - switch (result) - { - case 0: - col.DataType = typeof (DateTime); + if (result is null or >= 4) + { + return; + } - break; + switch (result) + { + case 0: + col.DataType = typeof (DateTime); - case 1: - col.DataType = typeof (int); + break; - break; + case 1: + col.DataType = typeof (int); - case 2: - col.DataType = typeof (double); + break; - break; + case 2: + col.DataType = typeof (double); - case 3: - col.DataType = typeof (string); + break; - break; - } + case 3: + col.DataType = typeof (string); - _currentTable.Columns.Add (col); - col.SetOrdinal (newColIdx); - _tableView.Update (); + break; } + + _currentTable.Columns.Add (col); + col.SetOrdinal (newColIdx); + _tableView.Update (); } private void AddRow () @@ -163,7 +165,7 @@ private void AddRow () DataRow newRow = _currentTable.NewRow (); - int newRowIdx = Math.Min (Math.Max (0, _tableView.SelectedRow + 1), _tableView.Table!.Rows); + int newRowIdx = Math.Min (Math.Max (0, (_tableView.Value?.Cursor.Y ?? 0) + 1), _tableView.Table!.Rows); _currentTable.Rows.InsertAt (newRow, newRowIdx); _tableView.Update (); @@ -176,23 +178,14 @@ private void Align (Alignment newAlignment) return; } - ColumnStyle style = _tableView.Style.GetOrCreateColumnStyle (_tableView.SelectedColumn); + ColumnStyle style = _tableView.Style.GetOrCreateColumnStyle (_tableView.Value?.Cursor.X ?? 0); style.Alignment = newAlignment; - if (_miLeftCheckBox is { }) - { - _miLeftCheckBox.Value = style.Alignment == Alignment.Start ? CheckState.Checked : CheckState.UnChecked; - } + _miLeftCheckBox?.Value = style.Alignment == Alignment.Start ? CheckState.Checked : CheckState.UnChecked; - if (_miRightCheckBox is { }) - { - _miRightCheckBox.Value = style.Alignment == Alignment.End ? CheckState.Checked : CheckState.UnChecked; - } + _miRightCheckBox?.Value = style.Alignment == Alignment.End ? CheckState.Checked : CheckState.UnChecked; - if (_miCenteredCheckBox is { }) - { - _miCenteredCheckBox.Value = style.Alignment == Alignment.Center ? CheckState.Checked : CheckState.UnChecked; - } + _miCenteredCheckBox?.Value = style.Alignment == Alignment.Center ? CheckState.Checked : CheckState.UnChecked; _tableView.Update (); } @@ -204,7 +197,7 @@ private void DeleteColum () return; } - if (_tableView.SelectedColumn == -1) + if (_tableView.Value is null) { MessageBox.ErrorQuery (_tableView!.App!, "No Column", "No column selected", "Ok"); @@ -213,7 +206,7 @@ private void DeleteColum () try { - _currentTable.Columns.RemoveAt (_tableView.SelectedColumn); + _currentTable.Columns.RemoveAt (_tableView.Value.Cursor.X); _tableView.Update (); } catch (Exception ex) @@ -222,28 +215,33 @@ private void DeleteColum () } } - private void EditCurrentCell (object? sender, CellActivatedEventArgs e) + private void EditCurrentCell (object? sender, CommandEventArgs e) { - if (e.Table is null || _currentTable is null || _tableView is null) + if (_tableView?.Table is null || _currentTable is null) { return; } - var oldValue = _currentTable.Rows [e.Row] [e.Col].ToString (); + int col = _tableView.Value?.Cursor.X ?? 0; + int row = _tableView.Value?.Cursor.Y ?? 0; + + var oldValue = _currentTable.Rows [row] [col].ToString (); - if (GetText ("Enter new value", _currentTable.Columns [e.Col].ColumnName, oldValue ?? "", out string newText)) + if (!GetText ("Enter new value", _currentTable.Columns [col].ColumnName, oldValue ?? "", out string newText)) { - try - { - _currentTable.Rows [e.Row] [e.Col] = string.IsNullOrWhiteSpace (newText) ? DBNull.Value : newText; - } - catch (Exception ex) - { - MessageBox.ErrorQuery (_tableView!.App!, "Failed to set text", ex.Message, "Ok"); - } + return; + } - _tableView.Update (); + try + { + _currentTable.Rows [row] [col] = string.IsNullOrWhiteSpace (newText) ? DBNull.Value : newText; } + catch (Exception ex) + { + MessageBox.ErrorQuery (_tableView!.App!, "Failed to set text", ex.Message, "Ok"); + } + + _tableView.Update (); } private bool GetText (string title, string label, string initialText, out string enteredText) @@ -285,7 +283,7 @@ private void MoveColumn () return; } - if (_tableView.SelectedColumn == -1) + if (_tableView.Value is null) { MessageBox.ErrorQuery (_tableView!.App!, "No Column", "No column selected", "Ok"); @@ -294,18 +292,19 @@ private void MoveColumn () try { - DataColumn currentCol = _currentTable.Columns [_tableView.SelectedColumn]; + DataColumn currentCol = _currentTable.Columns [_tableView.Value.Cursor.X]; - if (GetText ("Move Column", "New Index:", currentCol.Ordinal.ToString (), out string newOrdinal)) + if (!GetText ("Move Column", "New Index:", currentCol.Ordinal.ToString (), out string newOrdinal)) { - int newIdx = Math.Min (Math.Max (0, int.Parse (newOrdinal)), _tableView.Table!.Columns - 1); + return; + } + int newIdx = Math.Min (Math.Max (0, int.Parse (newOrdinal)), _tableView.Table!.Columns - 1); - currentCol.SetOrdinal (newIdx); + currentCol.SetOrdinal (newIdx); - _tableView.SetSelection (newIdx, _tableView.SelectedRow, false); - _tableView.EnsureSelectedCellIsVisible (); - _tableView.SetNeedsDraw (); - } + _tableView.SetSelection (newIdx, _tableView.Value!.Cursor.Y, false); + _tableView.EnsureCursorIsVisible (); + _tableView.SetNeedsDraw (); } catch (Exception ex) { @@ -320,7 +319,7 @@ private void MoveRow () return; } - if (_tableView.SelectedRow == -1) + if (_tableView.Value is null) { MessageBox.ErrorQuery (_tableView!.App!, "No Rows", "No row selected", "Ok"); @@ -329,32 +328,33 @@ private void MoveRow () try { - int oldIdx = _tableView.SelectedRow; + int oldIdx = _tableView.Value.Cursor.Y; DataRow currentRow = _currentTable.Rows [oldIdx]; - if (GetText ("Move Row", "New Row:", oldIdx.ToString (), out string newOrdinal)) + if (!GetText ("Move Row", "New Row:", oldIdx.ToString (), out string newOrdinal)) { - int newIdx = Math.Min (Math.Max (0, int.Parse (newOrdinal)), _tableView.Table!.Rows - 1); + return; + } + int newIdx = Math.Min (Math.Max (0, int.Parse (newOrdinal)), _tableView.Table!.Rows - 1); - if (newIdx == oldIdx) - { - return; - } + if (newIdx == oldIdx) + { + return; + } - object? [] arrayItems = currentRow.ItemArray; - _currentTable.Rows.Remove (currentRow); + object? [] arrayItems = currentRow.ItemArray; + _currentTable.Rows.Remove (currentRow); - // Removing and Inserting the same DataRow seems to result in it loosing its values so we have to create a new instance - DataRow newRow = _currentTable.NewRow (); - newRow.ItemArray = arrayItems; + // Removing and Inserting the same DataRow seems to result in it loosing its values so we have to create a new instance + DataRow newRow = _currentTable.NewRow (); + newRow.ItemArray = arrayItems; - _currentTable.Rows.InsertAt (newRow, newIdx); + _currentTable.Rows.InsertAt (newRow, newIdx); - _tableView.SetSelection (_tableView.SelectedColumn, newIdx, false); - _tableView.EnsureSelectedCellIsVisible (); - _tableView.SetNeedsDraw (); - } + _tableView.SetSelection (_tableView.Value!.Cursor.X, newIdx, false); + _tableView.EnsureCursorIsVisible (); + _tableView.SetNeedsDraw (); } catch (Exception ex) { @@ -364,50 +364,43 @@ private void MoveRow () private bool NoTableLoaded () { - if (_tableView?.Table is null) + if (_tableView?.Table is { }) { - MessageBox.ErrorQuery (_tableView!.App!, "No Table Loaded", "No table has currently be opened", "Ok"); - - return true; + return false; } + MessageBox.ErrorQuery (_tableView!.App!, "No Table Loaded", "No table has currently be opened", "Ok"); - return false; + return true; } - private void OnSelectedCellChanged (object? sender, SelectedCellChangedEventArgs e) + private void OnValueChanged (object? sender, ValueChangedEventArgs e) { if (_selectedCellTextField is null || _tableView is null) { return; } + int cursorRow = _tableView.Value?.Cursor.Y ?? 0; + int cursorCol = _tableView.Value?.Cursor.X ?? 0; + // only update the text box if the user is not manually editing it if (!_selectedCellTextField.HasFocus) { - _selectedCellTextField.Text = $"{_tableView.SelectedRow},{_tableView.SelectedColumn}"; + _selectedCellTextField.Text = $"{cursorRow},{cursorCol}"; } - if (_tableView.Table is null || _tableView.SelectedColumn == -1) + if (_tableView.Table is null || _tableView.Value is null) { return; } - ColumnStyle? style = _tableView.Style.GetColumnStyleIfAny (_tableView.SelectedColumn); + ColumnStyle? style = _tableView.Style.GetColumnStyleIfAny (cursorCol); - if (_miLeftCheckBox is { }) - { - _miLeftCheckBox.Value = style?.Alignment == Alignment.Start ? CheckState.Checked : CheckState.UnChecked; - } + _miLeftCheckBox?.Value = style?.Alignment == Alignment.Start ? CheckState.Checked : CheckState.UnChecked; - if (_miRightCheckBox is { }) - { - _miRightCheckBox.Value = style?.Alignment == Alignment.End ? CheckState.Checked : CheckState.UnChecked; - } + _miRightCheckBox?.Value = style?.Alignment == Alignment.End ? CheckState.Checked : CheckState.UnChecked; - if (_miCenteredCheckBox is { }) - { - _miCenteredCheckBox.Value = style?.Alignment == Alignment.Center ? CheckState.Checked : CheckState.UnChecked; - } + _miCenteredCheckBox?.Value = style?.Alignment == Alignment.Center ? CheckState.Checked : CheckState.UnChecked; } private void Open () @@ -463,15 +456,9 @@ private void Open (string filename) // Only set the current filename if we successfully loaded the entire file _currentFile = filename; - if (_selectedCellTextField?.SuperView is { }) - { - _selectedCellTextField.SuperView.Enabled = true; - } + _selectedCellTextField?.SuperView?.Enabled = true; - if (_app?.TopRunnableView is { }) - { - _app.TopRunnableView.Title = $"{GetName ()} - {Path.GetFileName (_currentFile)}"; - } + _app?.TopRunnableView?.Title = $"{GetName ()} - {Path.GetFileName (_currentFile)}"; } catch (Exception ex) { @@ -488,13 +475,14 @@ private void RenameColumn () return; } - DataColumn currentCol = _currentTable.Columns [_tableView.SelectedColumn]; + DataColumn currentCol = _currentTable.Columns [_tableView.Value?.Cursor.X ?? 0]; - if (GetText ("Rename Column", "Name:", currentCol.ColumnName, out string newName)) + if (!GetText ("Rename Column", "Name:", currentCol.ColumnName, out string newName)) { - currentCol.ColumnName = newName; - _tableView.Update (); + return; } + currentCol.ColumnName = newName; + _tableView.Update (); } private void Save () @@ -544,8 +532,7 @@ private void SelectedCellLabel_TextChanged (object? sender, EventArgs e) if (match.Success) { - _tableView.SelectedColumn = int.Parse (match.Groups [2].Value); - _tableView.SelectedRow = int.Parse (match.Groups [1].Value); + _tableView.SetSelection (int.Parse (match.Groups [2].Value), int.Parse (match.Groups [1].Value), false); } } @@ -556,7 +543,7 @@ private void SetFormat () return; } - DataColumn col = _currentTable.Columns [_tableView.SelectedColumn]; + DataColumn col = _currentTable.Columns [_tableView.Value?.Cursor.X ?? 0]; if (col.DataType == typeof (string)) { @@ -570,23 +557,16 @@ private void SetFormat () ColumnStyle style = _tableView.Style.GetOrCreateColumnStyle (col.Ordinal); - if (GetText ("Format", "Pattern:", style.Format ?? "", out string newPattern)) - { - style.Format = newPattern; - _tableView.Update (); - } - } - - private void SetTable (DataTable dataTable) - { - if (_tableView is null) + if (!GetText ("Format", "Pattern:", style.Format ?? "", out string newPattern)) { return; } - - _tableView.Table = new DataTableSource (_currentTable = dataTable); + style.Format = newPattern; + _tableView.Update (); } + private void SetTable (DataTable dataTable) => _tableView?.Table = new DataTableSource (_currentTable = dataTable); + private void Sort (bool asc) { if (NoTableLoaded () || _currentTable is null || _tableView is null) @@ -594,14 +574,14 @@ private void Sort (bool asc) return; } - if (_tableView.SelectedColumn == -1) + if (_tableView.Value is null) { MessageBox.ErrorQuery (_tableView!.App!, "No Column", "No column selected", "Ok"); return; } - string colName = _tableView.Table!.ColumnNames [_tableView.SelectedColumn]; + string colName = _tableView.Table!.ColumnNames [_tableView.Value.Cursor.X]; _currentTable.DefaultView.Sort = colName + (asc ? " asc" : " desc"); SetTable (_currentTable.DefaultView.ToTable ()); @@ -614,27 +594,29 @@ private void TableViewKeyPress (object? sender, Key e) return; } - if (e.KeyCode == Key.Delete) + if (e.KeyCode != Key.Delete) + { + return; + } + + if (_tableView.FullRowSelect) { - if (_tableView.FullRowSelect) + // Delete button deletes all rows when in full row mode + foreach (int toRemove in _tableView.GetAllSelectedCells ().Select (p => p.Y).Distinct ().OrderByDescending (i => i)) { - // Delete button deletes all rows when in full row mode - foreach (int toRemove in _tableView.GetAllSelectedCells ().Select (p => p.Y).Distinct ().OrderByDescending (i => i)) - { - _currentTable.Rows.RemoveAt (toRemove); - } + _currentTable.Rows.RemoveAt (toRemove); } - else + } + else + { + // otherwise set all selected cells to null + foreach (Point pt in _tableView.GetAllSelectedCells ()) { - // otherwise set all selected cells to null - foreach (Point pt in _tableView.GetAllSelectedCells ()) - { - _currentTable.Rows [pt.Y] [pt.X] = DBNull.Value; - } + _currentTable.Rows [pt.Y] [pt.X] = DBNull.Value; } - - _tableView.Update (); - e.Handled = true; } + + _tableView.Update (); + e.Handled = true; } } diff --git a/Examples/UICatalog/Scenarios/FileDialogExamples.cs b/Examples/UICatalog/Scenarios/FileDialogExamples.cs index 5152bce8df..5681307de3 100644 --- a/Examples/UICatalog/Scenarios/FileDialogExamples.cs +++ b/Examples/UICatalog/Scenarios/FileDialogExamples.cs @@ -1,4 +1,5 @@ using System.IO.Abstractions; + // ReSharper disable AccessToDisposedClosure namespace UICatalog.Scenarios; @@ -204,8 +205,8 @@ private void CreateDialog (IApplication app) fd.Style.UseColors = _cbUseColors.Value == CheckState.Checked; - fd.Style.TreeStyle.ShowBranchLines = _cbShowTreeBranchLines.Value == CheckState.Checked; - fd.Style.TableStyle.AlwaysShowHeaders = _cbAlwaysTableShowHeaders.Value == CheckState.Checked; + fd.Style.TableStyle?.AlwaysShowHeaders = _cbAlwaysTableShowHeaders.Value == CheckState.Checked; + fd.Style.TreeStyle?.ShowBranchLines = _cbShowTreeBranchLines.Value == CheckState.Checked; IDirectoryInfoFactory dirInfoFactory = new FileSystem ().DirectoryInfo; @@ -236,6 +237,8 @@ private void CreateDialog (IApplication app) fd.Style.CancelButtonText = _tbCancelButton.Text; } + fd.Path = Environment.GetFolderPath (Environment.SpecialFolder.UserProfile); + var result = app.Run (fd) as int?; IReadOnlyList multiSelected = fd.MultiSelected; diff --git a/Examples/UICatalog/Scenarios/ListColumns.cs b/Examples/UICatalog/Scenarios/ListColumns.cs index 5a733a4f98..23b6fc6b50 100644 --- a/Examples/UICatalog/Scenarios/ListColumns.cs +++ b/Examples/UICatalog/Scenarios/ListColumns.cs @@ -53,21 +53,17 @@ public override void Main () app.Init (); _app = app; - using Window appWindow = new () - { - Title = GetQuitKeyAndName (), - BorderStyle = LineStyle.None - }; + using Window appWindow = new () { Title = GetQuitKeyAndName (), BorderStyle = LineStyle.None }; // MenuBar MenuBar menuBar = new (); - _listColView = new () + _listColView = new TableView { Y = Pos.Bottom (menuBar), Width = Dim.Fill (), Height = Dim.Fill (1), - Style = new () + Style = new TableStyle { ShowHeaders = false, ShowHorizontalHeaderOverline = false, @@ -78,16 +74,13 @@ public override void Main () }; ListColumnStyle listColStyle = new (); - // Status Bar - StatusBar statusBar = new ( - [ - new (Key.F2, "OpenBigListEx", () => OpenSimpleList (true)), - new (Key.F3, "CloseExample", CloseExample), - new (Key.F4, "OpenSmListEx", () => OpenSimpleList (false)), - new (Application.GetDefaultKey (Command.Quit), "Quit", Quit) - ] - ); + StatusBar statusBar = new ([ + new Shortcut (Key.F2, "OpenBigListEx", () => OpenSimpleList (true)), + new Shortcut (Key.F3, "CloseExample", CloseExample), + new Shortcut (Key.F4, "OpenSmListEx", () => OpenSimpleList (false)), + new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", Quit) + ]); // Selected cell label Label selectedCellLabel = new () @@ -99,188 +92,109 @@ public override void Main () TextAlignment = Alignment.End }; - _listColView.SelectedCellChanged += (s, e) => - { - if (_listColView is not null) - { - selectedCellLabel.Text = $"{_listColView.SelectedRow},{_listColView.SelectedColumn}"; - } - }; + _listColView.ValueChanged += (s, e) => + { + if (_listColView is { }) + { + selectedCellLabel.Text = $"{_listColView.Value?.Cursor.Y ?? 0},{_listColView.Value?.Cursor.X ?? 0}"; + } + }; _listColView.KeyDown += TableViewKeyPress; - _alternatingScheme = new () + _alternatingScheme = new Scheme { Disabled = appWindow.GetAttributeForRole (VisualRole.Disabled), HotFocus = appWindow.GetAttributeForRole (VisualRole.HotFocus), Focus = appWindow.GetAttributeForRole (VisualRole.Focus), - Normal = new (Color.White, Color.BrightBlue) + Normal = new Attribute (Color.White, Color.BrightBlue) }; _listColView.KeyBindings.ReplaceCommands (Key.Space, Command.Accept); // Setup menu checkboxes - _topLineCheckBox = new () + _topLineCheckBox = new CheckBox { - Title = "_TopLine", - Value = _listColView.Style.ShowHorizontalHeaderOverline ? CheckState.Checked : CheckState.UnChecked + Title = "_TopLine", Value = _listColView.Style.ShowHorizontalHeaderOverline ? CheckState.Checked : CheckState.UnChecked }; _topLineCheckBox.ValueChanged += (s, e) => ToggleTopline (); - _bottomLineCheckBox = new () + _bottomLineCheckBox = new CheckBox { - Title = "_BottomLine", - Value = _listColView.Style.ShowHorizontalBottomLine ? CheckState.Checked : CheckState.UnChecked + Title = "_BottomLine", Value = _listColView.Style.ShowHorizontalBottomLine ? CheckState.Checked : CheckState.UnChecked }; _bottomLineCheckBox.ValueChanged += (s, e) => ToggleBottomline (); - _cellLinesCheckBox = new () + _cellLinesCheckBox = new CheckBox { - Title = "_CellLines", - Value = _listColView.Style.ShowVerticalCellLines ? CheckState.Checked : CheckState.UnChecked + Title = "_CellLines", Value = _listColView.Style.ShowVerticalCellLines ? CheckState.Checked : CheckState.UnChecked }; _cellLinesCheckBox.ValueChanged += (s, e) => ToggleCellLines (); - _expandLastColumnCheckBox = new () + _expandLastColumnCheckBox = new CheckBox { - Title = "_ExpandLastColumn", - Value = _listColView.Style.ExpandLastColumn ? CheckState.Checked : CheckState.UnChecked + Title = "_ExpandLastColumn", Value = _listColView.Style.ExpandLastColumn ? CheckState.Checked : CheckState.UnChecked }; _expandLastColumnCheckBox.ValueChanged += (s, e) => ToggleExpandLastColumn (); - _alwaysUseNormalColorForVerticalCellLinesCheckBox = new () + _alwaysUseNormalColorForVerticalCellLinesCheckBox = new CheckBox { Title = "_AlwaysUseNormalColorForVerticalCellLines", Value = _listColView.Style.AlwaysUseNormalColorForVerticalCellLines ? CheckState.Checked : CheckState.UnChecked }; _alwaysUseNormalColorForVerticalCellLinesCheckBox.ValueChanged += (s, e) => ToggleAlwaysUseNormalColorForVerticalCellLines (); - _smoothScrollingCheckBox = new () + _smoothScrollingCheckBox = new CheckBox { - Title = "_SmoothHorizontalScrolling", - Value = _listColView.Style.SmoothHorizontalScrolling ? CheckState.Checked : CheckState.UnChecked + Title = "_SmoothHorizontalScrolling", Value = _listColView.Style.SmoothHorizontalScrolling ? CheckState.Checked : CheckState.UnChecked }; _smoothScrollingCheckBox.ValueChanged += (s, e) => ToggleSmoothScrolling (); - _alternatingColorsCheckBox = new () - { - Title = "Alternating Colors" - }; + _alternatingColorsCheckBox = new CheckBox { Title = "Alternating Colors" }; _alternatingColorsCheckBox.ValueChanged += (s, e) => ToggleAlternatingColors (); - _cursorCheckBox = new () + _cursorCheckBox = new CheckBox { Title = "Invert Selected Cell First Character", Value = _listColView.Style.InvertSelectedCellFirstCharacter ? CheckState.Checked : CheckState.UnChecked }; _cursorCheckBox.ValueChanged += (s, e) => ToggleInvertSelectedCellFirstCharacter (); - _orientVerticalCheckBox = new () + _orientVerticalCheckBox = new CheckBox { - Title = "_OrientVertical", - Value = listColStyle.Orientation == Orientation.Vertical ? CheckState.Checked : CheckState.UnChecked + Title = "_OrientVertical", Value = listColStyle.Orientation == Orientation.Vertical ? CheckState.Checked : CheckState.UnChecked }; _orientVerticalCheckBox.ValueChanged += (s, e) => ToggleVerticalOrientation (); - _scrollParallelCheckBox = new () - { - Title = "_ScrollParallel", - Value = listColStyle.ScrollParallel ? CheckState.Checked : CheckState.UnChecked - }; + _scrollParallelCheckBox = new CheckBox { Title = "_ScrollParallel", Value = listColStyle.ScrollParallel ? CheckState.Checked : CheckState.UnChecked }; _scrollParallelCheckBox.ValueChanged += (s, e) => ToggleScrollParallel (); - menuBar.Add ( - new MenuBarItem ( - Strings.menuFile, - [ - new MenuItem - { - Title = "Open_BigListExample", - Action = () => OpenSimpleList (true) - }, - new MenuItem - { - Title = "Open_SmListExample", - Action = () => OpenSimpleList (false) - }, - new MenuItem - { - Title = "_CloseExample", - Action = CloseExample - }, - new MenuItem - { - Title = Strings.cmdQuit, - Action = Quit - } - ] - ) - ); - - menuBar.Add ( - new MenuBarItem ( - "_View", - [ - new MenuItem - { - CommandView = _topLineCheckBox - }, - new MenuItem - { - CommandView = _bottomLineCheckBox - }, - new MenuItem - { - CommandView = _cellLinesCheckBox - }, - new MenuItem - { - CommandView = _expandLastColumnCheckBox - }, - new MenuItem - { - CommandView = _alwaysUseNormalColorForVerticalCellLinesCheckBox - }, - new MenuItem - { - CommandView = _smoothScrollingCheckBox - }, - new MenuItem - { - CommandView = _alternatingColorsCheckBox - }, - new MenuItem - { - CommandView = _cursorCheckBox - } - ] - ) - ); - - menuBar.Add ( - new MenuBarItem ( - "_List", - [ - new MenuItem - { - CommandView = _orientVerticalCheckBox - }, - new MenuItem - { - CommandView = _scrollParallelCheckBox - }, - new MenuItem - { - Title = "Set _Max Cell Width", - Action = SetListMaxWidth - }, - new MenuItem - { - Title = "Set Mi_n Cell Width", - Action = SetListMinWidth - } - ] - ) - ); + menuBar.Add (new MenuBarItem (Strings.menuFile, + [ + new MenuItem { Title = "Open_BigListExample", Action = () => OpenSimpleList (true) }, + new MenuItem { Title = "Open_SmListExample", Action = () => OpenSimpleList (false) }, + new MenuItem { Title = "_CloseExample", Action = CloseExample }, + new MenuItem { Title = Strings.cmdQuit, Action = Quit } + ])); + + menuBar.Add (new MenuBarItem ("_View", + [ + new MenuItem { CommandView = _topLineCheckBox }, + new MenuItem { CommandView = _bottomLineCheckBox }, + new MenuItem { CommandView = _cellLinesCheckBox }, + new MenuItem { CommandView = _expandLastColumnCheckBox }, + new MenuItem { CommandView = _alwaysUseNormalColorForVerticalCellLinesCheckBox }, + new MenuItem { CommandView = _smoothScrollingCheckBox }, + new MenuItem { CommandView = _alternatingColorsCheckBox }, + new MenuItem { CommandView = _cursorCheckBox } + ])); + + menuBar.Add (new MenuBarItem ("_List", + [ + new MenuItem { CommandView = _orientVerticalCheckBox }, + new MenuItem { CommandView = _scrollParallelCheckBox }, + new MenuItem { Title = "Set _Max Cell Width", Action = SetListMaxWidth }, + new MenuItem { Title = "Set Mi_n Cell Width", Action = SetListMinWidth } + ])); // Add views in order of visual appearance appWindow.Add (menuBar, _listColView, selectedCellLabel, statusBar); @@ -290,15 +204,15 @@ public override void Main () private void CloseExample () { - if (_listColView is not null) + if (_listColView is { }) { _listColView.Table = null; } } - private void OpenSimpleList (bool big) { SetTable (BuildSimpleList (big ? 1023 : 31)); } + private void OpenSimpleList (bool big) => SetTable (BuildSimpleList (big ? 1023 : 31)); - private void Quit () { _listColView?.App?.RequestStop (); } + private void Quit () => _listColView?.App?.RequestStop (); private void RunListWidthDialog (string prompt, Action setter, Func getter) { @@ -308,13 +222,9 @@ private void RunListWidthDialog (string prompt, Action setter, F } var accepted = false; - Dialog d = new Dialog - { - Title = prompt, - Buttons = [new () { Title = Strings.btnCancel }, new () { Title = Strings.btnOk }] - }; + var d = new Dialog { Title = prompt, Buttons = [new Button { Title = Strings.btnCancel }, new Button { Title = Strings.btnOk }] }; - TextField tf = new () { Text = getter (_listColView).ToString (), X = 0, Y = 0, Width = Dim.Fill (0, minimumContentDim: 50) }; + TextField tf = new () { Text = getter (_listColView).ToString (), X = 0, Y = 0, Width = Dim.Fill (0, 50) }; d.Add (tf); tf.SetFocus (); @@ -409,8 +319,7 @@ private void ToggleAlwaysUseNormalColorForVerticalCellLines () return; } - _listColView.Style.AlwaysUseNormalColorForVerticalCellLines = - _alwaysUseNormalColorForVerticalCellLinesCheckBox.Value == CheckState.Checked; + _listColView.Style.AlwaysUseNormalColorForVerticalCellLines = _alwaysUseNormalColorForVerticalCellLinesCheckBox.Value == CheckState.Checked; _listColView.Update (); } @@ -501,9 +410,7 @@ private void ToggleVerticalOrientation () return; } - listTableSource.Style.Orientation = _orientVerticalCheckBox.Value == CheckState.Checked - ? Orientation.Vertical - : Orientation.Horizontal; + listTableSource.Style.Orientation = _orientVerticalCheckBox.Value == CheckState.Checked ? Orientation.Vertical : Orientation.Horizontal; _listColView.SetNeedsDraw (); } } diff --git a/Examples/UICatalog/Scenarios/MinimalSpinnerDemo.cs b/Examples/UICatalog/Scenarios/MinimalSpinnerDemo.cs index 67b03204c2..046c08b59c 100644 --- a/Examples/UICatalog/Scenarios/MinimalSpinnerDemo.cs +++ b/Examples/UICatalog/Scenarios/MinimalSpinnerDemo.cs @@ -12,49 +12,30 @@ public override void Main () using Window main = new () { Title = GetQuitKeyAndName () }; - SpinnerView spinner = new () + SpinnerView spinner = new () { X = Pos.Center (), Y = Pos.Center (), Visible = true, AutoSpin = true }; + + CheckBox chkVisible = new () { - X = Pos.Center (), - Y = Pos.Center (), - AutoSpin = true + Text = "Visible", X = Pos.Center (), Y = Pos.Bottom (spinner) + 1, Value = spinner.Visible ? CheckState.Checked : CheckState.UnChecked }; + chkVisible.ValueChanged += (_, e) => { spinner.Visible = e.NewValue == CheckState.Checked; }; CheckBox chkAutoSpin = new () { - Text = "AutoSpin", - X = Pos.Center (), - Y = Pos.Bottom (spinner) + 1, - Value = CheckState.Checked + Text = "AutoSpin", X = Pos.Center (), Y = Pos.Bottom (chkVisible) + 1, Value = spinner.AutoSpin ? CheckState.Checked : CheckState.UnChecked }; - chkAutoSpin.ValueChanged += (_, e) => spinner.AutoSpin = e.NewValue == CheckState.Checked; + chkAutoSpin.ValueChanged += (_, e) => { spinner.AutoSpin = e.NewValue == CheckState.Checked; }; - CheckBox chkSyncWithTerminal = new () - { - Text = "SyncWithTerminal", - X = Pos.Center (), - Y = Pos.Bottom (chkAutoSpin) + 1, - Value = CheckState.UnChecked - }; + CheckBox chkSyncWithTerminal = new () { Text = "SyncWithTerminal", X = Pos.Center (), Y = Pos.Bottom (chkAutoSpin) + 1, Value = CheckState.UnChecked }; chkSyncWithTerminal.ValueChanged += (_, e) => spinner.SyncWithTerminal = e.NewValue == CheckState.Checked; - Label lblSequence = new () - { - Text = "Sequence (comma-separated):", - X = Pos.Center (), - Y = Pos.Bottom (chkSyncWithTerminal) + 1 - }; + Label lblSequence = new () { Text = "Sequence (comma-separated):", X = Pos.Center (), Y = Pos.Bottom (chkSyncWithTerminal) + 1 }; + + TextField tfSequence = new () { Text = string.Join (",", spinner.Sequence), X = Pos.Center (), Y = Pos.Bottom (lblSequence), Width = 30 }; - TextField tfSequence = new () - { - Text = string.Join (",", spinner.Sequence), - X = Pos.Center (), - Y = Pos.Bottom (lblSequence), - Width = 30 - }; tfSequence.Accepting += (_, _) => { - string [] frames = tfSequence.Text - .Split (',', StringSplitOptions.RemoveEmptyEntries); + string [] frames = tfSequence.Text.Split (',', StringSplitOptions.RemoveEmptyEntries); if (frames.Length > 0) { @@ -62,15 +43,12 @@ public override void Main () } }; - Button btnAdvance = new () - { - Text = "Advance", - X = Pos.Center (), - Y = Pos.Bottom (tfSequence) + 1 - }; + Button btnAdvance = new () { Text = "Advance", X = Pos.Center (), Y = Pos.Bottom (tfSequence) + 1 }; btnAdvance.Accepting += (_, _) => spinner.AdvanceAnimation (); - main.Add (spinner, chkAutoSpin, chkSyncWithTerminal, lblSequence, tfSequence, btnAdvance); + main.AssignHotKeys = true; + + main.Add (spinner, chkVisible, chkAutoSpin, chkSyncWithTerminal, lblSequence, tfSequence, btnAdvance); app.Run (main); } diff --git a/Examples/UICatalog/Scenarios/MultiColouredTable.cs b/Examples/UICatalog/Scenarios/MultiColouredTable.cs index c3c84f9000..f92bd3dba6 100644 --- a/Examples/UICatalog/Scenarios/MultiColouredTable.cs +++ b/Examples/UICatalog/Scenarios/MultiColouredTable.cs @@ -22,40 +22,23 @@ public override void Main () app.Init (); _app = app; - using Window appWindow = new () - { - Title = GetQuitKeyAndName (), - BorderStyle = LineStyle.None, - }; + using Window appWindow = new (); + appWindow.Title = GetQuitKeyAndName (); + appWindow.BorderStyle = LineStyle.None; // MenuBar MenuBar menu = new (); - menu.Add ( - new MenuBarItem ( - Strings.menuFile, - [ - new MenuItem - { - Title = Strings.cmdQuit, - Action = Quit - } - ] - ) - ); - - _tableView = new () { X = 0, Y = Pos.Bottom (menu), Width = Dim.Fill (), Height = Dim.Fill (1) }; + menu.Add (new MenuBarItem (Strings.menuFile, [new MenuItem { Title = Strings.cmdQuit, Action = Quit }])); + + _tableView = new TableViewColors { X = 0, Y = Pos.Bottom (menu), Width = Dim.Fill (), Height = Dim.Fill (1) }; // StatusBar - StatusBar statusBar = new ( - [ - new (Application.GetDefaultKey (Command.Quit), "Quit", Quit) - ] - ); + StatusBar statusBar = new ([new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", Quit)]); appWindow.Add (menu, _tableView, statusBar); - _tableView.CellActivated += EditCurrentCell; + _tableView.Accepted += EditCurrentCell; DataTable dt = new (); dt.Columns.Add ("Col1"); @@ -68,57 +51,55 @@ public override void Main () dt.Rows.Add (DBNull.Value, DBNull.Value); dt.Rows.Add (DBNull.Value, DBNull.Value); - _tableView.SetScheme ( - new () - { - Disabled = appWindow.GetAttributeForRole (VisualRole.Disabled), - HotFocus = appWindow.GetAttributeForRole (VisualRole.HotFocus), - Focus = appWindow.GetAttributeForRole (VisualRole.Focus), - Normal = new (Color.DarkGray, Color.Black) - } - ); + _tableView.SetScheme (new Scheme + { + Disabled = appWindow.GetAttributeForRole (VisualRole.Disabled), + HotFocus = appWindow.GetAttributeForRole (VisualRole.HotFocus), + Focus = appWindow.GetAttributeForRole (VisualRole.Focus), + Normal = new Attribute (Color.DarkGray, Color.Black) + }); _tableView.Table = new DataTableSource (_table = dt); app.Run (appWindow); } - private void EditCurrentCell (object? sender, CellActivatedEventArgs e) + private void EditCurrentCell (object? sender, CommandEventArgs e) { - if (e.Table is null || _table is null || _tableView is null) + if (_tableView?.Table is null || _table is null) { return; } - string? oldValue = e.Table [e.Row, e.Col].ToString (); + int col = _tableView.Value?.Cursor.X ?? 0; + int row = _tableView.Value?.Cursor.Y ?? 0; - if (GetText ("Enter new value", e.Table.ColumnNames [e.Col], oldValue ?? "", out string newText)) + var oldValue = _tableView.Table [row, col].ToString (); + + if (!GetText ("Enter new value", _tableView.Table.ColumnNames [col], oldValue ?? "", out string newText)) { - try - { - _table.Rows [e.Row] [e.Col] = - string.IsNullOrWhiteSpace (newText) ? DBNull.Value : newText; - } - catch (Exception ex) - { - MessageBox.ErrorQuery (_tableView!.App!, "Failed to set text", ex.Message, "Ok"); - } + return; + } - _tableView.Update (); + try + { + _table.Rows [row] [col] = string.IsNullOrWhiteSpace (newText) ? DBNull.Value : newText; } + catch (Exception ex) + { + MessageBox.ErrorQuery (_tableView!.App!, "Failed to set text", ex.Message, "Ok"); + } + + _tableView.Update (); } private bool GetText (string title, string label, string initialText, out string enteredText) { - Dialog d = new () - { - Title = title, - Buttons = [new () { Title = Strings.btnCancel }, new () { Title = Strings.btnOk }] - }; + Dialog d = new () { Title = title, Buttons = [new Button { Title = Strings.btnCancel }, new Button { Title = Strings.btnOk }] }; Label lbl = new () { X = 0, Y = 1, Text = label }; - TextField tf = new () { Text = initialText, X = 0, Y = 2, Width = Dim.Fill (0, minimumContentDim: 50) }; + TextField tf = new () { Text = initialText, X = 0, Y = 2, Width = Dim.Fill (0, 50) }; d.Add (lbl, tf); tf.SetFocus (); @@ -132,7 +113,7 @@ private bool GetText (string title, string label, string initialText, out string return okPressed; } - private void Quit () { _tableView?.App?.RequestStop (); } + private void Quit () => _tableView?.App?.RequestStop (); private class TableViewColors : TableView { @@ -145,7 +126,7 @@ protected override void RenderCell (Attribute cellColor, string render, bool isP { if (unicorns != -1 && i >= unicorns && i <= unicorns + 8) { - SetAttribute (new (Color.White, cellColor.Background)); + SetAttribute (new Attribute (Color.White, cellColor.Background)); } if (rainbows != -1 && i >= rainbows && i <= rainbows + 8) @@ -155,60 +136,42 @@ protected override void RenderCell (Attribute cellColor, string render, bool isP switch (letterOfWord) { case 0: - SetAttribute (new (Color.Red, cellColor.Background)); + SetAttribute (new Attribute (Color.Red, cellColor.Background)); break; + case 1: - SetAttribute ( - new ( - Color.BrightRed, - cellColor.Background - ) - ); + SetAttribute (new Attribute (Color.BrightRed, cellColor.Background)); break; + case 2: - SetAttribute ( - new ( - Color.BrightYellow, - cellColor.Background - ) - ); + SetAttribute (new Attribute (Color.BrightYellow, cellColor.Background)); break; + case 3: - SetAttribute (new (Color.Green, cellColor.Background)); + SetAttribute (new Attribute (Color.Green, cellColor.Background)); break; + case 4: - SetAttribute ( - new ( - Color.BrightGreen, - cellColor.Background - ) - ); + SetAttribute (new Attribute (Color.BrightGreen, cellColor.Background)); break; + case 5: - SetAttribute ( - new ( - Color.BrightBlue, - cellColor.Background - ) - ); + SetAttribute (new Attribute (Color.BrightBlue, cellColor.Background)); break; + case 6: - SetAttribute ( - new ( - Color.BrightCyan, - cellColor.Background - ) - ); + SetAttribute (new Attribute (Color.BrightCyan, cellColor.Background)); break; + case 7: - SetAttribute (new (Color.Cyan, cellColor.Background)); + SetAttribute (new Attribute (Color.Cyan, cellColor.Background)); break; } diff --git a/Examples/UICatalog/Scenarios/TableEditor.cs b/Examples/UICatalog/Scenarios/TableEditor.cs index 53c9117cb9..338d38ec51 100644 --- a/Examples/UICatalog/Scenarios/TableEditor.cs +++ b/Examples/UICatalog/Scenarios/TableEditor.cs @@ -2,6 +2,7 @@ using System.Data; using System.Globalization; using System.Text; +// ReSharper disable StringLiteralTypo namespace UICatalog.Scenarios; @@ -169,8 +170,8 @@ public class TableEditor : Scenario private TableView? _tableView; /// - /// Builds a simple table in which cell values contents are the index of the cell. This helps testing that - /// scrolling etc is working correctly and not skipping out any rows/columns when paging + /// Builds a simple table in which cell values contents are the index of the cell. This helps to test that + /// scrolling etc. is working correctly and not skipping out any rows/columns when paging /// /// /// @@ -253,8 +254,8 @@ public override void Main () appWindow.Add (_tableView); - _tableView!.SelectedCellChanged += (_, _) => { selectedCellLabel.Text = $"{_tableView!.SelectedRow},{_tableView!.SelectedColumn}"; }; - _tableView!.CellActivated += EditCurrentCell; + _tableView!.ValueChanged += (_, _) => { selectedCellLabel.Text = $"{_tableView!.Value?.Cursor.Y ?? 0},{_tableView!.Value?.Cursor.X ?? 0}"; }; + _tableView!.Accepted += EditCurrentCell; _tableView!.KeyDown += TableViewKeyPress; //SetupScrollBar (); @@ -316,8 +317,6 @@ public override void Main () } }; - _tableView!.KeyBindings.ReplaceCommands (Key.Space, Command.Accept); - // Run - Start the application. app.Run (appWindow); } @@ -369,7 +368,7 @@ private MenuBarItem CreateViewMenu () _tableView!.Style.ShowHorizontalHeaderUnderline = state; _tableView!.Update (); }), - CreateCheckBoxMenuItem ("Bottomline", + CreateCheckBoxMenuItem ("BottomLine", "_BottomLine", _tableView!.Style.ShowHorizontalBottomLine, state => @@ -722,30 +721,33 @@ private void ClearColumnStyles () private void CloseExample () => _tableView!.Table = null; - private void EditCurrentCell (object? sender, CellActivatedEventArgs e) + private void EditCurrentCell (object? sender, CommandEventArgs args) { - if (e.Table is not DataTableSource || _currentTable == null) + if (_tableView?.Table is not DataTableSource || _currentTable == null) { return; } - int tableCol = ToTableCol (e.Col); + int col = _tableView.Value?.Cursor.X ?? 0; + int row = _tableView.Value?.Cursor.Y ?? 0; + + int tableCol = ToTableCol (col); if (tableCol < 0) { return; } - object o = _currentTable.Rows [e.Row] [tableCol]; + object o = _currentTable.Rows [row] [tableCol]; string title = o is uint u ? GetUnicodeCategory (u) + $"(0x{o:X4})" : "Enter new value"; - var oldValue = _currentTable.Rows [e.Row] [tableCol].ToString (); + var oldValue = _currentTable.Rows [row] [tableCol].ToString (); var ok = new Button { Text = Strings.btnOk }; var cancel = new Button { Text = Strings.btnCancel }; var d = new Dialog { Title = title, Buttons = [cancel, ok] }; - var lbl = new Label { X = 0, Y = 1, Text = _tableView!.Table!.ColumnNames [e.Col] }; + var lbl = new Label { X = 0, Y = 1, Text = _tableView!.Table!.ColumnNames [col] }; var tf = new TextField { Text = oldValue!, X = 0, Y = 2, Width = Dim.Fill (0, 50) }; d.Add (lbl, tf); @@ -762,7 +764,7 @@ private void EditCurrentCell (object? sender, CellActivatedEventArgs e) try { - _currentTable.Rows [e.Row] [tableCol] = string.IsNullOrWhiteSpace (tf.Text) ? DBNull.Value : tf.Text; + _currentTable.Rows [row] [tableCol] = string.IsNullOrWhiteSpace (tf.Text) ? DBNull.Value : tf.Text; } catch (Exception ex) { @@ -792,12 +794,12 @@ private IEnumerable GetChildren (FileSystemInfo arg) return null; } - if (_tableView!.SelectedColumn < 0 || _tableView!.SelectedColumn > _tableView!.Table.Columns) + if (_tableView!.Value is null || _tableView!.Value.Cursor.X > _tableView!.Table.Columns) { return null; } - return _tableView!.SelectedColumn; + return _tableView!.Value.Cursor.X; } private string GetHumanReadableFileSize (FileSystemInfo fsi) @@ -885,7 +887,7 @@ private void OpenTreeExample () { _tableView!.Style.ColumnStyles.Clear (); - TreeView tree = new () { AspectGetter = f => f.Name, TreeBuilder = new DelegateTreeBuilder (GetChildren, f => false) }; + TreeView tree = new () { AspectGetter = f => f.Name, TreeBuilder = new DelegateTreeBuilder (GetChildren, _ => false) }; TreeTableSource source = new (_tableView, "Name", diff --git a/Examples/UICatalog/Scenarios/TableViewTest.cs b/Examples/UICatalog/Scenarios/TableViewTest.cs index c0fd8f84a0..bcfe817504 100644 --- a/Examples/UICatalog/Scenarios/TableViewTest.cs +++ b/Examples/UICatalog/Scenarios/TableViewTest.cs @@ -1,15 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using Terminal.Gui.Views; - -namespace UICatalog.Scenarios; +namespace UICatalog.Scenarios; [ScenarioMetadata ("TableViewTest", "Demonstrates and tests TableView.")] [ScenarioCategory ("TableView")] [ScenarioCategory ("Controls")] [ScenarioCategory ("Dialogs")] - public class TableViewTest : Scenario { private TableView tableView; @@ -29,79 +23,56 @@ public override void Main () Height = Dim.Fill (1) // status bar }; - optionsView = new View () + optionsView = new View { - Y = 0, X = 0, - Width = Dim.Fill (), Height = Dim.Auto(), + Y = 0, + X = 0, + Width = Dim.Fill (), + Height = Dim.Auto (), BorderStyle = LineStyle.Single, - Title = "Options", + Title = "Options" }; - var offsetLabel = new Label () - { - X = 0, Y = Pos.Bottom (optionsView), - Text = "Offset", - }; + var offsetLabel = new Label { X = 0, Y = Pos.Bottom (optionsView), Text = "Offset" }; - var colOffsetUpDown = new NumericUpDown () - { - X = Pos.Right (offsetLabel), Y = Pos.Bottom (optionsView), - }; + NumericUpDown colOffsetUpDown = new() { X = Pos.Right (offsetLabel), Y = Pos.Bottom (optionsView) }; colOffsetUpDown.Padding.Thickness = new Thickness (1, 0, 1, 0); - var setColOffsetButton = new Button () - { - X = Pos.Right (colOffsetUpDown), Y = Pos.Bottom (optionsView), - Text = "Set", - }; - setColOffsetButton.Padding.Thickness = new Thickness (1,0,1,0); + var setColOffsetButton = new Button { X = Pos.Right (colOffsetUpDown), Y = Pos.Bottom (optionsView), Text = "Set" }; + setColOffsetButton.Padding.Thickness = new Thickness (1, 0, 1, 0); setColOffsetButton.Accepting += (sender, args) => tableView.ColumnOffset = colOffsetUpDown.Value; - var rowOffsetUpDown = new NumericUpDown () - { - X = Pos.Right (setColOffsetButton), Y = Pos.Bottom (optionsView), - }; + NumericUpDown rowOffsetUpDown = new() { X = Pos.Right (setColOffsetButton), Y = Pos.Bottom (optionsView) }; rowOffsetUpDown.Padding.Thickness = new Thickness (1, 0, 1, 0); - var setRowOffsetButton = new Button () - { - X = Pos.Right (rowOffsetUpDown), Y = Pos.Bottom (optionsView), - Text = "Set", - }; + var setRowOffsetButton = new Button { X = Pos.Right (rowOffsetUpDown), Y = Pos.Bottom (optionsView), Text = "Set" }; setRowOffsetButton.Padding.Thickness = new Thickness (1, 0, 1, 0); setRowOffsetButton.Accepting += (sender, args) => tableView.RowOffset = rowOffsetUpDown.Value; - var selectedRowUpDown = new NumericUpDown () - { - X = Pos.Right (setRowOffsetButton), Y = Pos.Bottom (optionsView), - }; + NumericUpDown selectedRowUpDown = new() { X = Pos.Right (setRowOffsetButton), Y = Pos.Bottom (optionsView) }; selectedRowUpDown.Padding.Thickness = new Thickness (1, 0, 1, 0); - var setSelectedRowButton = new Button () - { - X = Pos.Right (selectedRowUpDown), Y = Pos.Bottom (optionsView), - Text = "Set", - }; + var setSelectedRowButton = new Button { X = Pos.Right (selectedRowUpDown), Y = Pos.Bottom (optionsView), Text = "Set" }; setSelectedRowButton.Padding.Thickness = new Thickness (1, 0, 1, 0); - setSelectedRowButton.Accepting += (sender, args) => tableView.SelectedRow = selectedRowUpDown.Value; + setSelectedRowButton.Accepting += (sender, args) => tableView.SetSelection (tableView.Value?.Cursor.X ?? 0, selectedRowUpDown.Value, false); tableView = new TableView { - X = 0, Y = Pos.Bottom(offsetLabel), - Width = Dim.Fill (), Height = Dim.Fill (), - + X = 0, + Y = Pos.Bottom (offsetLabel), + Width = Dim.Fill (), + Height = Dim.Fill (), Table = new DataTableSource (TableView.BuildDemoDataTable (6, 30)) }; tableView.DrawComplete += (sender, args) => offsetLabel.Text = $"{tableView.ColumnOffset} - {tableView.RowOffset} {tableView.Viewport.Location}"; - tableView.Style.ColumnStyles [2] = new ColumnStyle () {Alignment = Alignment.End}; + tableView.Style.ColumnStyles [2] = new ColumnStyle { Alignment = Alignment.End }; tableView.Style.ColumnStyles [6] = new ColumnStyle (); (string text, Func iv, Action hndlr) [] options = [ - ("Scrollbars Auto", () => tableView.ViewportSettings.HasFlag (ViewportSettingsFlags.HasScrollBars), - b => + ("Scrollbars Auto", () => tableView.ViewportSettings.HasFlag (ViewportSettingsFlags.HasScrollBars), b => { if (b) { @@ -118,24 +89,27 @@ public override void Main () ("ShowVerticalHeaderLines", () => tableView.Style.ShowVerticalHeaderLines, b => tableView.Style.ShowVerticalHeaderLines = b), ("ShowHorizontalHeaderUnderline", () => tableView.Style.ShowHorizontalHeaderUnderline, b => tableView.Style.ShowHorizontalHeaderUnderline = b), ("ShowVerticalCellLines", () => tableView.Style.ShowVerticalCellLines, b => tableView.Style.ShowVerticalCellLines = b), - ("InvertSelectedCellFirstCharacter", () => tableView.Style.InvertSelectedCellFirstCharacter, b => tableView.Style.InvertSelectedCellFirstCharacter = b), + ("InvertSelectedCellFirstCharacter", () => tableView.Style.InvertSelectedCellFirstCharacter, + b => tableView.Style.InvertSelectedCellFirstCharacter = b), ("ShowHorizontalBottomline", () => tableView.Style.ShowHorizontalBottomLine, b => tableView.Style.ShowHorizontalBottomLine = b), ("ExpandLastColumn", () => tableView.Style.ExpandLastColumn, b => tableView.Style.ExpandLastColumn = b), ("FullRowSelect", () => tableView.FullRowSelect, b => tableView.FullRowSelect = b), ("SmoothHorizontalScrolling", () => tableView.Style.SmoothHorizontalScrolling, b => tableView.Style.SmoothHorizontalScrolling = b), ("UseAllRowsForContentCalculation", () => tableView.UseAllRowsForContentCalculation, b => tableView.UseAllRowsForContentCalculation = b), - ("MinAcceptableWidth (limit col 6 = 15)", () => tableView.Style.ColumnStyles[6].MinAcceptableWidth < TableView.DEFAULT_MIN_ACCEPTABLE_WIDTH, b => tableView.Style.ColumnStyles[6].MinAcceptableWidth = b ? 15 : TableView.DEFAULT_MIN_ACCEPTABLE_WIDTH), + ("MinAcceptableWidth (limit col 6 = 15)", () => tableView.Style.ColumnStyles [6].MinAcceptableWidth < TableView.DEFAULT_MIN_ACCEPTABLE_WIDTH, + b => tableView.Style.ColumnStyles [6].MinAcceptableWidth = b ? 15 : TableView.DEFAULT_MIN_ACCEPTABLE_WIDTH) ]; View priorView = null; foreach ((string text, Func iv, Action hndlr) tuple in options) { - CheckBox cb = new CheckBox() + var cb = new CheckBox { - X = 0, Y = priorView != null ? Pos.Bottom(priorView) : 0, + X = 0, + Y = priorView != null ? Pos.Bottom (priorView) : 0, Text = tuple.text, - Value = tuple.iv () ? CheckState.Checked : CheckState.UnChecked, + Value = tuple.iv () ? CheckState.Checked : CheckState.UnChecked }; cb.ValueChanged += (s, e) => @@ -146,15 +120,21 @@ public override void Main () // without it some changes do not reflect until the next user interaction // some cases here might work, but only because a redraw is forced when Clicking the checkbox // which seems to be not correct! Changing the checkbox should redraw the checkbox, but not all views - tableView.Update(); + tableView.Update (); }; priorView = cb; optionsView.Add (cb); } - - - win.Add (optionsView, offsetLabel, colOffsetUpDown, setColOffsetButton, rowOffsetUpDown, setRowOffsetButton, selectedRowUpDown, setSelectedRowButton, tableView); + win.Add (optionsView, + offsetLabel, + colOffsetUpDown, + setColOffsetButton, + rowOffsetUpDown, + setRowOffsetButton, + selectedRowUpDown, + setSelectedRowButton, + tableView); app.Run (win); } @@ -162,7 +142,7 @@ public override void Main () public class RedrawLabel : View { - int redrawCount = 0; + private int redrawCount; ///// //public override string Text @@ -180,7 +160,7 @@ public class RedrawLabel : View // redrawCount++; // return base.OnDrawingContent (context); //} - /// + /// protected override bool OnDrawingContent (DrawContext context) { base.OnDrawingContent (context); diff --git a/Examples/UICatalog/UICatalogRunnable.cs b/Examples/UICatalog/UICatalogRunnable.cs index 4dc1b1137b..f1a88ad060 100644 --- a/Examples/UICatalog/UICatalogRunnable.cs +++ b/Examples/UICatalog/UICatalogRunnable.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Collections.ObjectModel; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; @@ -49,7 +49,7 @@ public override void BeginInit () { _categoryList.SelectedItem = null; } - _scenarioList.SelectedRow = _cachedScenarioIndex; + _scenarioList.SetSelection (0, _cachedScenarioIndex, false); base.BeginInit (); } @@ -93,7 +93,7 @@ protected override void OnIsModalChanged (bool newIsModal) } _categoryList?.EnsureSelectedItemVisible (); - _scenarioList?.EnsureSelectedCellIsVisible (); + _scenarioList?.EnsureCursorIsVisible (); if (ShowStatusBar) { @@ -119,7 +119,7 @@ protected override void OnIsRunningChanged (bool newIsRunning) if (_scenarioList is { } && App is { } && _scenarioList.Table is { }) { ShowScenarioErrorsDialog (App, - _scenarioList.Table [_scenarioList.SelectedRow, 0].ToString () ?? string.Empty, + _scenarioList.Table [_scenarioList.Value?.Cursor.Y ?? 0, 0].ToString () ?? string.Empty, UICatalog.LogCapture.GetScenarioLogs ()); } @@ -150,17 +150,17 @@ private MenuBar CreateMenuBar () new MenuBarItem (Strings.menuFile, [ new MenuItem - { - Title = Strings.cmdQuit, - HelpText = "Quit UI Catalog", - Key = Application.GetDefaultKey (Command.Quit), - Action = RequestStop, - Command = Command.Quit - } - ]), + { + Title = Strings.cmdQuit, + HelpText = "Quit UI Catalog", + Key = Application.GetDefaultKey (Command.Quit), + Action = RequestStop, + Command = Command.Quit + } + ]), new MenuBarItem ("_Themes", CreateThemeMenuItems ()), new MenuBarItem ("Diag_nostics", CreateDiagnosticMenuItems ()), - new MenuBarItem ("_Logging", CreateLoggingMenuItems ()!), + new MenuBarItem ("_Logging", CreateLoggingMenuItems ()), new MenuBarItem (Strings.menuHelp, [ new MenuItem ("_Documentation", @@ -587,7 +587,7 @@ private TableView CreateScenarioList () scenarioList.Style.ColumnStyles.Add (0, new ColumnStyle { MaxWidth = longestName, MinWidth = longestName, MinAcceptableWidth = longestName }); scenarioList.Style.ColumnStyles.Add (1, new ColumnStyle { MaxWidth = 1 }); - scenarioList.CellActivated += ScenarioView_OpenSelectedItem; + scenarioList.Accepted += ScenarioView_OpenSelectedItem; // TableView typically is a grid where nav keys are biased for moving left/right. scenarioList.KeyBindings.Remove (Key.Home); @@ -607,17 +607,17 @@ private TableView CreateScenarioList () /// Launches the selected scenario, setting the global _selectedScenario /// /// - private void ScenarioView_OpenSelectedItem (object? sender, EventArgs? e) + private void ScenarioView_OpenSelectedItem (object? sender, CommandEventArgs e) { // Save selected item state _cachedCategoryIndex = _categoryList?.SelectedItem; if (_scenarioList is { }) { - _cachedScenarioIndex = _scenarioList.SelectedRow; + _cachedScenarioIndex = _scenarioList.Value?.Cursor.Y ?? 0; // Set the Result to the selected scenario name - Result = _scenarioList.Table? [_scenarioList.SelectedRow, 0]; + Result = _scenarioList.Table? [_scenarioList.Value?.Cursor.Y ?? 0, 0]; } Logging.Information ($"Scenario Selected; Stopping {GetType ().Name}: {Result}"); App?.RequestStop (); diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParserPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParserPattern.cs index 9132090660..1df4e89509 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParserPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyboardParserPattern.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace Terminal.Gui.Drivers; /// @@ -29,7 +31,7 @@ public abstract class AnsiKeyboardParserPattern /// /// Creates a new instance of the class. /// - protected AnsiKeyboardParserPattern () { _name = GetType ().Name; } + protected AnsiKeyboardParserPattern () => _name = GetType ().Name; /// /// Returns the described by the escape sequence. @@ -39,9 +41,14 @@ public abstract class AnsiKeyboardParserPattern public Key? GetKey (string? input) { Key? key = GetKeyImpl (input); - //Logging.Trace ($"{nameof (AnsiKeyboardParser)} interpreted {input} as {key} using {_name}"); - return key; + // See https://github.com/gui-cs/Terminal.Gui/issues/5067 + Debug.Assert (key is { Handled: false }); + + // Create a copy just to be safe; the patterns are supposed to create new Key instances, + // but we don't want to accidentally share references + // See https://github.com/gui-cs/Terminal.Gui/issues/5067 + return new Key (key); } /// @@ -69,7 +76,7 @@ protected static Key ApplyModifiersAndEventType (string modifierField, Key key) return key; } - int modifiers = System.Math.Max (0, encodedModifiers - 1); + int modifiers = Math.Max (0, encodedModifiers - 1); if ((modifiers & 0b1) != 0) { diff --git a/Terminal.Gui/Drivers/AnsiHandling/CsiCursorPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/CsiCursorPattern.cs index e87e3f60c3..d7b029a6c4 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/CsiCursorPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/CsiCursorPattern.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Diagnostics; namespace Terminal.Gui.Drivers; @@ -56,11 +57,14 @@ public class CsiCursorPattern : AnsiKeyboardParserPattern return null; } + // See https://github.com/gui-cs/Terminal.Gui/issues/5067 + Debug.Assert (!key.Handled); + if (string.IsNullOrEmpty (modifierGroup)) { - return key; + return new Key (key); } - return ApplyModifiersAndEventType (modifierGroup, key); + return ApplyModifiersAndEventType (modifierGroup, new Key (key)); } } diff --git a/Terminal.Gui/Drivers/AnsiHandling/CsiKeyPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/CsiKeyPattern.cs index 3ef1799363..ae100c5705 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/CsiKeyPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/CsiKeyPattern.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.RegularExpressions; namespace Terminal.Gui.Drivers; @@ -34,7 +35,7 @@ public class CsiKeyPattern : AnsiKeyboardParserPattern }; /// - public override bool IsMatch (string? input) { return _pattern.IsMatch (input!); } + public override bool IsMatch (string? input) => _pattern.IsMatch (input!); /// protected override Key? GetKeyImpl (string? input) @@ -59,14 +60,17 @@ public class CsiKeyPattern : AnsiKeyboardParserPattern return null; } + // See https://github.com/gui-cs/Terminal.Gui/issues/5067 + Debug.Assert (!key.Handled); + // If there's no modifier, just return the key. string modifierField = match.Groups [2].Value; if (string.IsNullOrEmpty (modifierField)) { - return key; + return new Key (key); } - return ApplyModifiersAndEventType (modifierField, key); + return ApplyModifiersAndEventType (modifierField, new Key (key)); } } diff --git a/Terminal.Gui/Drivers/AnsiHandling/EscAsAltPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/EscAsAltPattern.cs index fd6b543219..63cc473bc4 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/EscAsAltPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/EscAsAltPattern.cs @@ -4,7 +4,7 @@ namespace Terminal.Gui.Drivers; internal class EscAsAltPattern : AnsiKeyboardParserPattern { - public EscAsAltPattern () { IsLastMinute = true; } + public EscAsAltPattern () => IsLastMinute = true; #pragma warning disable IDE1006 // Naming Styles private static readonly Regex _pattern = new (@"^\u001b([\u0001-\u001a\u001fa-zA-Z0-9_])$"); diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs index 85aabb2086..b598cd8c74 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs @@ -1,5 +1,5 @@ +using System.Diagnostics; using System.Globalization; -using System.Text; using System.Text.RegularExpressions; namespace Terminal.Gui.Drivers; @@ -92,24 +92,20 @@ public class KittyKeyboardPattern : AnsiKeyboardParserPattern } // Extract alternate key codes (kitty flag 4: report alternate keys) - KeyCode shiftedKeyCode = KeyCode.Null; - KeyCode baseLayoutKeyCode = KeyCode.Null; + var shiftedKeyCode = KeyCode.Null; + var baseLayoutKeyCode = KeyCode.Null; - if (match.Groups [2].Success - && int.TryParse (match.Groups [2].Value, CultureInfo.InvariantCulture, out int shiftedCode) - && shiftedCode > 0) + if (match.Groups [2].Success && int.TryParse (match.Groups [2].Value, CultureInfo.InvariantCulture, out int shiftedCode) && shiftedCode > 0) { shiftedKeyCode = (KeyCode)shiftedCode; } - if (match.Groups [3].Success - && int.TryParse (match.Groups [3].Value, CultureInfo.InvariantCulture, out int baseCode) - && baseCode > 0) + if (match.Groups [3].Success && int.TryParse (match.Groups [3].Value, CultureInfo.InvariantCulture, out int baseCode) && baseCode > 0) { baseLayoutKeyCode = (KeyCode)baseCode; } - string associatedText = string.Empty; + var associatedText = string.Empty; if (match.Groups [5].Success) { @@ -230,9 +226,7 @@ private static (Key Key, string ModifierField) NormalizeShiftedPrintableKey (Key { string [] parts = modifierField.Split (':'); - if (parts.Length == 0 - || !int.TryParse (parts [0], CultureInfo.InvariantCulture, out int encodedModifiers) - || encodedModifiers <= 1) + if (parts.Length == 0 || !int.TryParse (parts [0], CultureInfo.InvariantCulture, out int encodedModifiers) || encodedModifiers <= 1) { return (key, modifierField); } @@ -244,7 +238,7 @@ private static (Key Key, string ModifierField) NormalizeShiftedPrintableKey (Key return (key, modifierField); } - Rune printableRune = default (Rune); + var printableRune = default (Rune); if (!string.IsNullOrEmpty (key.AssociatedText)) { @@ -263,7 +257,7 @@ private static (Key Key, string ModifierField) NormalizeShiftedPrintableKey (Key if (printableRune == default (Rune) && key.ShiftedKeyCode != KeyCode.Null) { - Rune shiftedRune = Key.ToRune (key.ShiftedKeyCode); + var shiftedRune = Key.ToRune (key.ShiftedKeyCode); if (!Rune.IsControl (shiftedRune)) { @@ -308,6 +302,7 @@ private static (Key Key, string ModifierField) NormalizeShiftedPrintableKey (Key { 57447, ModifierKey.RightShift }, { 57448, ModifierKey.RightCtrl }, { 57449, ModifierKey.RightAlt }, + // 57453 = ISO_Level3_Shift (AltGr). Treat it as a dedicated modifier so // standalone AltGr does not fall through as a printable Private Use Area rune. { 57453, ModifierKey.AltGr }, @@ -321,7 +316,10 @@ private static (Key Key, string ModifierField) NormalizeShiftedPrintableKey (Key { if (_functionalKeyMap.TryGetValue (kittyCode, out Key? functionalKey)) { - return functionalKey; + // See https://github.com/gui-cs/Terminal.Gui/issues/5067 + Debug.Assert (!functionalKey.Handled); + + return new Key (functionalKey); } if (_modifierKeyMap.TryGetValue (kittyCode, out ModifierKey modifierKey)) diff --git a/Terminal.Gui/Drivers/AnsiHandling/Ss3Pattern.cs b/Terminal.Gui/Drivers/AnsiHandling/Ss3Pattern.cs index cf2804072c..0e81762631 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/Ss3Pattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/Ss3Pattern.cs @@ -9,11 +9,11 @@ namespace Terminal.Gui.Drivers; public class Ss3Pattern : AnsiKeyboardParserPattern { #pragma warning disable IDE1006 // Naming Styles - private static readonly Regex _pattern = new (@"^\u001bO([PQRStDCABOHFwqysu])$"); + private static readonly Regex _pattern = new Regex (@"^\u001bO([PQRStDCABOHFwqysu])$"); #pragma warning restore IDE1006 // Naming Styles /// - public override bool IsMatch (string? input) { return _pattern.IsMatch (input!); } + public override bool IsMatch (string? input) => _pattern.IsMatch (input!); /// /// Returns the ss3 key that corresponds to the provided input escape sequence diff --git a/Terminal.Gui/FileServices/FileSystemColorProvider.cs b/Terminal.Gui/FileServices/FileSystemColorProvider.cs index 63a616cde8..4b0de3e12b 100644 --- a/Terminal.Gui/FileServices/FileSystemColorProvider.cs +++ b/Terminal.Gui/FileServices/FileSystemColorProvider.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; +// ReSharper disable StringLiteralTypo namespace Terminal.Gui.FileServices; @@ -289,7 +290,8 @@ public class FileSystemColorProvider { ".epp", StringToColor ("#FFA61A") }, { ".scala", StringToColor ("#DE3423") }, { ".sc", StringToColor ("#DE3423") }, - { ".iLogicVb", StringToColor ("#A63B22") } + { ".iLogicVb", StringToColor ("#A63B22") }, + { ".lnk", StringToColor ("#696969") } }; /// Mapping of file/dir name to color. diff --git a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs index a44ddd049d..46ce46f338 100644 --- a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs +++ b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs @@ -7,10 +7,10 @@ namespace Terminal.Gui.FileServices; public class FileSystemTreeBuilder : ITreeBuilder, IComparer { /// Creates a new instance of the class. - public FileSystemTreeBuilder () { Sorter = this; } + public FileSystemTreeBuilder () => Sorter = this; /// Gets or sets a flag indicating whether to show files as leaf elements in the tree. Defaults to true. - public bool IncludeFiles { get; } = true; + public bool IncludeFiles { get; set; } = true; /// Gets or sets the order of directory children. Defaults to . public IComparer Sorter { get; set; } @@ -28,17 +28,35 @@ public int Compare (IFileSystemInfo x, IFileSystemInfo y) return 1; } - return x.Name.CompareTo (y.Name); + if (x is { } && y is { }) + { + return string.Compare (x.Name, y.Name, StringComparison.Ordinal); + } + + return 0; } /// public bool SupportsCanExpand => true; /// - public bool CanExpand (IFileSystemInfo toExpand) { return TryGetChildren (toExpand).Any (); } + public bool CanExpand (IFileSystemInfo toExpand) + { + if (toExpand is IFileInfo) + { + return false; + } + + if (IsReparsePoint (toExpand)) + { + return false; + } + + return TryGetChildren (toExpand).Any (); + } /// - public IEnumerable GetChildren (IFileSystemInfo forObject) { return TryGetChildren (forObject).OrderBy (k => k, Sorter); } + public IEnumerable GetChildren (IFileSystemInfo forObject) => TryGetChildren (forObject).OrderBy (k => k, Sorter); private IEnumerable TryGetChildren (IFileSystemInfo entry) { @@ -47,6 +65,12 @@ private IEnumerable TryGetChildren (IFileSystemInfo entry) return Enumerable.Empty (); } + // Prevent traversal cycles through symlinks/junctions/mount points. + if (IsReparsePoint (entry)) + { + return Enumerable.Empty (); + } + var dir = (IDirectoryInfo)entry; try @@ -58,4 +82,6 @@ private IEnumerable TryGetChildren (IFileSystemInfo entry) return Enumerable.Empty (); } } + + private static bool IsReparsePoint (IFileSystemInfo entry) => (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; } diff --git a/Terminal.Gui/Input/Command.cs b/Terminal.Gui/Input/Command.cs index 22a42b2e70..8fd51dafc9 100644 --- a/Terminal.Gui/Input/Command.cs +++ b/Terminal.Gui/Input/Command.cs @@ -161,7 +161,7 @@ public enum Command /// Extends the selection to the right on the current row/line. RightEndExtend, - /// Toggles the selection. + /// Toggles the selection (or a specific element of the selection). ToggleExtend, #endregion diff --git a/Terminal.Gui/Resources/config.json b/Terminal.Gui/Resources/config.json index 69ab9036af..1dec0b1066 100644 --- a/Terminal.Gui/Resources/config.json +++ b/Terminal.Gui/Resources/config.json @@ -31,9 +31,6 @@ // --------------- View Specific Settings --------------- "PopoverMenu.DefaultKey": "Shift+F10", - "FileDialog.MaxSearchResults": 10000, - "FileDialogStyle.DefaultUseColors": false, - "FileDialogStyle.DefaultUseUnicodeCharacters": false, // --------------- Themes ----------------- "Themes": [ diff --git a/Terminal.Gui/ViewBase/View.Command.cs b/Terminal.Gui/ViewBase/View.Command.cs index cd89419c87..d1cde141e8 100644 --- a/Terminal.Gui/ViewBase/View.Command.cs +++ b/Terminal.Gui/ViewBase/View.Command.cs @@ -948,6 +948,9 @@ protected virtual void OnActivated (ICommandContext? ctx) { } // can distinguish a user-initiated HotKey activation from a programmatic one. InvokeCommand (Command.Activate, ctx?.Binding); + // QUESTION: Why do we return true here, indicating the hotkey was handled, + // QUESTION: when we still want the key event to propagate for text input scenarios? + // QUESTION: Should we return false to allow further processing? return true; } diff --git a/Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs b/Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs index 21e3ce7d10..ee3d1fee9f 100644 --- a/Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs +++ b/Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs @@ -1,4 +1,3 @@ -#nullable disable namespace Terminal.Gui.Views; /// Collection navigator for cycling selections in a . @@ -7,19 +6,25 @@ internal class TableCollectionNavigator : CollectionNavigatorBase private readonly TableView _tableView; /// Creates a new instance for navigating the data in the wrapped . - public TableCollectionNavigator (TableView tableView) { this._tableView = tableView; } + public TableCollectionNavigator (TableView tableView) => _tableView = tableView; /// protected override object ElementAt (int idx) { - int col = _tableView.FullRowSelect ? 0 : _tableView.SelectedColumn; - object rawValue = _tableView.Table [idx, col]; + int col = _tableView.FullRowSelect ? 0 : _tableView.Value?.Cursor.X ?? 0; + object? rawValue = _tableView.Table? [idx, col]; - ColumnStyle style = _tableView.Style.GetColumnStyleIfAny (col); + if (rawValue is null or DBNull) + { + return string.Empty; + } - return style?.RepresentationGetter?.Invoke (rawValue) ?? rawValue; + ColumnStyle? style = _tableView.Style.GetColumnStyleIfAny (col); + string? representation = style?.RepresentationGetter?.Invoke (rawValue); + + return representation ?? rawValue; } /// - protected override int GetCollectionLength () { return _tableView.Table.Rows; } + protected override int GetCollectionLength () => _tableView.Table?.Rows ?? 0; } diff --git a/Terminal.Gui/Views/DatePicker.cs b/Terminal.Gui/Views/DatePicker.cs index d14926f51e..b81664edc1 100644 --- a/Terminal.Gui/Views/DatePicker.cs +++ b/Terminal.Gui/Views/DatePicker.cs @@ -304,21 +304,26 @@ private void SetInitialProperties (DateTime date) CreateCalendar (); SelectDayOnCalendar (Value.Day); - _calendar.CellActivated += (_, e) => - { - object dayValue = _table!.Rows [e.Row] [e.Col]; - - bool isDay = int.TryParse (dayValue.ToString (), out int day); - - if (!isDay) - { - return; - } - - ChangeDayDate (day); - SelectDayOnCalendar (day); - Text = Value.ToString (Format); - }; + _calendar.Activated += (_, _) => + { + object dayValue = _table!.Rows [_calendar.Value!.Cursor.Y] [_calendar.Value.Cursor.X]; + + bool isDay = int.TryParse (dayValue.ToString (), out int day); + + if (!isDay) + { + return; + } + + ChangeDayDate (day); + SelectDayOnCalendar (day); + Text = Value.ToString (Format); + }; + + _calendar.Accepted += (_, e) => + { + RaiseAccepted (e.Context); + }; Width = Dim.Auto (DimAutoStyle.Content); Height = Dim.Auto (DimAutoStyle.Content); diff --git a/Terminal.Gui/Views/Dialog.cs b/Terminal.Gui/Views/Dialog.cs index b56e6de860..3235cc1886 100644 --- a/Terminal.Gui/Views/Dialog.cs +++ b/Terminal.Gui/Views/Dialog.cs @@ -102,7 +102,7 @@ public class Dialog : Dialog get => ((IRunnable)this).Result is int value ? value : null; set { - if (value > Buttons.Length - 1 || value < 0) + if (value >= Buttons.Length || value < 0) { throw new ArgumentOutOfRangeException (nameof (value), @"Result value must be a valid button index or null."); } diff --git a/Terminal.Gui/Views/DialogTResult.cs b/Terminal.Gui/Views/DialogTResult.cs index 0954b8aabf..81d4a37fd6 100644 --- a/Terminal.Gui/Views/DialogTResult.cs +++ b/Terminal.Gui/Views/DialogTResult.cs @@ -177,16 +177,12 @@ protected override bool OnAccepting (CommandEventArgs args) RequestStop (); return sourceView is IAcceptTarget { IsDefault: false }; - } /// protected override void OnViewportChanged (DrawEventArgs e) { - //if (!IsInitialized) - { - SetContentSize (new Size (Math.Max (_minimumButtonsSize.Width, Viewport.Width), Math.Max (_minimumButtonsSize.Height, Viewport.Height))); - } + SetContentSize (new Size (Math.Max (_minimumButtonsSize.Width, Viewport.Width), Math.Max (_minimumButtonsSize.Height, Viewport.Height))); base.OnViewportChanged (e); } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs index 665dbcfcfc..7e61853e16 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs @@ -119,7 +119,6 @@ private bool FinishAccept () MultiSelected = string.IsNullOrWhiteSpace (Path) ? Enumerable.Empty ().ToList ().AsReadOnly () : new List { Path }.AsReadOnly (); } - // TODO: TableView should not always return true from OnCellActivated. Result = 2; // Ok button index if (!IsModal) @@ -141,7 +140,7 @@ private bool FinishAccept () _tableView.EnsureValidSelection (); - if (_tableView.SelectedRow < 0) + if (_tableView.Value is null) { return null; } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs index e1552268ac..80377139c3 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs @@ -31,8 +31,9 @@ internal void PushState (IDirectoryInfo d, bool addCurrentStateToHistory, bool s /// internal void RestoreSelection (IFileSystemInfo toRestore) { - _tableView.SelectedRow = State!.Children.IndexOf (r => r.FileSystemInfo == toRestore); - _tableView.EnsureSelectedCellIsVisible (); + int row = State!.Children.IndexOf (r => r.FileSystemInfo == toRestore); + _tableView.SetSelection (0, row >= 0 ? row : 0, false); + _tableView.EnsureCursorIsVisible (); } private bool CancelSearch () @@ -123,7 +124,12 @@ private void PushState (FileDialogState newState, bool addCurrentStateToHistory, { _tableView.Viewport = _tableView.Viewport with { Y = 0 }; } - _tableView.SelectedRow = 0; + + if (_tableView.Viewport.X != 0) + { + _tableView.Viewport = _tableView.Viewport with { X = 0 }; + } + _tableView.SetSelection (0, 0, false); SetNeedsDraw (); UpdateNavigationVisibility (); @@ -218,6 +224,13 @@ private void SetPathToSelectedObject (IFileSystemInfo? selected) } } + string path = _tbPath.Text; + + if (string.IsNullOrWhiteSpace (path)) + { + return; + } + Path = selected.FullName; } @@ -254,7 +267,7 @@ private void SetTreeVisible (bool visible) _tableViewContainer.X = 0; _tableViewContainer.Width = Dim.Fill (); _tableViewContainer.Arrangement = ViewArrangement.Fixed; - _tableViewContainer.Border.Thickness = new Thickness (0); + _tableViewContainer.Border.Thickness = new Thickness (1, 0, 0, 0); } _btnTreeToggle.Text = GetTreeToggleText (visible); diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs index 18daee48bf..63ceee5180 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs @@ -33,16 +33,16 @@ private void TableViewHandleCommandNotBound (object? sender, CommandEventArgs e) } PopoverMenu contextMenu = new ([ - new MenuItem (Strings.fdCtxNew, string.Empty, New), - new MenuItem (Strings.fdCtxRename, string.Empty, () => Rename (App)), - new MenuItem (Strings.fdCtxDelete, string.Empty, Delete) - ]); + new MenuItem (Strings.fdCtxNew, string.Empty, New), + new MenuItem (Strings.fdCtxRename, string.Empty, () => Rename (App)), + new MenuItem (Strings.fdCtxDelete, string.Empty, Delete) + ]); // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused // and the context menu is disposed when it is closed. App!.Popovers?.Register (contextMenu); - Point pos = new (_tableView.FrameToScreen ().X + 15, _tableView.FrameToScreen ().Y + _tableView.SelectedRow + _tableView.GetHeaderHeight ()); + Point pos = new (_tableView.FrameToScreen ().X + 15, _tableView.FrameToScreen ().Y + (_tableView.Value?.Cursor.Y ?? 0) + _tableView.GetHeaderHeight ()); contextMenu.MakeVisible (pos); } @@ -101,28 +101,6 @@ internal void SortColumn (int col, bool isAsc) ApplySort (); } -#if MENU_V1 - private void AllowedTypeMenuClicked (int idx) - { - IAllowedType allow = AllowedTypes [idx]; - - for (var i = 0; i < AllowedTypes.Count; i++) - { - _allowedTypeMenuItems! [i].Checked = i == idx; - } - - _allowedTypeMenu!.Title = allow.ToString ()!; - - CurrentFilter = allow; - - _tbPath.ClearAllSelection (); - _tbPath.Autocomplete.ClearSuggestions (); - - State?.RefreshChildren (); - WriteStateToTableView (); - } -#endif - private string AspectGetter (object o) { var fsi = (IFileSystemInfo)o; @@ -136,14 +114,14 @@ private string AspectGetter (object o) return (Style.IconProvider.GetIconWithOptionalSpace (fsi) + fsi.Name).Trim (); } - private void CellActivate (object? sender, CellActivatedEventArgs obj) + private void TableViewOnAccepted (object? sender, CommandEventArgs e) { if (TryAcceptMulti ()) { return; } - FileSystemInfoStats stats = RowToStats (obj.Row); + FileSystemInfoStats stats = RowToStats (_tableView.Value!.Cursor.Y); if (stats.FileSystemInfo is IDirectoryInfo d) { @@ -174,16 +152,16 @@ private Scheme ColorGetter (CellColorGetterArgs args) return _tableView.GetScheme (); } - Color color = Style.ColorProvider.GetColor (stats.FileSystemInfo!) ?? new Color (Color.White); - var black = new Color (Color.Black); + Color foreground = Style.ColorProvider.GetColor (stats.FileSystemInfo!) ?? GetAttributeForRole (VisualRole.Normal).Foreground; + Color background = GetAttributeForRole (VisualRole.Normal).Background; // TODO: Add some kind of cache for this return new Scheme { - Normal = new Attribute (color, black), - HotNormal = new Attribute (color, black), - Focus = new Attribute (black, color), - HotFocus = new Attribute (black, color) + Normal = new Attribute (foreground, background), + HotNormal = new Attribute (foreground, background), + Focus = new Attribute (background, foreground), + HotFocus = new Attribute (background, foreground) }; } @@ -238,10 +216,10 @@ private void ShowCellContextMenu (Point? clickedCell, Mouse e) } PopoverMenu contextMenu = new ([ - new MenuItem (Strings.fdCtxNew, string.Empty, New), - new MenuItem (Strings.fdCtxRename, string.Empty, () => Rename (App)), - new MenuItem (Strings.fdCtxDelete, string.Empty, Delete) - ]); + new MenuItem (Strings.fdCtxNew, string.Empty, New), + new MenuItem (Strings.fdCtxRename, string.Empty, () => Rename (App)), + new MenuItem (Strings.fdCtxDelete, string.Empty, Delete) + ]); _tableView.SetSelection (clickedCell.Value.X, clickedCell.Value.Y, false); @@ -257,11 +235,11 @@ private void ShowHeaderContextMenu (int clickedCol, Mouse e) string sort = GetProposedNewSortOrder (clickedCol, out bool isAsc); PopoverMenu contextMenu = new ([ - new MenuItem (string.Format (Strings.fdCtxHide, StripArrows (_tableView.Table!.ColumnNames [clickedCol])), - string.Empty, - () => HideColumn (clickedCol)), - new MenuItem (StripArrows (sort), string.Empty, () => SortColumn (clickedCol, isAsc)) - ]); + new MenuItem (string.Format (Strings.fdCtxHide, StripArrows (_tableView.Table!.ColumnNames [clickedCol])), + string.Empty, + () => HideColumn (clickedCol)), + new MenuItem (StripArrows (sort), string.Empty, () => SortColumn (clickedCol, isAsc)) + ]); // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused // and the context menu is disposed when it is closed. @@ -307,9 +285,10 @@ private bool TableView_KeyDown (Key keyEvent) } } - private void TableView_SelectedCellChanged (object? sender, SelectedCellChangedEventArgs obj) + // private void TableViewOnActivated (object? sender, EventArgs e) + private void TableViewOnValueChanged (object? sender, ValueChangedEventArgs e) { - if (!_tableView.HasFocus || obj.NewRow == -1 || obj.Table.Rows == 0) + if (!_tableView.HasFocus || _tableView.Value is null || _tableView.Table?.Rows == 0) { return; } @@ -319,7 +298,7 @@ private void TableView_SelectedCellChanged (object? sender, SelectedCellChangedE return; } - FileSystemInfoStats stats = RowToStats (obj.NewRow); + FileSystemInfoStats stats = RowToStats (_tableView.Value.Cursor.Y); IFileSystemInfo? dest = stats.IsParent ? State!.Directory : stats.FileSystemInfo; diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.cs index e30f3bd614..aaa0a9a2f7 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.cs @@ -23,20 +23,28 @@ public partial class FileDialog : Dialog, IDesignable /// Locking object for ensuring only a single executes at once. internal readonly object _onlyOneSearchLock = new (); + private readonly IFileSystem? _fileSystem; + private readonly Button _btnBack; - private readonly Button _btnCancel; + + /// + /// Gets the cancel button for the dialog. This is useful for checking if the user canceled the dialog by comparing + /// the to the index of this button in the array. + /// + public Button CancelButton { get; } + private readonly Button _btnForward; private readonly Button _btnOk; private readonly Button _btnUp; - private readonly Button _btnTreeToggle; - private readonly IFileSystem? _fileSystem; private readonly FileDialogHistory _history; private readonly SpinnerView _spinnerView; private readonly View _tableViewContainer; private readonly TableView _tableView; private readonly TextField _tbFind; private readonly TextField _tbPath; + private readonly Button _btnTreeToggle; private readonly TreeView _treeView; + private Dictionary _treeRoots = new (); private DropDownList? _typeFilterDropDown; private int _currentSortColumn; private bool _currentSortIsAsc = true; @@ -44,7 +52,6 @@ public partial class FileDialog : Dialog, IDesignable private string? _feedback; private bool _pushingState; - private Dictionary _treeRoots = new (); /// Initializes a new instance of the class. public FileDialog () : this (new FileSystem ()) { } @@ -53,31 +60,21 @@ public FileDialog () : this (new FileSystem ()) { } /// This overload is mainly useful for testing. internal FileDialog (IFileSystem? fileSystem) { - // Scrollbars are disabled by default (VisibilityMode.Manual and Visible = false) - // No need to explicitly set them + Height = Dim.Percent (80); + Width = Dim.Percent (80); _fileSystem = fileSystem; Style = new FileDialogStyle (fileSystem); - ButtonAlignment = Alignment.End; ButtonAlignmentModes = AlignmentModes.IgnoreFirstOrLast; // Ensure we get Accept for any subviews; esp TreeView CommandsToBubbleUp = [Command.Accept]; - _btnCancel = new Button { Text = Strings.btnCancel }; + CancelButton = new Button { Text = Strings.btnCancel }; _btnOk = new Button { Text = Style.OkButtonText }; - // Tree toggle button - Goes in Dialog Button Area - _btnTreeToggle = new Button { NoPadding = true }; - - _btnTreeToggle.Accepting += (_, e) => - { - e.Handled = true; - ToggleTreeVisibility (); - }; - _btnUp = new Button { X = 0, Y = 1, NoPadding = true }; _btnUp.Text = GetUpButtonText (); @@ -108,7 +105,7 @@ internal FileDialog (IFileSystem? fileSystem) _tbPath = new TextField { // This sets the default width of the FileDialog as it is the widest subview - Width = Dim.Fill (0, 75) + Width = Dim.Fill () }; _tbPath.KeyDown += (_, k) => @@ -126,17 +123,27 @@ internal FileDialog (IFileSystem? fileSystem) // Create table view container (right pane) _tableViewContainer = new View { - X = 0, + X = -1, Y = Pos.Bottom (_btnBack), Width = Dim.Fill (), - Height = Dim.Fill (0, 15), + Height = Dim.Fill (), Arrangement = ViewArrangement.LeftResizable, BorderStyle = LineStyle.Dashed, SuperViewRendersLineCanvas = true, + TabStop = TabBehavior.TabStop, CanFocus = true, Id = "_tableViewContainer" }; + // Tree toggle button - Goes in Dialog Button Area + _btnTreeToggle = new Button { NoPadding = true }; + + _btnTreeToggle.Accepting += (_, e) => + { + e.Handled = true; + ToggleTreeVisibility (); + }; + // Create tree view container (left pane) _treeView = new TreeView { @@ -144,13 +151,23 @@ internal FileDialog (IFileSystem? fileSystem) Y = Pos.Bottom (_btnBack), Width = Dim.Fill (30, _tableViewContainer), Height = Dim.Height (_tableViewContainer), - Visible = false + Visible = true }; - _tableView = new TableView { Width = Dim.Fill (), Height = Dim.Fill (1), FullRowSelect = true, Id = "_tableView" }; + var fileDialogTreeBuilder = new FileSystemTreeBuilder { IncludeFiles = false }; + _treeView.TreeBuilder = fileDialogTreeBuilder; + _treeView.AspectGetter = AspectGetter; + Style.TreeStyle = _treeView.Style; + + _treeView.SelectionChanged += TreeView_SelectionChanged; + _treeView.KeystrokeNavigator.Matcher = new FileSystemCollectionNavigationMatcher (); + + _tableView = new TableView { Width = Dim.Fill (), Height = Dim.Fill (_tbFind!) - 1, FullRowSelect = true, Id = "_tableView" }; _tableView.CollectionNavigator = new FileDialogCollectionNavigator (this, _tableView); _tableView.KeyBindings.ReplaceCommands (Key.Space, Command.Toggle); _tableView.Activating += OnTableViewActivating; + _tableView.ViewportSettings |= ViewportSettingsFlags.HasScrollBars; + Style.TableStyle = _tableView.Style; ColumnStyle nameStyle = Style.TableStyle.GetOrCreateColumnStyle (0); @@ -169,34 +186,22 @@ internal FileDialog (IFileSystem? fileSystem) typeStyle.MinWidth = 6; typeStyle.ColorGetter = ColorGetter; - var fileDialogTreeBuilder = new FileSystemTreeBuilder (); - _treeView.TreeBuilder = fileDialogTreeBuilder; - _treeView.AspectGetter = AspectGetter; - Style.TreeStyle = _treeView.Style; - - _treeView.SelectionChanged += TreeView_SelectionChanged; - _treeView.KeystrokeNavigator.Matcher = new FileSystemCollectionNavigationMatcher (); - _tableViewContainer.Add (_tableView); - _tableView.Style.ShowHorizontalHeaderOverline = true; + _tableView.Style.ShowHorizontalHeaderOverline = false; _tableView.Style.ShowVerticalCellLines = true; - _tableView.Style.ShowVerticalHeaderLines = true; + _tableView.Style.ShowVerticalHeaderLines = false; _tableView.Style.AlwaysShowHeaders = true; - _tableView.Style.ShowHorizontalHeaderUnderline = true; - - _history = new FileDialogHistory (this); - - _tbPath.TextChanged += (_, _) => PathChanged (); - - _tableView.CellActivated += CellActivate; + _tableView.Style.ShowHorizontalHeaderUnderline = false; + _tableView.Style.ShowHorizontalBottomLine = false; + _tableView.Accepted += TableViewOnAccepted; _tableView.KeyDown += (_, k) => k.Handled = TableView_KeyDown (k); - _tableView.SelectedCellChanged += TableView_SelectedCellChanged; + _tableView.ValueChanged += TableViewOnValueChanged; _tableView.KeyBindings.ReplaceCommands (Key.Home, Command.Start); _tableView.KeyBindings.ReplaceCommands (Key.End, Command.End); _tableView.KeyBindings.ReplaceCommands (Key.Home.WithShift, Command.StartExtend); - _tableView.KeyBindings.ReplaceCommands (Key.End.WithShift, Command.EndExtend); + _tableView.KeyBindings.ReplaceCommands (Key.End.WithShift, Command.EndExtend); _history = new FileDialogHistory (this); // Changing the key-bindings of a View is not allowed, however, // by default, Runnable doesn't bind to Command.Context, so @@ -205,12 +210,19 @@ internal FileDialog (IFileSystem? fileSystem) _tableView.KeyBindings.Add (Key.Space.WithCtrl, Command.Context); _tableView.MouseBindings.Add (MouseFlags.RightButtonClicked, Command.Context); - _tbFind = new TextField { X = 0, Width = Dim.Width (_tableView), Y = Pos.Bottom (_tableView), Id = "_tbFind" }; + _tbPath.TextChanged += (_, _) => PathChanged (); + + _tbFind = new TextField { X = 1, Width = Dim.Width (_tableView) - 1, Y = Pos.AnchorEnd (), Id = "_tbFind" }; _spinnerView = new SpinnerView { // The spinner view is positioned over the last column of _tbFind - X = Pos.Right (_tbFind) - 1, Y = Pos.Top (_tbFind), Visible = false + X = Pos.Right (_tbFind) - 8, + Y = Pos.Top (_tbFind), + Width = Dim.Auto (), + Visible = false, + Style = new SpinnerStyle.Aesthetic (), + Arrangement = ViewArrangement.Overlapped }; _tbFind.TextChanged += (_, _) => RestartSearch (); @@ -228,13 +240,14 @@ internal FileDialog (IFileSystem? fileSystem) o.Handled = true; } }; + AllowsMultipleSelection = false; UpdateNavigationVisibility (); // Add the toggle along with OK/Cancel so they align as a group AddButton (_btnTreeToggle); - AddButton (_btnCancel); + AddButton (CancelButton); AddButton (_btnOk); Add (_tbPath); @@ -242,12 +255,13 @@ internal FileDialog (IFileSystem? fileSystem) Add (_btnBack); Add (_btnForward); Add (_treeView); - Add (_tableViewContainer); - _tableViewContainer.Add (_tbFind); - _tableViewContainer.Add (_spinnerView); // Default: Tree hidden and splitter hidden SetTreeVisible (false); + + Add (_tableViewContainer); + _tableViewContainer.Add (_tbFind); + _tableViewContainer.Add (_spinnerView); } /// @@ -313,6 +327,17 @@ public string Path { _tbPath.Text = value; _tbPath.MoveEnd (); + + //IDirectoryInfo dir = StringToDirectoryInfo (value); + + //StringComparison comparison = OperatingSystem.IsWindows () ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + //if (_treeView.ExpandParents (dir, (left, right) => string.Equals (left.FullName, right.FullName, comparison), out IFileSystemInfo? matched) + // && matched is { }) + //{ + // // _treeView.EnsureVisible (matched); + // // _treeView.SelectedObject = matched; + //} } } @@ -335,6 +360,7 @@ public string Path /// Event fired when user attempts to confirm a selection (or multi selection). Allows you to cancel the selection /// or undertake alternative behavior e.g. open a dialog "File already exists, Overwrite? yes/no". /// + // TODO: Refactor to use CWP public event EventHandler? FilesSelected; @@ -352,7 +378,7 @@ protected override void OnIsRunningChanged (bool newIsRunning) // May have been updated after instance was constructed _btnOk.Text = Style.OkButtonText; - _btnCancel.Text = Style.CancelButtonText; + CancelButton.Text = Style.CancelButtonText; _btnUp.Text = GetUpButtonText (); _btnBack.Text = GetBackButtonText (); _btnForward.Text = GetForwardButtonText (); @@ -367,7 +393,6 @@ protected override void OnIsRunningChanged (bool newIsRunning) _treeRoots = Style.TreeRootGetter (); Style.IconProvider.IsOpenGetter = _treeView.IsExpanded; - _treeView.AddObjects (_treeRoots.Keys); // if filtering on file type is configured then create the DropDownList and establish @@ -577,7 +602,7 @@ private void UpdateChildrenToFound () Parent._tbPath.InsertionPoint, this)); Parent.WriteStateToTableView (); - + Parent._spinnerView.AutoSpin = true; Parent._spinnerView.Visible = true; Parent._spinnerView.SetNeedsDraw (); }); diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogCollectionNavigator.cs b/Terminal.Gui/Views/FileDialogs/FileDialogCollectionNavigator.cs index 9e0590b833..23a80eb512 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogCollectionNavigator.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogCollectionNavigator.cs @@ -1,22 +1,13 @@ -#nullable disable namespace Terminal.Gui.Views; internal class FileDialogCollectionNavigator (FileDialog fileDialog, TableView tableView) : CollectionNavigatorBase { protected override object ElementAt (int idx) { - object val = FileDialogTableSource.GetRawColumnValue ( - tableView.SelectedColumn, - fileDialog.State?.Children [idx] - ); + object val = FileDialogTableSource.GetRawColumnValue (tableView.Value?.Cursor.X ?? 0, fileDialog.State?.Children [idx]); - if (val is null) - { - return string.Empty; - } - - return val.ToString ().Trim ('.'); + return val.ToString ()?.Trim ('.') ?? string.Empty; } - protected override int GetCollectionLength () { return fileDialog.State?.Children.Length ?? 0; } + protected override int GetCollectionLength () => fileDialog.State?.Children.Length ?? 0; } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogStyle.cs b/Terminal.Gui/Views/FileDialogs/FileDialogStyle.cs index 21f29794c7..4917d80596 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogStyle.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogStyle.cs @@ -1,20 +1,19 @@ -#nullable disable using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO.Abstractions; -using static System.Environment; namespace Terminal.Gui.Views; /// Stores style settings for . public class FileDialogStyle { - private readonly IFileSystem _fileSystem; + private readonly IFileSystem? _fileSystem; /// Creates a new instance of the class. - public FileDialogStyle (IFileSystem fileSystem) + public FileDialogStyle (IFileSystem? fileSystem) { _fileSystem = fileSystem; + TreeRootGetter = DefaultTreeRootGetter; DateFormat = CultureInfo.CurrentCulture.DateTimeFormat.SortableDateTimePattern; @@ -46,7 +45,7 @@ public FileDialogStyle (IFileSystem fileSystem) /// files via /// [ConfigurationProperty (Scope = typeof (SettingsScope))] - public static bool DefaultUseColors { get; set; } + public static bool DefaultUseColors { get; set; } = true; /// /// Gets or sets the default value to use for . This can be populated from .tui @@ -107,7 +106,7 @@ public FileDialogStyle (IFileSystem fileSystem) public string SizeColumnName { get; set; } = Strings.fdSize; /// Gets the style settings for the table of files (in currently selected directory). - public TableStyle TableStyle { get; internal set; } + public TableStyle? TableStyle { get; internal set; } /// /// Gets or Sets the method for getting the root tree objects that are displayed in the collapse-able tree in the @@ -118,7 +117,7 @@ public FileDialogStyle (IFileSystem fileSystem) public Func> TreeRootGetter { get; set; } /// Gets the style settings for the collapse-able directory/places tree - public TreeStyle TreeStyle { get; internal set; } + public TreeStyle? TreeStyle { get; internal set; } /// Gets or sets the header text displayed in the Type column of the files table. public string TypeColumnName { get; set; } = Strings.fdType; @@ -138,24 +137,31 @@ public FileDialogStyle (IFileSystem fileSystem) /// public string WrongFileTypeFeedback { get; set; } = Strings.fdWrongFileTypeFeedback; - /// - /// - /// Gets or sets a flag that determines behaviour when opening (double click/enter) or selecting a - /// directory in a . - /// - /// If (the default) then the is simply - /// updated to the new directory path. - /// If then any typed or previously selected file - /// name is preserved (e.g. "c:/hello.csv" when opening "temp" becomes "c:/temp/hello.csv"). - /// + /// + /// Gets or sets a flag that determines behaviour when opening (double click/enter) or selecting a + /// directory in a . + /// + /// + /// If (the default) then the is simply + /// updated to the new directory path. + /// + /// + /// If then any typed or previously selected file + /// name is preserved (e.g. "c:/hello.csv" when opening "temp" becomes "c:/temp/hello.csv"). + /// /// public bool PreserveFilenameOnDirectoryChanges { get; set; } - - [UnconditionalSuppressMessage ("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] + [UnconditionalSuppressMessage ("AOT", + "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", + Justification = "")] private Dictionary DefaultTreeRootGetter () { + if (_fileSystem is null) + { + return []; + } Dictionary roots = new (); try @@ -174,11 +180,11 @@ private Dictionary DefaultTreeRootGetter () try { - foreach (SpecialFolder special in Enum.GetValues (typeof (SpecialFolder)).Cast ()) + foreach (Environment.SpecialFolder special in Enum.GetValues (typeof (Environment.SpecialFolder)).Cast ()) { try { - string path = GetFolderPath (special); + string path = Environment.GetFolderPath (special); if (string.IsNullOrWhiteSpace (path)) { diff --git a/Terminal.Gui/Views/FileDialogs/OpenDialog.cs b/Terminal.Gui/Views/FileDialogs/OpenDialog.cs index b5af0c9c66..669635cb44 100644 --- a/Terminal.Gui/Views/FileDialogs/OpenDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/OpenDialog.cs @@ -36,7 +36,7 @@ public OpenDialog () { } /// Returns the selected files, or an empty list if nothing has been selected /// The file paths. public IReadOnlyList FilePaths => - ((IRunnable)this).Result is null || Result == 1 ? Enumerable.Empty ().ToList ().AsReadOnly () : + ((IRunnable)this).Result is null || Result == Buttons.IndexOf (CancelButton) ? Enumerable.Empty ().ToList ().AsReadOnly () : AllowsMultipleSelection ? MultiSelected : new ReadOnlyCollection ([Path]); /// diff --git a/Terminal.Gui/Views/FileDialogs/SaveDialog.cs b/Terminal.Gui/Views/FileDialogs/SaveDialog.cs index a43a8e2af0..1940895ed9 100644 --- a/Terminal.Gui/Views/FileDialogs/SaveDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/SaveDialog.cs @@ -1,5 +1,4 @@ -#nullable disable -// +// // FileDialog.cs: File system dialogs for open and save // // TODO: @@ -18,28 +17,24 @@ namespace Terminal.Gui.Views; /// /// /// To use, create an instance of , and pass it to -/// . This will run the dialog modally, and when this returns, +/// . This will run the dialog modally, and when +/// this returns, /// the property will contain the selected file name or null if the user canceled. /// /// public class SaveDialog : FileDialog { /// Initializes a new . - public SaveDialog () - { - Style.OkButtonText = Strings.btnSave; - } + public SaveDialog () => Style.OkButtonText = Strings.btnSave; + + internal SaveDialog (IFileSystem fileSystem) : base (fileSystem) => Style.OkButtonText = Strings.btnSave; - internal SaveDialog (IFileSystem fileSystem) : base (fileSystem) - { - Style.OkButtonText = Strings.btnSave; - } /// /// Gets the name of the file the user selected for saving, or null if the user canceled the /// . /// /// The name of the file. - public string FileName => ((IRunnable)this).Result is null || Result == 1 ? null : Path; + public string? FileName => (this as IRunnable).Result is null || Result == Buttons.IndexOf (CancelButton) ? null : Path; /// Gets the default title for the . /// @@ -58,6 +53,7 @@ protected override string GetDefaultTitle () titleParts.Add (Strings.fdFile); break; + case OpenMode.Directory: titleParts.Add (Strings.fdDirectory); diff --git a/Terminal.Gui/Views/HexView.cs b/Terminal.Gui/Views/HexView.cs index 0b782b6680..a3d1c94e8d 100644 --- a/Terminal.Gui/Views/HexView.cs +++ b/Terminal.Gui/Views/HexView.cs @@ -659,6 +659,11 @@ protected override bool OnKeyDownNotHandled (Key keyEvent) return false; } + if (keyEvent.IsCtrl) + { + return false; + } + if (_leftSideHasFocus) { int value; diff --git a/Terminal.Gui/Views/TableView/CellActivatedEventArgs.cs b/Terminal.Gui/Views/TableView/CellActivatedEventArgs.cs deleted file mode 100644 index 442b75b16b..0000000000 --- a/Terminal.Gui/Views/TableView/CellActivatedEventArgs.cs +++ /dev/null @@ -1,33 +0,0 @@ -#nullable enable -namespace Terminal.Gui.Views; - -// TOOD: SHould support Handled -/// Defines the event arguments for event -public class CellActivatedEventArgs : EventArgs -{ - /// Creates a new instance of arguments describing a cell being activated in - /// - /// - /// - public CellActivatedEventArgs (ITableSource t, int col, int row) - { - Table = t; - Col = col; - Row = row; - } - - /// The column index of the cell that is being activated - /// - public int Col { get; } - - /// The row index of the cell that is being activated - /// - public int Row { get; } - - /// - /// 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 - /// - /// - public ITableSource Table { get; } -} diff --git a/Terminal.Gui/Views/TableView/CellToggledEventArgs.cs b/Terminal.Gui/Views/TableView/CellToggledEventArgs.cs deleted file mode 100644 index 5cec50c4f9..0000000000 --- a/Terminal.Gui/Views/TableView/CellToggledEventArgs.cs +++ /dev/null @@ -1,35 +0,0 @@ -#nullable enable -namespace Terminal.Gui.Views; - -/// Event args for the event. -public class CellToggledEventArgs : EventArgs -{ - /// Creates a new instance of arguments describing a cell being toggled in - /// - /// - /// - public CellToggledEventArgs (ITableSource t, int col, int row) - { - Table = t; - Col = col; - Row = row; - } - - /// Gets or sets whether to cancel the processing of this event - public bool Cancel { get; set; } - - /// The column index of the cell that is being toggled - /// - public int Col { get; } - - /// The row index of the cell that is being toggled - /// - public int Row { get; } - - /// - /// 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 - /// - /// - public ITableSource Table { get; } -} diff --git a/Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapper.cs b/Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapper.cs index db36476553..41759e6dab 100644 --- a/Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapper.cs +++ b/Terminal.Gui/Views/TableView/CheckBoxTableSourceWrapper.cs @@ -21,15 +21,16 @@ public abstract class CheckBoxTableSourceWrapperBase : ITableSource /// registration. /// /// The original data source of the that you want to add checkboxes to. - public CheckBoxTableSourceWrapperBase (TableView tableView, ITableSource toWrap) + protected CheckBoxTableSourceWrapperBase (TableView tableView, ITableSource toWrap) { Wrapping = toWrap; _tableView = tableView; - _tableView.KeyBindings.ReplaceCommands (Key.Space, Command.Toggle); + _tableView.KeyBindings.ReplaceCommands (Key.Space, Command.ToggleExtend); + // Intercept ToggleExtend before it reaches the default handler + _tableView.KeyDown += HandleSpaceKeyDown; _tableView.Activating += TableView_Activating; - _tableView.CellToggled += TableView_CellToggled; } /// @@ -65,17 +66,17 @@ public CheckBoxTableSourceWrapperBase (TableView tableView, ITableSource toWrap) { get { - if (col == 0) + if (col != 0) { - if (UseRadioButtons) - { - return IsChecked (row) ? RadioCheckedRune : RadioUnCheckedRune; - } + return Wrapping [row, col - 1]; + } - return IsChecked (row) ? CheckedRune : UnCheckedRune; + if (UseRadioButtons) + { + return IsChecked (row) ? RadioCheckedRune : RadioUnCheckedRune; } - return Wrapping [row, col - 1]; + return IsChecked (row) ? CheckedRune : UnCheckedRune; } } @@ -111,7 +112,7 @@ public string [] ColumnNames /// protected abstract void ToggleAllRows (); - /// Flips the checked state of the given / + /// Flips the checked state of the given . /// protected abstract void ToggleRow (int row); @@ -122,18 +123,20 @@ public string [] ColumnNames /// protected abstract void ToggleRows (int [] range); - private void TableView_CellToggled (object? sender, CellToggledEventArgs e) + private void HandleSpaceKeyDown (object? sender, Key e) { - // Suppress default toggle behavior when using checkboxes - // and instead handle ourselves + if (e != Key.Space) + { + return; + } + int [] 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; + e.Handled = true; return; } @@ -146,7 +149,7 @@ private void TableView_CellToggled (object? sender, CellToggledEventArgs e) ToggleRows (range); } - e.Cancel = true; + e.Handled = true; _tableView.SetNeedsDraw (); } diff --git a/Terminal.Gui/Views/TableView/ColumnStyle.cs b/Terminal.Gui/Views/TableView/ColumnStyle.cs index 282c7b382a..da23327888 100644 --- a/Terminal.Gui/Views/TableView/ColumnStyle.cs +++ b/Terminal.Gui/Views/TableView/ColumnStyle.cs @@ -55,14 +55,12 @@ public class ColumnStyle /// public int MinWidth { get; set; } - private bool _visible = true; - /// /// Gets or Sets a value indicating whether the column should be visible to the user. This affects both whether it /// is rendered and whether it can be selected. Defaults to true. /// /// If is 0 then will always return false. - public bool Visible { get => MaxWidth >= 0 && _visible; set => _visible = value; } + public bool Visible { get => MaxWidth >= 0 && field; set; } = true; /// /// Returns the alignment for the cell based on and / diff --git a/Terminal.Gui/Views/TableView/ListTableSource.cs b/Terminal.Gui/Views/TableView/ListTableSource.cs index f339f61b7c..a1ce3bd452 100644 --- a/Terminal.Gui/Views/TableView/ListTableSource.cs +++ b/Terminal.Gui/Views/TableView/ListTableSource.cs @@ -41,13 +41,13 @@ public ListTableSource (IList list, TableView tableView, ListColumnStyle style) tableView.DrawingContent += TableView_DrawContent; } - /// + /// Creates a new instance with default . public ListTableSource (IList list, TableView tableView) : this (list, tableView, new ListColumnStyle ()) { } - /// The number of items in the IList source + /// The number of items in the source. public int Count => List.Count; - /// The data table this source wraps. + /// The this source wraps. public DataTable DataTable { get; private set; } /// @@ -153,7 +153,7 @@ private int CalculateMaxLength () return maxLength; } - /// Creates a DataTable from an IList to display in a + /// Creates a from an to display in a . private DataTable CreateTable (int cols = 1) { var table = new DataTable (); @@ -189,7 +189,7 @@ private void TableView_DrawContent (object? sender, DrawEventArgs e) } _lastBounds = _tableView.Viewport; - _lastMinCellWidth = _tableView.MaxCellWidth; + _lastMinCellWidth = _tableView.MinCellWidth; _lastMaxCellWidth = _tableView.MaxCellWidth; _lastStyle = Style; _lastList = List; diff --git a/Terminal.Gui/Views/TableView/SelectedCellChangedEventArgs.cs b/Terminal.Gui/Views/TableView/SelectedCellChangedEventArgs.cs deleted file mode 100644 index eb3e3815cd..0000000000 --- a/Terminal.Gui/Views/TableView/SelectedCellChangedEventArgs.cs +++ /dev/null @@ -1,52 +0,0 @@ -#nullable enable -namespace Terminal.Gui.Views; - -/// Defines the event arguments for -public class SelectedCellChangedEventArgs : EventArgs -{ - /// - /// Creates a new instance of arguments describing a change in selected cell in a - /// - /// - /// - /// - /// - /// - public SelectedCellChangedEventArgs (ITableSource t, int oldCol, int newCol, int oldRow, int newRow) - { - Table = t; - OldCol = oldCol; - NewCol = newCol; - OldRow = oldRow; - NewRow = newRow; - } - - /// The newly selected column index. - /// - public int NewCol { get; } - - /// The newly selected row index. - /// - public int NewRow { get; } - - /// - /// The previous selected column index. May be invalid e.g. when the selection has been changed as a result of - /// replacing the existing Table with a smaller one - /// - /// - public int OldCol { get; } - - /// - /// The previous selected row index. May be invalid e.g. when the selection has been changed as a result of - /// deleting rows from the table - /// - /// - public int OldRow { get; } - - /// - /// 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 - /// - /// - public ITableSource Table { get; } -} diff --git a/Terminal.Gui/Views/TableView/TableSelection.cs b/Terminal.Gui/Views/TableView/TableSelection.cs index 317fa11a66..3f479405f0 100644 --- a/Terminal.Gui/Views/TableView/TableSelection.cs +++ b/Terminal.Gui/Views/TableView/TableSelection.cs @@ -1,29 +1,81 @@ -#nullable enable -namespace Terminal.Gui.Views; +namespace Terminal.Gui.Views; -/// Describes a selected region of the table -public class TableSelection +/// +/// Represents the complete selection state of a : the cursor position and all +/// extended selection regions. Used as the T in . +/// +/// +/// A (as the value of ) means +/// "no selection" — either no is assigned or the selection was explicitly cleared. +/// A non-null always has a non-null . +/// +public class TableSelection : IEquatable { - /// Creates a new selected area starting at the origin corner and covering the provided rectangular area - /// - /// - public TableSelection (Point origin, Rectangle rect) + /// Creates a new with the specified cursor and regions. + /// The cursor cell position (navigation anchor). Must not be . + /// All extended selection regions (may be empty for cursor-only selection). + public TableSelection (Point cursor, IReadOnlyList? regions) { - Origin = origin; - Rectangle = rect; + Cursor = cursor; + Regions = regions ?? []; } - /// - /// True if the selection was made through and therefore should persist even - /// through keyboard navigation. - /// - public bool IsToggled { get; set; } + /// Creates a cursor-only with no extended regions. + /// The cursor cell position. + public TableSelection (Point cursor) : this (cursor, []) { } - /// Corner of the where selection began - /// - public Point Origin { get; set; } + /// The cursor cell used for navigation. Always non-null on a non-null . + public Point Cursor { get; } - /// Area selected - /// - public Rectangle Rectangle { get; set; } + /// All extended selection regions. May be empty if only the cursor cell is selected. + public IReadOnlyList Regions { get; } + + /// Returns if the given cell is within any of the . + public bool Contains (int col, int row) => Regions.Any (t => t.Rectangle.Contains (col, row)); + + /// + public bool Equals (TableSelection? other) + { + if (other is null) + { + return false; + } + + if (Cursor != other.Cursor) + { + return false; + } + + if (Regions.Count != other.Regions.Count) + { + return false; + } + + for (var i = 0; i < Regions.Count; i++) + { + if (!Regions [i].Equals (other.Regions [i])) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals (object? obj) => Equals (obj as TableSelection); + + /// + public override int GetHashCode () + { + HashCode hash = new (); + hash.Add (Cursor); + + foreach (TableSelectionRegion region in Regions) + { + hash.Add (region); + } + + return hash.ToHashCode (); + } } diff --git a/Terminal.Gui/Views/TableView/TableSelectionRegion.cs b/Terminal.Gui/Views/TableView/TableSelectionRegion.cs new file mode 100644 index 0000000000..556060e7e4 --- /dev/null +++ b/Terminal.Gui/Views/TableView/TableSelectionRegion.cs @@ -0,0 +1,45 @@ +namespace Terminal.Gui.Views; + +/// Describes a single contiguous rectangular selection region within a . +public class TableSelectionRegion : IEquatable +{ + /// Creates a new selected area starting at the origin corner and covering the provided rectangular area. + /// The corner where the selection began. + /// The rectangular area of the selection. + public TableSelectionRegion (Point origin, Rectangle rect) + { + Origin = origin; + Rectangle = rect; + } + + /// + /// if the selection was made through (e.g. Ctrl+Click) + /// and therefore should persist even through keyboard navigation. + /// + public bool IsExtended { get; init; } + + /// Corner of the where selection began. + public Point Origin { get; init; } + + /// Area selected. + public Rectangle Rectangle { get; init; } + + /// + public bool Equals (TableSelectionRegion? other) + { + if (other is null) + { + return false; + } + + return Origin == other.Origin + && Rectangle == other.Rectangle + && IsExtended == other.IsExtended; + } + + /// + public override bool Equals (object? obj) => Equals (obj as TableSelectionRegion); + + /// + public override int GetHashCode () => HashCode.Combine (Origin, Rectangle, IsExtended); +} diff --git a/Terminal.Gui/Views/TableView/TableStyle.cs b/Terminal.Gui/Views/TableView/TableStyle.cs index d71fbdb7d8..d05ebaf71c 100644 --- a/Terminal.Gui/Views/TableView/TableStyle.cs +++ b/Terminal.Gui/Views/TableView/TableStyle.cs @@ -22,25 +22,23 @@ public class TableStyle public Dictionary ColumnStyles { get; set; } = new (); /// - /// Determines rendering when the last column in the table is visible, but it's content or + /// Determines rendering when the last column in the table is visible, but its content or /// is less than the remaining space in the control. True (the default) will expand /// the column to fill the remaining bounds of the control. False will draw a column ending line and leave a blank /// column that cannot be selected in the remaining space. /// - /// public bool ExpandLastColumn { get; set; } = true; /// /// True to invert the colors of the first symbol of the selected cell in the . This gives - /// the appearance of a cursor for when the doesnt otherwise show this + /// the appearance of a cursor for when the doesn't otherwise show this. /// public bool InvertSelectedCellFirstCharacter { get; set; } /// - /// Delegate for coloring specific rows in a different color. For cell color - /// + /// Delegate for coloring specific rows in a different color. For cell color see + /// . /// - /// public RowColorGetterDelegate? RowColorGetter { get; set; } /// diff --git a/Terminal.Gui/Views/TableView/TableView.CellMapping.cs b/Terminal.Gui/Views/TableView/TableView.CellMapping.cs index afe982f7ed..6a41ad4503 100644 --- a/Terminal.Gui/Views/TableView/TableView.CellMapping.cs +++ b/Terminal.Gui/Views/TableView/TableView.CellMapping.cs @@ -17,7 +17,7 @@ public partial class TableView public Point? ScreenToCell (int clientX, int clientY) => ScreenToCell (clientX, clientY, out _, out _); /// - /// . Returns the column and row of that corresponds to a given point on the screen (relative + /// Returns the column and row of that corresponds to a given point on the screen (relative /// to the control client area). Returns null if the point is in the header, no table is loaded or outside the control /// bounds. /// diff --git a/Terminal.Gui/Views/TableView/TableView.Content.cs b/Terminal.Gui/Views/TableView/TableView.Content.cs new file mode 100644 index 0000000000..93bd81176f --- /dev/null +++ b/Terminal.Gui/Views/TableView/TableView.Content.cs @@ -0,0 +1,270 @@ +namespace Terminal.Gui.Views; + +public partial class TableView +{ + /// + /// Gets or sets whether all rows should be used when calculating content size. When , + /// only visible rows are used for column width calculations. + /// + public bool UseAllRowsForContentCalculation + { + get; + set + { + field = value; + RefreshContentSize (); + } + } + + private ColumnToRender []? _columnsToRenderCache; + + /// + /// Horizontal scroll offset. The index of the first column in to display when rendering + /// the view. + /// + /// This property allows very wide tables to be rendered with horizontal scrolling + public int ColumnOffset + { + get => _columnsToRenderCache?.Count (c => c.X + c.Width <= Viewport.X) ?? 0; + set + { + if (value < 0) + { + value = 0; + } + + if (_columnsToRenderCache == null) + { + CalculateContentSize (); + } + + int cacheLength = _columnsToRenderCache?.Length ?? 0; + + if (cacheLength == 0) + { + // No visible columns — early return leaves Viewport.X unchanged + return; + } + + if (value >= cacheLength) + { + value = cacheLength - 1; + } + + int prev = ColumnOffset; + Viewport = Viewport with { X = _columnsToRenderCache! [value].X }; + + if (prev != ColumnOffset) + { + SetNeedsDraw (); + } + } + } + + /// + /// Vertical scroll offset. The index of the first row in to display in the first non header + /// line of the control when rendering the view. + /// + public int RowOffset + { + get => Style.AlwaysShowHeaders ? Viewport.Y : Math.Max (Viewport.Y - GetHeaderHeightIfAny (), 0); + set + { + int oldViewportY = Viewport.Y; + + Viewport = Viewport with { Y = value == 0 ? 0 : Style.AlwaysShowHeaders ? value : GetHeaderHeightIfAny () + value }; + + if (Viewport.Y != oldViewportY) + { + SetNeedsDraw (); + } + } + } + + /// + /// Recalculates and updates the content size based on the current state. + /// + /// + /// Call this method after making changes that affect the content's dimensions to ensure the + /// layout remains accurate. + /// Also call this if data in Table has changed. + /// + public void RefreshContentSize () => SetContentSize (CalculateContentSize ()); + + private bool _inCalculatingContentSize; + + /// + protected override void OnViewportChanged (DrawEventArgs e) + { + base.OnViewportChanged (e); + + if (_inCalculatingContentSize) + { + return; + } + + if (e.OldViewport.Size != e.NewViewport.Size || (!UseAllRowsForContentCalculation && e.OldViewport.Y != e.NewViewport.Y)) + { + RefreshContentSize (); + } + } + + /// + /// Gets the maximum top-left coordinates to which the viewport can be scrolled within the content area. + /// + /// + /// The returned point represents the largest X and Y values for the viewport's position such + /// that the entire viewport remains within the bounds of the content. + /// + public Point MaxViewPort () + { + Size contentSize = GetContentSize (); + int maxX = Math.Max (contentSize.Width - Viewport.Width, 0); + int maxY = Math.Max (contentSize.Height - Viewport.Height, 0); + + return new Point (maxX, maxY); + } + + /// + /// Updates and where they are outside the bounds of the table + /// (by adjusting them to the nearest existing cell). Has no effect if has not been set. + /// + /// + /// Changes will not be immediately visible in the display until you call + /// + public void EnsureValidScrollOffsets () + { + if (TableIsNullOrInvisible ()) + { + return; + } + + Point maxViewPort = MaxViewPort (); + + if (Viewport.Y > maxViewPort.Y) + { + Viewport = Viewport with { Y = Math.Max (maxViewPort.Y, 0) }; + } + + if (Viewport.X > maxViewPort.X) + { + Viewport = Viewport with { X = Math.Max (maxViewPort.X, 0) }; + } + } + + private Size? CalculateContentSize () + { + var contentSize = new Size (0, 0); + _inCalculatingContentSize = true; + + try + { + int headerHeight = GetHeaderHeightIfAny (); + int headerHeightVisible = CurrentHeaderHeightVisible (); + contentSize.Height += headerHeight + Table?.Rows ?? 0; + + if (Style.ShowHorizontalBottomLine) + { + contentSize.Height++; + } + + // we assume that padding is 0 here + var padding = 0; + List columnsToRender = new (); + + if (Table != null) + { + List<(int colIdx, ColumnStyle? colStyle)> nonHiddenColumns = Enumerable.Range (0, Table.Columns) + .Select (c => (colIdx: c, colStyle: Style.GetColumnStyleIfAny (c))) + .Where (e => e.colStyle?.Visible != false) + .ToList (); + + int lastColIdx = nonHiddenColumns.Any () ? nonHiddenColumns.Last ().colIdx : -1; + + //right border + contentSize.Width += Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0; + + var startRow = 0; + int rowsToRender = Table.Rows; + + if (!UseAllRowsForContentCalculation) + { + startRow = Style.AlwaysShowHeaders ? Viewport.Y : Math.Max (Viewport.Y - headerHeight, 0); + + rowsToRender = Math.Min (Viewport.Height - headerHeightVisible, Table.Rows - startRow); + } + + // Calculate the content size based on the table's data + foreach ((int colIdx, ColumnStyle? colStyle) in nonHiddenColumns) + { + int maxContentSize = CalculateMaxCellWidth (colIdx, colStyle, startRow, rowsToRender) + padding; + int colWidth = maxContentSize + padding; + + if (MinCellWidth > 0 && colWidth < MinCellWidth + padding) + { + if (MinCellWidth > MaxCellWidth) + { + colWidth = MaxCellWidth + padding; + } + else + { + colWidth = MinCellWidth + padding; + } + } + + // ToDo: MinAcceptableWidth handling? + // if (colStyle is { MinAcceptableWidth: > 0 } + + bool isVeryLast = colIdx == lastColIdx; + + if (isVeryLast) + { + //remaining space for last column + int remainingSpace = Viewport.Width - contentSize.Width - (Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0); + + if (Style.ExpandLastColumn && colWidth < remainingSpace) + { + colWidth = remainingSpace; + } + } + + columnsToRender.Add (new ColumnToRender (colIdx, contentSize.Width, colWidth + 1, lastColIdx == colIdx)); + + contentSize.Width += colWidth; + + if (!isVeryLast) + { + // for separator symbols between columns + contentSize.Width += 1; + } + } + + // for left border + contentSize.Width += Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0; + } + else + { + contentSize.Width = 0; + } + + _columnsToRenderCache = columnsToRender.ToArray (); + + //check if it makes sense to scroll to left or up if the scrolled viewport is bigger than needed + if (Viewport.X + Viewport.Width > contentSize.Width) + { + Viewport = Viewport with { X = Math.Max (contentSize.Width - Viewport.Width, 0) }; + } + + if (Viewport.Y + Viewport.Height > contentSize.Height) + { + Viewport = Viewport with { Y = Math.Max (contentSize.Height - Viewport.Height, 0) }; + } + } + finally + { + _inCalculatingContentSize = false; + } + + return contentSize; + } +} diff --git a/Terminal.Gui/Views/TableView/TableView.Drawing.cs b/Terminal.Gui/Views/TableView/TableView.Drawing.cs index e82eafec83..321be8d6e1 100644 --- a/Terminal.Gui/Views/TableView/TableView.Drawing.cs +++ b/Terminal.Gui/Views/TableView/TableView.Drawing.cs @@ -1,16 +1,12 @@ namespace Terminal.Gui.Views; -/// -/// Displays and enables infinite scrolling through tabular data based on a . -/// See the TableView Deep Dive for more. -/// public partial class TableView { /// - /// calculates the current header height based on what is visible - /// This respects the viewport Y position and the AlwaysShowHeaders style + /// Calculates the current header height based on what is visible. + /// This respects the viewport Y position and the style. /// - /// height + /// Header height in rows. protected int CurrentHeaderHeightVisible () { if (!ShouldRenderHeaders ()) @@ -26,6 +22,29 @@ protected int CurrentHeaderHeightVisible () return Math.Min (Math.Max (GetHeaderHeight () - Viewport.Y, 0), Viewport.Height); } + /// Returns the amount of vertical space required to display the header + /// + internal int GetHeaderHeight () + { + int heightRequired = Style.ShowHeaders ? 1 : 0; + + if (Style.ShowHorizontalHeaderOverline) + { + heightRequired++; + } + + if (Style.ShowHorizontalHeaderUnderline) + { + heightRequired++; + } + + return heightRequired; + } + + /// Returns the amount of vertical space currently occupied by the header or 0 if it is not visible. + /// + internal int GetHeaderHeightIfAny () => ShouldRenderHeaders () ? GetHeaderHeight () : 0; + /// protected override bool OnDrawingContent (DrawContext? context) { @@ -45,9 +64,9 @@ protected override bool OnDrawingContent (DrawContext? context) { // Render something like: /* - ┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐ - │ArithmeticComparator│chi │Healthboard│Interpretation│Labnumber│ - └────────────────────┴──────────┴───────────┴──────────────┴─────────┘ + ┌────────────────────┬─────────┬────────────┬──────────────┬──────────┐ + │ArithmeticComparator│chi │Health board│Interpretation│Lab number│ + └────────────────────┴─────────┴────────────┴──────────────┴──────────┘ */ bool ShouldRenderNextHeaderLine () => @@ -123,14 +142,14 @@ bool ShouldRenderNextHeaderLine () => /// /// Override to provide custom multi-coloring to cells. Use methods like . - /// The cursor will already be in the correct position when rendering. You must render the full + /// The terminal cursor will already be in the correct position when rendering. You must render the full /// or the view will not look right. For simpler color provision use /// . For changing the content that is rendered use /// . /// - /// + /// The to use for the cell. /// - /// + /// True if this cell is the cursor cell (used for inversion). protected virtual void RenderCell (Attribute cellAttribute, string render, bool isPrimaryCell) { // If the cell is the selected col/row then draw the first rune in inverted colors @@ -211,7 +230,7 @@ private void RenderRune (int col, int row, Rune rune) private void RenderHeaderMidline (int row, int availableWidth, ColumnToRender [] columnsToRender) { // Renders something like: - // │ArithmeticComparator│chi │Healthboard│Interpretation│Labnumber│ + // │ArithmeticComparator│chi │Health board│Interpretation│Lab number│ ClearLine (row, Viewport.Width); // render start of line @@ -305,7 +324,6 @@ private void RenderHeaderUnderline (int row, int availableWidth, ColumnToRender // if the next column is the start of a header else if (columnsToRender.Any (r => r.X == c + 1)) { - /*TODO: is ┼ symbol in Driver?*/ rune = Style.ShowVerticalCellLines ? Glyphs.Cross : Glyphs.BottomTee; } else if (c == availableWidth - 1) @@ -379,8 +397,8 @@ private void RenderRow (int row, int rowToRender, ColumnToRender [] columnsToRen Attribute cellColor = isSelectedCell ? focused ? scheme.Focus : scheme.Active : Enabled ? scheme.Normal : scheme.Disabled; string render = TruncateOrPad (val, representation, current.Width, colStyle); - // While many cells can be selected (see MultiSelectedRegions) only one cell is the primary (drives navigation etc) - bool isPrimaryCell = current.Column == _selectedColumn && rowToRender == _selectedRow; + // While many cells can be selected (see MultiSelectedRegions) only one cell is the primary (drives navigation etc.) + bool isPrimaryCell = current.Column == _cursorColumn && rowToRender == _cursorRow; Move (current.X - Viewport.X, row); RenderCell (cellColor, render, isPrimaryCell); @@ -449,20 +467,190 @@ private void RenderSeparator (int col, int row, bool isHeader) } /// - /// This decides if we should render headers at all (no matter what the style settings are) - /// This may be a candidate to remove in future - /// (old implementation needed this logic to decide if the header is in current view (RowOffset)) + /// Determines whether headers should be rendered based on current viewport state. + /// + private bool ShouldRenderHeaders () => !TableIsNullOrInvisible (); + + + private void AddRuneAt (int col, int row, Rune ch) + { + Move (col, row); + AddRune (ch); + } + + /// + /// Returns the maximum of the name and the maximum length of data that will be rendered + /// starting at and rendering /// + /// ColumnIndex + /// + /// index of first row + /// Count of rows to inspect /// + private int CalculateMaxCellWidth (int col, ColumnStyle? colStyle, int startRow, int rowsToRender) + { + int spaceRequired = _table!.ColumnNames [col].GetColumns (); + + // if table has no rows + if (Table is not { Rows: > 0 }) + { + return spaceRequired; + } - // TODO: a candidate to remove - private bool ShouldRenderHeaders () + for (int i = startRow; i < startRow + rowsToRender; i++) + { + // expand required space if cell is bigger than the last biggest cell or header + spaceRequired = Math.Max (spaceRequired, GetRepresentation (Table [i, col], colStyle).GetColumns ()); + } + + // Don't require more space than the style allows + if (colStyle is { }) + { + // enforce maximum cell width based on style + if (spaceRequired > colStyle.MaxWidth) + { + spaceRequired = colStyle.MaxWidth; + } + + // enforce minimum cell width based on style + if (spaceRequired < colStyle.MinWidth) + { + spaceRequired = colStyle.MinWidth; + } + } + + // enforce maximum cell width based on global table style + if (spaceRequired > MaxCellWidth) + { + spaceRequired = MaxCellWidth; + } + + return spaceRequired; + } + + /// + /// Returns the value that should be rendered to best represent a strongly typed read + /// from + /// + /// + /// Optional style defining how to represent cell values + /// + private string GetRepresentation (object value, ColumnStyle? colStyle) + { + if (value == DBNull.Value) + { + return NullSymbol; + } + + return colStyle is { } ? colStyle.GetRepresentation (value) : value.ToString () ?? string.Empty; + } + + /// + /// Truncates or pads so that it occupies exactly + /// using the alignment specified in (or left + /// if no style is defined) + /// + /// The object in this cell of the + /// The string representation of + /// + /// Optional style indicating custom alignment for the cell + /// + private static string TruncateOrPad (object originalCellValue, string representation, int availableHorizontalSpace, ColumnStyle? colStyle) + { + if (string.IsNullOrEmpty (representation)) + { + return new string (' ', availableHorizontalSpace); + } + + // if value is too wide, truncate by grapheme cluster to avoid splitting surrogate pairs + if (representation.GetColumns () >= availableHorizontalSpace) + { + StringBuilder sb = new (); + int remaining = availableHorizontalSpace; + + foreach (string grapheme in GraphemeHelper.GetGraphemes (representation)) + { + int w = grapheme.GetColumns (); + + if (remaining - w <= 0) + { + break; + } + + sb.Append (grapheme); + remaining -= w; + } + + return sb.ToString (); + } + + // pad it out with spaces to the given alignment + int toPad = availableHorizontalSpace - (representation.GetColumns () + 1 /*leave 1 space for cell boundary*/); + + return (colStyle?.GetAlignment (originalCellValue) ?? Alignment.Start) switch + { + Alignment.Start => representation + new string (' ', toPad), + Alignment.End => new string (' ', toPad) + representation, + + // TODO: With single line cells, centered and justified are the same right? + Alignment.Center or Alignment.Fill => new string (' ', (int)Math.Floor (toPad / 2.0)) + + // round down + representation + + new string (' ', (int)Math.Ceiling (toPad / 2.0)), // round up + _ => representation + new string (' ', toPad) + }; + } + + /// + /// Returns the cells that shall be shown (all cells except the hidden ones) + /// + /// + private ColumnToRender [] NonHiddenCellInfos () { if (TableIsNullOrInvisible ()) { - return false; + return []; } - return true; + if (_columnsToRenderCache == null) + { + RefreshContentSize (); + } + + return _columnsToRenderCache ?? []; + } + + /// Clears a line of the console by filling it with spaces + /// + /// + private void ClearLine (int row, int width) + { + if (App?.Screen.Height == 0) + { + return; + } + + Move (0, row); + SetAttribute (GetAttributeForRole (VisualRole.Normal)); + AddStr (new string (' ', width)); + } + + /// Describes a desire to render a column at a given horizontal position in the UI + internal class ColumnToRender (int col, int x, int width, bool isVeryLast) + { + /// The column to render + public int Column { get; set; } = col; + + /// True if this column is the very last column in the (not just the last visible column) + public bool IsVeryLast { get; } = isVeryLast; + + /// + /// The width that the column should occupy as calculated by . Note + /// that this includes space for padding i.e. the separator between columns. + /// + public int Width { get; internal set; } = width; + + /// The horizontal position to begin rendering the column at + public int X { get; set; } = x; } } diff --git a/Terminal.Gui/Views/TableView/TableView.Mouse.cs b/Terminal.Gui/Views/TableView/TableView.Mouse.cs deleted file mode 100644 index 8837fc04ed..0000000000 --- a/Terminal.Gui/Views/TableView/TableView.Mouse.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace Terminal.Gui.Views; - -/// -/// Displays and enables infinite scrolling through tabular data based on a . -/// See the TableView Deep Dive for more. -/// -public partial class TableView -{ - /// - protected override bool OnMouseEvent (Mouse me) - { - if (!me.Flags.FastHasFlags (MouseFlags.LeftButtonClicked) - && !me.Flags.FastHasFlags (MouseFlags.LeftButtonDoubleClicked) - && me.Flags != MouseFlags.WheeledDown - && me.Flags != MouseFlags.WheeledUp - && me.Flags != MouseFlags.WheeledLeft - && me.Flags != MouseFlags.WheeledRight) - { - return false; - } - - if (!HasFocus && CanFocus) - { - SetFocus (); - } - - if (TableIsNullOrInvisible ()) - { - return false; - } - - // Scroll wheel flags - switch (me.Flags) - { - case MouseFlags.WheeledDown: - Viewport = Viewport with { Y = Viewport.Y + 1 }; - EnsureValidScrollOffsets (); - - //SetNeedsDraw (); - return true; - - case MouseFlags.WheeledUp: - Viewport = Viewport with { Y = Viewport.Y - 1 }; - EnsureValidScrollOffsets (); - - //SetNeedsDraw (); - return true; - - case MouseFlags.WheeledRight: - Viewport = Viewport with { X = Viewport.X + 1 }; - EnsureValidScrollOffsets (); - - //SetNeedsDraw (); - return true; - - case MouseFlags.WheeledLeft: - Viewport = Viewport with { X = Viewport.X - 1 }; - EnsureValidScrollOffsets (); - - //SetNeedsDraw (); - return true; - } - - int boundsX = me.Position!.Value.X; - int boundsY = me.Position!.Value.Y; - - if (me.Flags.FastHasFlags (MouseFlags.LeftButtonClicked)) - { - Point? hit = ScreenToCell (boundsX, boundsY); - - if (hit is { }) - { - if (MultiSelect && HasControlOrAlt (me)) - { - UnionSelection (hit.Value.X, hit.Value.Y); - } - else - { - SetSelection (hit.Value.X, hit.Value.Y, me.Flags.FastHasFlags (MouseFlags.Shift)); - } - - Update (); - } - } - - // Double-clicking a cell activates - if (me.Flags != MouseFlags.LeftButtonDoubleClicked) - { - return me.Handled; - } - - Point? clickedCell = ScreenToCell (boundsX, boundsY); - - return clickedCell is not { } ? me.Handled : OnCellActivated (new CellActivatedEventArgs (Table!, clickedCell.Value.X, clickedCell.Value.Y)); - } -} diff --git a/Terminal.Gui/Views/TableView/TableView.Navigation.cs b/Terminal.Gui/Views/TableView/TableView.Navigation.cs index 4f94cf4b6e..d2776a9dd4 100644 --- a/Terminal.Gui/Views/TableView/TableView.Navigation.cs +++ b/Terminal.Gui/Views/TableView/TableView.Navigation.cs @@ -3,76 +3,18 @@ namespace Terminal.Gui.Views; -/// -/// Displays and enables infinite scrolling through tabular data based on a . -/// See the TableView Deep Dive for more. -/// public partial class TableView { /// The default minimum cell width for public const int DEFAULT_MIN_ACCEPTABLE_WIDTH = 100; - private ITableSource? _table; - - /// The data table to render in the view. Setting this property automatically updates and redraws the control. - public ITableSource? Table - { - get => _table; - set - { - _table = value; - RefreshContentSize (); - Update (); - } - } - - /// - /// Gets or sets whether all rows should be used when calculating content size. When , - /// only visible rows are used for column width calculations. - /// - public bool UseAllRowsForContentCalculation + private bool? HandleRight (ICommandContext? ctx) { - get; - set - { - field = value; - RefreshContentSize (); - } - } - - /// - /// Recalculates and updates the content size based on the current state. - /// - /// - /// Call this method after making changes that affect the content's dimensions to ensure the - /// layout remains accurate. - /// Also call this if data in Table has changed. - /// - public void RefreshContentSize () => SetContentSize (CalculateContentSize ()); - - /// - protected override void OnViewportChanged (DrawEventArgs e) - { - base.OnViewportChanged (e); - - if (_inCalculatingContentSize) - { - return; - } - - if (e.OldViewport.Size != e.NewViewport.Size || (!UseAllRowsForContentCalculation && e.OldViewport.Y != e.NewViewport.Y)) - { - RefreshContentSize (); //mainly needed only for ExpandLastColumn?! - } - } - - private bool? HandleRight (ICommandContext? _) - { - int oldSelectedCol = SelectedColumn; + int oldCursorCol = _cursorColumn; int oldViewportX = Viewport.X; - bool result = ChangeSelectionByOffsetWithReturn (1, 0); + bool result = MoveCursorByOffsetWithReturn (1, 0, ctx); - if (oldSelectedCol != SelectedColumn || Viewport.X >= MaxViewPort ().X) + if (oldCursorCol != _cursorColumn || Viewport.X >= MaxViewPort ().X) { return result; } @@ -82,11 +24,11 @@ protected override void OnViewportChanged (DrawEventArgs e) return result; } - private bool? HandleUp (ICommandContext? _) + private bool? HandleUp (ICommandContext? ctx) { - if (SelectedRow != 0) + if (_cursorRow != 0) { - return ChangeSelectionByOffsetWithReturn (0, -1); + return MoveCursorByOffsetWithReturn (0, -1, ctx); } if (Viewport.Y <= 0) @@ -98,11 +40,11 @@ protected override void OnViewportChanged (DrawEventArgs e) return true; } - private bool? HandleDown (ICommandContext? _) + private bool? HandleDown (ICommandContext? ctx) { - if (Table == null || SelectedRow < Table.Rows - 1) + if (Table == null || _cursorRow < Table.Rows - 1) { - return ChangeSelectionByOffsetWithReturn (0, 1); + return MoveCursorByOffsetWithReturn (0, 1, ctx); } if (Viewport.Y >= GetContentHeight () - Viewport.Height) @@ -112,18 +54,18 @@ protected override void OnViewportChanged (DrawEventArgs e) Viewport = Viewport with { Y = Viewport.Y + 1 }; return true; - } - /// Moves the selection down by one page + /// Moves the cursor down by one page. /// true to extend the current selection (if any) instead of replacing - public void PageDown (bool extend) + /// The command context + public bool PageDown (bool extend, ICommandContext? ctx) { - int oldSelectedRow = SelectedRow; - ChangeSelectionByOffset (0, Viewport.Height /* - CurrentHeaderHeightVisible ()*/, extend); + int oldCursorRow = _cursorRow; + MoveCursorByOffset (0, Viewport.Height /* - CurrentHeaderHeightVisible ()*/, extend, ctx); //after scrolling the cells, also scroll to lower line - int remainingJump = Viewport.Height - (SelectedRow - oldSelectedRow); + int remainingJump = Viewport.Height - (_cursorRow - oldCursorRow); Point maxViewPort = MaxViewPort (); if (remainingJump > 0 && Viewport.Y < maxViewPort.Y) @@ -132,17 +74,20 @@ public void PageDown (bool extend) } Update (); + + return true; } - /// Moves the selection up by one page + /// Moves the cursor up by one page. /// true to extend the current selection (if any) instead of replacing - public void PageUp (bool extend) + /// The command context + public bool PageUp (bool extend, ICommandContext? ctx) { - int oldSelectedRow = SelectedRow; - ChangeSelectionByOffset (0, -Viewport.Height /* - CurrentHeaderHeightVisible ()*/, extend); + int oldCursorRow = _cursorRow; + MoveCursorByOffset (0, -Viewport.Height /* - CurrentHeaderHeightVisible ()*/, extend, ctx); //after scrolling the cells, also scroll to header - int remainingJump = Viewport.Height - (oldSelectedRow - SelectedRow); + int remainingJump = Viewport.Height - (oldCursorRow - _cursorRow); if (remainingJump > 0 && Viewport.Y > 0) { @@ -150,61 +95,13 @@ public void PageUp (bool extend) } Update (); - } - - /// - /// Moves or extends the selection to the final cell in the table (nX,nY). If is - /// enabled then selection instead moves to ( ,nY) i.e. no horizontal scrolling. - /// - /// true to extend the current selection (if any) instead of replacing - public void ChangeSelectionToEndOfTable (bool extend) - { - int finalColumn = Table!.Columns - 1; - SetSelection (FullRowSelect ? SelectedColumn : finalColumn, Table.Rows - 1, extend); - Update (); - } - - /// - /// Moves or extends the selection to the first cell in the table (0,0). If is enabled - /// then selection instead moves to ( ,0) i.e. no horizontal scrolling. - /// - /// true to extend the current selection (if any) instead of replacing - public void ChangeSelectionToStartOfTable (bool extend) - { - SetSelection (FullRowSelect ? SelectedColumn : 0, 0, extend); - Update (); - } - /// - /// Returns a new rectangle between the two points with positive width/height regardless of relative positioning - /// of the points. pt1 is always considered the point - /// - /// Origin point for the selection in X - /// Origin point for the selection in Y - /// End point for the selection in X - /// End point for the selection in Y - /// True if selection is result of - /// - private TableSelection CreateTableSelection (int pt1X, int pt1Y, int pt2X, int pt2Y, bool toggle = false) - { - int top = Math.Max (Math.Min (pt1Y, pt2Y), 0); - int bot = Math.Max (Math.Max (pt1Y, pt2Y), 0); - int left = Math.Max (Math.Min (pt1X, pt2X), 0); - int right = Math.Max (Math.Max (pt1X, pt2X), 0); - - // Rect class is inclusive of Top Left but exclusive of Bottom Right so extend by 1 - return new TableSelection (new Point (pt1X, pt1Y), new Rectangle (left, top, right - left + 1, bot - top + 1)) { IsToggled = toggle }; + return true; } - /// Returns a single point as a - /// - /// - /// - private TableSelection CreateTableSelection (int x, int y) => CreateTableSelection (x, y, x, y); - private bool CycleToNextTableEntryBeginningWith (Key key) { - int row = SelectedRow; + int row = _cursorRow; // There is a multi select going on and not just for the current row if (GetAllSelectedCells ().Any (c => c.Y != row)) @@ -212,16 +109,19 @@ private bool CycleToNextTableEntryBeginningWith (Key key) return false; } - int? match = CollectionNavigator.GetNextMatchingItem (row, (char)key); + // Pass null when there is no valid selection (row < 0), so the navigator starts from the beginning + int? rowForNavigator = row < 0 ? null : row; + int? match = CollectionNavigator.GetNextMatchingItem (rowForNavigator, (char)key); if (match == null) { return false; } - SelectedRow = match.Value; + _cursorRow = match.Value; + CommitSelectionState (); EnsureValidSelection (); - EnsureSelectedCellIsVisible (); + EnsureCursorIsVisible (); SetNeedsDraw (); return true; @@ -234,68 +134,4 @@ private bool CycleToNextTableEntryBeginningWith (Key key) /// private bool TableIsNullOrInvisible () => Table is not { Columns: > 0 } || Enumerable.Range (0, Table.Columns).All (c => Style.GetColumnStyleIfAny (c)?.Visible is false); - - /// - /// Generates a new demo with the given number of (min 5) and - /// - /// - /// - /// - /// - public static DataTable BuildDemoDataTable (int cols, int rows) - { - var dt = new DataTable (); - var explicitCols = 6; - dt.Columns.Add (new DataColumn ("StrCol", typeof (string))); - dt.Columns.Add (new DataColumn ("DateCol", typeof (DateTime))); - dt.Columns.Add (new DataColumn ("IntCol", typeof (int))); - dt.Columns.Add (new DataColumn ("DoubleCol", typeof (double))); - dt.Columns.Add (new DataColumn ("NullsCol", typeof (string))); - dt.Columns.Add (new DataColumn ("Unicode", typeof (string))); - dt.Columns.Add (new DataColumn ("VarLength", typeof (string))); //ColIdx = 6 - - for (var i = 0; i < cols - explicitCols; i++) - { - dt.Columns.Add ("Column" + (i + explicitCols)); - } - - var r = new Random (100); - - string numberText = NumberText (rows); - - for (var i = 0; i < rows; i++) - { - List row = - [ - $"Demo text in row {i}", - new DateTime (2000 + i, 12, 25), - r.Next (i), - r.NextDouble () * i - 0.5 /*add some negatives to demo styles*/, - DBNull.Value, - "Les Mise" + char.ConvertFromUtf32 (int.Parse ("0301", NumberStyles.HexNumber)) + "rables", - numberText [..i] - ]; - - for (var j = 0; j < cols - explicitCols; j++) - { - row.Add ("SomeValue" + r.Next (100)); - } - - dt.Rows.Add (row.ToArray ()); - } - - return dt; - - static string NumberText (int len) - { - var result = string.Empty; - - for (var i = 1; i <= len; i++) - { - result += i % 10; - } - - return result; - } - } } diff --git a/Terminal.Gui/Views/TableView/TableView.Selection.cs b/Terminal.Gui/Views/TableView/TableView.Selection.cs index 9cccf18e81..0298b0787b 100644 --- a/Terminal.Gui/Views/TableView/TableView.Selection.cs +++ b/Terminal.Gui/Views/TableView/TableView.Selection.cs @@ -1,130 +1,123 @@ -using System.Data; - namespace Terminal.Gui.Views; -/// -/// Displays and enables infinite scrolling through tabular data based on a . -/// See the TableView Deep Dive for more. -/// public partial class TableView { - /// True to select the entire row at once. False to select individual cells. Defaults to false - public bool FullRowSelect { get; set; } + #region Cursor - /// True to allow regions to be selected - /// - public bool MultiSelect { get; set; } = true; + private int _cursorColumn = -1; + private int _cursorRow = -1; /// - /// When is enabled this property contain all rectangles of selected cells. Rectangles - /// describe column/rows selected in (not screen coordinates) + /// Moves the cursor by the provided offsets. Optionally starting a box selection (see ). /// - /// - public Stack MultiSelectedRegions { get; } = new (); + /// Offset in number of columns + /// Offset in number of rows + /// True to create a multi cell selection or adjust an existing one + /// The command context. + public bool MoveCursorByOffset (int offsetX, int offsetY, bool extendExistingSelection, ICommandContext? ctx) + { + SetSelection (_cursorColumn + offsetX, _cursorRow + offsetY, extendExistingSelection, ctx); + Update (); - private int _selectedColumn; + return true; + } - /// The index of in that the user has currently selected - public int SelectedColumn + /// Moves the cursor (or extends the selection) to the last cell in the current row. + /// true to extend the current selection (if any) instead of replacing + /// The command context + public bool MoveCursorToEndOfRow (bool extend, ICommandContext? ctx) { - get => _selectedColumn; - set + if (TableIsNullOrInvisible ()) { - int oldValue = _selectedColumn; + return false; + } - // try to prevent this being set to an out-of-bounds column - _selectedColumn = TableIsNullOrInvisible () ? 0 : Math.Min (Table!.Columns - 1, Math.Max (0, value)); + SetSelection (Table!.Columns - 1, _cursorRow, extend, ctx); + Update (); - if (oldValue != _selectedColumn) - { - RaiseSelectedCellChanged (new SelectedCellChangedEventArgs (Table!, oldValue, SelectedColumn, SelectedRow, SelectedRow)); - } - } + return true; } - private int _selectedRow; - - /// The index of in that the user has currently selected - public int SelectedRow + /// Moves the cursor (or extends the selection) to the first cell in the current row. + /// true to extend the current selection (if any) instead of replacing + /// The command context + public bool MoveCursorToStartOfRow (bool extend, ICommandContext? ctx) { - get => _selectedRow; - set + if (TableIsNullOrInvisible ()) { - int oldValue = _selectedRow; - _selectedRow = TableIsNullOrInvisible () ? 0 : Math.Min (Table!.Rows - 1, Math.Max (0, value)); - - if (oldValue != _selectedRow) - { - RaiseSelectedCellChanged (new SelectedCellChangedEventArgs (Table!, SelectedColumn, SelectedColumn, oldValue, _selectedRow)); - } + return false; } + + SetSelection (0, _cursorRow, extend, ctx); + Update (); + + return true; } /// - /// Private override of that returns true if the selection has - /// changed as a result of moving the selection. Used by key handling logic to determine whether e.g. - /// the cursor right resulted in a change or should be forwarded on to toggle logic handling. + /// Private override of that returns if the + /// changed as a result of moving the cursor. /// /// /// + /// The command context. /// - private bool ChangeSelectionByOffsetWithReturn (int offsetX, int offsetY) + private bool MoveCursorByOffsetWithReturn (int offsetX, int offsetY, ICommandContext? ctx) { - TableViewSelectionSnapshot oldSelection = GetSelectionSnapshot (); - SetSelection (SelectedColumn + offsetX, SelectedRow + offsetY, false); + TableSelection? oldValue = Value; + SetSelection (_cursorColumn + offsetX, _cursorRow + offsetY, false, ctx); Update (); - return !SelectionIsSame (oldSelection); - } - - private TableViewSelectionSnapshot GetSelectionSnapshot () => new (SelectedColumn, SelectedRow, MultiSelectedRegions.Select (s => s.Rectangle).ToArray ()); - - private bool SelectionIsSame (TableViewSelectionSnapshot oldSelection) - { - TableViewSelectionSnapshot newSelection = GetSelectionSnapshot (); - - return oldSelection.SelectedColumn == newSelection.SelectedColumn - && oldSelection.SelectedRow == newSelection.SelectedRow - && oldSelection.MultiSelection.SequenceEqual (newSelection.MultiSelection); + return !Equals (oldValue, Value); } /// - /// Moves the and by the provided offsets. Optionally - /// starting a box selection (see ) + /// Moves the cursor (or extends the selection) to the final cell in the table (nX,nY). If + /// is enabled then the cursor instead moves to (cursor.X, nY) — no horizontal scrolling. /// - /// Offset in number of columns - /// Offset in number of rows - /// True to create a multi cell selection or adjust an existing one - public void ChangeSelectionByOffset (int offsetX, int offsetY, bool extendExistingSelection) - { - SetSelection (SelectedColumn + offsetX, SelectedRow + offsetY, extendExistingSelection); - Update (); - } - - /// Moves or extends the selection to the last cell in the current row /// true to extend the current selection (if any) instead of replacing - public void ChangeSelectionToEndOfRow (bool extend) + /// The command context + public bool MoveCursorToEndOfTable (bool extend, ICommandContext? ctx) { - SetSelection (Table!.Columns - 1, SelectedRow, extend); + if (TableIsNullOrInvisible ()) + { + return false; + } + + int finalColumn = Table!.Columns - 1; + SetSelection (FullRowSelect ? _cursorColumn : finalColumn, Table.Rows - 1, extend, ctx); Update (); + + return true; } - /// Moves or extends the selection to the first cell in the current row + /// + /// Moves the cursor (or extends the selection) to the first cell in the table (0,0). If + /// is enabled then the cursor instead moves to (cursor.X, 0) — no horizontal scrolling. + /// /// true to extend the current selection (if any) instead of replacing - public void ChangeSelectionToStartOfRow (bool extend) + /// The command context + public bool MoveCursorToStartOfTable (bool extend, ICommandContext? ctx) { - SetSelection (0, SelectedRow, extend); + if (TableIsNullOrInvisible ()) + { + return false; + } + + SetSelection (FullRowSelect ? _cursorColumn : 0, 0, extend, ctx); Update (); + + return true; } /// - /// Updates scroll offsets to ensure that the selected cell is visible. Has no effect if has + /// Updates scroll offsets to ensure that the cursor cell is visible. Has no effect if has /// not been set. /// /// /// Changes will not be immediately visible in the display until you call /// - public void EnsureSelectedCellIsVisible () + public void EnsureCursorIsVisible () { if (Table is null || Table.Columns <= 0) { @@ -134,40 +127,29 @@ public void EnsureSelectedCellIsVisible () ColumnToRender [] cellInfos = NonHiddenCellInfos (); int headerHeight = GetHeaderHeightIfAny (); - ColumnToRender? selectedColToRender = cellInfos.FirstOrDefault (c => c.Column == SelectedColumn); + ColumnToRender? cursorColToRender = cellInfos.FirstOrDefault (c => c.Column == _cursorColumn); - if (SelectedColumn < 0 || selectedColToRender == null || SelectedRow < 0 || SelectedRow >= Table.Rows) + if (_cursorColumn < 0 || cursorColToRender == null || _cursorRow < 0 || _cursorRow >= Table.Rows) { return; } - int rowStart; - int rowEnd; - - if (Style.AlwaysShowHeaders) - { - rowStart = Viewport.Y; - rowEnd = Viewport.Y + Viewport.Height - headerHeight - 1; - } - else - { - rowStart = Math.Max (Viewport.Y - headerHeight, 0); - rowEnd = Viewport.Y + Viewport.Height - headerHeight - 1; - } + int rowStart = Style.AlwaysShowHeaders ? Viewport.Y : Math.Max (Viewport.Y - headerHeight, 0); + int rowEnd = Viewport.Y + Viewport.Height - headerHeight - 1; if (rowEnd < rowStart) { return; } - if (SelectedRow < rowStart) + if (_cursorRow < rowStart) { - Viewport = Viewport with { Y = Viewport.Y - (rowStart - SelectedRow) }; + Viewport = Viewport with { Y = Viewport.Y - (rowStart - _cursorRow) }; } - if (SelectedRow > rowEnd) + if (_cursorRow > rowEnd) { - Viewport = Viewport with { Y = Viewport.Y + (SelectedRow - rowEnd) }; + Viewport = Viewport with { Y = Viewport.Y + (_cursorRow - rowEnd) }; } //first column that is visible from start @@ -176,40 +158,70 @@ public void EnsureSelectedCellIsVisible () //last column that is visible (at least the start) ColumnToRender? colEnd = cellInfos.LastOrDefault (c => c.X < Viewport.Right); - if (colEnd is { } && SelectedColumn >= colEnd.Column) + if (colEnd is { } && _cursorColumn >= colEnd.Column) { if (Style.SmoothHorizontalScrolling) { - //bring selected col into view - Viewport = Viewport with { X = Math.Min (selectedColToRender.X, selectedColToRender.X + selectedColToRender.Width - Viewport.Width) }; + //bring cursor col into view + Viewport = Viewport with { X = Math.Min (cursorColToRender.X, cursorColToRender.X + cursorColToRender.Width - Viewport.Width) }; } else { - //bring selected col to start of viewport - Viewport = Viewport with { X = selectedColToRender.X }; + //bring cursor col to start of viewport + Viewport = Viewport with { X = cursorColToRender.X }; } } - if (colStart is { } && SelectedColumn >= colStart.Column) + if (colStart is { } && _cursorColumn >= colStart.Column) { return; } if (Style.SmoothHorizontalScrolling) { - //bring selected col into view - Viewport = Viewport with { X = selectedColToRender.X - 1 }; + //bring cursor col into view + Viewport = Viewport with { X = cursorColToRender.X - 1 }; } else { - //bring selected col to end of viewport - Viewport = Viewport with { X = selectedColToRender.X - Math.Max (Viewport.Width - selectedColToRender.Width, 0) }; + //bring cursor col to end of viewport + Viewport = Viewport with { X = cursorColToRender.X - Math.Max (Viewport.Width - cursorColToRender.Width, 0) }; + } + } + + /// + /// Syncs the internal cursor and from the current . + /// + private void SyncCursorFromValue () + { + if (_value is null) + { + _cursorColumn = -1; + _cursorRow = -1; + MultiSelectedRegions.Clear (); + + return; + } + + _cursorColumn = _value.Cursor.X; + _cursorRow = _value.Cursor.Y; + + // Rebuild MultiSelectedRegions from Value.Regions (deep copy) + MultiSelectedRegions.Clear (); + + foreach (TableSelectionRegion region in _value.Regions) + { + MultiSelectedRegions.Push (new TableSelectionRegion (region.Origin, region.Rectangle) { IsExtended = region.IsExtended }); } } + #endregion Cursor + + #region Selection + /// - /// Updates , and where - /// they are outside the bounds of the table (by adjusting them to the nearest existing cell). Has no effect if + /// Updates the cursor position, the , and to ensure they are + /// within the bounds of the table (by adjusting them to the nearest existing cell). Has no effect if /// has not been set. /// /// @@ -225,16 +237,16 @@ public void EnsureValidSelection () return; } - SelectedColumn = Math.Max (Math.Min (SelectedColumn, Table!.Columns - 1), 0); - SelectedRow = Math.Max (Math.Min (SelectedRow, Table.Rows - 1), 0); + _cursorColumn = Math.Max (Math.Min (_cursorColumn, Table!.Columns - 1), 0); + _cursorRow = Math.Max (Math.Min (_cursorRow, Table.Rows - 1), 0); - // If SelectedColumn is invisible move it to a visible one - SelectedColumn = GetNearestVisibleColumn (SelectedColumn, true, true); - IEnumerable oldRegions = MultiSelectedRegions.ToArray ().Reverse (); + // If _cursorColumn is invisible move it to a visible one + _cursorColumn = GetNearestVisibleColumn (_cursorColumn, true, true); + IEnumerable oldRegions = MultiSelectedRegions.ToArray ().Reverse (); MultiSelectedRegions.Clear (); // evaluate - foreach (TableSelection region in oldRegions) + foreach (TableSelectionRegion region in oldRegions) { // ignore regions entirely below current table state if (region.Rectangle.Top >= Table.Rows) @@ -248,23 +260,25 @@ public void EnsureValidSelection () continue; } - // ensure region's origin exists - region.Origin = new Point (Math.Max (Math.Min (region.Origin.X, Table.Columns - 1), 0), Math.Max (Math.Min (region.Origin.Y, Table.Rows - 1), 0)); + // Clamp region to table bounds + Point clampedOrigin = new (Math.Max (Math.Min (region.Origin.X, Table.Columns - 1), 0), Math.Max (Math.Min (region.Origin.Y, Table.Rows - 1), 0)); + + Rectangle clampedRect = Rectangle.FromLTRB (region.Rectangle.Left, + region.Rectangle.Top, + Math.Max (Math.Min (region.Rectangle.Right, Table.Columns), 0), + Math.Max (Math.Min (region.Rectangle.Bottom, Table.Rows), 0)); - // ensure regions do not go over edge of table bounds - region.Rectangle = Rectangle.FromLTRB (region.Rectangle.Left, - region.Rectangle.Top, - Math.Max (Math.Min (region.Rectangle.Right, Table.Columns), 0), - Math.Max (Math.Min (region.Rectangle.Bottom, Table.Rows), 0)); - MultiSelectedRegions.Push (region); + MultiSelectedRegions.Push (new TableSelectionRegion (clampedOrigin, clampedRect) { IsExtended = region.IsExtended }); } } + /// True to select the entire row at once. False to select individual cells. Defaults to . + public bool FullRowSelect { get; set; } + /// /// Returns all cells in any (if is enabled) and the - /// selected cell + /// cursor cell. /// - /// public IEnumerable GetAllSelectedCells () { if (TableIsNullOrInvisible () || Table!.Rows == 0) @@ -276,7 +290,7 @@ public IEnumerable GetAllSelectedCells () HashSet toReturn = []; // If there are one or more rectangular selections - if (MultiSelect && MultiSelectedRegions.Any ()) + if (MultiSelect && MultiSelectedRegions.Count > 0) { // Quiz any cells for whether they are selected. For performance, we only need to check those between the top left and lower right vertex of // selection regions @@ -297,20 +311,20 @@ public IEnumerable GetAllSelectedCells () } } - // if there are no region selections then it is just the active cell + // if there are no region selections then it is just the cursor cell // if we are selecting the full row if (FullRowSelect) { - // all cells in active row are selected + // all cells in cursor row are selected for (var x = 0; x < Table.Columns; x++) { - toReturn.Add (new Point (x, SelectedRow)); + toReturn.Add (new Point (x, _cursorRow)); } } else { // Not full row select and no multi selections - toReturn.Add (new Point (SelectedColumn, SelectedRow)); + toReturn.Add (new Point (_cursorColumn, _cursorRow)); } return toReturn; @@ -318,7 +332,7 @@ public IEnumerable GetAllSelectedCells () /// /// - /// Returns true if the given cell is selected either because it is the active cell or part of a multi cell + /// Returns true if the given cell is selected either because it is the cursor cell or part of a multi cell /// selection (e.g. ). /// /// Returns if is . @@ -338,40 +352,54 @@ public bool IsSelected (int col, int row) return true; } - return row == SelectedRow && (col == SelectedColumn || FullRowSelect); + return row == _cursorRow && (col == _cursorColumn || FullRowSelect); } + /// True to allow multi-cell region selections. Defaults to . + public bool MultiSelect { get; set; } = true; + + /// + /// When is enabled, contains all rectangles of selected cells. Rectangles + /// describe column/row regions selected in (not screen coordinates). + /// Use to read the current selection state (cursor + regions). + /// + public Stack MultiSelectedRegions { get; } = new (); + /// /// When is on, creates selection over all cells in the table (replacing any old /// selection regions) /// - public void SelectAll () + public bool SelectAll () { if (TableIsNullOrInvisible () || !MultiSelect || Table!.Rows == 0) { - return; + return false; } ClearMultiSelectedRegions (true); - // Create a single region over entire table, set the origin of the selection to the active cell so that a followup spread selection e.g. shift-right + // Create a single region over entire table, set the origin to the cursor cell so that a followup spread selection e.g. shift-right // behaves properly - MultiSelectedRegions.Push (new TableSelection (new Point (SelectedColumn, SelectedRow), new Rectangle (0, 0, Table.Columns, _table!.Rows))); + MultiSelectedRegions.Push (new TableSelectionRegion (new Point (_cursorColumn, _cursorRow), new Rectangle (0, 0, Table.Columns, _table!.Rows))); + CommitSelectionState (); Update (); + + return true; } /// - /// Moves the and to the given col/row in - /// . Optionally starting a box selection (see ) + /// Moves the cursor to the given col/row in . + /// Optionally starts a box selection (see ). /// - /// - /// + /// Column index. + /// Row index. /// True to create a multi cell selection or adjust an existing one - public void SetSelection (int col, int row, bool extendExistingSelection) + /// The command context. + public void SetSelection (int col, int row, bool extendExistingSelection, ICommandContext? ctx = null) { // if we are trying to increase the column index then // we are moving right otherwise we are moving left - bool lookRight = col > _selectedColumn; + bool lookRight = col > _cursorColumn; col = GetNearestVisibleColumn (col, lookRight, true); if (!MultiSelect || !extendExistingSelection) @@ -382,28 +410,141 @@ public void SetSelection (int col, int row, bool extendExistingSelection) if (extendExistingSelection) { // If we are extending current selection but there isn't one - if (MultiSelectedRegions.Count == 0 || MultiSelectedRegions.All (m => m.IsToggled)) + if (MultiSelectedRegions.Count == 0 || MultiSelectedRegions.All (m => m.IsExtended)) { - // Create a new region between the old active cell and the new cell - TableSelection rect = CreateTableSelection (SelectedColumn, SelectedRow, col, row); + // Create a new region between the old cursor cell and the new cell + TableSelectionRegion rect = CreateTableSelectionRegion (_cursorColumn, _cursorRow, col, row); MultiSelectedRegions.Push (rect); } else { // Extend the current head selection to include the new cell - TableSelection head = MultiSelectedRegions.Pop (); - TableSelection newRect = CreateTableSelection (head.Origin.X, head.Origin.Y, col, row); + TableSelectionRegion head = MultiSelectedRegions.Pop (); + TableSelectionRegion newRect = CreateTableSelectionRegion (head.Origin.X, head.Origin.Y, col, row); MultiSelectedRegions.Push (newRect); } } - SelectedColumn = col; - SelectedRow = row; + // Write backing fields directly and commit once to avoid double-fire + _cursorColumn = TableIsNullOrInvisible () ? 0 : Math.Min (Table!.Columns - 1, Math.Max (0, col)); + _cursorRow = TableIsNullOrInvisible () ? 0 : Math.Min (Table!.Rows - 1, Math.Max (0, row)); + CommitSelectionState (); + } + + /// + /// Returns unless the is false for the indexed + /// column. If so then the index returned is nudged to the nearest visible column. + /// + /// Returns unchanged if it is invalid (e.g. out of bounds). + /// The input column index. + /// + /// When nudging invisible selections look right first. to look right, + /// to look left. + /// + /// + /// If we cannot find anything visible when looking in direction of + /// then should we look in the opposite direction instead? Use true if you want to push a + /// selection to a valid index no matter what. Use false if you are primarily interested in learning about directional + /// column visibility. + /// + private int GetNearestVisibleColumn (int columnIndex, bool lookRight, bool allowBumpingInOppositeDirection) => + TryGetNearestVisibleColumn (columnIndex, lookRight, allowBumpingInOppositeDirection, out int answer) ? answer : columnIndex; + + private bool TryGetNearestVisibleColumn (int columnIndex, bool lookRight, bool allowBumpingInOppositeDirection, out int idx) + { + // if the column index provided is out of bounds + if (_table is null || columnIndex < 0 || columnIndex >= _table.Columns) + { + idx = columnIndex; + + return false; + } + + // get the column visibility by index (if no style visible is true) + bool [] columnVisibility = Enumerable.Range (0, Table!.Columns).Select (c => Style.GetColumnStyleIfAny (c)?.Visible ?? true).ToArray (); + + // column is visible + if (columnVisibility [columnIndex]) + { + idx = columnIndex; + + return true; + } + + int increment = lookRight ? 1 : -1; + + // move in that direction + for (int i = columnIndex; i >= 0 && i < columnVisibility.Length; i += increment) + { + // if we find a visible column + if (!columnVisibility [i]) + { + continue; + } + + idx = i; + + return true; + } + + // Caller only wants to look in one direction, and we did not find any + // visible columns in that direction + if (!allowBumpingInOppositeDirection) + { + idx = columnIndex; + + return false; + } + + // Caller will let us look in the other direction so + // now look other way + increment = -increment; + + for (int i = columnIndex; i >= 0 && i < columnVisibility.Length; i += increment) + { + // if we find a visible column + if (!columnVisibility [i]) + { + continue; + } + + idx = i; + + return true; + } + + // nothing seems to be visible so just return input index + idx = columnIndex; + + return false; + } + + /// + /// Returns a new rectangle between the two points with positive width/height regardless of relative positioning + /// of the points. pt1 is always considered the point + /// + /// Origin point for the selection in X + /// Origin point for the selection in Y + /// End point for the selection in X + /// End point for the selection in Y + /// True if selection is result of + /// + private static TableSelectionRegion CreateTableSelectionRegion (int pt1X, int pt1Y, int pt2X, int pt2Y, bool toggle = false) + { + int top = Math.Max (Math.Min (pt1Y, pt2Y), 0); + int bot = Math.Max (Math.Max (pt1Y, pt2Y), 0); + int left = Math.Max (Math.Min (pt1X, pt2X), 0); + int right = Math.Max (Math.Max (pt1X, pt2X), 0); + + // Rect class is inclusive of Top Left but exclusive of Bottom Right so extend by 1 + return new TableSelectionRegion (new Point (pt1X, pt1Y), new Rectangle (left, top, right - left + 1, bot - top + 1)) { IsExtended = toggle }; } - // TODO: Refactor to use CWP - /// Invokes the event - private void RaiseSelectedCellChanged (SelectedCellChangedEventArgs args) => SelectedCellChanged?.Invoke (this, args); + /// Returns a single point as a + /// + /// + /// + private static TableSelectionRegion CreateTableSelectionRegion (int x, int y) => CreateTableSelectionRegion (x, y, x, y); private void ClearMultiSelectedRegions (bool keepToggledSelections) { @@ -414,89 +555,134 @@ private void ClearMultiSelectedRegions (bool keepToggledSelections) return; } - IEnumerable oldRegions = MultiSelectedRegions.ToArray ().Reverse (); + IEnumerable oldRegions = MultiSelectedRegions.ToArray ().Reverse (); MultiSelectedRegions.Clear (); - foreach (TableSelection region in oldRegions) + foreach (TableSelectionRegion region in oldRegions) { - if (region.IsToggled) + if (region.IsExtended) { MultiSelectedRegions.Push (region); } } } - private IEnumerable GetMultiSelectedRegionsContaining (int col, int row) + /// Syncs the from the internal cursor/region state. + private void CommitSelectionState () => UpdateValueFromInternalState (); + + private IEnumerable GetMultiSelectedRegionsContaining (int col, int row) { if (!MultiSelect) { - return Enumerable.Empty (); + return []; } - if (FullRowSelect) - { - return MultiSelectedRegions.Where (r => r.Rectangle.Bottom > row && r.Rectangle.Top <= row); - } - - return MultiSelectedRegions.Where (r => r.Rectangle.Contains (col, row)); + return FullRowSelect + ? MultiSelectedRegions.Where (r => r.Rectangle.Bottom > row && r.Rectangle.Top <= row) + : MultiSelectedRegions.Where (r => r.Rectangle.Contains (col, row)); } - private bool? ToggleCurrentCellSelection () + /// + /// Handles : extends or un-extends a cell from the multi-selection. + /// For keyboard (Space): toggles the current cell's extended state. + /// For mouse with Ctrl: unions the clicked cell into the selection. + /// For mouse with Alt: extends/creates a rectangular region to the clicked cell. + /// + private bool? ToggleExtend (ICommandContext? ctx) { - var e = new CellToggledEventArgs (Table!, _selectedColumn, _selectedRow); - OnCellToggled (e); - - if (e.Cancel) + // Mouse-based extend (Ctrl+Click or Alt+Click) + if (ctx?.Binding is MouseBinding { MouseEvent: { } } mouseBinding) { - return false; + return ToggleExtendMouse (mouseBinding); } + // Keyboard-based toggle (Space) + return ToggleExtendKeyboard (); + } + + /// Handles keyboard-based ToggleExtend (Space key): toggles the current cell's extended state. + private bool? ToggleExtendKeyboard () + { if (!MultiSelect) { return null; } - TableSelection [] regions = GetMultiSelectedRegionsContaining (_selectedColumn, _selectedRow).ToArray (); - TableSelection [] toggles = regions.Where (s => s.IsToggled).ToArray (); + TableSelectionRegion [] regions = GetMultiSelectedRegionsContaining (_cursorColumn, _cursorRow).ToArray (); + TableSelectionRegion [] extendedAtCursor = regions.Where (s => s.IsExtended).ToArray (); - // Toggle it off - if (toggles.Any ()) + if (extendedAtCursor.Length > 0) { - IEnumerable oldRegions = MultiSelectedRegions.ToArray ().Reverse (); + // Toggle OFF: remove extended regions that contain the cursor cell + IEnumerable oldRegions = MultiSelectedRegions.ToArray ().Reverse (); MultiSelectedRegions.Clear (); - foreach (TableSelection region in oldRegions) + foreach (TableSelectionRegion region in oldRegions) { - if (!toggles.Contains (region)) + if (!extendedAtCursor.Contains (region)) { MultiSelectedRegions.Push (region); } } } - else + else if (regions.Length > 0) { - // user is toggling selection within a rectangular - // select. So toggle the full region - if (regions.Any ()) - { - foreach (TableSelection r in regions) - { - r.IsToggled = true; - } - } - else + // Cursor is inside a non-extended rectangular region — mark matching regions as extended + IEnumerable oldRegions = MultiSelectedRegions.ToArray ().Reverse (); + MultiSelectedRegions.Clear (); + + foreach (TableSelectionRegion region in oldRegions) { - // Toggle on a single cell selection - MultiSelectedRegions.Push (CreateTableSelection (_selectedColumn, SelectedRow, _selectedColumn, _selectedRow, true)); + MultiSelectedRegions.Push (regions.Contains (region) + ? new TableSelectionRegion (region.Origin, region.Rectangle) { IsExtended = true } + : region); } } + else + { + // No region contains the cursor — toggle ON a single-cell extended region + MultiSelectedRegions.Push (CreateTableSelectionRegion (_cursorColumn, _cursorRow, _cursorColumn, _cursorRow, true)); + } return true; } - /// Unions the current selected cell (and/or regions) with the provided cell and makes it the active one. - /// - /// + /// Handles mouse-based ToggleExtend: Ctrl+Click unions, Alt+Click extends. + private bool? ToggleExtendMouse (MouseBinding mouseBinding) + { + int boundsX = mouseBinding.MouseEvent!.Position!.Value.X; + int boundsY = mouseBinding.MouseEvent.Position!.Value.Y; + Point? hit = ScreenToCell (boundsX, boundsY); + + if (hit is null || !MultiSelect) + { + return false; + } + + if (mouseBinding.MouseEvent.Flags.FastHasFlags (MouseFlags.Ctrl)) + { + UnionSelection (hit.Value.X, hit.Value.Y); + Update (); + + return true; + } + + if (!mouseBinding.MouseEvent.Flags.FastHasFlags (MouseFlags.Alt)) + { + return false; + } + + SetSelection (hit.Value.X, hit.Value.Y, true); + Update (); + + return false; + } + + /// + /// Toggles the provided cell in/out of the multi-selection. If the cell is already covered by a region, + /// that region is removed (toggle off). Otherwise, a new single-cell region is added (toggle on) and the + /// previous cursor position is preserved. + /// private void UnionSelection (int col, int row) { if (!MultiSelect || TableIsNullOrInvisible ()) @@ -505,19 +691,127 @@ private void UnionSelection (int col, int row) } EnsureValidSelection (); - int oldColumn = SelectedColumn; - int oldRow = SelectedRow; - // move us to the new cell - SelectedColumn = col; - SelectedRow = row; - MultiSelectedRegions.Push (CreateTableSelection (col, row)); + // Check if the target cell is already covered by an existing region + TableSelectionRegion [] existingRegions = GetMultiSelectedRegionsContaining (col, row).ToArray (); + + if (existingRegions.Length > 0) + { + // Toggle OFF: remove all regions that contain the target cell + IEnumerable oldRegions = MultiSelectedRegions.ToArray ().Reverse (); + MultiSelectedRegions.Clear (); - // if the old cell was not part of a rectangular select - // or otherwise selected we need to retain it in the selection - if (!IsSelected (oldColumn, oldRow)) + foreach (TableSelectionRegion region in oldRegions) + { + if (!existingRegions.Contains (region)) + { + MultiSelectedRegions.Push (region); + } + } + } + else { - MultiSelectedRegions.Push (CreateTableSelection (oldColumn, oldRow)); + // Toggle ON: add a region for the new cell + int oldColumn = _cursorColumn; + int oldRow = _cursorRow; + + _cursorColumn = col; + _cursorRow = row; + MultiSelectedRegions.Push (CreateTableSelectionRegion (col, row)); + + // Retain the old cursor position in the selection if it's not already covered + if (!IsSelected (oldColumn, oldRow)) + { + MultiSelectedRegions.Push (CreateTableSelectionRegion (oldColumn, oldRow)); + } } + + CommitSelectionState (); } + + #endregion Selection + + #region IValue Implementation + + /// + public event EventHandler>? ValueChangedUntyped; + + /// + public event EventHandler>? ValueChanging; + + /// + public event EventHandler>? ValueChanged; + + /// + /// Called when is about to change. Return to cancel the change. + /// + protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false; + + /// + /// Called when has changed. + /// + protected virtual void OnValueChanged (ValueChangedEventArgs args) { } + + private TableSelection? _value; + + /// + public TableSelection? Value + { + get => _value; + set + { + if (Equals (_value, value)) + { + return; + } + + TableSelection? oldValue = _value; + ValueChangingEventArgs changingArgs = new (oldValue, value); + + if (OnValueChanging (changingArgs) || changingArgs.Handled) + { + return; + } + + ValueChanging?.Invoke (this, changingArgs); + + if (changingArgs.Handled) + { + return; + } + + _value = changingArgs.NewValue; + SetNeedsDraw (); + + // Sync internal cursor state from Value + SyncCursorFromValue (); + + ValueChangedEventArgs changedArgs = new (oldValue, _value); + OnValueChanged (changedArgs); + ValueChanged?.Invoke (this, changedArgs); + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldValue, _value)); + } + } + + /// + /// Builds a from the current internal state and sets . + /// + private void UpdateValueFromInternalState () + { + if (TableIsNullOrInvisible ()) + { + Value = null; + + return; + } + + // Deep-copy regions so Value snapshots are immutable + List regions = MultiSelectedRegions.Reverse () + .Select (r => new TableSelectionRegion (r.Origin, r.Rectangle) { IsExtended = r.IsExtended }) + .ToList (); + TableSelection newSelection = new (new Point (_cursorColumn, _cursorRow), regions); + Value = newSelection; + } + + #endregion IValue Implementation } diff --git a/Terminal.Gui/Views/TableView/TableView.cs b/Terminal.Gui/Views/TableView/TableView.cs index b4a90329be..ba9d834bcb 100644 --- a/Terminal.Gui/Views/TableView/TableView.cs +++ b/Terminal.Gui/Views/TableView/TableView.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Globalization; namespace Terminal.Gui.Views; @@ -38,7 +39,8 @@ namespace Terminal.Gui.Views; /// Ctrl+Home / Ctrl+End Moves to the first or last row. /// /// -/// Shift+<movement> Extends the selection in the given direction. +/// Shift+<movement> +/// Extends the selection in the given direction. /// /// /// Ctrl+A Selects all cells. @@ -54,7 +56,7 @@ namespace Terminal.Gui.Views; /// /// /// -public partial class TableView : View, IDesignable +public partial class TableView : View, IValue, IDesignable { /// /// The default maximum cell width for and @@ -70,13 +72,18 @@ public partial class TableView : View, IDesignable /// Do not set in parallelizable unit tests. /// /// - /// - /// - /// The binding () is instance-dependent - /// and is added directly in the constructor. - /// - /// - public new static Dictionary? DefaultKeyBindings { get; set; } = new (); + public new static Dictionary? DefaultKeyBindings { get; set; } = new () + { + // Emacs navigation + [Command.Up] = Bind.All (Key.P.WithCtrl), + [Command.Down] = Bind.All (Key.N.WithCtrl), + [Command.PageDown] = Bind.All (Key.V.WithCtrl), + + // Add Home/End as additional Start/End bindings (the base layer also provides Ctrl+Home/Ctrl+End) + [Command.Start] = Bind.All (Key.Home), + [Command.End] = Bind.All (Key.End), + [Command.ToggleExtend] = Bind.All (Key.Space) + }; /// Initializes a class. /// The table to display in the control @@ -93,200 +100,69 @@ public TableView () // Things this view knows how to do AddCommand (Command.Right, HandleRight); - - AddCommand (Command.Left, () => ChangeSelectionByOffsetWithReturn (-1, 0)); - + AddCommand (Command.Left, (ctx) => MoveCursorByOffsetWithReturn (-1, 0, ctx)); AddCommand (Command.Up, HandleUp); - AddCommand (Command.Down, HandleDown); - - AddCommand (Command.PageUp, - () => - { - PageUp (false); - - return true; - }); - - AddCommand (Command.PageDown, - () => - { - PageDown (false); - - return true; - }); - - AddCommand (Command.LeftStart, - () => - { - ChangeSelectionToStartOfRow (false); - - return true; - }); - - AddCommand (Command.RightEnd, - () => - { - ChangeSelectionToEndOfRow (false); - - return true; - }); - - AddCommand (Command.Start, - () => - { - ChangeSelectionToStartOfTable (false); - - return true; - }); - - AddCommand (Command.End, - () => - { - ChangeSelectionToEndOfTable (false); - - return true; - }); - - AddCommand (Command.RightExtend, - () => - { - ChangeSelectionByOffset (1, 0, true); - - return true; - }); - - AddCommand (Command.LeftExtend, - () => - { - ChangeSelectionByOffset (-1, 0, true); - - return true; - }); - - AddCommand (Command.UpExtend, - () => - { - ChangeSelectionByOffset (0, -1, true); - - return true; - }); - - AddCommand (Command.DownExtend, - () => - { - ChangeSelectionByOffset (0, 1, true); - - return true; - }); - - AddCommand (Command.PageUpExtend, - () => - { - PageUp (true); - - return true; - }); - - AddCommand (Command.PageDownExtend, - () => - { - PageDown (true); - - return true; - }); - - AddCommand (Command.LeftStartExtend, - () => - { - ChangeSelectionToStartOfRow (true); - - return true; - }); - - AddCommand (Command.RightEndExtend, - () => - { - ChangeSelectionToEndOfRow (true); - - return true; - }); - - AddCommand (Command.StartExtend, - () => - { - ChangeSelectionToStartOfTable (true); - - return true; - }); - - AddCommand (Command.EndExtend, - () => - { - ChangeSelectionToEndOfTable (true); - - return true; - }); - - AddCommand (Command.SelectAll, - () => - { - SelectAll (); - - return true; - }); - AddCommand (Command.Accept, () => OnCellActivated (new CellActivatedEventArgs (Table!, SelectedColumn, SelectedRow))); - - AddCommand (Command.Toggle, _ => ToggleCurrentCellSelection () is true); - - AddCommand (Command.Activate, ctx => RaiseActivating (ctx) is true); + AddCommand (Command.PageUp, ctx => PageUp (false, ctx)); + AddCommand (Command.PageDown, ctx => PageDown (false, ctx)); + AddCommand (Command.LeftStart, ctx => MoveCursorToStartOfRow (false, ctx)); + AddCommand (Command.RightEnd, ctx => MoveCursorToEndOfRow (false, ctx)); + AddCommand (Command.Start, ctx => MoveCursorToStartOfTable (false, ctx)); + AddCommand (Command.End, ctx => MoveCursorToEndOfTable (false, ctx)); + AddCommand (Command.RightExtend, ctx => MoveCursorByOffset (1, 0, true, ctx)); + AddCommand (Command.LeftExtend, ctx => MoveCursorByOffset (-1, 0, true, ctx)); + AddCommand (Command.UpExtend, ctx => MoveCursorByOffset (0, -1, true, ctx)); + AddCommand (Command.DownExtend, ctx => MoveCursorByOffset (0, 1, true, ctx)); + AddCommand (Command.PageUpExtend, ctx => PageUp (true, ctx)); + AddCommand (Command.PageDownExtend, ctx => PageDown (true, ctx)); + AddCommand (Command.LeftStartExtend, ctx => MoveCursorToStartOfRow (true, ctx)); + AddCommand (Command.RightEndExtend, ctx => MoveCursorToEndOfRow (true, ctx)); + AddCommand (Command.StartExtend, ctx => MoveCursorToStartOfTable (true, ctx)); + AddCommand (Command.EndExtend, ctx => MoveCursorToEndOfTable (true, ctx)); + AddCommand (Command.ToggleExtend, ToggleExtend); + AddCommand (Command.SelectAll, _ => SelectAll ()); // Apply configurable key bindings (base View layer + TableView-specific layer) ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); - // CellActivationKey is instance-dependent, so it stays as a direct binding - KeyBindings.Remove (CellActivationKey); - KeyBindings.Add (CellActivationKey, Command.Accept); + MouseBindings.ReplaceCommands (MouseFlags.WheeledRight, Command.Right); + MouseBindings.ReplaceCommands (MouseFlags.WheeledLeft, Command.Left); + MouseBindings.ReplaceCommands (MouseFlags.WheeledDown, Command.Down); + MouseBindings.ReplaceCommands (MouseFlags.WheeledUp, Command.Up); + MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); + MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked | MouseFlags.Ctrl, Command.ToggleExtend); + MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked | MouseFlags.Alt, Command.ToggleExtend); + MouseBindings.ReplaceCommands (MouseFlags.LeftButtonDoubleClicked, Command.Accept); } - /// Navigator for cycling the selected item in the table by typing. Set to null to disable this feature. - public ICollectionNavigator CollectionNavigator { get; set; } + private ITableSource? _table; - /// - /// Horizontal scroll offset. The index of the first column in to display when rendering - /// the view. - /// - /// This property allows very wide tables to be rendered with horizontal scrolling - public int ColumnOffset + /// The data table to render in the view. Setting this property automatically updates and redraws the control. + public ITableSource? Table { - get => _columnsToRenderCache?.Count (c => c.X + c.Width <= Viewport.X) ?? 0; + get => _table; set { - if (value < 0) - { - value = 0; - } + _table = value; - if (_columnsToRenderCache == null) + if (_table is null || _table.Columns <= 0 || _table.Rows <= 0) { - CalculateContentSize (); + Value = null; } - - if (value >= (_columnsToRenderCache?.Length ?? 0)) + else { - value = (_columnsToRenderCache?.Length ?? 0) - 1; + SetSelection (0, 0, false); } - int prev = ColumnOffset; - Viewport = Viewport with { X = _columnsToRenderCache! [value].X }; - if (prev != ColumnOffset) - { - SetNeedsDraw (); - } + RefreshContentSize (); + Update (); } } + /// Navigator for cycling the selected item in the table by typing. Set to null to disable this feature. + public ICollectionNavigator CollectionNavigator { get; set; } + /// /// The maximum number of characters to render in any given column. This prevents one long column from pushing /// out all the others @@ -299,26 +175,6 @@ public int ColumnOffset /// The text representation that should be rendered for cells with the value public string NullSymbol { get; set; } = "-"; - /// - /// Vertical scroll offset. The index of the first row in to display in the first non header - /// line of the control when rendering the view. - /// - public int RowOffset - { - get => Style.AlwaysShowHeaders ? Viewport.Y : Math.Max (Viewport.Y - GetHeaderHeightIfAny (), 0); - set - { - int oldViewportY = Viewport.Y; - - Viewport = Viewport with { Y = value == 0 ? 0 : Style.AlwaysShowHeaders ? value : GetHeaderHeightIfAny () + value }; - - if (Viewport.Y != oldViewportY) - { - SetNeedsDraw (); - } - } - } - /// /// The symbol to add after each cell value and header value to visually separate values (if not using vertical /// gridlines) @@ -338,24 +194,6 @@ public TableStyle Style } } - private ColumnToRender []? _columnsToRenderCache; - - private bool _inCalculatingContentSize; - - /// - /// This event is raised when a cell is activated e.g. by double-clicking or pressing - /// - /// - public event EventHandler? CellActivated; - - /// This event is raised when a cell is toggled (see - public event EventHandler? CellToggled; - - private record TableViewSelectionSnapshot (int SelectedColumn, int SelectedRow, Rectangle [] MultiSelection); - - /// This event is raised when the selected cell in the table changes. - public event EventHandler? SelectedCellChanged; - /// /// Updates the view to reflect changes to and to ( / /// ) etc. @@ -374,449 +212,165 @@ public void Update () EnsureValidScrollOffsets (); EnsureValidSelection (); - EnsureSelectedCellIsVisible (); + EnsureCursorIsVisible (); SetNeedsDraw (); } - // TODO: Refactor to use CWP - /// Invokes the event - /// - /// if the CellActivated event was raised. - protected virtual bool OnCellActivated (CellActivatedEventArgs args) - { - CellActivated?.Invoke (this, args); - - return CellActivated is { }; - } - - // TODO: Refactor to use CWP - /// Invokes the event - /// - protected virtual void OnCellToggled (CellToggledEventArgs args) => CellToggled?.Invoke (this, args); - - /// Returns the amount of vertical space required to display the header + /// + /// Returns true if the given indexes a visible column otherwise false. Returns + /// false for indexes that are out of bounds. + /// + /// /// - internal int GetHeaderHeight () + private bool IsColumnVisible (int columnIndex) { - int heightRequired = Style.ShowHeaders ? 1 : 0; - - if (Style.ShowHorizontalHeaderOverline) - { - heightRequired++; - } - - if (Style.ShowHorizontalHeaderUnderline) + // if the column index provided is out of bounds + if (_table is null || columnIndex < 0 || columnIndex >= _table.Columns) { - heightRequired++; + return false; } - return heightRequired; - } - - /// Returns the amount of vertical space currently occupied by the header or 0 if it is not visible. - /// - internal int GetHeaderHeightIfAny () => ShouldRenderHeaders () ? GetHeaderHeight () : 0; - - private void AddRuneAt (int col, int row, Rune ch) - { - Move (col, row); - AddRune (ch); + return Style.GetColumnStyleIfAny (columnIndex)?.Visible ?? true; } - /// - /// Returns the maximum of the name and the maximum length of data that will be rendered - /// starting at and rendering - /// - /// ColumnIndex - /// - /// index of first row - /// Count of rows to inspect - /// - private int CalculateMaxCellWidth (int col, ColumnStyle? colStyle, int startRow, int rowsToRender) + /// + protected override void OnActivated (ICommandContext? ctx) { - int spaceRequired = _table!.ColumnNames [col].GetColumns (); - - // if table has no rows - if (Table is not { Rows: > 0 }) + if (ctx?.Binding is KeyBinding { Key: { } } keyBinding && keyBinding.Key == Key.Space) { - return spaceRequired; + ToggleExtend (ctx); + + return; } - for (int i = startRow; i < startRow + rowsToRender; i++) + if (ctx?.Binding is not MouseBinding mouseBinding || mouseBinding.MouseEvent is null) { - // expand required space if cell is bigger than the last biggest cell or header - spaceRequired = Math.Max (spaceRequired, GetRepresentation (Table [i, col], colStyle).GetColumns ()); + return; } + int boundsX = mouseBinding.MouseEvent.Position!.Value.X; + int boundsY = mouseBinding.MouseEvent.Position!.Value.Y; - // Don't require more space than the style allows - if (colStyle is { }) + if (!mouseBinding.MouseEvent.Flags.FastHasFlags (MouseFlags.LeftButtonClicked)) { - // enforce maximum cell width based on style - if (spaceRequired > colStyle.MaxWidth) - { - spaceRequired = colStyle.MaxWidth; - } - - // enforce minimum cell width based on style - if (spaceRequired < colStyle.MinWidth) - { - spaceRequired = colStyle.MinWidth; - } + return; } + Point? hit = ScreenToCell (boundsX, boundsY); - // enforce maximum cell width based on global table style - if (spaceRequired > MaxCellWidth) + if (hit is null) { - spaceRequired = MaxCellWidth; + return; } + SetSelection (hit.Value.X, hit.Value.Y, mouseBinding.MouseEvent.Flags.FastHasFlags (MouseFlags.Shift)); - return spaceRequired; + Update (); } - /// - /// Returns the cells that shall be shown (all cells except the hidden ones) - /// - /// - private ColumnToRender [] NonHiddenCellInfos () + /// + protected override bool OnKeyDown (Key key) { if (TableIsNullOrInvisible ()) { - return Array.Empty (); - } - - if (_columnsToRenderCache == null) - { - RefreshContentSize (); + return false; } - return _columnsToRenderCache ?? Array.Empty (); - } - - private Size? CalculateContentSize () - { - var contentSize = new Size (0, 0); - _inCalculatingContentSize = true; - - try + if (key == HotKey) { - int headerHeight = GetHeaderHeightIfAny (); - int headerHeightVisible = CurrentHeaderHeightVisible (); - contentSize.Height += headerHeight + Table?.Rows ?? 0; - - if (Style.ShowHorizontalBottomLine) - { - contentSize.Height++; - } - - // we assume that padding is 0 here - var padding = 0; - List columnsToRender = new (); - - if (Table != null) - { - List<(int colIdx, ColumnStyle? colStyle)> nonHiddenColumns = Enumerable.Range (0, Table.Columns) - .Select (c => (colIdx: c, colStyle: Style.GetColumnStyleIfAny (c))) - .Where (e => e.colStyle?.Visible != false) - .ToList (); - - int lastColIdx = nonHiddenColumns.Any () ? nonHiddenColumns.Last ().colIdx : -1; - - //right border - contentSize.Width += Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0; - - var startRow = 0; - int rowsToRender = Table.Rows; - - if (!UseAllRowsForContentCalculation) - { - startRow = Style.AlwaysShowHeaders ? Viewport.Y : Math.Max (Viewport.Y - headerHeight, 0); - - rowsToRender = Math.Min (Viewport.Height - headerHeightVisible, Table.Rows - startRow); - } - - // Calculate the content size based on the table's data - foreach ((int colIdx, ColumnStyle? colStyle) in nonHiddenColumns) - { - int maxContentSize = CalculateMaxCellWidth (colIdx, colStyle, startRow, rowsToRender) + padding; - int colWidth = maxContentSize + padding; - - if (MinCellWidth > 0 && colWidth < MinCellWidth + padding) - { - if (MinCellWidth > MaxCellWidth) - { - colWidth = MaxCellWidth + padding; - } - else - { - colWidth = MinCellWidth + padding; - } - } - - // ToDo: MinAcceptableWidth handling? - // if (colStyle is { MinAcceptableWidth: > 0 } - - bool isVeryLast = colIdx == lastColIdx; - - if (isVeryLast) - { - //remaining space for last column - int remainingSpace = Viewport.Width - contentSize.Width - (Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0); - - if (Style.ExpandLastColumn && colWidth < remainingSpace) - { - colWidth = remainingSpace; - } - } - - columnsToRender.Add (new ColumnToRender (colIdx, contentSize.Width, colWidth + 1, maxContentSize, lastColIdx == colIdx)); - - contentSize.Width += colWidth; - - if (!isVeryLast) - { - // for separator symbols between columns - contentSize.Width += 1; - } - } - - // for left border - contentSize.Width += Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0; - } - else - { - contentSize.Width = 0; - } - - _columnsToRenderCache = columnsToRender.ToArray (); - - //check if it makes sense to scroll to left or up if the scrolled viewport is bigger than needed - if (Viewport.X + Viewport.Width > contentSize.Width) - { - Viewport = Viewport with { X = Math.Max (contentSize.Width - Viewport.Width, 0) }; - } - - if (Viewport.Y + Viewport.Height > contentSize.Height) - { - Viewport = Viewport with { Y = Math.Max (contentSize.Height - Viewport.Height, 0) }; - } - } - finally - { - _inCalculatingContentSize = false; + return CycleToNextTableEntryBeginningWith (key); } - return contentSize; + return false; } - /// Clears a line of the console by filling it with spaces - /// - /// - private void ClearLine (int row, int width) + /// + protected override bool OnKeyDownNotHandled (Key key) { - if (App?.Screen.Height == 0) + if (key.AsRune is var rune && rune != default (Rune) && Rune.IsControl (rune)) { - return; + return false; } - Move (0, row); - SetAttribute (GetAttributeForRole (VisualRole.Normal)); - AddStr (new string (' ', width)); - } - - /// - /// Returns unless the is false for the indexed - /// column. If so then the index returned is nudged to the nearest visible column. - /// - /// Returns unchanged if it is invalid (e.g. out of bounds). - /// The input column index. - /// - /// When nudging invisible selections look right first. to look right, - /// to look left. - /// - /// - /// If we cannot find anything visible when looking in direction of - /// then should we look in the opposite direction instead? Use true if you want to push a - /// selection to a valid index no matter what. Use false if you are primarily interested in learning about directional - /// column visibility. - /// - private int GetNearestVisibleColumn (int columnIndex, bool lookRight, bool allowBumpingInOppositeDirection) - { - if (TryGetNearestVisibleColumn (columnIndex, lookRight, allowBumpingInOppositeDirection, out int answer)) + if (key.IsAlt || key.IsCtrl) { - return answer; + // Never insert modified keys + return false; } - return columnIndex; - } - - /// - /// Returns the value that should be rendered to best represent a strongly typed read - /// from - /// - /// - /// Optional style defining how to represent cell values - /// - private string GetRepresentation (object value, ColumnStyle? colStyle) - { - if (value == DBNull.Value) + // Ignore other control characters. + if (string.IsNullOrEmpty (key.AsGrapheme) && key is { IsKeyCodeAtoZ: false, KeyCode: < KeyCode.Space or > KeyCode.CharMask }) { - return NullSymbol; + return false; } - return colStyle is { } ? colStyle.GetRepresentation (value) : value.ToString () ?? string.Empty; - } - - private bool HasControlOrAlt (Mouse me) => me.Flags.FastHasFlags (MouseFlags.Alt) || me.Flags.FastHasFlags (MouseFlags.Ctrl); - - /// - /// Returns true if the given indexes a visible column otherwise false. Returns - /// false for indexes that are out of bounds. - /// - /// - /// - private bool IsColumnVisible (int columnIndex) - { - // if the column index provided is out of bounds - if (_table is null || columnIndex < 0 || columnIndex >= _table.Columns) + if (HasFocus && Table?.Rows != 0) { - return false; + return CycleToNextTableEntryBeginningWith (key); } - return Style.GetColumnStyleIfAny (columnIndex)?.Visible ?? true; + return true; } /// - /// Truncates or pads so that it occupies exactly - /// using the alignment specified in (or left - /// if no style is defined) + /// Generates a new demo with the given number of (min 5) and + /// /// - /// The object in this cell of the - /// The string representation of - /// - /// Optional style indicating custom alignment for the cell + /// + /// /// - private string TruncateOrPad (object originalCellValue, string representation, int availableHorizontalSpace, ColumnStyle? colStyle) + public static DataTable BuildDemoDataTable (int cols, int rows) { - if (string.IsNullOrEmpty (representation)) - { - return new string (' ', availableHorizontalSpace); - } + var dt = new DataTable (); + var explicitCols = 6; + dt.Columns.Add (new DataColumn ("StrCol", typeof (string))); + dt.Columns.Add (new DataColumn ("DateCol", typeof (DateTime))); + dt.Columns.Add (new DataColumn ("IntCol", typeof (int))); + dt.Columns.Add (new DataColumn ("DoubleCol", typeof (double))); + dt.Columns.Add (new DataColumn ("NullsCol", typeof (string))); + dt.Columns.Add (new DataColumn ("Unicode", typeof (string))); + dt.Columns.Add (new DataColumn ("VarLength", typeof (string))); //ColIdx = 6 - // if value is too wide - if (representation.GetColumns () >= availableHorizontalSpace) + for (var i = 0; i < cols - explicitCols; i++) { - return new string (representation.TakeWhile (c => (availableHorizontalSpace -= ((Rune)c).GetColumns ()) > 0).ToArray ()); + dt.Columns.Add ("Column" + (i + explicitCols)); } - // pad it out with spaces to the given alignment - int toPad = availableHorizontalSpace - (representation.GetColumns () + 1 /*leave 1 space for cell boundary*/); - - return (colStyle?.GetAlignment (originalCellValue) ?? Alignment.Start) switch - { - Alignment.Start => representation + new string (' ', toPad), - Alignment.End => new string (' ', toPad) + representation, - - // TODO: With single line cells, centered and justified are the same right? - Alignment.Center or Alignment.Fill => new string (' ', (int)Math.Floor (toPad / 2.0)) - + // round down - representation - + new string (' ', (int)Math.Ceiling (toPad / 2.0)), // round up - _ => representation + new string (' ', toPad) - }; - } - - private bool TryGetNearestVisibleColumn (int columnIndex, bool lookRight, bool allowBumpingInOppositeDirection, out int idx) - { - // if the column index provided is out of bounds - if (_table is null || columnIndex < 0 || columnIndex >= _table.Columns) - { - idx = columnIndex; - - return false; - } + var r = new Random (100); - // get the column visibility by index (if no style visible is true) - bool [] columnVisibility = Enumerable.Range (0, Table!.Columns).Select (c => Style.GetColumnStyleIfAny (c)?.Visible ?? true).ToArray (); + string numberText = NumberText (rows); - // column is visible - if (columnVisibility [columnIndex]) + for (var i = 0; i < rows; i++) { - idx = columnIndex; - - return true; - } + List row = + [ + $"Demo text in row {i}", + new DateTime (2000 + i, 12, 25), + r.Next (i), + r.NextDouble () * i - 0.5 /*add some negatives to demo styles*/, + DBNull.Value, + "Les Mise" + char.ConvertFromUtf32 (int.Parse ("0301", NumberStyles.HexNumber)) + "rables", + numberText [..i] + ]; - int increment = lookRight ? 1 : -1; - - // move in that direction - for (int i = columnIndex; i >= 0 && i < columnVisibility.Length; i += increment) - { - // if we find a visible column - if (!columnVisibility [i]) + for (var j = 0; j < cols - explicitCols; j++) { - continue; + row.Add ("SomeValue" + r.Next (100)); } - idx = i; - - return true; - } - - // Caller only wants to look in one direction, and we did not find any - // visible columns in that direction - if (!allowBumpingInOppositeDirection) - { - idx = columnIndex; - - return false; + dt.Rows.Add (row.ToArray ()); } - // Caller will let us look in the other direction so - // now look other way - increment = -increment; + return dt; - for (int i = columnIndex; i >= 0 && i < columnVisibility.Length; i += increment) + static string NumberText (int len) { - // if we find a visible column - if (!columnVisibility [i]) + var result = string.Empty; + + for (var i = 1; i <= len; i++) { - continue; + result += i % 10; } - idx = i; - - return true; + return result; } - - // nothing seems to be visible so just return input index - idx = columnIndex; - - return false; - } - - /// Describes a desire to render a column at a given horizontal position in the UI - internal class ColumnToRender (int col, int x, int width, int maxContentSize, bool isVeryLast) - { - /// The column to render - public int Column { get; set; } = col; - - /// True if this column is the very last column in the (not just the last visible column) - public bool IsVeryLast { get; } = isVeryLast; - - /// - /// The width that the column should occupy as calculated by . Note - /// that this includes space for padding i.e. the separator between columns. - /// - public int Width { get; internal set; } = width; - - /// - /// The maximum size of the content that will be rendered in this column as calculated by - /// . - /// - public int MaxContentSize { get; internal set; } = maxContentSize; - - /// The horizontal position to begin rendering the column at - public int X { get; set; } = x; } bool IDesignable.EnableForDesign () @@ -826,102 +380,4 @@ bool IDesignable.EnableForDesign () return true; } - - // TODO: Update to use Key instead of KeyCode - /// The key which when pressed should trigger event. Defaults to Enter. - public KeyCode CellActivationKey - { - get; - set - { - if (field == value) - { - return; - } - - if (KeyBindings.TryGet (field, out _)) - { - KeyBindings.Replace (field, value); - } - else - { - KeyBindings.Add (value, Command.Accept); - } - - field = value; - } - } = KeyCode.Enter; - - /// - protected override bool OnKeyDown (Key key) - { - if (TableIsNullOrInvisible ()) - { - return false; - } - - // If the key was bound to key command, let normal KeyDown processing happen. This enables overriding the default handling. - // See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939 - if (KeyBindings.TryGet (key, out _)) - { - return false; - } - - if (HasFocus - && Table?.Rows != 0 - && key != KeyBindings.GetFirstFromCommands (Command.Accept) - && key != CellActivationKey - && CollectionNavigator.Matcher.IsCompatibleKey (key) - && !key.KeyCode.FastHasFlags (KeyCode.CtrlMask) - && !key.KeyCode.FastHasFlags (KeyCode.AltMask) - && Rune.IsLetterOrDigit ((Rune)key)) - { - return CycleToNextTableEntryBeginningWith (key); - } - - return false; - } - - /// - /// Gets the maximum top-left coordinates to which the viewport can be scrolled within the content area. - /// - /// - /// The returned point represents the largest X and Y values for the viewport's position such - /// that the entire viewport remains within the bounds of the content. - /// - public Point MaxViewPort () - { - Size contentSize = GetContentSize (); - int maxX = Math.Max (contentSize.Width - Viewport.Width, 0); - int maxY = Math.Max (contentSize.Height - Viewport.Height, 0); - - return new Point (maxX, maxY); - } - - /// - /// Updates and where they are outside the bounds of the table - /// (by adjusting them to the nearest existing cell). Has no effect if has not been set. - /// - /// - /// Changes will not be immediately visible in the display until you call - /// - public void EnsureValidScrollOffsets () - { - if (TableIsNullOrInvisible ()) - { - return; - } - - Point maxViewPort = MaxViewPort (); - - if (Viewport.Y > maxViewPort.Y) - { - Viewport = Viewport with { Y = Math.Max (maxViewPort.Y, 0) }; - } - - if (Viewport.X > maxViewPort.X) - { - Viewport = Viewport with { X = Math.Max (maxViewPort.X, 0) }; - } - } } diff --git a/Terminal.Gui/Views/TableView/TreeTableSource.cs b/Terminal.Gui/Views/TableView/TreeTableSource.cs index d093ee1a96..6800b1d8b2 100644 --- a/Terminal.Gui/Views/TableView/TreeTableSource.cs +++ b/Terminal.Gui/Views/TableView/TreeTableSource.cs @@ -25,8 +25,8 @@ public class TreeTableSource : IEnumerableTableSource, IDisposable where T /// Getter methods for each additional property you want to present in the table. For example: /// /// new () { - /// { "Colname1", (t)=>t.SomeField}, - /// { "Colname2", (t)=>t.SomeOtherField} + /// { "Col name1", (t)=>t.SomeField}, + /// { "Col name2", (t)=>t.SomeOtherField} /// } /// /// @@ -114,7 +114,7 @@ private bool IsInTreeColumn (int column, bool isKeyboard) return true; } - // we cannot just check that SelectedColumn is 0 because source may + // we cannot just check that the cursor column is 0 because source may // be wrapped e.g. with a CheckBoxTableSourceWrapperBase return colNames [column] == ColumnNames [0]; } @@ -123,12 +123,12 @@ private bool IsInTreeColumn (int column, bool isKeyboard) private void Table_KeyPress (object? sender, Key e) { - if (!IsInTreeColumn (_tableView.SelectedColumn, true)) + if (!IsInTreeColumn (_tableView.Value?.Cursor.X ?? 0, true)) { return; } - T? obj = _tree.GetObjectOnRow (_tableView.SelectedRow); + T? obj = _tree.GetObjectOnRow (_tableView.Value?.Cursor.Y ?? 0); if (obj is null) { diff --git a/Terminal.Gui/Views/TextInput/TextField/TextField.Keyboard.cs b/Terminal.Gui/Views/TextInput/TextField/TextField.Keyboard.cs index 7a8551c04f..93dbbc019e 100644 --- a/Terminal.Gui/Views/TextInput/TextField/TextField.Keyboard.cs +++ b/Terminal.Gui/Views/TextInput/TextField/TextField.Keyboard.cs @@ -73,6 +73,12 @@ protected override bool OnKeyDownNotHandled (Key a) return false; } + if (a.IsAlt || a.IsCtrl) + { + // Never insert modified keys + return false; + } + // Ignore other control characters. if (string.IsNullOrEmpty (a.AsGrapheme) && a is { IsKeyCodeAtoZ: false, KeyCode: < KeyCode.Space or > KeyCode.CharMask }) { diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs index 6fc3ab571c..e32ed82e4d 100644 --- a/Terminal.Gui/Views/TextInput/TextValidateField.cs +++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs @@ -419,6 +419,12 @@ protected override bool OnKeyDownNotHandled (Key key) return false; } + if (key.IsAlt || key.IsCtrl) + { + // Never insert modified keys + return false; + } + if (key.AsRune == default (Rune) || key == Application.GetDefaultKey (Command.Quit)) { return false; diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Keyboard.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Keyboard.cs index ee93112b0d..961df362e6 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.Keyboard.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Keyboard.cs @@ -142,6 +142,12 @@ protected override bool OnKeyDownNotHandled (Key a) return Autocomplete.ProcessKey (a); } + if (a.IsAlt || a.IsCtrl) + { + // Never insert modified keys + return false; + } + if (a.AsRune is { } rune && rune != default (Rune) && Rune.IsControl (rune)) { return false; diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index a792e38ad6..b6d00b2721 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -591,6 +591,7 @@ True True True + True True True True diff --git a/Tests/UnitTestsParallelizable/ViewBase/Keyboard/KeyboardEventTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Keyboard/KeyboardEventTests.cs index 5e474a576f..cbefc74404 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Keyboard/KeyboardEventTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Keyboard/KeyboardEventTests.cs @@ -5,18 +5,17 @@ namespace ViewBaseTests.Keyboard; - public class KeyboardEventTests (ITestOutputHelper output) : TestsAllViews { /// /// This tests that when a new key down event is sent to the view will fire the key-down related - /// events: KeyDown and KeyDownNotHandled. + /// events: KeyDown and KeyDownNotHandled. /// [Theory] [MemberData (nameof (AllViewTypes))] public void AllViews_NewKeyDownEvent_All_EventsFire (Type viewType) { - var view = CreateInstanceIfNotGeneric (viewType); + View view = CreateInstanceIfNotGeneric (viewType); if (view == null) { @@ -38,10 +37,10 @@ public void AllViews_NewKeyDownEvent_All_EventsFire (Type viewType) var keyDownNotHandled = false; view.KeyDownNotHandled += (s, a) => - { - a.Handled = true; - keyDownNotHandled = true; - }; + { + a.Handled = true; + keyDownNotHandled = true; + }; // Key.Empty is invalid, but it's used here to test that the event is fired Assert.True (view.NewKeyDownEvent (Key.Empty)); // this will be true because the ProcessKeyDown event handled it @@ -74,14 +73,7 @@ public void NewKeyDownEvent_Raised_With_Only_Key_Modifiers (bool shift, bool alt }; view.KeyDownNotHandled += (s, e) => { keyDownNotHandled = true; }; - view.NewKeyDownEvent ( - new ( - KeyCode.Null - | (shift ? KeyCode.ShiftMask : 0) - | (alt ? KeyCode.AltMask : 0) - | (control ? KeyCode.CtrlMask : 0) - ) - ); + view.NewKeyDownEvent (new Key (KeyCode.Null | (shift ? KeyCode.ShiftMask : 0) | (alt ? KeyCode.AltMask : 0) | (control ? KeyCode.CtrlMask : 0))); Assert.True (keyDownNotHandled); Assert.True (view.OnKeyDownCalled); Assert.True (view.OnProcessKeyDownCalled); @@ -106,15 +98,14 @@ public void NewKeyDownEvent_Handled_True_Stops_Processing () keyDown = true; }; - view.KeyDownNotHandled += (s, e) => - { - Assert.Equal (KeyCode.A, e.KeyCode); - Assert.False (keyDownNotHandled); - Assert.False (view.OnProcessKeyDownCalled); - e.Handled = true; - keyDownNotHandled = true; - }; + { + Assert.Equal (KeyCode.A, e.KeyCode); + Assert.False (keyDownNotHandled); + Assert.False (view.OnProcessKeyDownCalled); + e.Handled = true; + keyDownNotHandled = true; + }; view.NewKeyDownEvent (Key.A); Assert.True (keyDown); @@ -139,11 +130,11 @@ public void NewKeyDownEvent_KeyDown_Handled_Stops_Processing () }; view.KeyDownNotHandled += (s, e) => - { - keyDownNotHandled = true; - Assert.False (e.Handled); - Assert.Equal (KeyCode.N, e.KeyCode); - }; + { + keyDownNotHandled = true; + Assert.False (e.Handled); + Assert.Equal (KeyCode.N, e.KeyCode); + }; view.NewKeyDownEvent (Key.N); Assert.True (keyDownNotHandled); @@ -174,13 +165,13 @@ public void NewKeyDownEvent_ProcessKeyDown_Handled_Stops_Processing () }; view.KeyDownNotHandled += (s, e) => - { - Assert.Equal (KeyCode.A, e.KeyCode); - Assert.False (keyDownNotHandled); - Assert.True (view.OnProcessKeyDownCalled); - e.Handled = true; - keyDownNotHandled = true; - }; + { + Assert.Equal (KeyCode.A, e.KeyCode); + Assert.False (keyDownNotHandled); + Assert.True (view.OnProcessKeyDownCalled); + e.Handled = true; + keyDownNotHandled = true; + }; view.NewKeyDownEvent (Key.A); Assert.True (keyDown); @@ -216,10 +207,45 @@ public KeyBindingsTestView () public bool? CommandReturns { get; set; } } + /// + /// Baseline: A view that does NOT subscribe to KeyDownNotHandled does not interfere + /// with HotKey routing. Alt+T reaches the sibling Label's HotKey as expected. + /// + [Fact] + public void AltKey_Routed_To_Sibling_HotKey_When_FocusedView_Does_Not_Handle_KeyDownNotHandled () + { + // Copilot + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Window win = new (); + + Label label = new () { Text = "_Type text here:" }; + var hotKeyInvoked = false; + label.HandlingHotKey += (_, _) => hotKeyInvoked = true; + + // A plain view that can focus but does NOT handle KeyDownNotHandled + View focusable = new () { CanFocus = true, Width = 20, Y = 1 }; + + win.Add (label, focusable); + + SessionToken token = app.Begin (win); + focusable.SetFocus (); + Assert.True (focusable.HasFocus); + + Key altT = new (Key.T.WithAlt) { AssociatedText = "t" }; + app.InjectKey (altT); + + Assert.True (hotKeyInvoked, "Label's HotKey should fire when focused view ignores the key"); + + app.End (token!); + win.Dispose (); + } + /// A view that overrides the OnKey* methods so we can test that they are called. public class OnNewKeyTestView : View { - public OnNewKeyTestView () { CanFocus = true; } + public OnNewKeyTestView () => CanFocus = true; public bool CancelVirtualMethods { set; private get; } public bool OnKeyDownCalled { get; set; } public bool OnProcessKeyDownCalled { get; set; } diff --git a/Tests/UnitTestsParallelizable/ViewBase/ViewCommandTests.cs b/Tests/UnitTestsParallelizable/ViewBase/ViewCommandTests.cs index abef857eaf..d60517b8b1 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/ViewCommandTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/ViewCommandTests.cs @@ -263,6 +263,18 @@ void ViewOnActivating (object? sender, CommandEventArgs e) } } + [Fact] + public void HotKey_Command_Returns_True () + { + View view = new (); + + bool? result = view.InvokeCommand (Command.HotKey); + + Assert.True (result); + + view.Dispose (); + } + #endregion HotKey tests #region InvokeCommand Tests diff --git a/Tests/UnitTestsParallelizable/Views/ButtonTests.cs b/Tests/UnitTestsParallelizable/Views/ButtonTests.cs index a4cdbc7132..e765cf23d9 100644 --- a/Tests/UnitTestsParallelizable/Views/ButtonTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ButtonTests.cs @@ -117,7 +117,7 @@ public void Command_HotKey_RaisesAccepting_AndActivating () bool? result = button.InvokeCommand (Command.HotKey); - // HotKey should raise only Accepting, not Activating + // HotKey should raise Accepting and Activating Assert.True (activatingFired); Assert.True (acceptingFired); Assert.True (result); diff --git a/Tests/UnitTestsParallelizable/Views/HexViewTests.cs b/Tests/UnitTestsParallelizable/Views/HexViewTests.cs index 34fa1ab76f..5ca5c9571e 100644 --- a/Tests/UnitTestsParallelizable/Views/HexViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/HexViewTests.cs @@ -449,4 +449,73 @@ public void HexView_DoubleClick_TogglesSide () hexView.Dispose (); } + + /// + /// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4963 + /// Ctrl-modified keys must not edit hex data even when they look like valid hex digits. + /// + [Fact] + public void CtrlKey_Does_Not_Edit_HexView () + { + // Copilot + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + HexView hexView = new (new MemoryStream ([0x00, 0x01, 0x02, 0x03])) + { + Width = 80, + Height = 10 + }; + + Window win = new (); + win.Add (hexView); + + SessionToken? token = app.Begin (win); + hexView.SetFocus (); + Assert.True (hexView.HasFocus); + + // Ctrl+A with AssociatedText — 'A' is a valid hex digit but should NOT be inserted + Key ctrlA = new (Key.A.WithCtrl) { AssociatedText = "a" }; + hexView.NewKeyDownEvent (ctrlA); + + Assert.Empty (hexView.Edits); + + app.End (token!); + win.Dispose (); + } + + /// + /// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4963 + /// Alt-modified keys on the right (text) side must not edit data. + /// + [Fact] + public void AltKey_Does_Not_Edit_HexView_RightSide () + { + // Copilot + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + HexView hexView = new (new MemoryStream ([0x00, 0x01, 0x02, 0x03])) + { + Width = 80, + Height = 10 + }; + + Window win = new (); + win.Add (hexView); + + SessionToken? token = app.Begin (win); + hexView.SetFocus (); + + // Switch to right side by pressing Tab + hexView.NewKeyDownEvent (Key.Tab); + + Key altT = new (Key.T.WithAlt) { AssociatedText = "t" }; + hexView.NewKeyDownEvent (altT); + + Assert.Empty (hexView.Edits); + + app.End (token!); + win.Dispose (); + } } diff --git a/Tests/UnitTestsParallelizable/Views/LabelTests.cs b/Tests/UnitTestsParallelizable/Views/LabelTests.cs index cdc8a91af0..10e26ede2f 100644 --- a/Tests/UnitTestsParallelizable/Views/LabelTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LabelTests.cs @@ -233,7 +233,7 @@ public void CanFocus_True_HotKey_SetsFocus () Assert.True (view.HasFocus); // No focused view accepts Tab, and there's no other view to focus, so OnKeyDown returns false - Assert.True (app.Keyboard.RaiseKeyDownEvent (label.HotKey)); + app.Keyboard.RaiseKeyDownEvent (label.HotKey); Assert.True (label.HasFocus); Assert.False (view.HasFocus); } @@ -342,9 +342,7 @@ public void Label_CannotFocus_ByDefault () label.Dispose (); } - // Claude - Opus 4.5 - // Behavior documented in docfx/docs/command.md - View Command Behaviors table - // This test verifies current behavior which may change per issue #4473 + // BUGBUG: This test does not actually test what it says; just tests that the invoke returns true [Fact] public void Label_HotKey_ForwardsToNextFocusable () { diff --git a/Tests/UnitTestsParallelizable/Views/TableViewBaselineTests.cs b/Tests/UnitTestsParallelizable/Views/TableViewBaselineTests.cs new file mode 100644 index 0000000000..74828bbc04 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/TableViewBaselineTests.cs @@ -0,0 +1,869 @@ +// Baseline tests for TableView. These lock in current correct behavior +// so that the upcoming redesign (Issue #5064) doesn't introduce silent regressions. +// All tests in this file MUST PASS against the current (pre-refactor) code. + +using System.Data; +using JetBrains.Annotations; +using UnitTests; + +// ReSharper disable PossibleMultipleEnumeration +#pragma warning disable xUnit2012 + +namespace ViewsTests; + +[TestSubject (typeof (TableView))] +public class TableViewBaselineTests : TestDriverBase +{ + #region Helpers + + private static DataTableSource BuildTable (int cols, int rows) => BuildTable (cols, rows, out _); + + private static DataTableSource BuildTable (int cols, int rows, out DataTable dt) + { + dt = new DataTable (); + + for (var c = 0; c < cols; c++) + { + dt.Columns.Add ("Col" + c); + } + + for (var r = 0; r < rows; r++) + { + DataRow newRow = dt.NewRow (); + + for (var c = 0; c < cols; c++) + { + newRow [c] = $"R{r}C{c}"; + } + + dt.Rows.Add (newRow); + } + + return new DataTableSource (dt); + } + + /// Creates a TableView with the given dimensions and data, fully initialized. + private static TableView CreateTableView (int cols, int rows, int viewportWidth = 25, int viewportHeight = 5) + { + TableView tv = new () { Table = BuildTable (cols, rows), MultiSelect = true, Viewport = new Rectangle (0, 0, viewportWidth, viewportHeight) }; + tv.BeginInit (); + tv.EndInit (); + + return tv; + } + + #endregion + + #region A. Arrow Key Cell Movement + + [Fact] + public void ArrowRight_MovesCursorRight () + { + TableView tv = CreateTableView (5, 10); + + // Table setter puts us at (0,0) + Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.Cursor.Y); + + tv.NewKeyDownEvent (Key.CursorRight); + Assert.Equal (1, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.Cursor.Y); + } + + [Fact] + public void ArrowDown_MovesCursorDown () + { + TableView tv = CreateTableView (5, 10); + tv.NewKeyDownEvent (Key.CursorDown); + Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (1, tv.Value!.Cursor.Y); + } + + [Fact] + public void ArrowLeft_AtColumn0_DoesNotGoNegative () + { + TableView tv = CreateTableView (5, 10); + Assert.Equal (0, tv.Value!.Cursor.X); + + // Left at col 0 — should not go negative + // HACK: Without Application/focus context, the command returns false + // and doesn't transfer focus. The key assertion is column stays at 0. + tv.NewKeyDownEvent (Key.CursorLeft); + Assert.Equal (0, tv.Value!.Cursor.X); + } + + [Fact] + public void ArrowUp_AtRow0_DoesNotGoNegative () + { + TableView tv = CreateTableView (5, 10); + Assert.Equal (0, tv.Value!.Cursor.Y); + + tv.NewKeyDownEvent (Key.CursorUp); + Assert.Equal (0, tv.Value!.Cursor.Y); + } + + [Fact] + public void ArrowRight_AtLastColumn_ClampsToLastColumn () + { + TableView tv = CreateTableView (3, 5); + tv.SetSelection (2, tv.Value?.Cursor.Y ?? 0, false); // last column (0-indexed) + tv.NewKeyDownEvent (Key.CursorRight); + Assert.Equal (2, tv.Value!.Cursor.X); + } + + [Fact] + public void ArrowDown_AtLastRow_ClampsToLastRow () + { + TableView tv = CreateTableView (3, 5); + tv.SetSelection (tv.Value?.Cursor.X ?? 0, 4, false); // last row (0-indexed) + tv.NewKeyDownEvent (Key.CursorDown); + Assert.Equal (4, tv.Value!.Cursor.Y); + } + + [Fact] + public void ArrowKeys_MultipleSteps_TraversesGrid () + { + TableView tv = CreateTableView (5, 10); + + // Move to (2, 3) + tv.NewKeyDownEvent (Key.CursorRight); + tv.NewKeyDownEvent (Key.CursorRight); + tv.NewKeyDownEvent (Key.CursorDown); + tv.NewKeyDownEvent (Key.CursorDown); + tv.NewKeyDownEvent (Key.CursorDown); + + Assert.Equal (2, tv.Value!.Cursor.X); + Assert.Equal (3, tv.Value!.Cursor.Y); + } + + #endregion + + #region B. Page/Home/End Navigation + + [Fact] + public void PageDown_MovesByViewportHeight () + { + TableView tv = CreateTableView (3, 50, viewportHeight: 10); + Assert.Equal (0, tv.Value!.Cursor.Y); + + tv.PageDown (false, null); + Assert.Equal (10, tv.Value!.Cursor.Y); + } + + [Fact] + public void PageUp_MovesByViewportHeight () + { + TableView tv = CreateTableView (3, 50, viewportHeight: 10); + tv.SetSelection (tv.Value?.Cursor.X ?? 0, 20, false); + + tv.PageUp (false, null); + Assert.Equal (10, tv.Value!.Cursor.Y); + } + + [Fact] + public void PageDown_ClampsAtLastRow () + { + TableView tv = CreateTableView (3, 5, viewportHeight: 10); + Assert.Equal (0, tv.Value!.Cursor.Y); + + tv.PageDown (false, null); + Assert.Equal (4, tv.Value!.Cursor.Y); // last row is 4 (0-indexed, 5 rows) + } + + [Fact] + public void PageUp_ClampsAtRow0 () + { + TableView tv = CreateTableView (3, 50, viewportHeight: 10); + tv.SetSelection (tv.Value?.Cursor.X ?? 0, 3, false); + + tv.PageUp (false, null); + Assert.Equal (0, tv.Value!.Cursor.Y); + } + + [Fact] + public void Home_Key_MovesToStartOfRow () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (3, tv.Value?.Cursor.Y ?? 0, false); + + tv.NewKeyDownEvent (Key.Home); + Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.Cursor.Y); // row unchanged + } + + [Fact] + public void End_Key_MovesToEndOfRow () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (1, tv.Value?.Cursor.Y ?? 0, false); + + tv.NewKeyDownEvent (Key.End); + Assert.Equal (4, tv.Value!.Cursor.X); // last column (0-indexed, 5 cols) + Assert.Equal (0, tv.Value!.Cursor.Y); // row unchanged + } + + [Fact] + public void MoveCursorToStartOfTable_MovesToOrigin () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (3, 7, false); + + tv.MoveCursorToStartOfTable (false, null); + Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.Cursor.Y); + } + + [Fact] + public void MoveCursorToEndOfTable_MovesToLastCell () + { + TableView tv = CreateTableView (5, 10); + tv.MoveCursorToEndOfTable (false, null); + Assert.Equal (4, tv.Value!.Cursor.X); + Assert.Equal (9, tv.Value!.Cursor.Y); + } + + [Fact] + public void MoveCursorToEndOfTable_FullRowSelect_KeepsColumn () + { + TableView tv = CreateTableView (5, 10); + tv.FullRowSelect = true; + tv.SetSelection (2, tv.Value?.Cursor.Y ?? 0, false); + + tv.MoveCursorToEndOfTable (false, null); + Assert.Equal (2, tv.Value!.Cursor.X); // column preserved with FullRowSelect + Assert.Equal (9, tv.Value!.Cursor.Y); + } + + [Fact] + public void MoveCursorToStartOfRow_API () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (3, 5, false); + + tv.MoveCursorToStartOfRow (false, null); + Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (5, tv.Value!.Cursor.Y); // row unchanged + } + + [Fact] + public void MoveCursorToEndOfRow_API () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (1, 5, false); + + tv.MoveCursorToEndOfRow (false, null); + Assert.Equal (4, tv.Value!.Cursor.X); + Assert.Equal (5, tv.Value!.Cursor.Y); + } + + #endregion + + #region C. Selection Changed Events + + [Fact] + public void ArrowDown_FiresValueChanged () + { + TableView tv = CreateTableView (5, 10); + var fired = false; + Point? oldCursor = null; + Point? newCursor = null; + + tv.ValueChanged += (_, e) => + { + fired = true; + oldCursor = e.OldValue?.Cursor; + newCursor = e.NewValue?.Cursor; + }; + + tv.NewKeyDownEvent (Key.CursorDown); + Assert.True (fired); + Assert.Equal (new Point (0, 0), oldCursor); + Assert.Equal (new Point (0, 1), newCursor); + } + + [Fact] + public void SetSelection_SameValue_DoesNotFireEvent () + { + TableView tv = CreateTableView (5, 10); + var fireCount = 0; + tv.ValueChanged += (_, _) => fireCount++; + + // Setting to same value should not fire + tv.SetSelection (0, 0, false); + Assert.Equal (0, fireCount); + } + + [Fact] + public void SelectedColumn_Set_FiresValueChanged () + { + TableView tv = CreateTableView (5, 10); + var fired = false; + tv.ValueChanged += (_, _) => fired = true; + + tv.SetSelection (2, 0, false); + Assert.True (fired); + } + + [Fact] + public void SelectedRow_Set_FiresValueChanged () + { + TableView tv = CreateTableView (5, 10); + var fired = false; + tv.ValueChanged += (_, _) => fired = true; + + tv.SetSelection (0, 3, false); + Assert.True (fired); + } + + #endregion + + #region D. Multi-Select Baseline + + [Fact] + public void Toggle_AddsCurrentCellToMultiSelect () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (1, 2, false); + + tv.InvokeCommand (Command.ToggleExtend); + Assert.True (tv.IsSelected (1, 2)); + Assert.Single (tv.MultiSelectedRegions); + } + + [Fact] + public void Toggle_TwiceOnSameCell_RemovesFromMultiSelect () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (1, 2, false); + + tv.InvokeCommand (Command.ToggleExtend); + Assert.True (tv.MultiSelectedRegions.Any (r => r.IsExtended)); + + tv.InvokeCommand (Command.ToggleExtend); + + // After toggling off, the toggled region should be removed + Assert.DoesNotContain (tv.MultiSelectedRegions, r => r.IsExtended && r.Rectangle.Contains (1, 2)); + } + + [Fact] + public void Toggle_MultiSelectFalse_SelectionUnchanged () + { + TableView tv = CreateTableView (5, 10); + tv.MultiSelect = false; + tv.SetSelection (1, 2, false); + + tv.InvokeCommand (Command.ToggleExtend); + + // With MultiSelect=false, toggle should not add regions + Assert.Empty (tv.MultiSelectedRegions); + } + + [Fact] + public void Space_Key_TogglesSelection () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (0, 0, false); + + tv.NewKeyDownEvent (Key.Space); + Assert.True (tv.MultiSelectedRegions.Count > 0); + } + + [Fact] + public void SelectAll_SelectsEntireTable () + { + TableView tv = CreateTableView (4, 4); + tv.SelectAll (); + Assert.Equal (16, tv.GetAllSelectedCells ().Count ()); + } + + [Fact] + public void SelectAll_MultiSelectFalse_NoEffect () + { + TableView tv = CreateTableView (4, 4); + tv.MultiSelect = false; + tv.SelectAll (); + + // Without multi-select, SelectAll is a no-op + Assert.Empty (tv.MultiSelectedRegions); + } + + [Fact] + public void GetAllSelectedCells_NoCursorRegion_ReturnsCursorOnly () + { + TableView tv = CreateTableView (5, 10); + IEnumerable cells = tv.GetAllSelectedCells (); + Assert.Single (cells); + Assert.Contains (new Point (0, 0), cells); + } + + [Fact] + public void IsSelected_CursorCell_ReturnsTrue () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (2, 3, false); + Assert.True (tv.IsSelected (2, 3)); + } + + [Fact] + public void IsSelected_NonCursorCell_ReturnsFalse () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (0, 0, false); + Assert.False (tv.IsSelected (1, 1)); + } + + [Fact] + public void FullRowSelect_IsSelected_ReturnsTrueForEntireRow () + { + TableView tv = CreateTableView (5, 10); + tv.FullRowSelect = true; + tv.SetSelection (tv.Value?.Cursor.X ?? 0, 3, false); + + for (var col = 0; col < 5; col++) + { + Assert.True (tv.IsSelected (col, 3), $"Column {col} in selected row should be selected"); + } + + Assert.False (tv.IsSelected (0, 4), "Cell in non-selected row should not be selected"); + } + + [Fact] + public void ExtendSelection_ShiftRight_CreatesRegion () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (1, 1, false); + + tv.MoveCursorByOffset (1, 0, true, null); + + Assert.True (tv.IsSelected (1, 1), "Origin cell should be selected"); + Assert.True (tv.IsSelected (2, 1), "Extended cell should be selected"); + Assert.Equal (2, tv.Value!.Cursor.X); + } + + [Fact] + public void ExtendSelection_ShiftDown_CreatesRegion () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (0, 0, false); + + tv.MoveCursorByOffset (0, 2, true, null); + + Assert.True (tv.IsSelected (0, 0)); + Assert.True (tv.IsSelected (0, 1)); + Assert.True (tv.IsSelected (0, 2)); + Assert.Equal (2, tv.Value!.Cursor.Y); + } + + #endregion + + #region D2. Ctrl+Click Toggle (Mouse-based ToggleExtend) + + [Fact] + public void CtrlClick_AddsRegionAtClickedCell () // Copilot + { + // Test that Ctrl+Click (UnionSelection path) adds a region at the clicked cell. + TableView tv = CreateTableView (5, 10); + tv.SetSelection (0, 0, false); + tv.RefreshContentSize (); + + // Find what cell position (1, 3) maps to + Point? cell = tv.ScreenToCell (1, 3); + Assert.NotNull (cell); + + // Invoke ToggleExtend with a mouse binding context simulating Ctrl+Click + MouseBinding mouseBinding = new ([Command.ToggleExtend], new Mouse { Position = new Point (1, 3), Flags = MouseFlags.LeftButtonClicked | MouseFlags.Ctrl }); + CommandContext ctx = new () { Command = Command.ToggleExtend, Source = new WeakReference (tv), Binding = mouseBinding }; + tv.InvokeCommand (Command.ToggleExtend, ctx); + + Assert.True (tv.IsSelected (cell.Value.X, cell.Value.Y), "Ctrl+Click should select the clicked cell"); + Assert.True (tv.MultiSelectedRegions.Count > 0, "Ctrl+Click should add a region"); + } + + [Fact] + public void CtrlClick_TwiceOnSameCell_RemovesRegion () // Copilot + { + // Bug: UnionSelection (Ctrl+Click path) always adds regions but never removes them. + // Ctrl+Clicking the same cell twice should toggle it OFF (remove the region). + TableView tv = CreateTableView (5, 10); + tv.SetSelection (0, 0, false); + tv.RefreshContentSize (); + + Point? cell = tv.ScreenToCell (1, 3); + Assert.NotNull (cell); + int clickedCol = cell.Value.X; + int clickedRow = cell.Value.Y; + + // First Ctrl+Click — adds region + MouseBinding mouseBinding1 = new ([Command.ToggleExtend], new Mouse { Position = new Point (1, 3), Flags = MouseFlags.LeftButtonClicked | MouseFlags.Ctrl }); + CommandContext ctx1 = new () { Command = Command.ToggleExtend, Source = new WeakReference (tv), Binding = mouseBinding1 }; + tv.InvokeCommand (Command.ToggleExtend, ctx1); + Assert.Contains (tv.MultiSelectedRegions, r => r.Rectangle.Contains (clickedCol, clickedRow)); + + // Second Ctrl+Click on the same cell — should toggle OFF (remove the region) + MouseBinding mouseBinding2 = new ([Command.ToggleExtend], new Mouse { Position = new Point (1, 3), Flags = MouseFlags.LeftButtonClicked | MouseFlags.Ctrl }); + CommandContext ctx2 = new () { Command = Command.ToggleExtend, Source = new WeakReference (tv), Binding = mouseBinding2 }; + tv.InvokeCommand (Command.ToggleExtend, ctx2); + + // The region at the clicked cell should be removed (cursor may still be there, but no region) + Assert.DoesNotContain (tv.MultiSelectedRegions, r => r.Rectangle.Contains (clickedCol, clickedRow)); + } + + #endregion + + #region E. Edge Cases + + [Fact] + public void NullTable_ArrowKeysDoNotThrow () + { + TableView tv = new () { Viewport = new Rectangle (0, 0, 25, 5) }; + tv.BeginInit (); + tv.EndInit (); + + // Arrow keys are safe with null Table + tv.NewKeyDownEvent (Key.CursorRight); + tv.NewKeyDownEvent (Key.CursorDown); + tv.NewKeyDownEvent (Key.CursorLeft); + tv.NewKeyDownEvent (Key.CursorUp); + } + + [Fact] + public void NullTable_HomeEnd_DoesNotThrow () + { + // Previously this threw NullReferenceException because MoveCursorToEndOfRow + // used Table! without null check. Now fixed with null guard. + TableView tv = new () { Viewport = new Rectangle (0, 0, 25, 5) }; + tv.BeginInit (); + tv.EndInit (); + + tv.NewKeyDownEvent (Key.Home); + tv.NewKeyDownEvent (Key.End); + } + + [Fact] + public void NullTable_SelectedColumnAndRow_AreDefaults () + { + TableView tv = new (); + Assert.Null (tv.Value); + } + + [Fact] + public void EmptyTable_NoRows_NavigationDoesNotThrow () + { + DataTable dt = new (); + dt.Columns.Add ("Col0"); + + // 0 rows + + TableView tv = new () { Table = new DataTableSource (dt), Viewport = new Rectangle (0, 0, 25, 5) }; + tv.BeginInit (); + tv.EndInit (); + + tv.NewKeyDownEvent (Key.CursorDown); + tv.NewKeyDownEvent (Key.CursorRight); + } + + [Fact] + public void SingleCell_Table_BoundaryNavigation () + { + TableView tv = CreateTableView (1, 1); + Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.Cursor.Y); + + // Can't move anywhere + tv.NewKeyDownEvent (Key.CursorRight); + Assert.Equal (0, tv.Value!.Cursor.X); + + tv.NewKeyDownEvent (Key.CursorDown); + Assert.Equal (0, tv.Value!.Cursor.Y); + } + + [Fact] + public void SelectedColumn_SetBeyondBounds_Clamped () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (100, tv.Value?.Cursor.Y ?? 0, false); + Assert.Equal (4, tv.Value!.Cursor.X); // clamped to last column + } + + [Fact] + public void SelectedRow_SetBeyondBounds_Clamped () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (tv.Value?.Cursor.X ?? 0, 100, false); + Assert.Equal (9, tv.Value!.Cursor.Y); // clamped to last row + } + + [Fact] + public void SelectedColumn_SetNegative_ClampedToZero () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (-5, tv.Value?.Cursor.Y ?? 0, false); + Assert.Equal (0, tv.Value!.Cursor.X); + } + + [Fact] + public void SelectedRow_SetNegative_ClampedToZero () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (tv.Value?.Cursor.X ?? 0, -5, false); + Assert.Equal (0, tv.Value!.Cursor.Y); + } + + [Fact] + public void SetTable_SetsSelectionToOrigin () + { + TableView tv = new (); + Assert.Null (tv.Value); + + tv.Table = BuildTable (5, 10); + + // Table setter calls SetSelection(0, 0, false) + Assert.Equal (0, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.Cursor.Y); + } + + [Fact] + public void SetTable_Null_AfterHavingData () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (3, 7, false); + + tv.Table = null; + + // With null Table, Value becomes null and cursor resets to -1. + Assert.Null (tv.Value); + } + + [Fact] + public void GetAllSelectedCells_EmptyTable_ReturnsEmpty () + { + DataTable dt = new (); + dt.Columns.Add ("Col0"); + + // 0 rows + + TableView tv = new () { Table = new DataTableSource (dt), Viewport = new Rectangle (0, 0, 25, 5) }; + tv.BeginInit (); + tv.EndInit (); + + IEnumerable cells = tv.GetAllSelectedCells (); + Assert.Empty (cells); + } + + #endregion + + #region F. IValue Baseline + + [Fact] + public void Value_ReflectsCursorPosition () + { + TableView tv = CreateTableView (5, 10); + Assert.NotNull (tv.Value); + Assert.Equal (new Point (0, 0), tv.Value!.Cursor); + + tv.SetSelection (2, tv.Value?.Cursor.Y ?? 0, false); + Assert.Equal (new Point (2, 0), tv.Value!.Cursor); + + tv.SetSelection (tv.Value?.Cursor.X ?? 0, 3, false); + Assert.Equal (new Point (2, 3), tv.Value!.Cursor); + } + + [Fact] + public void Value_UpdatedByNavigation () + { + TableView tv = CreateTableView (5, 10); + tv.NewKeyDownEvent (Key.CursorRight); + tv.NewKeyDownEvent (Key.CursorDown); + Assert.NotNull (tv.Value); + Assert.Equal (new Point (1, 1), tv.Value!.Cursor); + } + + [Fact] + public void Value_SetByTableSetter () + { + TableView tv = new (); + + // Before Table is set, Value is null (no selection). + Assert.Null (tv.Value); + + tv.Table = BuildTable (5, 10); + Assert.NotNull (tv.Value); + Assert.Equal (new Point (0, 0), tv.Value!.Cursor); + } + + [Fact] + public void ValueChanged_FiresOnNavigation () + { + TableView tv = CreateTableView (5, 10); + var fired = false; + TableSelection? oldVal = null; + TableSelection? newVal = null; + + tv.ValueChanged += (_, e) => + { + fired = true; + oldVal = e.OldValue; + newVal = e.NewValue; + }; + + tv.NewKeyDownEvent (Key.CursorDown); + Assert.True (fired); + Assert.NotNull (oldVal); + Assert.Equal (new Point (0, 0), oldVal!.Cursor); + Assert.NotNull (newVal); + Assert.Equal (new Point (0, 1), newVal!.Cursor); + } + + #endregion + + #region G. Accept / Accepted + + [Fact] + public void Accept_Command_FiresAccepted () + { + TableView tv = CreateTableView (5, 10); + var fired = false; + tv.Accepted += (_, _) => fired = true; + + tv.InvokeCommand (Command.Accept); + Assert.True (fired); + } + + [Fact] + public void Enter_Key_FiresAccepted () + { + TableView tv = CreateTableView (5, 10); + var fired = false; + tv.Accepted += (_, _) => fired = true; + + tv.NewKeyDownEvent (Key.Enter); + Assert.True (fired); + } + + #endregion + + #region H. EnsureCursorIsVisible + + [Fact] + public void EnsureCursorIsVisible_NullTable_DoesNotThrow () + { + TableView tv = new () { Viewport = new Rectangle (0, 0, 25, 5) }; + + // Should not throw + tv.EnsureCursorIsVisible (); + } + + [Fact] + public void EnsureCursorIsVisible_ScrollsRowIntoView () + { + TableView tv = CreateTableView (3, 50, viewportHeight: 5); + + // Move to a row that is beyond viewport + tv.SetSelection (tv.Value?.Cursor.X ?? 0, 20, false); + tv.EnsureCursorIsVisible (); + + // After ensuring visibility, Viewport.Y should have adjusted + // so that row 20 is visible (i.e., Viewport.Y <= 20 < Viewport.Y + Viewport.Height) + Assert.True (tv.Viewport.Y <= 20, $"Viewport.Y ({tv.Viewport.Y}) should be <= 20"); + + // HACK: The exact Viewport.Y depends on header height calculation. + // We just assert the row is in the visible range. + int visibleEnd = tv.Viewport.Y + tv.Viewport.Height - 1; + Assert.True (visibleEnd >= 20, $"Visible end ({visibleEnd}) should be >= 20"); + } + + #endregion + + #region I. MoveCursorByOffset + + [Fact] + public void MoveCursorByOffset_Positive_MovesRight () + { + TableView tv = CreateTableView (5, 10); + tv.MoveCursorByOffset (2, 0, false, null); + Assert.Equal (2, tv.Value!.Cursor.X); + Assert.Equal (0, tv.Value!.Cursor.Y); + } + + [Fact] + public void MoveCursorByOffset_Negative_MovesLeft () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (3, tv.Value?.Cursor.Y ?? 0, false); + tv.MoveCursorByOffset (-2, 0, false, null); + Assert.Equal (1, tv.Value!.Cursor.X); + } + + [Fact] + public void MoveCursorByOffset_Extend_CreatesMultiSelectRegion () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (0, 0, false); + + tv.MoveCursorByOffset (2, 2, true, null); + + Assert.Equal (2, tv.Value!.Cursor.X); + Assert.Equal (2, tv.Value!.Cursor.Y); + Assert.True (tv.IsSelected (0, 0), "Origin should still be selected"); + Assert.True (tv.IsSelected (2, 2), "New position should be selected"); + Assert.True (tv.IsSelected (1, 1), "Cell in between should be selected"); + } + + [Fact] + public void MoveCursorByOffset_ClampsAtBounds () + { + TableView tv = CreateTableView (3, 5); + tv.SetSelection (2, 4, false); + + tv.MoveCursorByOffset (5, 5, false, null); + Assert.Equal (2, tv.Value!.Cursor.X); // clamped + Assert.Equal (4, tv.Value!.Cursor.Y); // clamped + } + + #endregion + + #region J. SetSelection + + [Fact] + public void SetSelection_MovesToSpecifiedCell () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (3, 7, false); + + Assert.Equal (3, tv.Value!.Cursor.X); + Assert.Equal (7, tv.Value!.Cursor.Y); + } + + [Fact] + public void SetSelection_Extend_KeepsRegion () + { + TableView tv = CreateTableView (5, 10); + tv.SetSelection (1, 1, false); + tv.SetSelection (3, 3, true); + + Assert.Equal (3, tv.Value!.Cursor.X); + Assert.Equal (3, tv.Value!.Cursor.Y); + Assert.True (tv.IsSelected (1, 1), "Origin of extend should be selected"); + Assert.True (tv.IsSelected (2, 2), "Interior cell should be selected"); + Assert.True (tv.IsSelected (3, 3), "End of extend should be selected"); + } + + [Fact] + public void SetSelection_NoExtend_ClearsOldRegions () + { + TableView tv = CreateTableView (5, 10); + + // Create a multi-select region + tv.SetSelection (0, 0, false); + tv.SetSelection (2, 2, true); + Assert.True (tv.MultiSelectedRegions.Count > 0); + + // Non-extend set clears regions (except toggled ones) + tv.SetSelection (4, 4, false); + Assert.False (tv.IsSelected (0, 0), "Old origin should no longer be selected"); + Assert.False (tv.IsSelected (2, 2), "Old extent should no longer be selected"); + Assert.True (tv.IsSelected (4, 4), "New position should be selected"); + } + + #endregion +} diff --git a/Tests/UnitTestsParallelizable/Views/TableViewLegacyTests.cs b/Tests/UnitTestsParallelizable/Views/TableViewLegacyTests.cs index ea4c538745..7f1436feeb 100644 --- a/Tests/UnitTestsParallelizable/Views/TableViewLegacyTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TableViewLegacyTests.cs @@ -1,5 +1,5 @@ // Copilot -#nullable enable + using System.Data; using UnitTests; @@ -32,13 +32,13 @@ private static DataTableSource BuildTable (int cols, int rows) dt.Rows.Add (newRow); } - return new (dt); + return new DataTableSource (dt); } [Fact] public void DeleteRow_SelectAll_AdjustsSelectionToPreventOverrun () { - TableView tableView = new () { Table = BuildTable (4, 4, out DataTable dt), MultiSelect = true, Viewport = new (0, 0, 10, 5) }; + TableView tableView = new () { Table = BuildTable (4, 4, out DataTable dt), MultiSelect = true, Viewport = new Rectangle (0, 0, 10, 5) }; tableView.BeginInit (); tableView.EndInit (); @@ -55,13 +55,13 @@ public void DeleteRow_SelectAll_AdjustsSelectionToPreventOverrun () [Fact] public void DeleteRow_SelectLastRow_AdjustsSelectionToPreventOverrun () { - TableView tableView = new () { Table = BuildTable (4, 4, out DataTable dt), MultiSelect = true, Viewport = new (0, 0, 10, 5) }; + TableView tableView = new () { Table = BuildTable (4, 4, out DataTable dt), MultiSelect = true, Viewport = new Rectangle (0, 0, 10, 5) }; tableView.BeginInit (); tableView.EndInit (); - tableView.ChangeSelectionToEndOfTable (false); + tableView.MoveCursorToEndOfTable (false, null); tableView.MultiSelectedRegions.Clear (); - tableView.MultiSelectedRegions.Push (new (new (0, 3), new (0, 3, 4, 1))); + tableView.MultiSelectedRegions.Push (new TableSelectionRegion (new Point (0, 3), new Rectangle (0, 3, 4, 1))); Assert.Equal (4, tableView.GetAllSelectedCells ().Count ()); @@ -77,7 +77,7 @@ public void EnsureValidScrollOffsets_LoadSmallerTable () TableView tableView = new (); tableView.BeginInit (); tableView.EndInit (); - tableView.Viewport = new (0, 0, 25, 10); + tableView.Viewport = new Rectangle (0, 0, 25, 10); tableView.Table = BuildTable (25, 50); tableView.RowOffset = 20; @@ -97,7 +97,7 @@ public void EnsureValidScrollOffsets_LoadSmallerTable () public void EnsureValidScrollOffsets_WithNoCells () { TableView tableView = new (); - tableView.Table = new DataTableSource (new ()); + tableView.Table = new DataTableSource (new DataTable ()); tableView.EnsureValidScrollOffsets (); @@ -108,25 +108,24 @@ public void EnsureValidScrollOffsets_WithNoCells () [Fact] public void GetAllSelectedCells_TwoIsolatedSelections_ReturnsSix () { - TableView tableView = new () { Table = BuildTable (20, 20), MultiSelect = true, Viewport = new (0, 0, 10, 5) }; + TableView tableView = new () { Table = BuildTable (20, 20), MultiSelect = true, Viewport = new Rectangle (0, 0, 10, 5) }; tableView.BeginInit (); tableView.EndInit (); tableView.MultiSelectedRegions.Clear (); - tableView.MultiSelectedRegions.Push (new (new (1, 1), new (1, 1, 2, 2))); - tableView.MultiSelectedRegions.Push (new (new (7, 3), new (7, 3, 2, 1))); - tableView.SelectedColumn = 8; - tableView.SelectedRow = 3; + tableView.MultiSelectedRegions.Push (new TableSelectionRegion (new Point (1, 1), new Rectangle (1, 1, 2, 2)) { IsExtended = true }); + tableView.MultiSelectedRegions.Push (new TableSelectionRegion (new Point (7, 3), new Rectangle (7, 3, 2, 1)) { IsExtended = true }); + tableView.SetSelection (8, 3, false); Point [] selected = tableView.GetAllSelectedCells ().ToArray (); Assert.Equal (6, selected.Length); - Assert.Equal (new (1, 1), selected [0]); - Assert.Equal (new (2, 1), selected [1]); - Assert.Equal (new (1, 2), selected [2]); - Assert.Equal (new (2, 2), selected [3]); - Assert.Equal (new (7, 3), selected [4]); - Assert.Equal (new (8, 3), selected [5]); + Assert.Equal (new Point (1, 1), selected [0]); + Assert.Equal (new Point (2, 1), selected [1]); + Assert.Equal (new Point (1, 2), selected [2]); + Assert.Equal (new Point (2, 2), selected [3]); + Assert.Equal (new Point (7, 3), selected [4]); + Assert.Equal (new Point (8, 3), selected [5]); } [Fact] @@ -147,35 +146,36 @@ public void IsSelected_MultiSelectionOn_BoxSelection () } [Fact] - public void SelectedCellChanged_NotFiredForSameValue () + public void ValueChanged_NotFiredForSameValue () { TableView tableView = new () { Table = BuildTable (25, 50) }; - bool called = false; - tableView.SelectedCellChanged += (_, _) => { called = true; }; + var called = false; + tableView.ValueChanged += (_, _) => { called = true; }; - tableView.SelectedColumn = 0; + // Initial value is already at (0,0), setting same should not fire + tableView.SetSelection (0, 0, false); Assert.False (called); - tableView.SelectedColumn = 10; + tableView.SetSelection (10, 0, false); Assert.True (called); } [Fact] - public void SelectedCellChanged_SelectedColumnIndexesCorrect () + public void ValueChanged_CursorIndexesCorrect () { TableView tableView = new () { Table = BuildTable (25, 50) }; - bool called = false; + var called = false; - tableView.SelectedCellChanged += (_, e) => - { - called = true; - Assert.Equal (0, e.OldCol); - Assert.Equal (10, e.NewCol); - }; + tableView.ValueChanged += (_, e) => + { + called = true; + Assert.Equal (0, e.OldValue!.Cursor.X); + Assert.Equal (10, e.NewValue!.Cursor.X); + }; - tableView.SelectedColumn = 10; + tableView.SetSelection (10, 0, false); Assert.True (called); } @@ -200,7 +200,7 @@ public void TestDataColumnCaption () [InlineData (false, 1, 0)] public void TableCollectionNavigator_FullRowSelect_True_False (bool fullRowSelect, int selectedCol, int expectedRow) { - TableView tableView = new () { FullRowSelect = fullRowSelect, SelectedColumn = selectedCol }; + TableView tableView = new () { FullRowSelect = fullRowSelect }; tableView.BeginInit (); tableView.EndInit (); @@ -210,7 +210,7 @@ public void TableCollectionNavigator_FullRowSelect_True_False (bool fullRowSelec dt.Rows.Add (1, 2); dt.Rows.Add (3, 4); tableView.Table = new DataTableSource (dt); - tableView.SelectedColumn = selectedCol; + tableView.SetSelection (selectedCol, tableView.Value?.Cursor.Y ?? 0, false); Assert.Equal (expectedRow, tableView.CollectionNavigator.GetNextMatchingItem (0, "3".ToCharArray () [0])); } @@ -218,10 +218,8 @@ public void TableCollectionNavigator_FullRowSelect_True_False (bool fullRowSelec [Fact] public void EnumerableTableSource_ColumnNamesAndRowCount () { - EnumerableTableSource source = new ( - [typeof (string), typeof (int), typeof (float)], - new () { { "Name", t => t.Name }, { "Namespace", t => t.Namespace! } } - ); + EnumerableTableSource source = new ([typeof (string), typeof (int), typeof (float)], + new Dictionary> { { "Name", t => t.Name }, { "Namespace", t => t.Namespace! } }); Assert.Equal (2, source.Columns); Assert.Equal (3, source.Rows); @@ -239,7 +237,7 @@ public void CheckBoxTableSourceWrapperByIndex_TogglesRow () dt.Rows.Add (1); dt.Rows.Add (2); - TableView tv = new () { Viewport = new (0, 0, 20, 5) }; + TableView tv = new () { Viewport = new Rectangle (0, 0, 20, 5) }; tv.BeginInit (); tv.EndInit (); tv.Table = new DataTableSource (dt); @@ -264,7 +262,7 @@ public void CheckBoxTableSourceWrapperByIndex_TogglesRow () private static DataTableSource BuildTable (int cols, int rows, out DataTable dt) { - dt = new (); + dt = new DataTable (); for (var c = 0; c < cols; c++) { @@ -283,6 +281,6 @@ private static DataTableSource BuildTable (int cols, int rows, out DataTable dt) dt.Rows.Add (newRow); } - return new (dt); + return new DataTableSource (dt); } } diff --git a/Tests/UnitTestsParallelizable/Views/TableViewTests.cs b/Tests/UnitTestsParallelizable/Views/TableViewTests.cs index 8f088dfb7f..7d1fe8e004 100644 --- a/Tests/UnitTestsParallelizable/Views/TableViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TableViewTests.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Reflection; using JetBrains.Annotations; using UnitTests; @@ -10,10 +11,10 @@ public class TableViewTests : TestDriverBase [Fact] public void CanTabOutOfTableViewUsingCursor_Left () { - GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField tf2); + GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField _); // Make the selected cell one in - tableView.SelectedColumn = 1; + tableView.SetSelection (1, tableView.Value?.Cursor.Y ?? 0, false); // Pressing left should move us to the first column without changing focus tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorLeft); @@ -33,10 +34,10 @@ public void CanTabOutOfTableViewUsingCursor_Left () [Fact] public void CanTabOutOfTableViewUsingCursor_Up () { - GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField tf2); + GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField _); // Make the selected cell one in - tableView.SelectedRow = 1; + tableView.SetSelection (tableView.Value?.Cursor.X ?? 0, 1, false); // First press should move us up tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorUp); @@ -56,10 +57,10 @@ public void CanTabOutOfTableViewUsingCursor_Up () [Fact] public void CanTabOutOfTableViewUsingCursor_Right () { - GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField tf2); + GetTableViewWithSiblings (out TextField _, out TableView tableView, out TextField tf2); // Make the selected cell one in from the rightmost column - tableView.SelectedColumn = tableView.Table!.Columns - 2; + tableView.SetSelection (tableView.Table!.Columns - 2, tableView.Value?.Cursor.Y ?? 0, false); // First press should move us to the rightmost column without changing focus tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorRight); @@ -79,10 +80,10 @@ public void CanTabOutOfTableViewUsingCursor_Right () [Fact] public void CanTabOutOfTableViewUsingCursor_Down () { - GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField tf2); + GetTableViewWithSiblings (out TextField _, out TableView tableView, out TextField tf2); // Make the selected cell one in from the bottommost row - tableView.SelectedRow = tableView.Table!.Rows - 2; + tableView.SetSelection (tableView.Value?.Cursor.X ?? 0, tableView.Table!.Rows - 2, false); // First press should move us to the bottommost row without changing focus tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorDown); @@ -102,10 +103,10 @@ public void CanTabOutOfTableViewUsingCursor_Down () [Fact] public void CanTabOutOfTableViewUsingCursor_Left_ClearsSelectionFirst () { - GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField tf2); + GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField _); // Make the selected cell one in - tableView.SelectedColumn = 1; + tableView.SetSelection (1, tableView.Value?.Cursor.Y ?? 0, false); // Pressing shift-left should give us a multi selection tableView.App!.Keyboard.RaiseKeyDownEvent (Key.CursorLeft.WithShift); @@ -135,23 +136,23 @@ public void CanTabOutOfTableViewUsingCursor_Left_ClearsSelectionFirst () /// /// Creates 3 views on with the focus in the - /// . This is a helper method to setup tests that want to + /// . This is a helper method to set up tests that want to /// explore moving input focus out of a tableview. /// - /// /// + /// /// - private void GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField tf2) + private static void GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField tf2) { - IApplication? app = Application.Create (); - Runnable? runnable = new (); + IApplication app = Application.Create (); + Runnable runnable = new (); app.Begin (runnable); - tableView = new (); - tableView.Viewport = new (0, 0, 25, 10); + tableView = new TableView (); + tableView.Viewport = new Rectangle (0, 0, 25, 10); - tf1 = new (); - tf2 = new (); + tf1 = new TextField (); + tf2 = new TextField (); runnable.Add (tf1); runnable.Add (tableView); runnable.Add (tf2); @@ -170,10 +171,11 @@ private void GetTableViewWithSiblings (out TextField tf1, out TableView tableVie /// Builds a simple table of string columns with the requested number of columns and rows /// /// + /// /// public static DataTableSource BuildTable (int cols, int rows, out DataTable dt) { - dt = new (); + dt = new DataTable (); for (var c = 0; c < cols; c++) { @@ -192,7 +194,7 @@ public static DataTableSource BuildTable (int cols, int rows, out DataTable dt) dt.Rows.Add (newRow); } - return new (dt); + return new DataTableSource (dt); } [Fact] @@ -213,25 +215,48 @@ public void TableView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator () tableView.HasFocus = true; tableView.KeyBindings.Add (Key.B, Command.Down); - Assert.Equal (0, tableView.SelectedRow); + Assert.Equal (0, tableView.Value!.Cursor.Y); // Keys should be consumed to move down the navigation i.e. to apricot Assert.True (tableView.NewKeyDownEvent (Key.B)); - Assert.Equal (1, tableView.SelectedRow); + Assert.Equal (1, tableView.Value!.Cursor.Y); Assert.True (tableView.NewKeyDownEvent (Key.B)); - Assert.Equal (2, tableView.SelectedRow); + Assert.Equal (2, tableView.Value!.Cursor.Y); // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle Assert.True (tableView.NewKeyDownEvent (Key.C)); - Assert.Equal (5, tableView.SelectedRow); + Assert.Equal (5, tableView.Value!.Cursor.Y); } - // Claude - Opus 4.5 - // Behavior documented in docfx/docs/command.md - View Command Behaviors table - // This test verifies current behavior which may change per issue #4473 [Fact] - public void TableView_Command_Activate_TogglesSelection () + public void TableView_CollectionNavigatorMatcher_HotKey_Finds_Item () + { + var dt = new DataTable (); + dt.Columns.Add ("blah"); + + dt.Rows.Add ("apricot"); + dt.Rows.Add ("arm"); + dt.Rows.Add ("bat"); + dt.Rows.Add ("batman"); + dt.Rows.Add ("bates hotel"); + dt.Rows.Add ("candle"); + + var tableView = new TableView (); + tableView.HotKey = Key.B; + tableView.Table = new DataTableSource (dt); + tableView.HasFocus = true; + + Assert.Equal (0, tableView.Value!.Cursor.Y); + + Assert.True (tableView.NewKeyDownEvent (Key.B)); + Assert.Equal (2, tableView.Value!.Cursor.Y); + } + + // Copilot + // Behavior: Space toggles multi-selection via ToggleExtend command + [Fact] + public void TableView_ToggleExtend_TogglesSelection () { var dt = new DataTable (); dt.Columns.Add ("Col1"); @@ -242,45 +267,36 @@ public void TableView_Command_Activate_TogglesSelection () tableView.BeginInit (); tableView.EndInit (); - // Space toggles cell selection (Activate command) - // Note: Returns false because RaiseActivating has no subscribers - // but the selection is still toggled - bool? result = tableView.InvokeCommand (Command.Activate); + tableView.InvokeCommand (Command.ToggleExtend); - // Command toggles selection but returns false (event not handled) - Assert.False (result); + Assert.True (tableView.MultiSelectedRegions.Count > 0); tableView.Dispose (); } - // Claude - Opus 4.5 - // Behavior documented in docfx/docs/command.md - View Command Behaviors table - // This test verifies current behavior which may change per issue #4473 + // Copilot [Fact] - public void TableView_Command_Accept_FiresCellActivated () + public void TableView_Command_Accept_FiresAccepted () { var dt = new DataTable (); dt.Columns.Add ("Col1"); dt.Rows.Add ("Data1"); TableView tableView = new () { Table = new DataTableSource (dt) }; - var cellActivatedFired = false; + var acceptedFired = false; - tableView.CellActivated += (_, _) => cellActivatedFired = true; + tableView.Accepted += (_, _) => acceptedFired = true; - bool? result = tableView.InvokeCommand (Command.Accept); + tableView.InvokeCommand (Command.Accept); - Assert.True (cellActivatedFired); - Assert.True (result); + Assert.True (acceptedFired); tableView.Dispose (); } - // Claude - Opus 4.5 - // Behavior documented in docfx/docs/command.md - View Command Behaviors table - // This test verifies current behavior which may change per issue #4473 + // Copilot [Fact] - public void TableView_Space_TogglesSelection () + public void TableView_Space_AddsToMultiSelectedRegions () { var dt = new DataTable (); dt.Columns.Add ("Col1"); @@ -290,36 +306,147 @@ public void TableView_Space_TogglesSelection () tableView.BeginInit (); tableView.EndInit (); - // Space triggers cell toggle (selection is toggled even though return value is false) - // This is because TableView.Activate returns false when no Activating handler sets Handled=true - bool? result = tableView.NewKeyDownEvent (Key.Space); + tableView.NewKeyDownEvent (Key.Space); - // Returns false because there's no handler that sets Handled=true - Assert.False (result); + Assert.True (tableView.MultiSelectedRegions.Count > 0); tableView.Dispose (); } - // Claude - Opus 4.5 - // Behavior documented in docfx/docs/command.md - View Command Behaviors table - // This test verifies current behavior which may change per issue #4473 + // Copilot [Fact] - public void TableView_Enter_FiresCellActivated () + public void TableView_Enter_FiresAccepted () { var dt = new DataTable (); dt.Columns.Add ("Col1"); dt.Rows.Add ("Data1"); TableView tableView = new () { Table = new DataTableSource (dt) }; - var cellActivatedFired = false; + var acceptedFired = false; + + tableView.Accepted += (_, _) => acceptedFired = true; + + tableView.NewKeyDownEvent (Key.Enter); + + Assert.True (acceptedFired); + + tableView.Dispose (); + } - tableView.CellActivated += (_, _) => cellActivatedFired = true; + // Copilot - regression: TableCollectionNavigator must not throw when table is null and view is focused + [Fact] + public void TableCollectionNavigator_NullTable_HasFocus_DoesNotThrow () + { + TableView tableView = new (); + tableView.HasFocus = true; - // Enter should trigger CellActivated via Accept command - bool? result = tableView.NewKeyDownEvent (Key.Enter); + // Table is null + HasFocus=true - keystroke navigation reached via OnKeyDownNotHandled + // should not throw InvalidOperationException from GetCollectionLength + Exception? ex = Record.Exception (() => tableView.NewKeyDownEvent (Key.A)); - Assert.True (cellActivatedFired); - Assert.True (result); + Assert.Null (ex); + } + + // Copilot - regression: TableCollectionNavigator must not throw when a cell value is null (custom ITableSource) + [Fact] + public void TableCollectionNavigator_NullCellValue_DoesNotThrow () + { + // Use a custom ITableSource that can return null for cell values + // (DataTable wraps null as DBNull.Value, so we need a custom source to test actual null) + TableView tableView = new () { Table = new NullCellTableSource () }; + tableView.HasFocus = true; + + // Pressing 'a' triggers keystroke navigation; row 0 has null cell, row 1 has "apple" + // Should not throw InvalidOperationException from ElementAt + Exception? ex = Record.Exception (() => tableView.NewKeyDownEvent (Key.A)); + + Assert.Null (ex); + + // Should land on "apple" (row 1), skipping the null-cell row gracefully + Assert.Equal (1, tableView.Value!.Cursor.Y); + + tableView.Dispose (); + } + + // Copilot - regression: TableCollectionNavigator returns string.Empty for DBNull cells + [Fact] + public void TableCollectionNavigator_DBNullCellValue_DoesNotThrow () + { + DataTable dt = new (); + dt.Columns.Add ("Col1"); + dt.Rows.Add (DBNull.Value); // DataTable stores this as DBNull.Value + dt.Rows.Add ("banana"); + dt.Rows.Add ("berry"); + + TableView tableView = new () { Table = new DataTableSource (dt) }; + tableView.HasFocus = true; + + Exception? ex = Record.Exception (() => tableView.NewKeyDownEvent (Key.B)); + + Assert.Null (ex); + Assert.Equal (1, tableView.Value!.Cursor.Y); + + tableView.Dispose (); + } + + /// A minimal that returns for the first cell. + private sealed class NullCellTableSource : ITableSource + { + // Row 0 intentionally holds null to exercise null-cell handling in TableCollectionNavigator + private readonly object? [] _data = [null, "apple", "apricot"]; + + public object this [int row, int col] + { +#pragma warning disable CS8603 // Possible null reference return - intentional for testing null-cell handling + get => _data [row]; +#pragma warning restore CS8603 + } + + public int Rows => _data.Length; + + public int Columns => 1; + + public string [] ColumnNames => ["Col1"]; + } + + // Copilot - regression: ColumnOffset setter must not throw when all columns are hidden (0 visible columns) + [Fact] + public void ColumnOffset_AllColumnsHidden_DoesNotThrow () + { + DataTable dt = new (); + dt.Columns.Add ("Col1"); + dt.Rows.Add ("a"); + + TableView tableView = new () { Table = new DataTableSource (dt) }; + tableView.BeginInit (); + tableView.EndInit (); + + // Hide the only column — this makes the cache empty (0 visible columns) + tableView.Style.GetOrCreateColumnStyle (0).Visible = false; + tableView.Update (); + + // Setting ColumnOffset=0 with an empty render cache previously computed value=-1 + // and then indexed _columnsToRenderCache![-1], causing IndexOutOfRangeException + Exception? ex = Record.Exception (() => tableView.ColumnOffset = 0); + + Assert.Null (ex); + Assert.Equal (0, tableView.ColumnOffset); + + tableView.Dispose (); + } + + // Copilot - regression: ColumnOffset setter must not throw when table is null + [Fact] + public void ColumnOffset_NullTable_DoesNotThrow () + { + TableView tableView = new (); + tableView.BeginInit (); + tableView.EndInit (); + + Exception? ex = Record.Exception (() => tableView.ColumnOffset = 0); + + Assert.Null (ex); + Assert.Equal (0, tableView.ColumnOffset); tableView.Dispose (); } @@ -327,27 +454,72 @@ public void TableView_Enter_FiresCellActivated () [Fact] public void Test_SumColumnWidth_GraphemeClusters () { - string family = "\U0001F468\u200D\U0001F469\u200D\U0001F466\u200D\U0001F466"; // 👨‍👩‍👦‍👦 + var family = "\U0001F468\u200D\U0001F469\u200D\U0001F466\u200D\U0001F466"; // 👨‍👩‍👦‍👦 Assert.Equal (8, family.EnumerateRunes ().Sum (c => c.GetColumns ())); Assert.Equal (2, family.GetColumns ()); - string technologist = "\U0001F469\u200D\U0001F4BB"; // 👩‍💻 + var technologist = "\U0001F469\u200D\U0001F4BB"; // 👩‍💻 Assert.Equal (4, technologist.EnumerateRunes ().Sum (c => c.GetColumns ())); Assert.Equal (2, technologist.GetColumns ()); } + // Copilot + [Fact] + public void TruncateOrPad_SurrogatePairs_DoesNotThrowOrCorrupt () + { + // TruncateOrPad iterates `char` values and casts each to `Rune`. + // Surrogate pairs (emoji, CJK supplementary) are two `char`s in UTF-16. + // Casting an isolated high/low surrogate to Rune throws ArgumentOutOfRangeException. + const string CELL_VALUE = "\U0001F389Hello"; // 🎉Hello — emoji is a surrogate pair + + // Sanity checks + Assert.True (char.IsHighSurrogate (CELL_VALUE [0])); + Assert.True (char.IsLowSurrogate (CELL_VALUE [1])); + Assert.Equal (7, CELL_VALUE.GetColumns ()); // emoji=2 + Hello=5 + + // Call private static TruncateOrPad via reflection with availableHorizontalSpace < string width + MethodInfo? method = typeof (TableView).GetMethod ("TruncateOrPad", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull (method); + + // availableHorizontalSpace=4 forces the truncation branch (7 >= 4) + Exception? ex = Record.Exception (() => method.Invoke (null, [CELL_VALUE, CELL_VALUE, 4, null])); + + // Bug: this throws TargetInvocationException wrapping ArgumentOutOfRangeException + // because (Rune)highSurrogate is invalid + Assert.Null (ex); + + var result = (string)method.Invoke (null, [CELL_VALUE, CELL_VALUE, 4, null])!; + + // Result must not contain isolated surrogates (paired surrogates in emoji are fine) + for (var i = 0; i < result.Length; i++) + { + if (char.IsHighSurrogate (result [i])) + { + Assert.True (i + 1 < result.Length && char.IsLowSurrogate (result [i + 1]), $"Isolated high surrogate at index {i}"); + i++; // skip the low surrogate + } + else + { + Assert.False (char.IsLowSurrogate (result [i]), $"Isolated low surrogate 0x{(int)result [i]:X4} at index {i}"); + } + } + + // Result width should not exceed available space + Assert.True (result.GetColumns () <= 4, $"Truncated result '{result}' exceeds available space"); + } + [Fact] public void Test_CalculateMaxCellWidth_UsesGraphemeWidth () { // setup IDriver driver = CreateTestDriver (); - string family = "\U0001F468\u200D\U0001F469\u200D\U0001F466\u200D\U0001F466"; // 👨‍👩‍👦‍👦 + var family = "\U0001F468\u200D\U0001F469\u200D\U0001F466\u200D\U0001F466"; // 👨‍👩‍👦‍👦 var tableView = new TableView { Driver = driver }; tableView.BeginInit (); tableView.EndInit (); tableView.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Accent); - tableView.Viewport = new (0, 0, 25, 5); + tableView.Viewport = new Rectangle (0, 0, 25, 5); tableView.Style.ShowHorizontalHeaderUnderline = true; tableView.Style.ShowHorizontalHeaderOverline = false; tableView.Style.AlwaysShowHeaders = true; @@ -364,15 +536,17 @@ public void Test_CalculateMaxCellWidth_UsesGraphemeWidth () tableView.Draw (); // verify - string actual = driver.ToString ()!; + var actual = driver.ToString (); string [] lines = actual.Replace ("\r\n", "\n").Split ('\n'); string headerRow = lines.First (l => l.Contains ('A') && l.Contains ('B')); int separatorIndex = headerRow.IndexOf ('│', 1); int separatorColumn = headerRow [..separatorIndex].GetColumns (); - Assert.True ( - separatorColumn <= 5, - $"Column A should be narrow (grapheme width 2), but separator at column {separatorColumn} suggests over-sized column. Header: '{headerRow}'" - ); + Assert.True (separatorColumn <= 5, + $"Column A should be narrow (grapheme width 2), but separator at column { + separatorColumn + } suggests over-sized column. Header: '{ + headerRow + }'"); } } diff --git a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs index 58a7854f50..d5d3890fb3 100644 --- a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs @@ -1504,4 +1504,39 @@ public void Text_Shorter_Than_Width_Should_Not_Scroll () Assert.Equal (0, tf.ScrollOffset); Assert.Equal (15, tf.Viewport.Width); } + + /// + /// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4963 + /// When Kitty keyboard protocol sets AssociatedText on Alt+letter keys, + /// TextField must not insert the text. Alt-modified keys are never text input. + /// + [Fact] + public void AltKey_With_AssociatedText_Does_Not_Insert_Into_TextField () + { + // Copilot + TextField tf = new () { Width = 20 }; + tf.SetFocus (); + + Key altT = new (Key.T.WithAlt) { AssociatedText = "t" }; + tf.NewKeyDownEvent (altT); + + Assert.Equal ("", tf.Text); + } + + /// + /// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4963 + /// Ctrl-modified keys with AssociatedText must not be inserted as text. + /// + [Fact] + public void CtrlKey_With_AssociatedText_Does_Not_Insert_Into_TextField () + { + // Copilot + TextField tf = new () { Width = 20 }; + tf.SetFocus (); + + Key ctrlT = new (Key.T.WithCtrl) { AssociatedText = "t" }; + tf.NewKeyDownEvent (ctrlT); + + Assert.Equal ("", tf.Text); + } } diff --git a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs index f85ef382b9..b960d3a38a 100644 --- a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs @@ -743,4 +743,48 @@ public void Text_Polymorphism_Works () Assert.Equal ("1234", field.Text); Assert.Equal ("1234", field.Text); // Should be same due to polymorphism } + + /// + /// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4963 + /// Alt-modified keys must not be inserted as text input. + /// + [Fact] + public void AltKey_With_AssociatedText_Does_Not_Insert_Into_TextValidateField () + { + // Copilot + TextValidateField field = new () + { + Provider = new NetMaskedTextProvider ("AAAA"), + Width = 20 + }; + field.SetFocus (); + + string before = field.Text; + Key altT = new (Key.T.WithAlt) { AssociatedText = "t" }; + field.NewKeyDownEvent (altT); + + Assert.Equal (before, field.Text); + } + + /// + /// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4963 + /// Ctrl-modified keys must not be inserted as text input. + /// + [Fact] + public void CtrlKey_With_AssociatedText_Does_Not_Insert_Into_TextValidateField () + { + // Copilot + TextValidateField field = new () + { + Provider = new NetMaskedTextProvider ("AAAA"), + Width = 20 + }; + field.SetFocus (); + + string before = field.Text; + Key ctrlT = new (Key.T.WithCtrl) { AssociatedText = "t" }; + field.NewKeyDownEvent (ctrlT); + + Assert.Equal (before, field.Text); + } } diff --git a/Tests/UnitTestsParallelizable/Views/TextViewTests.cs b/Tests/UnitTestsParallelizable/Views/TextViewTests.cs index 6f01192296..e4cc681ed5 100644 --- a/Tests/UnitTestsParallelizable/Views/TextViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextViewTests.cs @@ -3920,4 +3920,39 @@ 2 3 """; DriverAssert.AssertDriverContentsAre (expected, output, app.Driver); } + + /// + /// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4963 + /// When Kitty keyboard protocol sets AssociatedText on Alt+letter keys, + /// TextView must not insert the text. Alt-modified keys are never text input. + /// + [Fact] + public void AltKey_With_AssociatedText_Does_Not_Insert_Into_TextView () + { + // Copilot + TextView tv = new () { Width = 20, Height = 5 }; + tv.SetFocus (); + + Key altT = new (Key.T.WithAlt) { AssociatedText = "t" }; + tv.NewKeyDownEvent (altT); + + Assert.Equal ("", tv.Text); + } + + /// + /// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4963 + /// Ctrl-modified keys with AssociatedText must not be inserted as text. + /// + [Fact] + public void CtrlKey_With_AssociatedText_Does_Not_Insert_Into_TextView () + { + // Copilot + TextView tv = new () { Width = 20, Height = 5 }; + tv.SetFocus (); + + Key ctrlT = new (Key.T.WithCtrl) { AssociatedText = "t" }; + tv.NewKeyDownEvent (ctrlT); + + Assert.Equal ("", tv.Text); + } } diff --git a/docfx/docs/tableview.md b/docfx/docs/tableview.md index 1857a44a22..625ccc6ce7 100644 --- a/docfx/docs/tableview.md +++ b/docfx/docs/tableview.md @@ -1,85 +1,332 @@ -# Table View +# TableView Deep Dive -This control supports viewing and editing tabular data. It provides a view of a [System.DataTable](https://docs.microsoft.com/en-us/dotnet/api/system.data.datatable?view=net-5.0). +[TableView](~/api/Terminal.Gui.Views.TableView.yml) displays infinitely-sized tabular data from any [ITableSource](~/api/Terminal.Gui.Views.ITableSource.yml) and supports keyboard/mouse navigation, multi-cell selection, column styling, and checkbox columns. -System.DataTable is a core class of .net standard and can be created very easily +## Table of Contents -[TableView API Reference](~/api/Terminal.Gui.Views.TableView.yml) +- [Data Sources](#data-sources) +- [Selection Model](#selection-model) +- [Key & Mouse Bindings](#key--mouse-bindings) +- [Rendering & Scrolling](#rendering--scrolling) +- [Column Styling](#column-styling) +- [Checkbox Columns](#checkbox-columns) +- [Tree Tables](#tree-tables) +- [Events](#events) -## Csv Example +--- -You can create a DataTable from a CSV file by creating a new instance and adding columns and rows as you read them. For a robust solution however you might want to look into a CSV parser library that deals with escaping, multi line rows etc. +## Data Sources -```csharp -var dt = new DataTable(); -var lines = File.ReadAllLines(filename); +TableView does **not** own data. Assign an `ITableSource` to the `Table` property. -foreach(var h in lines[0].Split(',')){ - dt.Columns.Add(h); -} +### ITableSource + +The core interface. Implement it to bridge any data model into a TableView: -foreach(var line in lines.Skip(1)) { - dt.Rows.Add(line.Split(',')); +```csharp +public interface ITableSource +{ + int Rows { get; } + int Columns { get; } + string [] ColumnNames { get; } + object this [int row, int col] { get; } } ``` -## Database Example +### Built-in Implementations + +| Class | Use Case | +|-------|----------| +| `DataTableSource` | Wraps a `System.Data.DataTable` | +| `EnumerableTableSource` | Projects a collection of objects into columns via lambdas | +| `ListTableSource` | Wraps an `IList` into a multi-column layout | +| `TreeTableSource` | Adds expand/collapse tree behavior to rows | -All Ado.net database providers (Oracle, MySql, SqlServer etc) support reading data as DataTables for example: +### DataTable Example ```csharp -var dt = new DataTable(); +DataTable dt = new (); +dt.Columns.Add ("Name"); +dt.Columns.Add ("Age", typeof (int)); +dt.Rows.Add ("Alice", 30); +dt.Rows.Add ("Bob", 25); -using(var con = new SqlConnection("Server=myServerAddress;Database=myDataBase;Trusted_Connection=True;")) +TableView tv = new () { Table = new DataTableSource (dt) }; +``` + +### Object Collection Example + +```csharp +TableView tv = new () { - con.Open(); - var cmd = new SqlCommand("select * from myTable;",con); - var adapter = new SqlDataAdapter(cmd); + Table = new EnumerableTableSource ( + Process.GetProcesses (), + new Dictionary> () + { + { "ID", p => p.Id }, + { "Name", p => p.ProcessName }, + { "Threads", p => p.Threads.Count }, + }) +}; +``` - adapter.Fill(dt); +### CSV Example + +```csharp +DataTable dt = new (); +string [] lines = File.ReadAllLines (filename); + +foreach (string h in lines [0].Split (',')) +{ + dt.Columns.Add (h); } + +foreach (string line in lines.Skip (1)) +{ + dt.Rows.Add (line.Split (',')); +} + +TableView tv = new () { Table = new DataTableSource (dt) }; +``` + +--- + +## Selection Model + +TableView implements `IValue` to expose the complete selection state as a single value. + +### Key Types + +| Type | Description | +|------|-------------| +| `TableSelection` | Immutable snapshot: `Cursor` (a `Point`) + `Regions` (an `IReadOnlyList`) | +| `TableSelectionRegion` | A contiguous rectangular selection. Has `Origin`, `Rectangle`, and `IsExtended` | +| `Value` property | The current `TableSelection?`. `null` means no table is set or selection was cleared | + +### Cursor + +The cursor is the active cell — the anchor for navigation. Access it via `Value.Cursor` (`Point` where `X` = column index, `Y` = row index). + +Move the cursor programmatically with `SetSelection (col, row, extend)`. + +### Multi-Selection + +When `MultiSelect` is `true` (the default), users can create rectangular selection regions: + +- **Shift+Arrow** — extends a region from the cursor to the new position +- **Ctrl+Click** — unions the clicked cell as an independent extended selection +- **Space** (`Command.ToggleExtend`) — toggles the current cell's `IsExtended` state +- **Ctrl+A** — selects all cells + +Extended regions (`IsExtended = true`) persist through keyboard navigation. Non-extended regions are cleared on the next cursor move. + +### FullRowSelect + +When `FullRowSelect` is `true`, entire rows are selected instead of individual cells. All cells in the cursor's row are reported as selected by `GetAllSelectedCells ()` and `IsSelected ()`. + +### Reading the Selection + +```csharp +// Cursor position +Point cursor = tv.Value!.Cursor; // (col, row) + +// All selected cell coordinates +IEnumerable cells = tv.GetAllSelectedCells (); + +// Check if a specific cell is selected +bool sel = tv.IsSelected (col, row); ``` -## Displaying the table +--- + +## Key & Mouse Bindings + +### Default Key Bindings + +| Key | Command | +|-----|---------| +| Arrow keys | Move cursor one cell | +| Shift+Arrow | Extend selection | +| PageUp / PageDown | Move one page | +| Home / End | Move to start/end of row | +| Ctrl+Home / Ctrl+End | Move to first/last row | +| Shift+Home/End/Ctrl+Home/Ctrl+End | Extend selection to row/table boundary | +| Ctrl+A | Select all | +| Space | `Command.ToggleExtend` — toggle current cell's extended selection | + +### Default Mouse Bindings + +| Mouse Event | Command | +|-------------|---------| +| Click | `Command.Activate` — moves cursor to clicked cell | +| Ctrl+Click | `Command.ToggleExtend` — unions clicked cell into selection | +| Alt+Click | `Command.ToggleExtend` — extends rectangular region to clicked cell | +| Double-click | `Command.Accept` | +| Scroll wheel | Scroll up/down/left/right | + +### Customizing Bindings -Once you have set up your data table set it in the view: +TableView uses the standard `KeyBindings` and `MouseBindings` infrastructure. Override `DefaultKeyBindings` (static) or instance-level bindings. + +--- + +## Rendering & Scrolling + +TableView renders only the visible portion of the table. Horizontal and vertical scrolling is handled via `ColumnOffset` and `RowOffset` (backed by `Viewport`). + +### Table Rendering Model + +1. **Header** — column names with optional overline, underline, and vertical separators (controlled by `TableStyle`) +2. **Data rows** — rendered from `RowOffset` until viewport is filled +3. **Columns** — rendered from `ColumnOffset` right, each column sized by content width (clamped by `MinCellWidth` / `MaxCellWidth` and per-column `ColumnStyle`) + +### TableStyle + +`TableStyle` controls the visual appearance: + +| Property | Default | Description | +|----------|---------|-------------| +| `ShowHeaders` | `true` | Show column header row | +| `ShowHorizontalHeaderOverline` | `true` | Line above headers | +| `ShowHorizontalHeaderUnderline` | `true` | Line below headers | +| `ShowVerticalCellLines` | `true` | Vertical separators between cells | +| `ShowVerticalHeaderLines` | `true` | Vertical separators between headers | +| `ShowHorizontalBottomLine` | `false` | Line below last row | +| `AlwaysShowHeaders` | `false` | Lock headers when scrolling | +| `ExpandLastColumn` | `true` | Fill remaining space with last column | +| `SmoothHorizontalScrolling` | `true` | Minimal horizontal scroll increments | +| `InvertSelectedCellFirstCharacter` | `false` | Show cursor character inversion | +| `RowColorGetter` | `null` | Custom row coloring delegate | + +### EnsureCursorIsVisible + +After programmatic cursor changes, call `EnsureCursorIsVisible ()` to scroll the viewport so the cursor cell is on screen. `Update ()` does this automatically. + +--- + +## Column Styling + +Use `TableStyle.ColumnStyles` to customize individual columns: ```csharp -tableView = new TableView () { - X = 0, - Y = 0, - Width = 50, - Height = 10, +tv.Style.ColumnStyles [2] = new ColumnStyle +{ + Alignment = Alignment.End, + MaxWidth = 20, + MinWidth = 5, + Format = "C2", // currency format + ColorGetter = args => args.CellValue is int v && v < 0 + ? new Scheme () { Normal = new (Color.Red, Color.Black) } + : null }; +``` + +### ColumnStyle Properties + +| Property | Description | +|----------|-------------| +| `Alignment` | Default text alignment for the column | +| `AlignmentGetter` | Per-cell alignment delegate (overrides `Alignment`) | +| `ColorGetter` | Per-cell `Scheme` delegate | +| `RepresentationGetter` | Custom `object` → `string` conversion | +| `Format` | `IFormattable.ToString` format string | +| `MaxWidth` | Maximum column width in characters | +| `MinWidth` | Minimum column width in characters | +| `MinAcceptableWidth` | Flexible lower bound for column width | +| `Visible` | Hide the column entirely | + +--- + +## Checkbox Columns + +Wrap any `ITableSource` with a checkbox column using `CheckBoxTableSourceWrapperByIndex` or `CheckBoxTableSourceWrapperByObject`: + +```csharp +// By row index +CheckBoxTableSourceWrapperByIndex checkSrc = new (tv, tv.Table!); +tv.Table = checkSrc; + +// Read checked rows +HashSet checked = checkSrc.CheckedRows; +``` -tableView.Table = new DataTableSource(yourDataTable); +```csharp +// By object property +CheckBoxTableSourceWrapperByObject checkSrc = new ( + tv, + enumSource, + obj => obj.IsSelected, + (obj, val) => obj.IsSelected = val +); +tv.Table = checkSrc; ``` -## Object data -If your data objects are not stored in a `System.Data.DataTable` then you can instead -create a table using `EnumerableTableSource` or implementing your own `ITableSource` -class. +Space toggles checkboxes on the selected row(s). Clicking the checkbox column header toggles all rows. Set `UseRadioButtons = true` for single-select radio behavior. + +--- -For example to render data for the currently running processes: +## Tree Tables + +`TreeTableSource` combines `TreeView` expand/collapse with `TableView` column rendering: ```csharp -tableView.Table = new EnumerableTableDataSource (Process.GetProcesses (), - new Dictionary>() { - { "ID",(p)=>p.Id}, - { "Name",(p)=>p.ProcessName}, - { "Threads",(p)=>p.Threads.Count}, - { "Virtual Memory",(p)=>p.VirtualMemorySize64}, - { "Working Memory",(p)=>p.WorkingSet64}, - }); +TreeView tree = new () +{ + TreeBuilder = new DelegateTreeBuilder ( + d => d is DirectoryInfo dir ? dir.GetFileSystemInfos () : [], + d => d is DirectoryInfo), + AspectGetter = f => f.Name +}; + +tree.AddObject (new DirectoryInfo ("/")); + +TreeTableSource src = new ( + tv, + "Name", + tree, + new Dictionary> () + { + { "Size", f => f is FileInfo fi ? fi.Length : 0 }, + { "Modified", f => f.LastWriteTime } + }); + +tv.Table = src; ``` -## Table Rendering -TableView supports any size of table. 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. +Arrow Left/Right collapse/expand nodes when the tree column has focus. + +--- + +## Events + +TableView uses the standard `IValue` and `View` event patterns: -TableView uses `ColumnOffset` and `RowOffset` to determine the first visible cell of the `System.DataTable`. -Rendering then continues until the available console space is exhausted. Updating the `ColumnOffset` and -`RowOffset` changes which part of the table is rendered (scrolls the viewport). +| Event | When | +|-------|------| +| `ValueChanging` | Before `Value` changes. Set `Handled = true` to cancel. | +| `ValueChanged` | After `Value` changed. Use this to react to cursor/selection changes. | +| `Accepted` | User double-clicks or presses the Accept key on a cell. | +| `Activating` | User clicks a cell (`Command.Activate`). | -This approach ensures that no matter how big the table, only a small number of columns/rows need to be -evaluated for rendering. +### Example: Reacting to Cursor Movement + +```csharp +tv.ValueChanged += (sender, e) => +{ + if (e.NewValue is { } sel) + { + statusBar.Text = $"Row {sel.Cursor.Y}, Col {sel.Cursor.X}"; + } +}; +``` + +### Example: Handling Cell Activation + +```csharp +tv.Accepted += (sender, e) => +{ + Point cursor = tv.Value!.Cursor; + object cellValue = tv.Table! [cursor.Y, cursor.X]; + MessageBox.Query ("Cell", $"Value: {cellValue}", "OK"); +}; +```