diff --git a/src/ui/Logic/TableViewExtras.cs b/src/ui/Logic/TableViewExtras.cs index b146b899ee..62f03cf07a 100644 --- a/src/ui/Logic/TableViewExtras.cs +++ b/src/ui/Logic/TableViewExtras.cs @@ -273,6 +273,18 @@ public static TableView MakeTableView(bool alwaysSelected = true, bool multiSele UiUtil.ApplyTableViewRowStyle(tableView); + // SelectionMode.AlwaysSelected picks row 0 the moment ItemsSource is assigned, but that + // pick only reaches the internal selection model (and SelectedItem/SelectedIndex) - the + // SelectedItems collection stays empty and no SelectionChanged is raised. Repair it + // (#13230), see SyncSelectedItemsWithSelection. + tableView.PropertyChanged += (_, e) => + { + if (e.Property == ItemsControl.ItemsSourceProperty) + { + SyncSelectedItemsWithSelection(tableView); + } + }; + // Home/End/PageUp/PageDown (and Ctrl+Home/End) list navigation in every grid (#13194). // Handlers attached earlier in the routing path (window-level tunnel handlers, shortcut // dispatch) still win; this is the fallback when nothing else handled the key. @@ -281,6 +293,44 @@ public static TableView MakeTableView(bool alwaysSelected = true, bool multiSele return tableView; } + /// + /// Puts the rows the control considers selected into its + /// collection when that collection is empty but the selection model is not. + /// + /// selects row 0 as soon as ItemsSource is assigned + /// to a populated collection. That selection reaches the selection model - the row is drawn + /// highlighted and SelectedItem/SelectedIndex point at it - but SelectedItems is left empty and + /// no SelectionChanged is raised, and neither a layout pass nor a Selection.Clear()/Select(0) + /// repairs it; only moving the selection to a different row does. Everything in Subtitle Edit + /// reads the selection through SelectedItems, so the grid showed row 1 highlighted while the + /// app saw nothing selected: the edit box, Show and Duration went blank the moment anything + /// re-read the selection, and a shift-selection starting at row 1 silently left row 1 out of + /// every operation (issue #13230). + /// + /// + public static void SyncSelectedItemsWithSelection(TableView tableView) + { + var selectedItems = tableView.SelectedItems; + if (selectedItems == null || selectedItems.Count > 0) + { + return; + } + + var selection = tableView.Selection; + if (selection.Count == 0) + { + return; + } + + foreach (var item in selection.SelectedItems) + { + if (item != null) + { + selectedItems.Add(item); + } + } + } + /// /// A read-only text cell whose flow direction follows its own content, the way the /// main subtitle grid's text cells do. Use it for every column showing subtitle text diff --git a/tests/UI/Logic/TableViewSelectionSyncTests.cs b/tests/UI/Logic/TableViewSelectionSyncTests.cs new file mode 100644 index 0000000000..e44bfafa8f --- /dev/null +++ b/tests/UI/Logic/TableViewSelectionSyncTests.cs @@ -0,0 +1,146 @@ +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Logic; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace UITests.Logic; + +/// +/// SelectionMode.AlwaysSelected picks row 0 when ItemsSource is assigned without ever filling the +/// control's SelectedItems collection. Subtitle Edit reads every selection through SelectedItems, +/// so the grid showed the first row highlighted while the app saw nothing selected - the edit box +/// and time codes went blank and a selection starting at row 1 left row 1 out (issue #13230). +/// repairs the collection on every ItemsSource change. +/// +public class TableViewSelectionSyncTests +{ + private sealed class Row + { + public string Text { get; set; } = string.Empty; + } + + private sealed class Vm : INotifyPropertyChanged + { + private object? _selected; + private int? _selectedIndex; + + public object? Selected + { + get => _selected; + set + { + _selected = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Selected))); + } + } + + public int? SelectedIndex + { + get => _selectedIndex; + set + { + _selectedIndex = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedIndex))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } + + /// + /// The main subtitle grid: multi-select, always selected, with the view model's current row and + /// index bound two-way - and filled the way SetSubtitles fills it (detach, fill, re-attach). + /// + private static (TableView Grid, Vm Vm, ObservableCollection Items) MakeGridWithRows(int rowCount) + { + var items = new ObservableCollection(); + var vm = new Vm(); + + var grid = TableViewExtras.MakeTableView(); + grid.Columns.Add(new TableViewColumn { Header = "Text", Binding = new Binding(nameof(Row.Text)) }); + grid.ItemsSource = items; + grid[!TableView.SelectedItemProperty] = new Binding(nameof(vm.Selected)) { Mode = BindingMode.TwoWay, Source = vm }; + grid[!TableView.SelectedIndexProperty] = new Binding(nameof(vm.SelectedIndex)) { Mode = BindingMode.TwoWay, Source = vm }; + + new Window { Width = 400, Height = 300, Content = grid }.Show(); + + grid.ItemsSource = null; + for (var i = 1; i <= rowCount; i++) + { + items.Add(new Row { Text = "line " + i }); + } + + grid.ItemsSource = items; + + return (grid, vm, items); + } + + [AvaloniaFact] + public void FirstRowIsInSelectedItemsAfterFillingTheGrid() + { + var (grid, vm, items) = MakeGridWithRows(20); + + Assert.Same(items[0], grid.SelectedItem); + Assert.Same(items[0], vm.Selected); + Assert.Equal(1, grid.Selection.Count); + + // Without the repair this is empty: the row is highlighted but nothing is selected as far + // as the rest of the application can tell. + Assert.Single(grid.SelectedItems!); + Assert.Same(items[0], grid.SelectedItems![0]); + } + + [AvaloniaFact] + public void SelectingARangeFromTheFirstRowKeepsTheFirstRow() + { + var (grid, _, items) = MakeGridWithRows(20); + + // How the main grid applies a shift-selection (SelectGridRange). + grid.Selection.BeginBatchUpdate(); + grid.Selection.Clear(); + grid.Selection.Select(4); + grid.Selection.SelectRange(0, 4); + grid.Selection.EndBatchUpdate(); + + Assert.Equal(5, grid.Selection.Count); + + // Without the repair row 0 is missing here, so italic/copy/delete skipped the first line. + var selected = grid.SelectedItems!.Cast().Select(r => r.Text).Order().ToArray(); + Assert.Equal(new[] { "line 1", "line 2", "line 3", "line 4", "line 5" }, selected); + Assert.Contains(items[0], grid.SelectedItems!.Cast()); + } + + [AvaloniaFact] + public void EmptyGridSelectsNothing() + { + var (grid, vm, _) = MakeGridWithRows(0); + + Assert.Equal(0, grid.Selection.Count); + Assert.Empty(grid.SelectedItems!); + Assert.Null(grid.SelectedItem); + Assert.Null(vm.Selected); + } + + [AvaloniaFact] + public void RefillingTheGridKeepsSelectedItemsInSync() + { + var (grid, vm, _) = MakeGridWithRows(20); + + // A later reload (format change, undo, import, ...) goes through the same detach/fill/attach. + var newItems = new ObservableCollection(); + for (var i = 1; i <= 5; i++) + { + newItems.Add(new Row { Text = "new " + i }); + } + + grid.ItemsSource = null; + grid.ItemsSource = newItems; + + Assert.Same(newItems[0], grid.SelectedItem); + Assert.Same(newItems[0], vm.Selected); + Assert.Single(grid.SelectedItems!); + Assert.Same(newItems[0], grid.SelectedItems![0]); + } +}