Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/ui/Logic/TableViewExtras.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -281,6 +293,44 @@ public static TableView MakeTableView(bool alwaysSelected = true, bool multiSele
return tableView;
}

/// <summary>
/// Puts the rows the control considers selected into its <see cref="SelectingItemsControl.SelectedItems"/>
/// collection when that collection is empty but the selection model is not.
/// <para>
/// <see cref="SelectionMode.AlwaysSelected"/> 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).
/// </para>
/// </summary>
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);
}
}
}

/// <summary>
/// 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
Expand Down
146 changes: 146 additions & 0 deletions tests/UI/Logic/TableViewSelectionSyncTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// <see cref="TableViewExtras.MakeTableView"/> repairs the collection on every ItemsSource change.
/// </summary>
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;
}

/// <summary>
/// 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).
/// </summary>
private static (TableView Grid, Vm Vm, ObservableCollection<Row> Items) MakeGridWithRows(int rowCount)
{
var items = new ObservableCollection<Row>();
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<Row>().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<Row>());
}

[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<Row>();
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]);
}
}