diff --git a/src/ui/Features/Main/Layout/InitLayout.cs b/src/ui/Features/Main/Layout/InitLayout.cs index 1adc68e948c..993227188f8 100644 --- a/src/ui/Features/Main/Layout/InitLayout.cs +++ b/src/ui/Features/Main/Layout/InitLayout.cs @@ -1007,6 +1007,14 @@ private static void CleanupControl(Control? control) dataGrid.Columns.Clear(); dataGrid.ContextFlyout = null; } + // Handle TableView (the main subtitle grid) - clear data bindings and sources + else if (control is TableView tableView) + { + tableView.ItemsSource = null; + tableView.SelectedItem = null; + tableView.Columns.Clear(); + tableView.ContextFlyout = null; + } // Handle TextBox - clear event handlers else if (control is TextBox textBox) { diff --git a/src/ui/Features/Main/Layout/InitListViewAndEditBox.cs b/src/ui/Features/Main/Layout/InitListViewAndEditBox.cs index 1a8af839d4b..a110386a5a0 100644 --- a/src/ui/Features/Main/Layout/InitListViewAndEditBox.cs +++ b/src/ui/Features/Main/Layout/InitListViewAndEditBox.cs @@ -57,26 +57,29 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel RowDefinitions = new RowDefinitions("*,Auto"), }; - vm.SubtitleGrid = new DataGrid - { - Height = double.NaN, - Margin = new Thickness(Se.Settings.Appearance.GridCompactMode ? 0 : 2), - ItemsSource = vm.Subtitles, - CanUserSortColumns = false, - IsReadOnly = true, - SelectionMode = DataGridSelectionMode.Extended, - DataContext = vm.Subtitles, - CanUserResizeColumns = true, - GridLinesVisibility = DataGridGridLinesVisibility.None, // Grid lines are rendered via cell themes - VerticalGridLinesBrush = UiUtil.GetBorderBrush(), - HorizontalGridLinesBrush = UiUtil.GetBorderBrush(), - FontSize = Se.Settings.Appearance.SubtitleGridFontSize, - }; - - // Trough paging and shift+click jump now come from the app-wide DataGrid style - // (DataGridScrollBarBehavior.EnableTroughPaging in Styles.axaml), so no per-grid call here. - - // hack to make drag and drop work on the DataGrid - also on empty rows + // TableView (Avalonia 12.1) pilot #3, after Show history (#12704) and the OCR grid + // (#13001): the main subtitle grid. TableView rows are ListBoxItems, so keyboard + // focus moves to the current row and UI Automation exposes it to screen readers - + // the DataGrid kept focus on itself, which made the grid unusable with a screen + // reader (issue #13015). Grid lines come from the TableView cell themes; sorting + // was already disabled on the DataGrid and TableView has none. + var subtitleGrid = TableViewExtras.MakeTableView(); + subtitleGrid.Height = double.NaN; + subtitleGrid.Margin = new Thickness(Se.Settings.Appearance.GridCompactMode ? 0 : 2); + subtitleGrid.ItemsSource = vm.Subtitles; + subtitleGrid.DataContext = vm.Subtitles; + subtitleGrid.FontSize = Se.Settings.Appearance.SubtitleGridFontSize; + + // Keep the vertical scrollbar at its full width instead of the thin + // expand-on-hover overlay: it then reserves its own layout space, so it never + // covers the outermost text column (the DataGrid needed an empty trailing + // gutter column for this, issue #12351) and it is an easier drag target. + ScrollViewer.SetAllowAutoHide(subtitleGrid, false); + + vm.SubtitleGrid = subtitleGrid; + vm.SubtitleGridDragSelect = new TableViewDragSelect(subtitleGrid, vm.ApplyDragSelectRange); + + // hack to make drag and drop work on the grid - also on empty rows var dropHost = new Border { Background = Brushes.Transparent, @@ -130,38 +133,40 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel } } - // Convert row indexes to alternating background brushes when the option is enabled. - var alternatingRowBrushConverter = alternatingRowBrush == null - ? null - : new FuncValueConverter(index => index % 2 == 1 ? alternatingRowBrush : Brushes.Transparent); + // Collapse hidden rows (style bindings evaluate against the row's item). + TableViewExtras.BindRowProperty(vm.SubtitleGrid, Visual.IsVisibleProperty, + new Binding(nameof(SubtitleLineViewModel.IsHidden)) { Converter = inverseBooleanConverter }); - // Set up data binding for row visibility based on IsHidden property - vm.SubtitleGrid.LoadingRow += (sender, e) => - { - e.Row.Bind(DataGridRow.IsVisibleProperty, new Binding(nameof(SubtitleLineViewModel.IsHidden)) + // Expose "number: text" as the row's accessible name so screen readers announce + // something meaningful when the row takes focus (issue #13015). + TableViewExtras.BindRowProperty(vm.SubtitleGrid, AutomationProperties.NameProperty, + new MultiBinding { - Converter = inverseBooleanConverter + StringFormat = "{0}: {1}", + Bindings = + { + new Binding(nameof(SubtitleLineViewModel.Number)), + new Binding(nameof(SubtitleLineViewModel.Text)), + }, }); - // Tint every other row. Binding to Index keeps the color in sync when rows are recycled, - // inserted, or removed. Selection still wins because :selected overrides BackgroundRectangle.Fill. - if (alternatingRowBrushConverter != null) - { - e.Row.Bind(DataGridRow.BackgroundProperty, new Binding(nameof(DataGridRow.Index)) - { - Source = e.Row, - Converter = alternatingRowBrushConverter, - }); - } - }; + // Tint every other row. Selection still wins because :selected has priority. + if (alternatingRowBrush != null) + { + TableViewExtras.ApplyAlternatingRows(vm.SubtitleGrid, alternatingRowBrush); + } - vm.SubtitleGrid.Columns.Add(new DataGridTemplateColumn + var columnManager = new TableViewColumnManager(vm.SubtitleGrid); + vm.SubtitleGridColumnManager = columnManager; + + columnManager.Add(new SeTableViewColumn { Header = Se.Language.General.NumberSymbol, Tag = SubtitleGridColumnKeys.Number, - Width = new DataGridLength(50), + Width = new GridLength(50), MinWidth = 40, - CellTheme = UiUtil.DataGridNoBorderCellTheme, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, namescope) => new StackPanel { @@ -183,13 +188,14 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel }) }); - var startColumn = new DataGridTemplateColumn + var startColumn = new SeTableViewColumn { Header = Se.Language.General.Show, Tag = SubtitleGridColumnKeys.Start, - Width = new DataGridLength(120), + Width = new GridLength(120), MinWidth = 100, - CellTheme = UiUtil.DataGridNoBorderCellTheme, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var border = new Border @@ -206,20 +212,21 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel return border; }), }; - vm.SubtitleGrid.Columns.Add(startColumn); - startColumn.Bind(DataGridColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnStartTime)) + columnManager.Add(startColumn); + startColumn.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnStartTime)) { Mode = BindingMode.OneWay, Source = vm }); - var hideColumn = new DataGridTemplateColumn + var hideColumn = new SeTableViewColumn { Header = Se.Language.General.Hide, Tag = SubtitleGridColumnKeys.End, - Width = new DataGridLength(120), + Width = new GridLength(120), MinWidth = 100, - CellTheme = UiUtil.DataGridNoBorderCellTheme, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var border = new Border @@ -236,20 +243,23 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel return border; }), }; - vm.SubtitleGrid.Columns.Add(hideColumn); - hideColumn.Bind(DataGridColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnEndTime)) + columnManager.Add(hideColumn); + hideColumn.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnEndTime)) { Mode = BindingMode.OneWay, Source = vm }); - var columnDuration = new DataGridTemplateColumn + // The DataGrid sized this column to content (Auto); TableView's layout treats + // Auto as star, so use a fixed width that fits the "8:88,888" duration format. + var columnDuration = new SeTableViewColumn { Header = Se.Language.General.Duration, Tag = SubtitleGridColumnKeys.Duration, - Width = new DataGridLength(1, DataGridLengthUnitType.Auto), + Width = new GridLength(90), MinWidth = 60, - CellTheme = UiUtil.DataGridNoBorderCellTheme, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var border = new Border @@ -269,20 +279,21 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel return border; }) }; - vm.SubtitleGrid.Columns.Add(columnDuration); - columnDuration.Bind(DataGridTextColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnDuration)) + columnManager.Add(columnDuration); + columnDuration.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnDuration)) { Mode = BindingMode.OneWay, Source = vm, }); - vm.SubtitleGrid.Columns.Add(new DataGridTemplateColumn + columnManager.Add(new SeTableViewColumn { Header = Se.Language.General.Text, Tag = SubtitleGridColumnKeys.Text, - Width = new DataGridLength(1, DataGridLengthUnitType.Star), + Width = new GridLength(1, GridUnitType.Star), MinWidth = 100, - CellTheme = UiUtil.DataGridNoBorderCellTheme, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var border = new Border @@ -312,13 +323,14 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel }) }); - var originalColumn = new DataGridTemplateColumn + var originalColumn = new SeTableViewColumn { Header = Se.Language.General.OriginalText, Tag = SubtitleGridColumnKeys.OriginalText, - Width = new DataGridLength(1, DataGridLengthUnitType.Star), // Stretch text column + Width = new GridLength(1, GridUnitType.Star), // Stretch text column MinWidth = 100, - CellTheme = UiUtil.DataGridNoBorderCellTheme, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var border = new Border @@ -346,20 +358,21 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel return border; }) }; - originalColumn.Bind(DataGridTextColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnOriginalText)) + originalColumn.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnOriginalText)) { Mode = BindingMode.OneWay, Source = vm }); - vm.SubtitleGrid.Columns.Add(originalColumn); + columnManager.Add(originalColumn); - var styleColumn = new DataGridTextColumn + var styleColumn = new SeTableViewColumn { Header = Se.Language.General.Style, Tag = SubtitleGridColumnKeys.Style, Binding = new Binding(nameof(SubtitleLineViewModel.Style)), - Width = new DataGridLength(120), - CellTheme = UiUtil.DataGridNoBorderCellTheme, + Width = new GridLength(120), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, }; var styleColumnMultiBinding = new MultiBinding @@ -371,15 +384,16 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel new Binding(nameof(vm.ShowColumnStyle)) { Source = vm, Mode = BindingMode.OneWay } } }; - styleColumn.Bind(DataGridColumn.IsVisibleProperty, styleColumnMultiBinding); - vm.SubtitleGrid.Columns.Add(styleColumn); + styleColumn.Bind(SeTableViewColumn.IsVisibleProperty, styleColumnMultiBinding); + columnManager.Add(styleColumn); - var columnGap = new DataGridTemplateColumn + var columnGap = new SeTableViewColumn { Header = Se.Language.General.Gap, Tag = SubtitleGridColumnKeys.Gap, - Width = new DataGridLength(100), - CellTheme = UiUtil.DataGridNoBorderCellTheme, + Width = new GridLength(100), + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var border = new Border @@ -399,34 +413,36 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel return border; }) }; - columnGap.Bind(DataGridTextColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnGap)) + columnGap.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnGap)) { Mode = BindingMode.OneWay, Source = vm, }); - vm.SubtitleGrid.Columns.Add(columnGap); + columnManager.Add(columnGap); - var actorColumn = new DataGridTextColumn + var actorColumn = new SeTableViewColumn { Header = Se.Language.General.Actor, Tag = SubtitleGridColumnKeys.Actor, Binding = new Binding(nameof(SubtitleLineViewModel.Actor)) { Mode = BindingMode.OneWay }, - Width = new DataGridLength(120), - CellTheme = UiUtil.DataGridNoBorderCellTheme, + Width = new GridLength(120), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, }; - vm.SubtitleGrid.Columns.Add(actorColumn); - actorColumn.Bind(DataGridColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnActor)) + columnManager.Add(actorColumn); + actorColumn.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnActor)) { Mode = BindingMode.OneWay, Source = vm, }); - var cpsColumn = new DataGridTemplateColumn + var cpsColumn = new SeTableViewColumn { Header = Se.Language.General.Cps, Tag = SubtitleGridColumnKeys.Cps, - Width = new DataGridLength(100), - CellTheme = UiUtil.DataGridNoBorderCellTheme, + Width = new GridLength(100), + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var border = new Border @@ -446,19 +462,20 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel return border; }) }; - vm.SubtitleGrid.Columns.Add(cpsColumn); - cpsColumn.Bind(DataGridColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnCps)) + columnManager.Add(cpsColumn); + cpsColumn.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnCps)) { Mode = BindingMode.OneWay, Source = vm, }); - var wpmColumn = new DataGridTemplateColumn + var wpmColumn = new SeTableViewColumn { Header = Se.Language.General.Wpm, Tag = SubtitleGridColumnKeys.Wpm, - Width = new DataGridLength(100), - CellTheme = UiUtil.DataGridNoBorderCellTheme, + Width = new GridLength(100), + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var border = new Border @@ -478,19 +495,20 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel return border; }) }; - vm.SubtitleGrid.Columns.Add(wpmColumn); - wpmColumn.Bind(DataGridColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnWpm)) + columnManager.Add(wpmColumn); + wpmColumn.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnWpm)) { Mode = BindingMode.OneWay, Source = vm, }); - var pixelWidthColumn = new DataGridTemplateColumn + var pixelWidthColumn = new SeTableViewColumn { Header = Se.Language.General.PixelWidth, Tag = SubtitleGridColumnKeys.PixelWidth, - Width = new DataGridLength(100), - CellTheme = UiUtil.DataGridNoBorderCellTheme, + Width = new GridLength(100), + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, CellTemplate = new FuncDataTemplate((value, nameScope) => { var textBlock = new TextBlock @@ -502,64 +520,44 @@ public static Grid MakeLayoutListViewAndEditBox(MainView mainPage, MainViewModel return textBlock; }) }; - vm.SubtitleGrid.Columns.Add(pixelWidthColumn); - pixelWidthColumn.Bind(DataGridColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnPixelWidth)) + columnManager.Add(pixelWidthColumn); + pixelWidthColumn.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnPixelWidth)) { Mode = BindingMode.OneWay, Source = vm, }); - var layerColumn = new DataGridTextColumn + var layerColumn = new SeTableViewColumn { Header = Se.Language.General.Layer, Tag = SubtitleGridColumnKeys.Layer, Binding = new Binding(nameof(SubtitleLineViewModel.Layer)), - Width = new DataGridLength(23), - CellTheme = UiUtil.DataGridNoBorderCellTheme, + Width = new GridLength(23), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, }; - vm.SubtitleGrid.Columns.Add(layerColumn); - layerColumn.Bind(DataGridColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnLayer)) + columnManager.Add(layerColumn); + layerColumn.Bind(SeTableViewColumn.IsVisibleProperty, new Binding(nameof(vm.ShowColumnLayer)) { Mode = BindingMode.OneWay, Source = vm, }); - // A narrow empty trailing column that reserves space for the DataGrid's overlay - // vertical scrollbar, so the bar covers this gutter instead of the outermost text - // column (issue #12351). This is the same "give the scrollbar its own space" - // approach used for the shortcut key column in the Shortcuts window; a trailing - // column is used here (rather than a fixed cell margin) because right to left mode - // moves the scrollbar to the other side and mirrors the grid, so the gutter follows - // it automatically in both directions without leaving a gap between the Text and - // Original columns in translation mode. Kept out of AutoFitColumns so it stays this - // fixed width, and excluded from width persistence as a non-stretchy layout helper. - vm.SubtitleGrid.Columns.Add(new DataGridTemplateColumn - { - Tag = SubtitleGridColumnKeys.ScrollbarGutter, - Header = string.Empty, - Width = new DataGridLength(20, DataGridLengthUnitType.Pixel), - MinWidth = 0, - CanUserResize = false, - CanUserReorder = false, - CellTheme = UiUtil.DataGridNoBorderCellTheme, - CellTemplate = new FuncDataTemplate((value, nameScope) => new Border()), - }); - - RestoreSubtitleGridColumnWidths(vm.SubtitleGrid); + RestoreSubtitleGridColumnWidths(columnManager); vm.SubtitleGrid.DataContext = vm.Subtitles; vm.SubtitleGrid.SelectionChanged += vm.SubtitleGrid_SelectionChanged; // Set up two-way binding for SelectedItem - vm.SubtitleGrid[!DataGrid.SelectedItemProperty] = new Binding(nameof(vm.SelectedSubtitle)) + vm.SubtitleGrid[!TableView.SelectedItemProperty] = new Binding(nameof(vm.SelectedSubtitle)) { Mode = BindingMode.TwoWay, Source = vm, }; // Set up two-way binding for SelectedIndex - vm.SubtitleGrid[!DataGrid.SelectedIndexProperty] = new Binding(nameof(vm.SelectedSubtitleIndex)) + vm.SubtitleGrid[!TableView.SelectedIndexProperty] = new Binding(nameof(vm.SelectedSubtitleIndex)) { Mode = BindingMode.TwoWay, Source = vm, @@ -1700,14 +1698,13 @@ internal static class SubtitleGridColumnKeys public const string Wpm = "Wpm"; public const string PixelWidth = "PixelWidth"; public const string Layer = "Layer"; - public const string ScrollbarGutter = "ScrollbarGutter"; } // The stretchy text columns keep filling the window, so their width is never stored. private static bool IsStretchyColumn(string key) => key == SubtitleGridColumnKeys.Text || key == SubtitleGridColumnKeys.OriginalText; - private static void RestoreSubtitleGridColumnWidths(DataGrid grid) + private static void RestoreSubtitleGridColumnWidths(TableViewColumnManager columnManager) { var saved = Se.Settings.General.SubtitleGridColumnWidths; if (saved == null || saved.Count == 0) @@ -1715,14 +1712,14 @@ private static void RestoreSubtitleGridColumnWidths(DataGrid grid) return; } - foreach (var column in grid.Columns) + foreach (var column in System.Linq.Enumerable.OfType(columnManager.Columns)) { if (column.Tag is string key && !IsStretchyColumn(key) && saved.TryGetValue(key, out var width) && width > 0) { - column.Width = new DataGridLength(width, DataGridLengthUnitType.Pixel); + column.Width = new GridLength(Math.Max(width, column.MinWidth)); } } } @@ -1730,19 +1727,18 @@ private static void RestoreSubtitleGridColumnWidths(DataGrid grid) // Snapshot the current (actual) width of each fixed column so it can be restored on // the next launch. Called on exit. Hidden columns report ActualWidth 0 and are skipped, // keeping their previously stored width. - public static void SaveSubtitleGridColumnWidths(DataGrid? grid) + public static void SaveSubtitleGridColumnWidths(TableViewColumnManager? columnManager) { - if (grid == null) + if (columnManager == null) { return; } var widths = Se.Settings.General.SubtitleGridColumnWidths ??= new(); - foreach (var column in grid.Columns) + foreach (var column in System.Linq.Enumerable.OfType(columnManager.Columns)) { if (column.Tag is string key && !IsStretchyColumn(key) - && key != SubtitleGridColumnKeys.ScrollbarGutter && column.ActualWidth > 0) { widths[key] = column.ActualWidth; diff --git a/src/ui/Features/Main/MainHelpers/RightToLeftHelper.cs b/src/ui/Features/Main/MainHelpers/RightToLeftHelper.cs index 8fcc20e80e1..503a99f6bd0 100644 --- a/src/ui/Features/Main/MainHelpers/RightToLeftHelper.cs +++ b/src/ui/Features/Main/MainHelpers/RightToLeftHelper.cs @@ -100,7 +100,7 @@ private static void MirrorTextEditGrid(Grid grid, FlowDirection flowDirection) /// the items source re-creates the rows; selection and scroll position are /// restored. /// - internal static void RefreshDataGridBindings(DataGrid? grid, System.Collections.IEnumerable? itemsSource, object? selected) + internal static void RefreshDataGridBindings(TableView? grid, System.Collections.IEnumerable? itemsSource, object? selected) { if (grid == null) { @@ -112,7 +112,7 @@ internal static void RefreshDataGridBindings(DataGrid? grid, System.Collections. if (selected != null) { grid.SelectedItem = selected; - grid.ScrollIntoView(selected, null); + grid.ScrollIntoView(selected); } } @@ -142,6 +142,10 @@ private static void SetFlowDirectionRecursive(Visual visual, FlowDirection flowD { dataGrid.FlowDirection = flowDirection; } + else if (visual is TableView tableView) + { + tableView.FlowDirection = flowDirection; + } else if (visual is Grid grid && grid.Name == "SubtitleTextEditGrid") { MirrorTextEditGrid(grid, flowDirection); diff --git a/src/ui/Features/Main/MainView.cs b/src/ui/Features/Main/MainView.cs index 3c7ab7ab20d..e769e96b373 100644 --- a/src/ui/Features/Main/MainView.cs +++ b/src/ui/Features/Main/MainView.cs @@ -112,7 +112,7 @@ protected override object Build() _vm.ContentGrid.InvalidateMeasure(); _vm.ContentGrid.InvalidateArrange(); - Dispatcher.UIThread.Post(() => _vm.SubtitleGrid.Focus()); + Dispatcher.UIThread.Post(() => TableViewExtras.FocusRow(_vm.SubtitleGrid)); }, DispatcherPriority.Loaded); }; diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index c4e874f7467..59920bdfc2d 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -288,7 +288,13 @@ public partial class MainViewModel : [ObservableProperty] private string _surroundWith3Text; [ObservableProperty] private bool _isSubtitleSecondaryVisible; - public DataGrid SubtitleGrid { get; set; } + public TableView SubtitleGrid { get; set; } + + // ListBox.SelectedItems is nullable (IList?) where DataGrid's was not; the grid + // always provides one, so this non-null view keeps the many call sites tidy. + public System.Collections.IList SubtitleGridSelectedItems => SubtitleGrid.SelectedItems!; + public TableViewColumnManager? SubtitleGridColumnManager { get; set; } + public TableViewDragSelect? SubtitleGridDragSelect { get; set; } public Border? SubtitleGridDropHost { get; set; } public SolidColorBrush? SubtitleGridAlternatingRowBrush { get; set; } public Window? Window { get; set; } @@ -545,7 +551,7 @@ public MainViewModel( EditTextTotalLength = string.Empty; EditTextTotalLengthBackground = Brushes.Transparent; StatusTextLeftLabel = new TextBlock(); - SubtitleGrid = new DataGrid(); + SubtitleGrid = new TableView(); EditTextBox = new TextBoxWrapper(new TextBox()); ContentGrid = new Grid(); MenuReopen = new MenuItem(); @@ -950,7 +956,7 @@ private void SetLayout(int layoutNumber) } SelectAndScrollToRow(Math.Max(0, idx)); - Dispatcher.UIThread.Post(() => SubtitleGrid.Focus()); + Dispatcher.UIThread.Post(() => TableViewExtras.FocusRow(SubtitleGrid)); RefreshSubtitlePreview(); if (!string.IsNullOrEmpty(_videoFileName)) @@ -1076,7 +1082,7 @@ private void PlayNextParagraph(bool loop) return; } - var selectedItems = SubtitleGrid.SelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); SubtitleLineViewModel? next; if (selectedItems.Count > 0) { @@ -1101,7 +1107,7 @@ private void PlayNextParagraph(bool loop) // deferred selection would null _playSelectionItem *after* we set it and break stop/loop. // Change selection first, then assign _playSelectionItem. SubtitleGrid.SelectedItem = next; - SubtitleGrid.ScrollIntoView(next, null); + SubtitleGrid.ScrollIntoView(next); vp.Position = next.StartTime.TotalSeconds; PinPlayheadTo(next.StartTime.TotalSeconds); _playSelectionItem = new PlaySelectionItem(new List { next }, next.EndTime, loop); @@ -1152,7 +1158,7 @@ private void PlayPreviousParagraph(bool loop) return; } - var selectedItems = SubtitleGrid.SelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); SubtitleLineViewModel? previous; if (selectedItems.Count > 0) { @@ -1177,7 +1183,7 @@ private void PlayPreviousParagraph(bool loop) // deferred selection would null _playSelectionItem *after* we set it and break stop/loop. // Mirror PlayNextParagraph — change selection first, then assign _playSelectionItem. SubtitleGrid.SelectedItem = previous; - SubtitleGrid.ScrollIntoView(previous, null); + SubtitleGrid.ScrollIntoView(previous); vp.Position = previous.StartTime.TotalSeconds; PinPlayheadTo(previous.StartTime.TotalSeconds); _playSelectionItem = new PlaySelectionItem(new List { previous }, previous.EndTime, loop); @@ -1523,7 +1529,7 @@ private async Task ShowAssaDraw() return; } - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); SetAssaResolution(false); @@ -1640,7 +1646,7 @@ private async Task ShowAssaGenerateBackground() return; } - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -1742,7 +1748,7 @@ private async Task ShowAssaApplyAdvancedEffect() var result = await ShowDialogAsync(vm => { var paragraphs = Subtitles.Select(p => new SubtitleLineViewModel(p)).ToList(); - var selectedParagraphs = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedParagraphs = SubtitleGridSelectedItems.Cast().ToList(); vm.Initialize(GetUpdateSubtitle(), paragraphs, selectedParagraphs, _videoFileName, _mediaInfo, AudioVisualizer); }); @@ -1758,7 +1764,7 @@ private async Task ShowAssaApplyCustomOverrideTags() var result = await ShowDialogAsync(vm => { var paragraphs = Subtitles.Select(p => new SubtitleLineViewModel(p)).ToList(); - var selectedParagraphs = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedParagraphs = SubtitleGridSelectedItems.Cast().ToList(); vm.Initialize(paragraphs, selectedParagraphs, _videoFileName); }); @@ -3632,7 +3638,7 @@ private async Task WaveformExtractAudio() return; } - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count != 1) { return; @@ -3868,7 +3874,7 @@ private async Task ShowWaveformGuessTimeCodes() [RelayCommand] private async Task RecalculateDurationSelectedLines() { - var selectedLines = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedLines = SubtitleGridSelectedItems.Cast().ToList(); if (selectedLines.Count == 0) { return; @@ -3921,7 +3927,7 @@ private async Task RecalculateDurationSelectedLines() [RelayCommand] private void SetDurationMaxCpsSelectedLines() { - var selectedLines = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedLines = SubtitleGridSelectedItems.Cast().ToList(); if (selectedLines.Count == 0) { return; @@ -4047,7 +4053,7 @@ private void WaveformShowWaveformAndSpectrogram() [RelayCommand] private async Task ShowPickLayer() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || AudioVisualizer?.WavePeaks == null || selectedItems.Count == 0) { return; @@ -4112,7 +4118,7 @@ private string GetNewFileName() [RelayCommand] private void ColumnDeleteText() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0) { return; @@ -4130,7 +4136,7 @@ private void ColumnDeleteText() [RelayCommand] private void ColumnDeleteTextAndShiftCellsUp() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0) { return; @@ -4181,7 +4187,7 @@ private void ColumnDeleteTextAndShiftCellsUp() [RelayCommand] private void ColumnInsertEmptyTextAndShiftCellsDown() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0) { return; @@ -4287,7 +4293,7 @@ await MessageBox.Show(Window, Se.Language.General.Error, Se.Language.General.Unk [RelayCommand] private async Task ColumnCopyTextFromOriginalToCurrent() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (!ShowColumnOriginalText || selectedItems.Count == 0) { return; @@ -4403,7 +4409,7 @@ await MessageBox.Show(Window, Se.Language.General.Error, Se.Language.General.Unk [RelayCommand] private void ColumnTextUp() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0) { return; @@ -4467,7 +4473,7 @@ private void ColumnTextUp() [RelayCommand] private void ColumnTextDown() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0) { return; @@ -4582,7 +4588,7 @@ private async Task VideoCut() [RelayCommand] private async Task CutVideoSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0) { return; @@ -4923,7 +4929,7 @@ private void ToolsMakeEmptyTranslationFromCurrentSubtitle() [RelayCommand] private void CopyTextFromOriginalToTranslation() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0 || !ShowColumnOriginalText) { return; @@ -4953,7 +4959,7 @@ private void CopyTextFromOriginalToTranslation() [RelayCommand] private void SwitchOriginalAndTranslationTextSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0 || !ShowColumnOriginalText) { return; @@ -4983,7 +4989,7 @@ private void SwitchOriginalAndTranslationTextSelectedLines() [RelayCommand] private void MergeOriginalIntoTranslationSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0 || !ShowColumnOriginalText) { return; @@ -5013,7 +5019,7 @@ private void MergeOriginalIntoTranslationSelectedLines() [RelayCommand] private async Task CopyTextFromOriginalToClipboard() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0 || !ShowColumnOriginalText) { return; @@ -5039,7 +5045,7 @@ private async Task CopyTextFromOriginalToClipboard() [RelayCommand] private async Task CopyTextToClipboard() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0) { return; @@ -5415,9 +5421,9 @@ await MessageBox.Show(Window, Se.Language.General.Error, private List GetSelectedSubtitleIndices() { var selectedIndices = new List(); - if (SubtitleGrid.SelectedItems != null) + if (SubtitleGridSelectedItems != null) { - foreach (var item in SubtitleGrid.SelectedItems.Cast()) + foreach (var item in SubtitleGridSelectedItems.Cast()) { var index = Subtitles.IndexOf(item); if (index >= 0) @@ -6536,7 +6542,7 @@ private async Task SpeechToTextSelectedLinesPromptForLangaugeFirstTime() private async Task SpeechToTextSelectedLines(bool promptEngineAndLanguage, string? language) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (Window == null || selectedItems.Count == 0 || string.IsNullOrEmpty(_videoFileName)) { return false; @@ -6656,7 +6662,7 @@ private void PlaySelectedLinesWithLoopAndFocusWaveform() private bool PlayerSelectedLines(bool loop) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); var vp = GetVideoPlayerControl(); if (Window == null || selectedItems.Count == 0 || vp == null) { @@ -7668,7 +7674,7 @@ private void GoToNextShotChange() [RelayCommand] private void ExtendSelectedLinesToNextShotChangeOrNextSubtitle() { - var selectedLines = SubtitleGrid.SelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); + var selectedLines = SubtitleGridSelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); var vp = GetVideoPlayerControl(); if (string.IsNullOrEmpty(_videoFileName) || vp == null || @@ -7730,7 +7736,7 @@ private void ExtendSelectedLinesToNextShotChangeOrNextSubtitle() [RelayCommand] private void SnapSelectedLinesToNearestShotChange() { - var selectedLines = SubtitleGrid.SelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); + var selectedLines = SubtitleGridSelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); var vp = GetVideoPlayerControl(); if (string.IsNullOrEmpty(_videoFileName) || vp == null || @@ -7815,7 +7821,7 @@ private void SnapSelectedLinesToNearestShotChange() [RelayCommand] private void ExtendSelectedLinesToPreviousShotChange() { - var selectedLines = SubtitleGrid.SelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); + var selectedLines = SubtitleGridSelectedItems.Cast().OrderBy(p => p.StartTime).ToList(); var vp = GetVideoPlayerControl(); if (string.IsNullOrEmpty(_videoFileName) || vp == null || @@ -7884,7 +7890,7 @@ private void ExtendSelectedLinesToPreviousShotChange() [RelayCommand] private void SnapSelectedLinesStartToNextShotChange() { - var selectedLines = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedLines = SubtitleGridSelectedItems.Cast().ToList(); if (string.IsNullOrEmpty(_videoFileName) || AudioVisualizer == null || AudioVisualizer.ShotChanges.Count == 0 || @@ -7930,7 +7936,7 @@ private void SnapSelectedLinesStartToNextShotChange() [RelayCommand] private void SnapSelectedLinesEndToPreviousShotChange() { - var selectedLines = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedLines = SubtitleGridSelectedItems.Cast().ToList(); if (string.IsNullOrEmpty(_videoFileName) || AudioVisualizer == null || AudioVisualizer.ShotChanges.Count == 0 || @@ -8001,7 +8007,7 @@ private void SetOutCueToClosestShotChangeRightGreenZone() => // the line is skipped. private void SetCueToClosestShotChangeGreenZone(bool isInCue, bool isLeftZone, string actionLabel) { - var selectedLines = SubtitleGrid.SelectedItems.Cast() + var selectedLines = SubtitleGridSelectedItems.Cast() .OrderBy(p => p.StartTime).ToList(); var vp = GetVideoPlayerControl(); if (string.IsNullOrEmpty(_videoFileName) || @@ -8274,7 +8280,7 @@ private void ShowAdjustAllTimes(bool forceSelectedLines) var result = _windowService.ShowWindow(Window, (window, vm) => { _adjustAllTimesViewModel = vm; - var selectedCount = SubtitleGrid.SelectedItems.Count; + var selectedCount = SubtitleGridSelectedItems.Count; vm.Initialize(this, selectedCount, forceSelectedLines); // uses call from IAdjustCallback: Adjust }); } @@ -8321,7 +8327,7 @@ private async Task ShowVisualSyncSelectedLines() return; } - var selectedLines = SubtitleGrid.SelectedItems.Cast() + var selectedLines = SubtitleGridSelectedItems.Cast() .OrderBy(p => p.StartTime.TotalMilliseconds) .ToList(); if (selectedLines.Count == 0) @@ -8393,7 +8399,7 @@ private async Task ShowSyncChangeSpeed() return; } - var selectedIndices = SubtitleGrid.SelectedItems + var selectedIndices = SubtitleGridSelectedItems .Cast() .Select(x => Subtitles.IndexOf(x)) .Where(i => i >= 0) @@ -8426,7 +8432,7 @@ private async Task ShowPointSync() var result = await ShowDialogAsync(vm => { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); var paragraphs = Subtitles.Select(p => new SubtitleLineViewModel(p)).ToList(); vm.Initialize(paragraphs, selectedItems, _videoFileName ?? string.Empty, _subtitleFileName ?? string.Empty, AudioVisualizer); }); @@ -8564,7 +8570,7 @@ private async Task ShowAutoTranslate() [RelayCommand] private async Task AutoTranslateSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0 || !ShowColumnOriginalText) { return; @@ -8665,7 +8671,7 @@ private async Task ShowTranslateViaCopyPaste() [RelayCommand] private async Task ChangeCasingSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -8718,7 +8724,7 @@ private async Task ChangeCasingSelectedLines() [RelayCommand] private async Task StatisticsSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -8757,7 +8763,7 @@ private async Task FillSelectedLinesWithClipboard() return; } - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count < 2) { return; @@ -8786,7 +8792,7 @@ private async Task FillSelectedLinesWithClipboard() [RelayCommand] private async Task MultipleReplaceSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -8882,7 +8888,7 @@ private async Task ShowBeautifyTimeCodesSelectedLines() return; } - var selectedItems = SubtitleGrid.SelectedItems.Cast() + var selectedItems = SubtitleGridSelectedItems.Cast() .OrderBy(p => p.StartTime) .ToList(); if (selectedItems.Count == 0) @@ -8921,7 +8927,7 @@ private async Task ShowBeautifyTimeCodesSelectedLines() [RelayCommand] private async Task FixCommonErrorsSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -8980,7 +8986,7 @@ private async Task RemoveTextForHearingImpairedSelectedLines() } // Work on the selected lines in grid order. - var selectedItems = new HashSet(SubtitleGrid.SelectedItems.Cast()); + var selectedItems = new HashSet(SubtitleGridSelectedItems.Cast()); var ordered = Subtitles.Where(s => selectedItems.Contains(s)).ToList(); if (ordered.Count == 0) { @@ -9032,7 +9038,7 @@ private async Task SaveSelectedLinesAs() return; } - var selectedItems = new HashSet(SubtitleGrid.SelectedItems.Cast()); + var selectedItems = new HashSet(SubtitleGridSelectedItems.Cast()); var ordered = Subtitles.Where(s => selectedItems.Contains(s)).ToList(); if (ordered.Count == 0) { @@ -9091,7 +9097,7 @@ await MessageBox.Show(Window!, Se.Language.General.Error, } } - private DataGrid _oldSubtitleGrid = new DataGrid(); + private TableView _oldSubtitleGrid = new TableView(); private ITextBoxWrapper _oldEditTextBox = new TextBoxWrapper(new TextBox()); private bool _oldGenerateSpectrogram; private string _oldSpectrogramStyle = string.Empty; @@ -9470,7 +9476,7 @@ private async Task AddOrEditBookmark() return; } - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -9503,7 +9509,7 @@ private async Task AddOrEditBookmark() [RelayCommand] private void ToggleBookmarkSelectedLinesNoText() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { if (item.Bookmark == null) @@ -9554,7 +9560,7 @@ private async Task ListBookmarks() [RelayCommand] private void RemoveBookmarkSelectedLines() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { item.Bookmark = null; @@ -10283,7 +10289,7 @@ private void MergeWithLineBeforeAsDialog() [RelayCommand] private void ToggleDialogDashes() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -10684,7 +10690,7 @@ private async Task ShowAlignmentPicker() return; } - var result = await ShowDialogAsync(vm => { vm.Initialize(selected, SubtitleGrid.SelectedItems.Count); }); + var result = await ShowDialogAsync(vm => { vm.Initialize(selected, SubtitleGridSelectedItems.Count); }); if (result.OkPressed) { @@ -12074,7 +12080,7 @@ private void ApplyLayoutDirectionForCurrentLanguage(double videoPosition) [RelayCommand] private void FixRightToLeftViaUnicodeControlCharacters() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { @@ -12087,7 +12093,7 @@ private void FixRightToLeftViaUnicodeControlCharacters() [RelayCommand] private void RemoveUnicodeControlCharacters() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { @@ -12100,7 +12106,7 @@ private void RemoveUnicodeControlCharacters() [RelayCommand] private void ReverseRightToLeftStartEnd() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { @@ -12115,7 +12121,7 @@ private async Task ShowModifySelection() { var result = await ShowDialogAsync(vm => { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); vm.Initialize(Subtitles.ToList(), selectedItems); }); @@ -12126,7 +12132,7 @@ private async Task ShowModifySelection() // Subtitles order. var newSelection = new HashSet(result.Selection); var current = new HashSet( - SubtitleGrid.SelectedItems.Cast()); + SubtitleGridSelectedItems.Cast()); List finalSelection; if (result.SelectionAdd) @@ -12152,50 +12158,55 @@ private async Task ShowModifySelection() _updateAudioVisualizer = true; } - // Above this many rows, applying a selection by adding to a realized DataGrid's - // SelectedItems one row at a time becomes O(n) visual work per row and hangs the - // UI (#11529). At/below it the in-place path is kept so small, frequent selections - // (a 2-10 row shift-click) don't pay the detach/reattach scroll-reset cost. - private const int GridSelectionDetachThreshold = 200; - - // Brackets a SubtitleGrid.SelectedItems mutation: suppresses the per-row - // selection-changed handler, and when is set, detaches - // ItemsSource (null then reattach) so the grid drops its realized rows before the - // mutation - the adds then only touch the grid's internal selection table and a - // single layout pass repaints afterwards. Does not raise SubtitleGridSelectionChanged; - // callers do that after any scroll handling. SelectedItems.Add needs an attached - // source, so we reattach before runs. - private void BatchGridSelection(bool detach, System.Action fill) + // Brackets a subtitle grid selection mutation: suppresses the per-row + // selection-changed handler and wraps the mutation in a selection-model batch + // update, so even a whole-file selection is applied as index ranges in one pass + // (the DataGrid needed an ItemsSource detach hack for this, #11529). Does not + // raise SubtitleGridSelectionChanged; callers do that after any scroll handling. + private void BatchGridSelection(System.Action fill) { var wasSkipping = _subtitleGridSelectionChangedSkip; _subtitleGridSelectionChangedSkip = true; - var itemsSource = SubtitleGrid.ItemsSource; + SubtitleGrid.Selection.BeginBatchUpdate(); try { - if (detach) - { - SubtitleGrid.ItemsSource = null; - SubtitleGrid.ItemsSource = itemsSource; - } - fill(); } finally { + SubtitleGrid.Selection.EndBatchUpdate(); _subtitleGridSelectionChangedSkip = wasSkipping; } } - // Replaces the subtitle grid selection with the given rows, detaching ItemsSource - // for large selections so it doesn't hang (#11529). + // Replaces the subtitle grid selection with a contiguous index range, putting + // first so it becomes the SelectedItem (the row the + // edit box shows), like the moving end of a shift-selection. + private void SelectGridRange(int startIndex, int endIndex, int currentIndex) + { + BatchGridSelection(() => + { + SubtitleGrid.Selection.Clear(); + SubtitleGrid.Selection.Select(currentIndex); + SubtitleGrid.Selection.SelectRange(startIndex, endIndex); + }); + } + + // Replaces the subtitle grid selection with the given rows. private void ApplyGridSelection(IReadOnlyList items) { - BatchGridSelection(items.Count > GridSelectionDetachThreshold, () => + // Map items to indexes in one pass - Selection works on indexes, and per-item + // IndexOf would be O(n²) on large selections. + var wanted = new HashSet(items); + BatchGridSelection(() => { - SubtitleGrid.SelectedItems.Clear(); - foreach (var item in items) + SubtitleGrid.Selection.Clear(); + for (var i = 0; i < Subtitles.Count && wanted.Count > 0; i++) { - SubtitleGrid.SelectedItems.Add(item); + if (wanted.Remove(Subtitles[i])) + { + SubtitleGrid.Selection.Select(i); + } } SelectedSubtitle = items.Count > 0 ? items[0] : null; @@ -12741,7 +12752,7 @@ await _windowService.ShowDialogAsync SubtitleGrid.Focus()); + Dispatcher.UIThread.Post(() => TableViewExtras.FocusRow(SubtitleGrid)); }, toggleKeys, showMediaInfoKeys, showMediaInformationOwnedBy, extraBindings, ReapplySelectedAudioTrack); fullScreenWindow.Show(Window!); _shortcutManager.ClearKeys(); @@ -12828,7 +12839,7 @@ private void ToggleVideoPlayerDisplayTimeLeft() [RelayCommand] private void Unbreak() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -12847,7 +12858,7 @@ private void UnbreakNoSpace() { // Unbreak without joining with a space - intended for CJK text where // words are not space-separated (SE 4's "Unbreak without space (CJK)"). - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -12871,7 +12882,7 @@ private void UnbreakNoSpace() [RelayCommand] private void AutoBreak() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0) { return; @@ -12892,7 +12903,7 @@ private void EvenlyDistributeSelectedLines() // Distributes the duration spanning the first..last selected paragraphs // proportionally to each paragraph's CPS character count, packing them // back-to-back with the configured minimum gap between them. - var selectedItems = SubtitleGrid.SelectedItems + var selectedItems = SubtitleGridSelectedItems .Cast() .OrderBy(p => Subtitles.IndexOf(p)) .ToList(); @@ -12949,7 +12960,7 @@ private async Task ShowToolsSplitBreakLongLinesSelectedLines() } var selectedSet = new HashSet( - SubtitleGrid.SelectedItems.Cast()); + SubtitleGridSelectedItems.Cast()); if (selectedSet.Count == 0) { return; @@ -13352,7 +13363,7 @@ private async Task WaveformSpeechToTextNewSelection() _insertService.InsertInCorrectPosition(Subtitles, newParagraph); AudioVisualizer.NewSelectionParagraph = null; SubtitleGrid.SelectedItem = newParagraph; - SubtitleGrid.ScrollIntoView(newParagraph, null); + SubtitleGrid.ScrollIntoView(newParagraph); Renumber(); _updateAudioVisualizer = true; @@ -13456,7 +13467,7 @@ private void FocusSubtitleGrid() } ActivateWindow(Window); - SubtitleGrid.Focus(); + TableViewExtras.FocusRow(SubtitleGrid); }); } @@ -13667,7 +13678,7 @@ private void WaveformSetStart() var isAssa = SelectedSubtitleFormat is AdvancedSubStationAlpha; if (isAssa) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { item.SetStartTimeOnly(TimeSpan.FromSeconds(videoPositionSeconds)); @@ -13711,7 +13722,7 @@ private void WaveformSetStartAndGoToNext() var isAssa = SelectedSubtitleFormat is AdvancedSubStationAlpha; if (isAssa) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { item.SetStartTimeOnly(TimeSpan.FromSeconds(videoPositionSeconds)); @@ -13744,7 +13755,7 @@ private void WaveformSetStartAndKeepDuration() var isAssa = SelectedSubtitleFormat is AdvancedSubStationAlpha; if (isAssa) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { item.SetStartTimeKeepDuration(TimeSpan.FromSeconds(videoPositionSeconds)); @@ -13778,7 +13789,7 @@ private void WaveformSetEnd() var isAssa = SelectedSubtitleFormat is AdvancedSubStationAlpha; if (isAssa) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); foreach (var item in selectedItems) { item.EndTime = TimeSpan.FromSeconds(videoPositionSeconds); @@ -14417,7 +14428,7 @@ private void ToggleFocusTextBoxAndSubtitleGrid() } else { - SubtitleGrid.Focus(); + TableViewExtras.FocusRow(SubtitleGrid); } } @@ -14819,7 +14830,7 @@ private void TextBoxDeleteSelection() [RelayCommand] private async Task SubtitleGridCut() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0 || Window == null) { return; @@ -14856,7 +14867,7 @@ private async Task SubtitleGridCut() [RelayCommand] private async Task SubtitleGridCopy() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0 || Window == null) { return; @@ -14886,7 +14897,7 @@ private void TrimWhitespaceSelectedLines() { var countOfTrimmedLines = 0; - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); var languageCode = LanguageAutoDetect.AutoDetectGoogleLanguage(GetUpdateSubtitle()); foreach (var s in selectedItems) { @@ -14971,7 +14982,7 @@ private static string GetDefaultAssaHeader() [RelayCommand] private void SetStyleForSelectedLines(string styleName) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); Dispatcher.UIThread.Post(() => { @@ -15001,7 +15012,7 @@ private async Task SetNewActorForSelectedLines(string styleName) [RelayCommand] private void SetActorForSelectedLines(string actorName) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); Dispatcher.UIThread.Post(() => { @@ -15465,61 +15476,28 @@ private void RestoreUndoRedoState(UndoRedoItem undoRedoObject) public void AutoFitColumns() { - var columns = SubtitleGrid.Columns - .Where(p => p.IsVisible && (p.Tag as string) != InitListViewAndEditBox.SubtitleGridColumnKeys.ScrollbarGutter) - .ToList(); + var columnManager = SubtitleGridColumnManager; + if (columnManager == null) + { + return; + } + // TableView has no content-based (Auto) sizing, so fit the time columns to the + // current time format and let the Text/Original star columns absorb the rest. var showHideWidth = MeasureShowHideColumnWidth(); - var numberOfStarColumns = 0; - for (var i = 0; i < columns.Count; i++) + foreach (var column in columnManager.Columns.OfType()) { - var column = columns[i]; - - var originalWidth = column.Width; - - if (column.Header.ToString() == Se.Language.General.Show || - column.Header.ToString() == Se.Language.General.Hide) - { - var width = Math.Max(column.MinWidth, showHideWidth); - column.Width = new DataGridLength(width, DataGridLengthUnitType.Pixel); - continue; - } - else - { - column.Width = new DataGridLength(1, DataGridLengthUnitType.Auto); - } - - SubtitleGrid.UpdateLayout(); - - if (column.Header.ToString() == Se.Language.General.OriginalText || - column.Header.ToString() == Se.Language.General.Text) - { - column.Width = new DataGridLength(1, DataGridLengthUnitType.Star); - numberOfStarColumns++; - } - else - { - column.Width = originalWidth; - } - - if (i == columns.Count - 1) + switch (column.Tag as string) { - if (numberOfStarColumns == 0) - { - column.Width = new DataGridLength(1, DataGridLengthUnitType.Star); - } - else if (numberOfStarColumns == 1 && column.Width.IsStar) - { - } - else - { - if (column.Header.ToString() != Se.Language.General.OriginalText && - column.Header.ToString() != Se.Language.General.Text) - { - column.Width = new DataGridLength(1, DataGridLengthUnitType.Auto); - } - } + case InitListViewAndEditBox.SubtitleGridColumnKeys.Start: + case InitListViewAndEditBox.SubtitleGridColumnKeys.End: + column.Width = new GridLength(Math.Max(column.MinWidth, showHideWidth)); + break; + case InitListViewAndEditBox.SubtitleGridColumnKeys.Text: + case InitListViewAndEditBox.SubtitleGridColumnKeys.OriginalText: + column.Width = new GridLength(1, GridUnitType.Star); + break; } } @@ -15576,14 +15554,14 @@ private void SelectAllRows() private void InverseRowSelection() { - if (SubtitleGrid.SelectedItems == null || Subtitles.Count == 0) + if (SubtitleGridSelectedItems == null || Subtitles.Count == 0) { return; } // Store currently selected items var selectedItems = - new HashSet(SubtitleGrid.SelectedItems.Cast()); + new HashSet(SubtitleGridSelectedItems.Cast()); // Inverting a small selection on a large file selects almost every row, so // apply via the detach/reattach helper to avoid the per-row hang (#11529). @@ -15619,7 +15597,7 @@ private void SelectAndScrollToRow(int index) { var itemToScroll = Subtitles[indexToScroll]; SubtitleGrid.SelectedItem = itemToScroll; - SubtitleGrid.ScrollIntoView(itemToScroll, null); + SubtitleGrid.ScrollIntoView(itemToScroll); if (Se.Settings.General.SubtitleGridCenterSelectedRow) { @@ -15633,127 +15611,17 @@ private void SelectAndScrollToRow(int index) }); } - // Avalonia's DataGrid.ScrollIntoView often leaves the target only partially visible - - // clipped at the bottom edge, or a row or two below the fold - because its viewport - // estimate is wrong with the variable-height subtitle rows (issue #11723). After the - // built-in scroll (which realizes the target row), measure that row against the actual - // rows-presenter viewport and nudge the vertical scrollbar by exactly how far it pokes - // out, so it ends up fully on screen. This is the non-centering counterpart to - // CenterSelectedRowInSubtitleGrid and uses the same deterministic scrollbar approach. + // TableView's ScrollIntoView can leave a variable-height row clipped at an edge + // (same class of problem as DataGrid issue #11723); the helper nudges the scroll + // offset by exactly how far the realized row pokes out. private void EnsureRowFullyVisibleInSubtitleGrid(SubtitleLineViewModel item) { - Dispatcher.UIThread.Post(() => - { - var row = SubtitleGrid.GetVisualDescendants().OfType() - .FirstOrDefault(r => ReferenceEquals(r.DataContext, item)); - if (row == null || row.Bounds.Height <= 0) - { - return; - } - - var rowsPresenter = SubtitleGrid.GetVisualDescendants().OfType().FirstOrDefault(); - if (rowsPresenter == null || rowsPresenter.Bounds.Height <= 0) - { - return; - } - - var verticalScrollBar = SubtitleGrid.GetVisualDescendants().OfType() - .FirstOrDefault(sb => sb.Orientation == Orientation.Vertical); - if (verticalScrollBar == null) - { - return; - } - - // row.Bounds is relative to the rows presenter, so these are viewport coordinates. - var rowTop = row.Bounds.Y; - var rowBottom = row.Bounds.Y + row.Bounds.Height; - double delta; - if (rowBottom > rowsPresenter.Bounds.Height) - { - delta = rowBottom - rowsPresenter.Bounds.Height; // pokes out the bottom -> scroll down - } - else if (rowTop < 0) - { - delta = rowTop; // pokes out the top -> scroll up (negative) - } - else - { - return; // already fully visible - } - - if (Math.Abs(delta) < 1) - { - return; - } - - var newValue = Math.Max(0, Math.Min(verticalScrollBar.Value + delta, verticalScrollBar.Maximum)); - if (Math.Abs(newValue - verticalScrollBar.Value) < 0.5) - { - return; - } - - verticalScrollBar.Value = newValue; - - // Avalonia's DataGrid hooks the scrollbar's Scroll event (not ValueChanged) to - // update the visible rows; invoke the internal handler via reflection. - var processVerticalScroll = typeof(DataGrid).GetMethod( - "ProcessVerticalScroll", - BindingFlags.NonPublic | BindingFlags.Instance); - processVerticalScroll?.Invoke(SubtitleGrid, new object[] { ScrollEventType.EndScroll }); - }, DispatcherPriority.Loaded); + TableViewExtras.EnsureRowFullyVisible(SubtitleGrid, item); } private void CenterSelectedRowInSubtitleGrid(SubtitleLineViewModel itemToCenter) { - Dispatcher.UIThread.Post(() => - { - var row = SubtitleGrid.GetVisualDescendants().OfType() - .FirstOrDefault(r => ReferenceEquals(r.DataContext, itemToCenter)); - if (row == null || row.Bounds.Height <= 0) - { - return; - } - - var rowsPresenter = SubtitleGrid.GetVisualDescendants().OfType().FirstOrDefault(); - if (rowsPresenter == null || rowsPresenter.Bounds.Height <= 0) - { - return; - } - - var verticalScrollBar = SubtitleGrid.GetVisualDescendants().OfType() - .FirstOrDefault(sb => sb.Orientation == Orientation.Vertical); - if (verticalScrollBar == null) - { - return; - } - - // Use the row's actual rendered Y inside the rows presenter — this is - // accurate regardless of variable row heights. The delta is exactly how - // much we need to shift the scrollbar to center the row. - var desiredY = (rowsPresenter.Bounds.Height - row.Bounds.Height) / 2.0; - var delta = row.Bounds.Y - desiredY; - if (Math.Abs(delta) < 1) - { - return; - } - - var newValue = Math.Max(0, Math.Min(verticalScrollBar.Value + delta, verticalScrollBar.Maximum)); - if (Math.Abs(newValue - verticalScrollBar.Value) < 0.5) - { - return; - } - - verticalScrollBar.Value = newValue; - - // Avalonia's DataGrid hooks the scrollbar's Scroll event (not - // ValueChanged) to update the visible rows. ScrollEventArgs/ScrollEvent - // aren't writable in this version, so invoke the internal handler via - // reflection. - var processVerticalScroll = typeof(DataGrid).GetMethod( - "ProcessVerticalScroll", - BindingFlags.NonPublic | BindingFlags.Instance); - processVerticalScroll?.Invoke(SubtitleGrid, new object[] { ScrollEventType.EndScroll }); - }, DispatcherPriority.Loaded); + TableViewExtras.CenterRow(SubtitleGrid, itemToCenter); } /// @@ -15824,7 +15692,7 @@ public void SelectAndScrollToSubtitle(SubtitleLineViewModel subtitle) if (subtitleToScroll != null && Subtitles.Contains(subtitleToScroll)) { SubtitleGrid.SelectedItem = subtitleToScroll; - SubtitleGrid.ScrollIntoView(subtitleToScroll, null); + SubtitleGrid.ScrollIntoView(subtitleToScroll); if (Se.Settings.General.SubtitleGridCenterSelectedRow) { @@ -16361,7 +16229,7 @@ await MessageBox.Show(Window!, Se.Language.General.Error, "This file seems to be { SelectAndScrollToRow(0); } - Dispatcher.UIThread.Post(() => SubtitleGrid.Focus()); + Dispatcher.UIThread.Post(() => TableViewExtras.FocusRow(SubtitleGrid)); if (Se.Settings.Video.AutoOpen && skipLoadVideo == false) { @@ -17038,7 +16906,7 @@ private async Task ImportSubtitleFromMatroskaFile(string fileName, string? // Put keyboard focus on the grid so shortcuts (e.g. Ctrl+S) work right // away after an "Open with" mkv extract, without a manual click (#12029). - Dispatcher.UIThread.Post(() => SubtitleGrid.Focus()); + Dispatcher.UIThread.Post(() => TableViewExtras.FocusRow(SubtitleGrid)); } } } @@ -17100,7 +16968,7 @@ private async Task ImportSubtitleFromMatroskaFile(string fileName, string? if (!IsImageSubtitleTrack(subtitleList[0])) { // Focus the grid so shortcuts (e.g. Ctrl+S) work immediately after the extract (#12029). - Dispatcher.UIThread.Post(() => SubtitleGrid.Focus()); + Dispatcher.UIThread.Post(() => TableViewExtras.FocusRow(SubtitleGrid)); } } else @@ -18309,7 +18177,7 @@ internal async void OnClosing(object? sender, WindowClosingEventArgs e) Se.Settings.General.ShowColumnCps = ShowColumnCps; Se.Settings.General.ShowColumnWpm = ShowColumnWpm; Se.Settings.General.ShowColumnLayer = ShowColumnLayer; - Layout.InitListViewAndEditBox.SaveSubtitleGridColumnWidths(SubtitleGrid); + Layout.InitListViewAndEditBox.SaveSubtitleGridColumnWidths(SubtitleGridColumnManager); Se.Settings.General.SelectCurrentSubtitleWhilePlaying = SelectCurrentSubtitleWhilePlaying; Se.Settings.Waveform.ShowToolbar = IsWaveformToolbarVisible; Se.Settings.Waveform.CenterVideoPosition = WaveformCenter; @@ -18611,7 +18479,7 @@ internal void OnLoaded() if (Window != null) { Window.Activate(); - SubtitleGrid.Focus(); + TableViewExtras.FocusRow(SubtitleGrid); SurroundWith1Text = string.Format(Se.Language.Options.Shortcuts.SurroundWithXY, Se.Settings.Surround1Left, Se.Settings.Surround1Right); SurroundWith2Text = string.Format(Se.Language.Options.Shortcuts.SurroundWithXY, Se.Settings.Surround2Left, Se.Settings.Surround2Right); @@ -19956,7 +19824,7 @@ private void MergeLineAfterKeepBreaks() private void MergeLinesSelected(MergeManager.BreakMode breakMode = MergeManager.BreakMode.Normal) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count == 0 || SelectedSubtitle == null) { return; @@ -19974,7 +19842,7 @@ private void MergeLinesSelected(MergeManager.BreakMode breakMode = MergeManager. private void MergeLinesSelectedAsDialog() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count != 2) { return; // only two items can be merged as dialog @@ -19988,7 +19856,7 @@ private void MergeLinesSelectedAsDialog() private void MergeLinesSelectedBilingual() { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count < 2) { return; @@ -20194,17 +20062,17 @@ public void SubtitleContextOpening(object? sender, EventArgs e) { var idx = SubtitleGrid.SelectedIndex; var count = Subtitles.Count; - MenuItemMergeAsDialog.IsVisible = SubtitleGrid.SelectedItems.Count == 2; - MenuItemMerge.IsVisible = SubtitleGrid.SelectedItems.Count > 1; + MenuItemMergeAsDialog.IsVisible = SubtitleGridSelectedItems.Count == 2; + MenuItemMerge.IsVisible = SubtitleGridSelectedItems.Count > 1; // With 2+ lines selected at least one of them has a neighbor on either side, // so the focused-index boundary check only applies to single selection (#12981) MenuItemExtendToLineBefore.IsVisible = Subtitles.Count > 1 && - (SubtitleGrid.SelectedItems.Count > 1 || (SubtitleGrid.SelectedItems.Count == 1 && idx > 0)); + (SubtitleGridSelectedItems.Count > 1 || (SubtitleGridSelectedItems.Count == 1 && idx > 0)); MenuItemExtendToLineAfter.IsVisible = Subtitles.Count > 1 && - (SubtitleGrid.SelectedItems.Count > 1 || (SubtitleGrid.SelectedItems.Count == 1 && idx < count - 1)); + (SubtitleGridSelectedItems.Count > 1 || (SubtitleGridSelectedItems.Count == 1 && idx < count - 1)); AreAssaContentMenuItemsVisible = false; - ShowAutoTranslateSelectedLines = SubtitleGrid.SelectedItems.Count > 0 && ShowColumnOriginalText; - HasMultipleLinesSelected = SubtitleGrid.SelectedItems.Count > 1; + ShowAutoTranslateSelectedLines = SubtitleGridSelectedItems.Count > 0 && ShowColumnOriginalText; + HasMultipleLinesSelected = SubtitleGridSelectedItems.Count > 1; ShowColumnLayerFlyoutMenuItem = IsFormatAssa; if (IsSubtitleGridFlyoutHeaderVisible) @@ -20226,11 +20094,11 @@ public void SubtitleContextOpening(object? sender, EventArgs e) else { IsSubtitleGridDataMenuVisible = true; - IsMergeWithNextOrPreviousVisible = SubtitleGrid.SelectedItems.Count == 1; + IsMergeWithNextOrPreviousVisible = SubtitleGridSelectedItems.Count == 1; IsInsertLineNoSelectionVisible = false; // Any single selected line, not only the last - a pre-timed file keeps its own time // codes, so inserting midway is a normal workflow (discussion #11744). - IsInsertSubtitleFileAfterLineVisible = SubtitleGrid.SelectedItems.Count == 1; + IsInsertSubtitleFileAfterLineVisible = SubtitleGridSelectedItems.Count == 1; if (IsFormatAssa || IsFormatSsa) { @@ -20349,7 +20217,7 @@ private void AddSubtitleGridSpellCheckMenuItems(MenuFlyout? flyout) // lines selected the per-word suggestions/"ignore all" would be ambiguous (they act on the // one clicked cell, not the selection). if (IsSubtitleGridFlyoutHeaderVisible || - SubtitleGrid.SelectedItems.Count != 1 || + SubtitleGridSelectedItems.Count != 1 || (!Se.Settings.Appearance.SubtitleGridLiveSpellCheck && !Se.Settings.Appearance.SubtitleTextBoxLiveSpellCheck)) { return; @@ -20569,7 +20437,7 @@ private List GetMisspelledWords(string? text) } // The pointer can be over the cell padding instead of the text block itself - var cell = hit.FindAncestorOfType(); + var cell = hit.FindAncestorOfType(); return cell?.GetVisualDescendants().OfType().FirstOrDefault(IsSubtitleGridTextBlock); } @@ -21642,30 +21510,26 @@ public void OnKeyUpHandler(object? sender, KeyEventArgs e) private bool _subtitleGridIsRightClick = false; private bool _subtitleGridIsLeftClick = false; private bool _subtitleGridIsControlPressed = false; - private int _dragSelectStartIndex = -1; - private int _dragSelectLastIndex = -1; - private int _dragSelectAppliedIndex = -1; private int _shiftSelectAnchorIndex = -1; private int _shiftSelectCurrentIndex = -1; private bool _mouseClickSetAnchor; - private int _dragSelectAutoScrollDirection; - private int _dragSelectAutoScrollStep = 1; - private bool _dragSelectHasMoved; - private DispatcherTimer? _dragSelectAutoScrollTimer; - private const double DragSelectAutoScrollEdgeSize = 28; - private const double DragSelectAutoScrollAccelerationPixels = 18; - private const int DragSelectAutoScrollMaxStep = 16; + + /// + /// Applies a drag-select range from - the moving + /// end becomes the SelectedItem, mirroring shift-selection. + /// + internal void ApplyDragSelectRange(int anchorIndex, int currentIndex) + { + SelectGridRange(Math.Min(anchorIndex, currentIndex), Math.Max(anchorIndex, currentIndex), currentIndex); + SubtitleGridSelectionChanged(); + } public void SubtitleGrid_PointerPressed(object? sender, PointerPressedEventArgs e) { - StopSubtitleGridDragSelectAutoScroll(); + SubtitleGridDragSelect?.Reset(); _subtitleGridIsControlPressed = false; _subtitleGridIsLeftClick = false; _subtitleGridIsRightClick = false; - _dragSelectStartIndex = -1; - _dragSelectLastIndex = -1; - _dragSelectAppliedIndex = -1; - _dragSelectHasMoved = false; IsSubtitleGridFlyoutHeaderVisible = false; if (sender is Control { ContextFlyout: not null } control) @@ -21681,25 +21545,19 @@ public void SubtitleGrid_PointerPressed(object? sender, PointerPressedEventArgs var isMultiSelectModifier = _subtitleGridIsControlPressed || (OperatingSystem.IsMacOS() && e.KeyModifiers.HasFlag(KeyModifiers.Meta)); - var hitTest = SubtitleGrid.InputHitTest(e.GetPosition(SubtitleGrid)); - var current = hitTest as Control; - while (current != null) + var hitTest = SubtitleGrid.InputHitTest(e.GetPosition(SubtitleGrid)) as Visual; + if (TableViewExtras.IsInColumnHeader(hitTest)) { - if (current is DataGridColumnHeader) - { - IsSubtitleGridFlyoutHeaderVisible = true; - IsMergeWithNextOrPreviousVisible = false; - _shiftSelectAnchorIndex = -1; - _shiftSelectCurrentIndex = -1; - return; - } - - if (current is ScrollBar) - { - return; - } + IsSubtitleGridFlyoutHeaderVisible = true; + IsMergeWithNextOrPreviousVisible = false; + _shiftSelectAnchorIndex = -1; + _shiftSelectCurrentIndex = -1; + return; + } - current = current.Parent as Control; + if (TableViewExtras.IsInScrollBar(hitTest)) + { + return; } var isShiftPressed = e.KeyModifiers.HasFlag(KeyModifiers.Shift); @@ -21717,21 +21575,9 @@ public void SubtitleGrid_PointerPressed(object? sender, PointerPressedEventArgs _shiftSelectAnchorIndex = anchor; _shiftSelectCurrentIndex = rowIndex; - var startIdx = Math.Min(anchor, rowIndex); - var endIdx = Math.Max(anchor, rowIndex); - - BatchGridSelection(endIdx - startIdx + 1 > GridSelectionDetachThreshold, () => - { - SubtitleGrid.SelectedItems.Clear(); - SubtitleGrid.SelectedItems.Add(Subtitles[rowIndex]); - for (var i = startIdx; i <= endIdx; i++) - { - if (i != rowIndex) - SubtitleGrid.SelectedItems.Add(Subtitles[i]); - } - }); + SelectGridRange(Math.Min(anchor, rowIndex), Math.Max(anchor, rowIndex), rowIndex); - SubtitleGrid.ScrollIntoView(Subtitles[rowIndex], null); + SubtitleGrid.ScrollIntoView(Subtitles[rowIndex]); SubtitleGridSelectionChanged(); e.Handled = true; return; @@ -21753,8 +21599,7 @@ public void SubtitleGrid_PointerPressed(object? sender, PointerPressedEventArgs _shiftSelectAnchorIndex = rowIndex; _shiftSelectCurrentIndex = rowIndex; _mouseClickSetAnchor = true; - _dragSelectStartIndex = rowIndex; - _dragSelectLastIndex = rowIndex; + SubtitleGridDragSelect?.Arm(rowIndex); } } } @@ -21767,11 +21612,7 @@ public void SubtitleGridDropHost_DoubleTapped(object? sender, TappedEventArgs e) return; } - StopSubtitleGridDragSelectAutoScroll(); - _dragSelectStartIndex = -1; - _dragSelectLastIndex = -1; - _dragSelectAppliedIndex = -1; - _dragSelectHasMoved = false; + SubtitleGridDragSelect?.Reset(); SubtitleGrid.SelectedItem = Subtitles[rowIndex]; OnSubtitleGridDoubleTapped(SubtitleGrid, e); @@ -21780,126 +21621,17 @@ public void SubtitleGridDropHost_DoubleTapped(object? sender, TappedEventArgs e) private int GetDataGridRowIndexFromPoint(Avalonia.Point position) { - var hitTest = SubtitleGrid.InputHitTest(position); - var current = hitTest as Control; - while (current != null) - { - if (current is DataGridRow row) - { - return row.Index; - } - - current = current.Parent as Control; - } - - return -1; + return TableViewExtras.GetRowIndexFromPoint(SubtitleGrid, position); } public void SubtitleGrid_PointerMoved(object? sender, PointerEventArgs e) { - if (_dragSelectStartIndex < 0 || !_subtitleGridIsLeftClick) + if (!_subtitleGridIsLeftClick) { return; } - var props = e.GetCurrentPoint(SubtitleGrid).Properties; - if (!props.IsLeftButtonPressed) - { - EndSubtitleGridDragSelect(e); - return; - } - - var position = e.GetPosition(SubtitleGrid); - UpdateSubtitleGridDragSelectAutoScroll(position); - - var currentIndex = GetDataGridRowIndexFromPoint(position); - if (currentIndex < 0) - { - return; - } - - var wasDragging = _dragSelectHasMoved; - DragSelectSubtitleGridToIndex(currentIndex); - if (_dragSelectHasMoved) - { - if (!wasDragging && sender is Control control) - { - e.Pointer.Capture(control); - } - - e.Handled = true; - } - } - - private void DragSelectSubtitleGridToIndex(int currentIndex) - { - if (_dragSelectStartIndex < 0 || currentIndex < 0 || currentIndex >= Subtitles.Count) - { - return; - } - - _dragSelectLastIndex = currentIndex; - - if (currentIndex == _dragSelectStartIndex && !_dragSelectHasMoved) - { - return; - } - - var firstMove = !_dragSelectHasMoved; - _dragSelectHasMoved = true; - - var anchor = _dragSelectStartIndex; - var newLo = Math.Min(anchor, currentIndex); - var newHi = Math.Max(anchor, currentIndex); - - _subtitleGridSelectionChangedSkip = true; - try - { - if (firstMove || _dragSelectAppliedIndex < 0) - { - // First move: the prior selection state isn't known, so set the whole - // range authoritatively (detaching for a very large initial range). - BatchGridSelection(newHi - newLo + 1 > GridSelectionDetachThreshold, () => - { - SubtitleGrid.SelectedItems.Clear(); - for (var i = newLo; i <= newHi; i++) - { - SubtitleGrid.SelectedItems.Add(Subtitles[i]); - } - }); - } - else - { - // Subsequent moves only touch the rows whose membership changed since - // the last applied endpoint, so dragging across a large range never - // rebuilds the whole selection (and we don't detach mid-drag, which - // would disrupt the active pointer capture). - var prevLo = Math.Min(anchor, _dragSelectAppliedIndex); - var prevHi = Math.Max(anchor, _dragSelectAppliedIndex); - for (var i = prevLo; i <= prevHi; i++) - { - if (i < newLo || i > newHi) - { - SubtitleGrid.SelectedItems.Remove(Subtitles[i]); - } - } - - for (var i = newLo; i <= newHi; i++) - { - if (i < prevLo || i > prevHi) - { - SubtitleGrid.SelectedItems.Add(Subtitles[i]); - } - } - } - } - finally - { - _dragSelectAppliedIndex = currentIndex; - _subtitleGridSelectionChangedSkip = false; - } - - SubtitleGridSelectionChanged(); + SubtitleGridDragSelect?.OnPointerMoved(sender, e); } private void HandleShiftArrowSelection(int direction) @@ -21911,8 +21643,8 @@ private void HandleShiftArrowSelection(int direction) if (_shiftSelectAnchorIndex < 0) { - var anchor = SelectedSubtitleIndex ?? (SubtitleGrid.SelectedItems.Count > 0 - ? Subtitles.IndexOf((SubtitleLineViewModel)SubtitleGrid.SelectedItems[0]!) + var anchor = SelectedSubtitleIndex ?? (SubtitleGridSelectedItems.Count > 0 + ? Subtitles.IndexOf((SubtitleLineViewModel)SubtitleGridSelectedItems[0]!) : -1); if (anchor < 0) { @@ -21934,171 +21666,35 @@ private void HandleShiftArrowSelection(int direction) var startIdx = Math.Min(_shiftSelectAnchorIndex, _shiftSelectCurrentIndex); var endIdx = Math.Max(_shiftSelectAnchorIndex, _shiftSelectCurrentIndex); - // Shift+Ctrl+Home/End can extend the selection across the whole file in one - // keypress, so detach for large ranges to avoid the per-row hang (#11529). - var detach = endIdx - startIdx + 1 > GridSelectionDetachThreshold; - BatchGridSelection(detach, () => - { - SubtitleGrid.SelectedItems.Clear(); + // The moving end goes in first so it becomes the SelectedItem - the row a plain + // arrow key afterwards continues from (and the one the edit box shows). + SelectGridRange(startIdx, endIdx, _shiftSelectCurrentIndex); - // Add the moving end of the selection first: the DataGrid only moves its current cell for - // the first item added into an empty selection. Filling the range in ascending order left - // the current cell on the anchor when extending downwards, so a plain arrow key afterwards - // jumped back to anchor+1 instead of continuing from the moving end. The shift+click path - // above adds the clicked row first for the same reason. - SubtitleGrid.SelectedItems.Add(Subtitles[_shiftSelectCurrentIndex]); - for (var i = startIdx; i <= endIdx; i++) - { - if (i != _shiftSelectCurrentIndex) - { - SubtitleGrid.SelectedItems.Add(Subtitles[i]); - } - } - }); - - var scrollTarget = _shiftSelectCurrentIndex; - if (detach) - { - // After detach/reattach, rows aren't realized until the layout pass fires, - // so ScrollIntoView fails silently when called synchronously here. - Dispatcher.UIThread.Post(() => - { - if (scrollTarget < Subtitles.Count) - { - SubtitleGrid.ScrollIntoView(Subtitles[scrollTarget], null); - } - }, DispatcherPriority.Background); - } - else - { - SubtitleGrid.ScrollIntoView(Subtitles[scrollTarget], null); - } + SubtitleGrid.ScrollIntoView(Subtitles[_shiftSelectCurrentIndex]); SubtitleGridSelectionChanged(); } private int GetSubtitleGridPageSize() { - var rowsPresenter = SubtitleGrid.GetVisualDescendants() - .OfType() - .FirstOrDefault(); - if (rowsPresenter != null && rowsPresenter.Bounds.Height > 0) - { - var rowHeight = SubtitleGrid.RowHeight; - if (!double.IsNaN(rowHeight) && rowHeight > 0) - { - return Math.Max(1, (int)Math.Ceiling(rowsPresenter.Bounds.Height / rowHeight) - 1); - } - } - - // Fallback for variable row heights: count rendered rows in the visual tree - var visibleRowCount = SubtitleGrid.GetVisualDescendants() - .OfType() - .Count(r => r.IsVisible && r.Bounds.Height > 0); - return Math.Max(1, visibleRowCount - 1); - } - - private void UpdateSubtitleGridDragSelectAutoScroll(Avalonia.Point position) - { - if (SubtitleGrid.Bounds.Height <= 0) - { - StopSubtitleGridDragSelectAutoScroll(); - return; - } - - if (position.Y < DragSelectAutoScrollEdgeSize) - { - var distanceFromEdge = DragSelectAutoScrollEdgeSize - position.Y; - StartSubtitleGridDragSelectAutoScroll(-1, distanceFromEdge); - } - else if (position.Y > SubtitleGrid.Bounds.Height - DragSelectAutoScrollEdgeSize) - { - var distanceFromEdge = position.Y - (SubtitleGrid.Bounds.Height - DragSelectAutoScrollEdgeSize); - StartSubtitleGridDragSelectAutoScroll(1, distanceFromEdge); - } - else - { - StopSubtitleGridDragSelectAutoScroll(); - } - } - - private void StartSubtitleGridDragSelectAutoScroll(int direction, double distanceFromEdge) - { - if (_dragSelectLastIndex < 0 || Subtitles.Count == 0) - { - return; - } - - _dragSelectAutoScrollDirection = direction; - _dragSelectAutoScrollStep = CalculateSubtitleGridDragSelectAutoScrollStep(distanceFromEdge); - - if (_dragSelectAutoScrollTimer != null) - { - if (!_dragSelectAutoScrollTimer.IsEnabled) - { - _dragSelectAutoScrollTimer.Start(); - } - - return; - } - - _dragSelectAutoScrollTimer = new DispatcherTimer - { - Interval = TimeSpan.FromMilliseconds(80), - }; - _dragSelectAutoScrollTimer.Tick += (_, _) => SubtitleGridDragSelectAutoScrollTick(); - _dragSelectAutoScrollTimer.Start(); - } - - private static int CalculateSubtitleGridDragSelectAutoScrollStep(double distanceFromEdge) - { - var step = 1 + (int)Math.Floor(Math.Max(0, distanceFromEdge) / DragSelectAutoScrollAccelerationPixels); - return Math.Clamp(step, 1, DragSelectAutoScrollMaxStep); - } - - private void StopSubtitleGridDragSelectAutoScroll() - { - _dragSelectAutoScrollDirection = 0; - _dragSelectAutoScrollStep = 1; - _dragSelectAutoScrollTimer?.Stop(); - } - - private void SubtitleGridDragSelectAutoScrollTick() - { - if (_dragSelectStartIndex < 0 || !_subtitleGridIsLeftClick || _dragSelectAutoScrollDirection == 0) - { - StopSubtitleGridDragSelectAutoScroll(); - return; - } - - var nextIndex = Math.Clamp( - _dragSelectLastIndex + _dragSelectAutoScrollDirection * _dragSelectAutoScrollStep, - 0, - Subtitles.Count - 1); - if (nextIndex == _dragSelectLastIndex) - { - StopSubtitleGridDragSelectAutoScroll(); - return; - } - - DragSelectSubtitleGridToIndex(nextIndex); - SubtitleGrid.ScrollIntoView(Subtitles[nextIndex], null); + return TableViewExtras.GetPageSize(SubtitleGrid); } public void SubtitleGrid_PointerReleased(object? sender, PointerReleasedEventArgs e) { - EndSubtitleGridDragSelect(e); + var hasMoved = SubtitleGridDragSelect?.HasMoved == true; + SubtitleGridDragSelect?.End(e); if (sender is Control { ContextFlyout: MenuFlyout menuFlyout } control) { - if (_subtitleGridIsRightClick && !_dragSelectHasMoved) + if (_subtitleGridIsRightClick && !hasMoved) { menuFlyout.ShowAt(control, true); } if (OperatingSystem.IsMacOS()) { - if (_subtitleGridIsLeftClick && _subtitleGridIsControlPressed && !_dragSelectHasMoved) + if (_subtitleGridIsLeftClick && _subtitleGridIsControlPressed && !hasMoved) { menuFlyout.ShowAt(control, true); e.Handled = true; @@ -22107,19 +21703,6 @@ public void SubtitleGrid_PointerReleased(object? sender, PointerReleasedEventArg } } - private void EndSubtitleGridDragSelect(PointerEventArgs e) - { - StopSubtitleGridDragSelectAutoScroll(); - if (_dragSelectStartIndex >= 0) - { - e.Pointer.Capture(null); - } - _dragSelectStartIndex = -1; - _dragSelectLastIndex = -1; - _dragSelectAppliedIndex = -1; - _dragSelectAutoScrollDirection = 0; - } - public void SubtitleGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e) { if (_subtitleGridSelectionChangedSkip) @@ -22142,7 +21725,7 @@ public void SubtitleGrid_SelectionChanged(object? sender, SelectionChangedEventA _shiftSelectCurrentIndex = -1; } - var selectedItems = SubtitleGrid.SelectedItems; + var selectedItems = SubtitleGridSelectedItems; // If user is trying to deselect the last selected item if (selectedItems.Count == 0 && e.AddedItems.Count == 0 && e.RemovedItems.Count == 1) @@ -22192,7 +21775,7 @@ private int IndexOfSubtitle(SubtitleLineViewModel item) private void SubtitleGridSelectionChanged() { - var selectedItems = SubtitleGrid.SelectedItems; + var selectedItems = SubtitleGridSelectedItems; EditTextBox.ClearSelection(); EditTextBoxOriginal.ClearSelection(); ResetPlaySelection(); @@ -22765,7 +22348,7 @@ private void StartTimers() vp.Position = p.StartTime.TotalSeconds; } - Dispatcher.UIThread.Post(() => { SubtitleGrid.ScrollIntoView(p, null); }); + Dispatcher.UIThread.Post(() => { SubtitleGrid.ScrollIntoView(p); }); } } @@ -23419,7 +23002,7 @@ internal void OnSubtitleGridDoubleTapped(object? sender, TappedEventArgs e) internal void OnSubtitleGridDoubleTapped(object? sender) { - if (sender is not DataGrid grid || grid.SelectedItem == null) + if (sender is not TableView grid || grid.SelectedItem == null) { return; } @@ -23544,7 +23127,7 @@ internal async void OnSubtitleGridSingleTapped(object? sender, TappedEventArgs e internal void OnSubtitleGridSingleTapped(object? sender) { - if (sender is not DataGrid grid || grid.SelectedItem == null) + if (sender is not TableView grid || grid.SelectedItem == null) { return; } @@ -23620,7 +23203,7 @@ public void AudioVisualizerOnToggleSelection(object sender, ParagraphEventArgs e Math.Abs(p.StartTime.TotalMilliseconds - e.Paragraph.StartTime.TotalMilliseconds) < 0.01); if (p != null) { - var selectedItems = SubtitleGrid.SelectedItems; + var selectedItems = SubtitleGridSelectedItems; if (selectedItems.Contains(p)) { if (selectedItems.Count != 1 || selectedItems[0] != p) @@ -23648,7 +23231,7 @@ public void Adjust(TimeSpan adjustment, bool adjustAll, bool adjustSelectedLines if (adjustSelectedLines) { - foreach (SubtitleLineViewModel p in SubtitleGrid.SelectedItems) + foreach (SubtitleLineViewModel p in SubtitleGridSelectedItems) { p.SetStartTimeKeepDuration(p.StartTime + adjustment); p.UpdateDuration(); @@ -23656,7 +23239,7 @@ public void Adjust(TimeSpan adjustment, bool adjustAll, bool adjustSelectedLines } else if (adjustSelectedLinesAndForward) { - var selectedItems = SubtitleGrid.SelectedItems.Cast().ToList(); + var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); if (selectedItems.Count > 0) { var firstSelectedIndex = selectedItems.Min(p => Subtitles.IndexOf(p)); diff --git a/src/ui/Logic/TableViewExtras.cs b/src/ui/Logic/TableViewExtras.cs new file mode 100644 index 00000000000..d1156ada5cf --- /dev/null +++ b/src/ui/Logic/TableViewExtras.cs @@ -0,0 +1,527 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// A with the extras SE's grids need beyond what the +/// control offers: a for stable column keys (width persistence), +/// a hint, and a bindable property. +/// TableViewColumn has no visibility concept, so a +/// watches and adds/removes the column from the TableView, +/// keeping the original column order. +/// +public class SeTableViewColumn : TableViewColumn +{ + public SeTableViewColumn() + { + // TableViewColumn defaults to Left, which makes cell content shrink-wrap + // horizontally - a cell template's colored background Border would then only + // cover the text instead of the whole cell (the DataGrid stretched cell + // content). Stretch restores full-cell backgrounds; templates that want + // centering (e.g. the number column) set it on their own root. + HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Stretch; + } + + public static readonly StyledProperty IsVisibleProperty = + AvaloniaProperty.Register(nameof(IsVisible), defaultValue: true); + + public bool IsVisible + { + get => GetValue(IsVisibleProperty); + set => SetValue(IsVisibleProperty, value); + } + + public object? Tag { get; set; } + + public double MinWidth { get; set; } +} + +/// +/// Owns the full, ordered column list of a and keeps the +/// control's live in sync with each +/// : hidden columns are removed from the +/// control (TableView renders every column it holds), visible ones are re-inserted +/// at their original position. +/// +public sealed class TableViewColumnManager +{ + private readonly TableView _tableView; + private readonly List _columns = new(); + + public TableViewColumnManager(TableView tableView) + { + _tableView = tableView; + } + + /// All managed columns in display order, including hidden ones. + public IReadOnlyList Columns => _columns; + + public void Add(TableViewColumn column) + { + _columns.Add(column); + if (column is SeTableViewColumn seColumn) + { + seColumn.PropertyChanged += (_, e) => + { + if (e.Property == SeTableViewColumn.IsVisibleProperty) + { + Sync(); + } + }; + } + + Sync(); + } + + private void Sync() + { + var target = _columns.Where(c => c is not SeTableViewColumn se || se.IsVisible).ToList(); + var live = _tableView.Columns; + + // Remove columns that should no longer be shown. After this, the live list is a + // subsequence of the target list (both preserve the master order), so the missing + // ones can simply be inserted at their target positions. + for (var i = live.Count - 1; i >= 0; i--) + { + if (!target.Contains(live[i])) + { + live.RemoveAt(i); + } + } + + for (var i = 0; i < target.Count; i++) + { + if (i >= live.Count || !ReferenceEquals(live[i], target[i])) + { + live.Insert(i, target[i]); + } + } + } +} + +/// +/// Reusable behaviors for -based grids: reliable scrolling +/// (center / fully-visible), row hit-testing, page size, batched selection updates and +/// per-row style bindings. These are the TableView counterparts of the hand-rolled +/// DataGrid machinery the main subtitle grid accumulated over time; use them for any +/// new TableView so the behavior stays consistent. +/// +public static class TableViewExtras +{ + /// + /// Creates a TableView with SE's standard look and behavior (multi-select, + /// resizable columns, tight row style). + /// + public static TableView MakeTableView(bool alwaysSelected = true) + { + var tableView = new TableView + { + SelectionMode = alwaysSelected + ? SelectionMode.Multiple | SelectionMode.AlwaysSelected + : SelectionMode.Multiple, + CanUserResizeColumns = true, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + + // The DataGrid had no background of its own, so SE's panel background showed + // through; TableView's theme paints SystemControlBackgroundChromeMediumLowBrush, + // which in dark mode is within a shade or two of the alternating-row tint + // (#2D2D2D) and swallowed it completely. Transparent restores the DataGrid + // backdrop so row tints contrast against the app background again. + Background = Brushes.Transparent, + }; + + UiUtil.ApplyTableViewRowStyle(tableView); + return tableView; + } + + /// + /// Adds a style that binds on every row to + /// (evaluated against the row's DataContext, i.e. the + /// item). Used e.g. to collapse hidden rows or to expose an accessible name. + /// + public static void BindRowProperty(TableView tableView, AvaloniaProperty property, BindingBase binding) + { + tableView.Styles.Add(new Style(x => x.OfType()) + { + Setters = { new Setter(property, binding) }, + }); + } + + /// + /// Tints every other row with . Applied per container from + /// the ContainerPrepared/ContainerIndexChanged events, so the tint stays correct + /// when rows are inserted, removed or recycled. Selection and hover still win: + /// the row theme's :selected/:pointerover styles set the template Border's + /// background directly, overriding the row's own Background. + /// + public static void ApplyAlternatingRows(TableView tableView, IBrush brush) + { + static void Apply(Control container, int index, IBrush alternatingBrush) + { + if (container is not TableViewRow row) + { + return; + } + + if (index % 2 == 1) + { + row.Background = alternatingBrush; + } + else + { + row.ClearValue(TemplatedControl.BackgroundProperty); + } + } + + tableView.ContainerPrepared += (_, e) => Apply(e.Container, e.Index, brush); + tableView.ContainerIndexChanged += (_, e) => Apply(e.Container, e.NewIndex, brush); + } + + /// + /// Moves keyboard focus to the selected row's container when it is realized, falling + /// back to the TableView itself. Focusing the row (not the list control) is what makes + /// the current line visible to screen readers via UI Automation (issue #13015). + /// + public static void FocusRow(TableView tableView) + { + if (tableView.SelectedItem is { } item && + tableView.ContainerFromItem(item) is { } container) + { + container.Focus(); + return; + } + + tableView.Focus(); + } + + /// + /// Returns the item index of the row under (relative to + /// the TableView), or -1 when the point is not over a row. + /// + public static int GetRowIndexFromPoint(TableView tableView, Point position) + { + var current = tableView.InputHitTest(position) as Control; + while (current != null) + { + if (current is TableViewRow row) + { + return tableView.IndexFromContainer(row); + } + + current = current.Parent as Control; + } + + return -1; + } + + /// True when the visual (from a hit test) is inside a column header. + public static bool IsInColumnHeader(Visual? visual) + { + return visual.FindAncestorOfType(includeSelf: true) != null; + } + + /// True when the visual (from a hit test) is inside a scrollbar. + public static bool IsInScrollBar(Visual? visual) + { + return visual.FindAncestorOfType(includeSelf: true) != null; + } + + /// + /// Number of rows that fit in the viewport (used as the PageUp/PageDown step). + /// Counts realized rows, which works with variable row heights. + /// + public static int GetPageSize(TableView tableView) + { + var visibleRowCount = tableView.GetVisualDescendants() + .OfType() + .Count(r => r.IsVisible && r.Bounds.Height > 0); + return Math.Max(1, visibleRowCount - 1); + } + + /// + /// Scrolls so 's row is vertically centered in the viewport. + /// Posted at Loaded priority so the built-in ScrollIntoView (which realizes the row) + /// has taken effect first. + /// + public static void CenterRow(TableView tableView, object item) + { + AdjustScrollForRow(tableView, item, (rowTop, rowHeight, viewportHeight) => + rowTop - (viewportHeight - rowHeight) / 2.0); + } + + /// + /// Nudges the scroll offset so 's row is fully on screen - + /// the non-centering counterpart of for variable-height rows + /// that ScrollIntoView leaves clipped at an edge. + /// + public static void EnsureRowFullyVisible(TableView tableView, object item) + { + AdjustScrollForRow(tableView, item, (rowTop, rowHeight, viewportHeight) => + { + var rowBottom = rowTop + rowHeight; + if (rowBottom > viewportHeight) + { + return rowBottom - viewportHeight; // pokes out the bottom -> scroll down + } + + if (rowTop < 0) + { + return rowTop; // pokes out the top -> scroll up (negative) + } + + return 0; + }); + } + + private static void AdjustScrollForRow(TableView tableView, object item, Func computeDelta) + { + Dispatcher.UIThread.Post(() => + { + if (tableView.ContainerFromItem(item) is not { } row || row.Bounds.Height <= 0) + { + return; + } + + var scrollViewer = tableView.GetVisualDescendants().OfType().FirstOrDefault(); + if (scrollViewer == null || scrollViewer.Viewport.Height <= 0) + { + return; + } + + // Row top in viewport coordinates. + var rowTop = row.TranslatePoint(new Point(0, 0), scrollViewer)?.Y; + if (rowTop == null) + { + return; + } + + var delta = computeDelta(rowTop.Value, row.Bounds.Height, scrollViewer.Viewport.Height); + if (Math.Abs(delta) < 1) + { + return; + } + + var offset = scrollViewer.Offset; + var newY = Math.Max(0, Math.Min(offset.Y + delta, scrollViewer.Extent.Height - scrollViewer.Viewport.Height)); + if (Math.Abs(newY - offset.Y) < 0.5) + { + return; + } + + scrollViewer.Offset = new Vector(offset.X, newY); + }, DispatcherPriority.Loaded); + } +} + +/// +/// Drag-to-select for a TableView: press on a row and drag to select the range, with +/// accelerating auto-scroll when the pointer nears the top/bottom edge. The host owns +/// how a range becomes a selection (it may batch, keep its own anchor bookkeeping and +/// raise its own changed notifications) via ; this class +/// owns the pointer/timer state machine. +/// +public sealed class TableViewDragSelect +{ + private const double AutoScrollEdgeSize = 28; + private const double AutoScrollAccelerationPixels = 18; + private const int AutoScrollMaxStep = 16; + + private readonly TableView _tableView; + + /// (anchorIndex, currentIndex) - replace the selection with that range. + private readonly Action _applyRange; + + private int _startIndex = -1; + private int _lastIndex = -1; + private int _autoScrollDirection; + private int _autoScrollStep = 1; + private DispatcherTimer? _autoScrollTimer; + + public TableViewDragSelect(TableView tableView, Action applyRange) + { + _tableView = tableView; + _applyRange = applyRange; + } + + /// True once the pointer has moved to another row during the current press. + public bool HasMoved { get; private set; } + + /// Arms a potential drag-select from a plain left press on the given row. + public void Arm(int rowIndex) + { + _startIndex = rowIndex; + _lastIndex = rowIndex; + } + + /// Resets all state (e.g. on pointer press, before re-arming). + public void Reset() + { + StopAutoScroll(); + _startIndex = -1; + _lastIndex = -1; + HasMoved = false; + } + + public void OnPointerMoved(object? sender, PointerEventArgs e) + { + if (_startIndex < 0) + { + return; + } + + if (!e.GetCurrentPoint(_tableView).Properties.IsLeftButtonPressed) + { + End(e); + return; + } + + var position = e.GetPosition(_tableView); + UpdateAutoScroll(position); + + var currentIndex = TableViewExtras.GetRowIndexFromPoint(_tableView, position); + if (currentIndex < 0) + { + return; + } + + var wasDragging = HasMoved; + DragTo(currentIndex); + if (HasMoved) + { + if (!wasDragging && sender is Control control) + { + e.Pointer.Capture(control); + } + + e.Handled = true; + } + } + + public void End(PointerEventArgs e) + { + StopAutoScroll(); + if (_startIndex >= 0) + { + e.Pointer.Capture(null); + } + + _startIndex = -1; + _lastIndex = -1; + } + + private void DragTo(int currentIndex) + { + var itemCount = _tableView.ItemCount; + if (_startIndex < 0 || currentIndex < 0 || currentIndex >= itemCount) + { + return; + } + + _lastIndex = currentIndex; + + if (currentIndex == _startIndex && !HasMoved) + { + return; + } + + HasMoved = true; + _applyRange(_startIndex, currentIndex); + } + + private void UpdateAutoScroll(Point position) + { + if (_tableView.Bounds.Height <= 0) + { + StopAutoScroll(); + return; + } + + if (position.Y < AutoScrollEdgeSize) + { + StartAutoScroll(-1, AutoScrollEdgeSize - position.Y); + } + else if (position.Y > _tableView.Bounds.Height - AutoScrollEdgeSize) + { + StartAutoScroll(1, position.Y - (_tableView.Bounds.Height - AutoScrollEdgeSize)); + } + else + { + StopAutoScroll(); + } + } + + private void StartAutoScroll(int direction, double distanceFromEdge) + { + if (_lastIndex < 0 || _tableView.ItemCount == 0) + { + return; + } + + _autoScrollDirection = direction; + var step = 1 + (int)Math.Floor(Math.Max(0, distanceFromEdge) / AutoScrollAccelerationPixels); + _autoScrollStep = Math.Clamp(step, 1, AutoScrollMaxStep); + + if (_autoScrollTimer != null) + { + if (!_autoScrollTimer.IsEnabled) + { + _autoScrollTimer.Start(); + } + + return; + } + + _autoScrollTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(80), + }; + _autoScrollTimer.Tick += (_, _) => AutoScrollTick(); + _autoScrollTimer.Start(); + } + + private void StopAutoScroll() + { + _autoScrollDirection = 0; + _autoScrollStep = 1; + _autoScrollTimer?.Stop(); + } + + private void AutoScrollTick() + { + if (_startIndex < 0 || _autoScrollDirection == 0) + { + StopAutoScroll(); + return; + } + + var itemCount = _tableView.ItemCount; + if (itemCount == 0) + { + StopAutoScroll(); + return; + } + + var nextIndex = Math.Clamp(_lastIndex + _autoScrollDirection * _autoScrollStep, 0, itemCount - 1); + if (nextIndex == _lastIndex) + { + StopAutoScroll(); + return; + } + + DragTo(nextIndex); + _tableView.ScrollIntoView(nextIndex); + } +} diff --git a/src/ui/Logic/UiUtil.cs b/src/ui/Logic/UiUtil.cs index fc92d0a7fff..db9b7244883 100644 --- a/src/ui/Logic/UiUtil.cs +++ b/src/ui/Logic/UiUtil.cs @@ -74,7 +74,7 @@ private static ControlTheme GetTableViewCellTheme(bool noPadding) { new Setter(TableViewCell.BackgroundProperty, Brushes.Transparent), new Setter(TableViewCell.PaddingProperty, padding), - new Setter(TableViewCell.BorderBrushProperty, GetBorderBrush()), + new Setter(TableViewCell.BorderBrushProperty, GetGridLineBrush()), new Setter(TableViewCell.BorderThicknessProperty, new Thickness(0, 0, showVertical ? 1 : 0, showHorizontal ? 1 : 0)), // vertical and horizontal lines new Setter(TableViewCell.TemplateProperty, TableViewCellTemplate), @@ -101,8 +101,11 @@ private static ControlTheme GetTableViewColumnHeaderTheme() // default; SE's custom themes (lighter dark, classic gray, pastel) override // both header types with the same brush via app styles in UiTheme. new Setter(TableViewColumnHeader.BackgroundProperty, GetDataGridHeaderBackgroundBrush()), - new Setter(TableViewColumnHeader.PaddingProperty, new Thickness(4, 2, 4, 4)), - new Setter(TableViewColumnHeader.BorderBrushProperty, GetBorderBrush()), + new Setter(TableViewColumnHeader.PaddingProperty, new Thickness(4, 6, 4, 5)), + // The faint grid-line brush, not the full border brush: with grid lines set + // to None these are the only separators in the grid, and at 0.5 opacity they + // read much stronger than anything the old DataGrid drew. + new Setter(TableViewColumnHeader.BorderBrushProperty, GetGridLineBrush()), // Both header lines always show, independently of the grid-lines setting: the // bottom line separates the header from the first row and the right line // separates the column headers from each other, the way DataGrid's header @@ -354,6 +357,18 @@ public static IBrush GetTextColor(double opacity = 1.0) private static readonly IBrush BorderBrushDark = new Avalonia.Media.Immutable.ImmutableSolidColorBrush(Colors.White, 0.5); private static readonly IBrush BorderBrushLight = new Avalonia.Media.Immutable.ImmutableSolidColorBrush(Colors.Black, 0.5); + // Fainter variant for the TableView's in-body grid lines: drawn as per-cell borders + // they read stronger than the old DataGrid's gridline pass, so tone them down. + private static readonly IBrush GridLineBrushDark = new Avalonia.Media.Immutable.ImmutableSolidColorBrush(Colors.White, 0.22); + private static readonly IBrush GridLineBrushLight = new Avalonia.Media.Immutable.ImmutableSolidColorBrush(Colors.Black, 0.22); + + public static IBrush GetGridLineBrush() + { + return Application.Current?.ActualThemeVariant == ThemeVariant.Dark + ? GridLineBrushDark + : GridLineBrushLight; + } + public static IBrush GetBorderBrush() { return Application.Current?.ActualThemeVariant == ThemeVariant.Dark