From 301d3d21c24eb5383e42e6e726f1f233bdc3f0c7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:10:10 +0700 Subject: [PATCH 01/23] fix(linux-grid): name each cell editor after its column --- .../crates/app/src/ui/grid/calendar_editor.rs | 2 + .../app/src/ui/grid/cell_accessibility.rs | 17 +++++++ linux/crates/app/src/ui/grid/cell_focus.rs | 48 ++++++++++++++++++- linux/crates/app/src/ui/grid/inline_editor.rs | 2 + .../app/src/ui/grid/multiline_editor.rs | 3 ++ 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/grid/calendar_editor.rs b/linux/crates/app/src/ui/grid/calendar_editor.rs index 7412602bc5..c57e4d5493 100644 --- a/linux/crates/app/src/ui/grid/calendar_editor.rs +++ b/linux/crates/app/src/ui/grid/calendar_editor.rs @@ -5,6 +5,7 @@ use tablepro_core::edit::CellInput; use tablepro_core::value::Temporal; use tablepro_core::{ColumnInfo, Value}; +use super::cell_accessibility::name_editor; use super::edit_commit::{commit_edit, send_commit}; use super::inline_editor::{InlineEditor, InlineOptions}; use super::{EditSnapshot, GridCell}; @@ -36,6 +37,7 @@ impl CalendarEditor { }; cell.cancel_edit(); let calendar = gtk::Calendar::new(); + name_editor(&calendar, cell); // Before the handler: set_date emits day-selected when the day // changes, and that is not a pick. if let CalendarStart::On(date) = &start { diff --git a/linux/crates/app/src/ui/grid/cell_accessibility.rs b/linux/crates/app/src/ui/grid/cell_accessibility.rs index b7179409e2..635270877c 100644 --- a/linux/crates/app/src/ui/grid/cell_accessibility.rs +++ b/linux/crates/app/src/ui/grid/cell_accessibility.rs @@ -1,6 +1,23 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::GridCell; use super::row_state_icon::RowPendingState; use crate::services::change_tracker::CellState; +/// An editor opened over a cell takes its column's name: Tab moves the +/// edit across columns, and the header that names them is never focused. +pub(super) fn name_editor(editor: &impl IsA, cell: &GridCell) { + if let Some(name) = cell + .drawing() + .and_then(|drawing| drawing.catalog.as_ref().map(|column| column.name.clone())) + { + editor + .as_ref() + .update_property(&[gtk::accessible::Property::Label(&name)]); + } +} + /// What a screen reader adds to a cell's value. A new row's cells say /// nothing of their own: the row says it is new. A NULL is named, since /// it reads as the text "NULL" would. diff --git a/linux/crates/app/src/ui/grid/cell_focus.rs b/linux/crates/app/src/ui/grid/cell_focus.rs index 26938c863d..e385abcbb4 100644 --- a/linux/crates/app/src/ui/grid/cell_focus.rs +++ b/linux/crates/app/src/ui/grid/cell_focus.rs @@ -144,7 +144,9 @@ mod tests { use tablepro_core::value::Temporal; use super::edit_row; - use crate::test_support::{FocusThief, after_next_frame, drain_main_context, first_descendant_of_type, wait_until}; + use crate::test_support::{ + FocusThief, after_next_frame, assert_labelled, drain_main_context, first_descendant_of_type, wait_until, + }; use crate::ui::grid::test_grid::{Grid, WAIT, edited_rows, key, named, text}; use crate::ui::grid::{GridCell, begin_edit}; @@ -438,6 +440,50 @@ mod tests { ); } + /// Tab moves an edit across the columns, and no header is focused on + /// the way, so each editor names the column it edits. + #[gtk4::test] + fn every_cell_editor_is_named_by_its_column() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("note", "text", false), + named("day", "date", false), + ], + vec![ + vec![Value::Int(1), Value::Text("one\ntwo".into()), day(15)], + vec![Value::Int(2), Value::Text("plain".into()), day(16)], + ], + ); + + let one_line = grid.cell_of(2, 1); + begin_edit(&one_line); + let field = one_line.inline_text().expect("the one-line editor"); + assert_eq!(assert_labelled(&field), Ok(())); + one_line.cancel_edit(); + + let lines = grid.cell_of(1, 1); + begin_edit(&lines); + let view = first_descendant_of_type::(&lines).expect("the multi-line editor"); + assert_eq!(assert_labelled(&view), Ok(())); + let save = crate::test_support::descendants(&lines) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some("Save")) + .expect("the Save button"); + assert!(gtk::test_accessible_has_property( + &save, + gtk::AccessibleProperty::KeyShortcuts + )); + lines.cancel_edit(); + + let dated = grid.cell_of(1, 2); + begin_edit(&dated); + let calendar = first_descendant_of_type::(&dated).expect("the calendar"); + assert_eq!(assert_labelled(&calendar), Ok(())); + dated.cancel_edit(); + } + #[gtk4::test] fn the_multi_line_editor_returns_focus_to_cell() { let grid = Grid::open( diff --git a/linux/crates/app/src/ui/grid/inline_editor.rs b/linux/crates/app/src/ui/grid/inline_editor.rs index 67d097dc90..911d4b5faa 100644 --- a/linux/crates/app/src/ui/grid/inline_editor.rs +++ b/linux/crates/app/src/ui/grid/inline_editor.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::{self as gtk, glib}; +use super::cell_accessibility::name_editor; use super::cell_focus::{edit_next, return_focus}; use super::edit_commit::{commit_edit, send_commit}; use super::edit_navigation::EditDirection; @@ -40,6 +41,7 @@ impl InlineEditor { text.set_input_purpose(options.purpose); text.set_placeholder_text(options.placeholder.as_deref()); text.set_text(initial); + name_editor(&text, cell); connect_commit(cell, &text); if let Some(display) = cell.content() { display.set_visible(false); diff --git a/linux/crates/app/src/ui/grid/multiline_editor.rs b/linux/crates/app/src/ui/grid/multiline_editor.rs index 38f9a3eb62..c6128de9b1 100644 --- a/linux/crates/app/src/ui/grid/multiline_editor.rs +++ b/linux/crates/app/src/ui/grid/multiline_editor.rs @@ -2,6 +2,7 @@ use gtk4::prelude::*; use gtk4::{self as gtk, glib}; use sourceview5::prelude::*; +use super::cell_accessibility::name_editor; use super::edit_commit::{commit_edit, send_commit}; use super::{EditSnapshot, GridCell}; use crate::application::{ShortcutOwner, component_trigger, controller_name}; @@ -24,6 +25,7 @@ impl MultilineEditor { view.set_wrap_mode(gtk::WrapMode::WordChar); view.set_auto_indent(true); crate::ui::style_scheme_resolver::apply(&view, "Adwaita"); + name_editor(&view, cell); let scrolled = gtk::ScrolledWindow::builder() .child(&view) @@ -33,6 +35,7 @@ impl MultilineEditor { .build(); let save = gtk::Button::with_label(&crate::i18n::gettext("Save")); save.add_css_class("suggested-action"); + crate::ui::a11y::announce_key_shortcuts(&save, "cell-editor.save-text"); let cancel = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); let buttons = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) From d3795e33ec1b6e49b6612785ce9b458a906d62f9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:10:23 +0700 Subject: [PATCH 02/23] fix(linux-a11y): name the filter strip's fields and rule rows --- .../app/src/test_support/accessible_label.rs | 3 +- .../app/src/ui/filter_strip/rule_row.rs | 6 +++ .../src/ui/filter_strip/rule_value_editor.rs | 16 ++++++-- linux/crates/app/src/ui/filter_strip/tests.rs | 39 ++++++++++++++++++- linux/crates/app/tests/accessibility/audit.rs | 5 ++- linux/data/resources/ui/filter-rule-row.ui | 6 +++ linux/data/resources/ui/filter-strip.ui | 2 + 7 files changed, 71 insertions(+), 6 deletions(-) diff --git a/linux/crates/app/src/test_support/accessible_label.rs b/linux/crates/app/src/test_support/accessible_label.rs index a45f98aa48..f64d98c5c6 100644 --- a/linux/crates/app/src/test_support/accessible_label.rs +++ b/linux/crates/app/src/test_support/accessible_label.rs @@ -22,7 +22,8 @@ pub(crate) fn assert_labelled(widget: &impl IsA) -> Result<(), Unl }; }; - if row.title().is_empty() { + // A row whose controls are its content names itself instead. + if row.title().is_empty() && !gtk4::test_accessible_has_property(widget, AccessibleProperty::Label) { return Err(UnlabelledWidget::EmptyRowTitle { type_name }); } if widget.is::() { diff --git a/linux/crates/app/src/ui/filter_strip/rule_row.rs b/linux/crates/app/src/ui/filter_strip/rule_row.rs index 0e76f37fd1..4481e283f6 100644 --- a/linux/crates/app/src/ui/filter_strip/rule_row.rs +++ b/linux/crates/app/src/ui/filter_strip/rule_row.rs @@ -68,6 +68,12 @@ mod imp { let remove = self.remove_button.get(); remove.set_icon_name(crate::ui::icons::USER_TRASH); crate::ui::a11y::describe_icon_button(&remove, &crate::i18n::gettext("Remove Rule"), None); + // The row has no title, its controls being the rule, and + // libadwaita names a row by its title label alone. + let row = self.obj(); + let row = row.upcast_ref::(); + row.reset_relation(gtk::AccessibleRelation::LabelledBy); + row.update_property(&[gtk::accessible::Property::Label(&crate::i18n::gettext("Filter Rule"))]); } } diff --git a/linux/crates/app/src/ui/filter_strip/rule_value_editor.rs b/linux/crates/app/src/ui/filter_strip/rule_value_editor.rs index 928d0829bc..7daced66c9 100644 --- a/linux/crates/app/src/ui/filter_strip/rule_value_editor.rs +++ b/linux/crates/app/src/ui/filter_strip/rule_value_editor.rs @@ -19,12 +19,14 @@ pub(super) fn value_editor(rule: &FilterRuleObject, column: &ColumnInfo, shape: } fn single_editor(rule: &FilterRuleObject, column: &ColumnInfo) -> gtk::Entry { + let label = crate::i18n::gettext("Value"); let entry = gtk::Entry::builder() - .placeholder_text(crate::i18n::gettext("Value")) + .placeholder_text(&label) .valign(gtk::Align::Center) .hexpand(true) .input_purpose(input_purpose_for(column)) .build(); + name(&entry, &label); if let Some(FilterValue::Single(text)) = rule.rule().value { entry.set_text(&text); } @@ -37,11 +39,13 @@ fn single_editor(rule: &FilterRuleObject, column: &ColumnInfo) -> gtk::Entry { fn pair_editor(rule: &FilterRuleObject, column: &ColumnInfo) -> gtk::Box { let bound = |placeholder: &str| { - gtk::Entry::builder() + let entry = gtk::Entry::builder() .placeholder_text(placeholder) .valign(gtk::Align::Center) .input_purpose(input_purpose_for(column)) - .build() + .build(); + name(&entry, placeholder); + entry }; let low = bound(&crate::i18n::gettext("From")); let high = bound(&crate::i18n::gettext("To")); @@ -87,6 +91,7 @@ fn list_editor(rule: &FilterRuleObject) -> gtk::Entry { .valign(gtk::Align::Center) .hexpand(true) .build(); + name(&entry, &crate::i18n::gettext("Values")); if let Some(FilterValue::List(items)) = rule.rule().value { entry.set_text(&items.join(", ")); } @@ -97,6 +102,11 @@ fn list_editor(rule: &FilterRuleObject) -> gtk::Entry { entry } +/// A placeholder is gone once the field holds text, so it names nothing. +fn name(entry: >k::Entry, label: &str) { + entry.update_property(&[gtk::accessible::Property::Label(label)]); +} + /// The comma-separated items of a list value, with blanks dropped. fn list_items(text: &str) -> Vec { text.split(',') diff --git a/linux/crates/app/src/ui/filter_strip/tests.rs b/linux/crates/app/src/ui/filter_strip/tests.rs index 2c7dffd113..43c4430474 100644 --- a/linux/crates/app/src/ui/filter_strip/tests.rs +++ b/linux/crates/app/src/ui/filter_strip/tests.rs @@ -4,7 +4,8 @@ use super::rule_operators::placeholder_column; use super::*; use crate::application::ShortcutOwner; use crate::test_support::{ - SignalLog, descendants, drain_main_context, first_descendant_of_type, tooltips_with_keys, unlabelled_icon_buttons, + SignalLog, assert_labelled, descendants, drain_main_context, first_descendant_of_type, tooltips_with_keys, + unlabelled_icon_buttons, }; fn rule(column: &str) -> FilterRule { @@ -218,6 +219,42 @@ fn icon_buttons_are_labelled_without_accelerator_hints() { assert_eq!(tooltips_with_keys(&strip), Vec::::new()); } +/// Placeholders go once text is typed, so each field, and the row that +/// holds a rule, has a name of its own. +#[gtk4::test] +fn every_field_of_the_strip_is_named() { + let rule = |column: &str, op: FilterOp, value: Option| FilterRule { + column: column.to_owned(), + op, + value, + }; + let strip = new_strip( + vec![integer("amount"), placeholder_column("name")], + &FilterSet { + rules: vec![ + rule("name", FilterOp::Eq, Some(FilterValue::Single("x".to_owned()))), + rule( + "amount", + FilterOp::Between, + Some(FilterValue::Pair("1".to_owned(), "2".to_owned())), + ), + rule("name", FilterOp::In, Some(FilterValue::List(vec!["a".to_owned()]))), + ], + ..FilterSet::default() + }, + ); + strip.set_revealed(true); + let fields: Vec = descendants(&strip) + .into_iter() + .filter(|widget| widget.is::() || widget.is::() || widget.is::()) + .collect(); + assert_eq!(fields.len(), 1 + 1 + 3 * 3 + 1 + 2 + 1, "the strip's fields"); + + for field in fields { + assert_eq!(assert_labelled(&field), Ok(()), "{}", field.type_().name()); + } +} + #[gtk4::test] fn an_applied_filter_rebuilds_the_rule_list() { let strip = strip_with(vec![rule("a"), rule("b")]); diff --git a/linux/crates/app/tests/accessibility/audit.rs b/linux/crates/app/tests/accessibility/audit.rs index cdcf43f36a..d1e3e354ae 100644 --- a/linux/crates/app/tests/accessibility/audit.rs +++ b/linux/crates/app/tests/accessibility/audit.rs @@ -138,7 +138,10 @@ fn judge(widget: >k4::Widget) -> Option { } fn judge_row(row: &adw::PreferencesRow) -> Option { - if row.title().is_empty() { + // A row whose controls are its content names itself instead. + if row.title().is_empty() + && !gtk4::test_accessible_has_property(row.upcast_ref::(), AccessibleProperty::Label) + { return Some(Missing::RowTitle); } if !row.is::() { diff --git a/linux/data/resources/ui/filter-rule-row.ui b/linux/data/resources/ui/filter-rule-row.ui index f43cd41101..cc37b4c050 100644 --- a/linux/data/resources/ui/filter-rule-row.ui +++ b/linux/data/resources/ui/filter-rule-row.ui @@ -6,11 +6,17 @@ center + + Column + center + + Operator + diff --git a/linux/data/resources/ui/filter-strip.ui b/linux/data/resources/ui/filter-strip.ui index 2a1315e6bd..e349e1bada 100644 --- a/linux/data/resources/ui/filter-strip.ui +++ b/linux/data/resources/ui/filter-strip.ui @@ -32,6 +32,7 @@ Match + combinator_dropdown @@ -119,6 +120,7 @@ WHERE + raw_entry From 179adc5ec2069c9641def8c10904546b1e49bb48 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:13:11 +0700 Subject: [PATCH 12/23] fix(linux-editor): list F6 as the way out of the SQL editor --- linux/crates/app/src/application/accelerators.rs | 3 +++ linux/crates/app/src/ui/editor/editor_actions.rs | 4 ++++ linux/crates/app/src/ui/editor/tests.rs | 16 ++++++++++++++++ linux/data/resources/shortcuts-dialog.ui | 6 ++++++ 4 files changed, 29 insertions(+) diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs index 110bcd4b30..4a55e514d2 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -209,6 +209,9 @@ pub const ACCELERATORS: &[Accelerator] = &[ &["Escape"], Listed(ShortcutSection::SqlEditor), ), + // GtkPaned's own binding, a signal rather than an action. Tab indents + // in the editor and Ctrl+Tab switches tabs, so this is its way out. + stock("paned.cycle-child-focus", &["F6"], Listed(ShortcutSection::SqlEditor)), local( SqlEditor, CAPTURE, diff --git a/linux/crates/app/src/ui/editor/editor_actions.rs b/linux/crates/app/src/ui/editor/editor_actions.rs index 850cd96317..92885efd5b 100644 --- a/linux/crates/app/src/ui/editor/editor_actions.rs +++ b/linux/crates/app/src/ui/editor/editor_actions.rs @@ -46,6 +46,10 @@ impl SqlEditor { self.add_controller(local_shortcut_controller(ShortcutOwner::SqlEditorTab)); crate::ui::a11y::announce_key_shortcuts(&imp.run_button.get(), "editor.run"); crate::ui::a11y::announce_key_shortcuts(&imp.stop_button.get(), "editor.cancel"); + crate::ui::a11y::announce_key_shortcuts( + imp.source_view.upcast_ref::(), + "paned.cycle-child-focus", + ); } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index a2137fc49e..bc717a36c1 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -424,6 +424,22 @@ fn the_icon_buttons_have_accessible_names() { ); } +/// Tab indents and Ctrl+Tab switches tabs, so the editor names F6, the +/// key its pane binds to move on to the results. +#[gtk4::test] +fn the_editor_names_its_way_out() { + use crate::test_support::shortcut_keys::{class_binding_keys, keystroke}; + let harness = editor_with(answering(), "SELECT 1"); + let view = harness.editor.imp().source_view.get(); + let paned = view.ancestor(gtk4::Paned::static_type()).expect("the editor's pane"); + + assert!(class_binding_keys(&paned).contains(&keystroke("F6"))); + assert!(gtk4::test_accessible_has_property( + view.upcast_ref::(), + gtk4::AccessibleProperty::KeyShortcuts + )); +} + #[gtk4::test] fn a_run_is_recorded_unless_it_carries_a_credential() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index 6960765c73..74cc9ad5ed 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -293,6 +293,12 @@ Escape + + + Move Between the Editor and the Results + F6 + + Format SQL From b6010d36b59f107650e04a8fa7135a859338485f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:13:18 +0700 Subject: [PATCH 13/23] fix(linux-grid): raise the contrast of rows marked for deletion --- linux/data/resources/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/linux/data/resources/style.css b/linux/data/resources/style.css index 40683461de..05e2c711b9 100644 --- a/linux/data/resources/style.css +++ b/linux/data/resources/style.css @@ -18,9 +18,11 @@ textview.sql-editor, textview.sql-editor text { background: color-mix(in srgb, var(--success-bg-color) 22%, transparent); } +/* The full error colour: at 70% the text fell to about 3.5:1 on the + tint, under the 4.5:1 body text needs. */ .tp-row-pending-delete { background: color-mix(in srgb, var(--error-bg-color) 16%, transparent); - color: color-mix(in srgb, var(--error-color) 70%, transparent); + color: var(--error-color); text-decoration: line-through; } From b1bee83459bc6982c204197c645ed84875ee0e33 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:13:19 +0700 Subject: [PATCH 14/23] fix(linux-a11y): name each grid after its table or result --- .../app/src/ui/browse_tab/data_handlers.rs | 2 + .../app/src/ui/browse_tab/load_tests.rs | 13 ++++++ linux/crates/app/src/ui/editor/mod.rs | 7 +++- .../app/src/ui/editor/result_widgets.rs | 42 ++++++++++++++++--- linux/crates/app/src/ui/result_pane/mod.rs | 8 ++-- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/data_handlers.rs b/linux/crates/app/src/ui/browse_tab/data_handlers.rs index 8d5164149a..2c37597f64 100644 --- a/linux/crates/app/src/ui/browse_tab/data_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/data_handlers.rs @@ -240,6 +240,8 @@ impl BrowseModel { }, self.grid_context(), ); + // A screen reader names the grid as the tab does. + column_view.update_property(&[gtk::accessible::Property::Label(&self.table_label())]); self.current_selection = Some(selection); self.current_column_view = Some(column_view.clone()); self.view_columns = data_columns(&column_view); diff --git a/linux/crates/app/src/ui/browse_tab/load_tests.rs b/linux/crates/app/src/ui/browse_tab/load_tests.rs index 3a1f4f5347..bc2dcaa617 100644 --- a/linux/crates/app/src/ui/browse_tab/load_tests.rs +++ b/linux/crates/app/src/ui/browse_tab/load_tests.rs @@ -41,6 +41,19 @@ fn keyed(ids: &[i64]) -> QueryResult { page } +#[gtk4::test] +fn the_grid_is_named_after_its_table() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + + assert!(gtk4::test_accessible_has_property( + &column_view(harness.tab()), + gtk4::AccessibleProperty::Label + )); +} + #[gtk4::test] fn refresh_keeps_grid_visible_and_sets_tab_loading() { let mut script = serving_table(); diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 8d2c0809e9..d7e389b3bb 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -473,7 +473,12 @@ impl SqlEditor { } }, }; - Some(result_widgets::entry_widget(entry, grid_sink, actions)) + Some(result_widgets::entry_widget( + usize::try_from(index).ok()?, + entry, + grid_sink, + actions, + )) } fn finish_run( diff --git a/linux/crates/app/src/ui/editor/result_widgets.rs b/linux/crates/app/src/ui/editor/result_widgets.rs index 3f6533d7fd..82783c34ea 100644 --- a/linux/crates/app/src/ui/editor/result_widgets.rs +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -17,8 +17,13 @@ pub struct PageActions { pub export_all: E, } -/// The widget a results list line shows. -pub fn entry_widget(entry: &RunLogEntry, grid_sink: &GridSink, actions: PageActions) -> gtk4::Widget +/// The widget the results list's `index`th line shows. +pub fn entry_widget( + index: usize, + entry: &RunLogEntry, + grid_sink: &GridSink, + actions: PageActions, +) -> gtk4::Widget where G: Fn(usize) + 'static, E: Fn(ExportSource) + 'static, @@ -28,7 +33,9 @@ where export_all, } = actions; match &entry.kind { - EntryKind::ResultSet(result) => result_set_page(result, grid_sink.clone(), export_all), + EntryKind::ResultSet(result) => { + result_set_page(result, &result.summary.tab_label(index), grid_sink.clone(), export_all) + } EntryKind::Command { rows_affected } => { let title = match rows_affected { Some(rows) => crate::i18n::ngettext_f( @@ -122,6 +129,31 @@ mod tests { } } + #[gtk4::test] + fn a_result_grid_is_named_as_its_line_in_the_list() { + let columns: std::sync::Arc<[tablepro_core::column::ResultColumn]> = + std::sync::Arc::from([tablepro_core::column::ResultColumn::new( + "a", + tablepro_core::column::ColumnType::untyped_text("text"), + )]); + let view = crate::ui::result_pane::ResultSetView::new(columns, tablepro_core::UserSql::new("SELECT a FROM t")); + let name = view.summary.tab_label(1); + let entry = RunLogEntry { + kind: EntryKind::ResultSet(view), + batch: Some(0), + preview: String::new(), + }; + + let widget = entry_widget(1, &entry, &sink(), actions(|_| {})); + + let grid = crate::test_support::first_descendant_of_type::(&widget).expect("the grid"); + assert_eq!(name, "Result 2 (0)"); + assert!(gtk4::test_accessible_has_property( + &grid, + gtk4::AccessibleProperty::Label + )); + } + #[gtk4::test] fn markup_in_error_is_rendered_literally() { let entry = RunLogEntry { @@ -133,7 +165,7 @@ mod tests { preview: String::new(), }; - let widget = entry_widget(&entry, &sink(), actions(|_| {})); + let widget = entry_widget(0, &entry, &sink(), actions(|_| {})); let page = widget.downcast::().expect("a status page"); assert_eq!( @@ -155,7 +187,7 @@ mod tests { let reached = std::rc::Rc::new(std::cell::Cell::new(None)); let seen = reached.clone(); - let widget = entry_widget(&entry, &sink(), actions(move |position| seen.set(Some(position)))); + let widget = entry_widget(0, &entry, &sink(), actions(move |position| seen.set(Some(position)))); let page = widget.downcast::().expect("a status page"); let button = page.child().and_downcast::().expect("Go to Error"); button.emit_clicked(); diff --git a/linux/crates/app/src/ui/result_pane/mod.rs b/linux/crates/app/src/ui/result_pane/mod.rs index ed8be63dd5..b9a406d42d 100644 --- a/linux/crates/app/src/ui/result_pane/mod.rs +++ b/linux/crates/app/src/ui/result_pane/mod.rs @@ -17,16 +17,18 @@ use crate::ui::export::ExportSource; use crate::ui::format::{format_thousands, plural_count}; use crate::ui::grid::{GridSink, RowObject, TabGridContext, build_result_grid, export_snapshot}; -/// The page for `view`. Export All on its banner hands `export_all` the -/// rows the grid shows, in the order it shows them, for the dialog's -/// Shown Rows. +/// The page for `view`, its grid named `name` as the results list names +/// it. Export All on its banner hands `export_all` the rows the grid +/// shows, in the order it shows them, for the dialog's Shown Rows. pub(crate) fn result_set_page( view: &ResultSetView, + name: &str, sink: GridSink, export_all: impl Fn(ExportSource) + 'static, ) -> gtk::Widget { let rows = view.rows.iter().cloned().map(RowObject::result).collect(); let (column_view, _selection) = build_result_grid(view.columns.clone(), rows, sink); + column_view.update_property(&[gtk::accessible::Property::Label(name)]); let banner = ResultLimitBanner::new(&view.summary); let columns = view.columns.clone(); let grid = column_view.downgrade(); From f91e578e6197a21cb68d1fbff41e6e404b47b337 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:13:26 +0700 Subject: [PATCH 15/23] fix(linux-a11y): say a tab has unsaved changes in its tooltip --- linux/crates/app/src/ui/workspace/close_flow.rs | 11 +++++++++++ linux/crates/app/src/ui/workspace/mod.rs | 11 +++++++++-- linux/crates/app/src/ui/workspace/tab_title_tests.rs | 4 ++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/close_flow.rs b/linux/crates/app/src/ui/workspace/close_flow.rs index 7ac36e62ad..f0b1b73e94 100644 --- a/linux/crates/app/src/ui/workspace/close_flow.rs +++ b/linux/crates/app/src/ui/workspace/close_flow.rs @@ -148,9 +148,20 @@ impl WorkspaceModel { /// window whether a logout would, so it holds the session back. pub(super) fn refresh_unsaved(&self) { let unsaved: HashSet = self.workspace_handle.dirty_tabs().iter().map(|tab| tab.id).collect(); + let marked: Vec = self + .workspace_tabs + .borrow() + .iter() + .filter(|(id, tab)| tab.page().icon().is_some() != unsaved.contains(id)) + .map(|(id, _)| *id) + .collect(); for (id, tab) in self.workspace_tabs.borrow().iter() { apply_unsaved_icon(tab.page(), unsaved.contains(id)); } + // The tooltip carries the mark to a screen reader. + for id in marked { + self.retitle_tab(id); + } let dirty = !unsaved.is_empty(); if self.reported_dirty.replace(Some(dirty)) == Some(dirty) { return; diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index f39644dee9..b075b09e42 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -316,10 +316,17 @@ impl WorkspaceTab { } /// Give `page` its title and tooltip. libadwaita treats an empty tooltip -/// as none, which clears the last one. +/// as none, which clears the last one. The tooltip says what the unsaved +/// icon shows, since a screen reader reads a tab's tooltip and not its +/// icon. pub(crate) fn apply_tab_title(page: &adw::TabPage, title: &tab_title::TabTitle) { page.set_title(&title.label); - page.set_tooltip(title.tooltip.as_deref().unwrap_or_default()); + let unsaved = page.icon().is_some().then(|| crate::i18n::gettext("Unsaved changes")); + let lines: Vec<&str> = [title.tooltip.as_deref(), unsaved.as_deref()] + .into_iter() + .flatten() + .collect(); + page.set_tooltip(&lines.join("\n")); } #[derive(Debug, Clone, Copy)] diff --git a/linux/crates/app/src/ui/workspace/tab_title_tests.rs b/linux/crates/app/src/ui/workspace/tab_title_tests.rs index a1b4d14f17..affbcd2aa8 100644 --- a/linux/crates/app/src/ui/workspace/tab_title_tests.rs +++ b/linux/crates/app/src/ui/workspace/tab_title_tests.rs @@ -80,9 +80,13 @@ fn unsaved_icon_follows_structure_tracker() { edit_structure(&workspace, id, a_column_added()); wait_until(WAIT, || modified(&page)).expect("the unsaved icon"); assert_eq!(page.title(), "New Table", "the title changed with the edit"); + // A screen reader reads the tab's tooltip, never its icon. + let tooltip = || page.tooltip().map(|tooltip| tooltip.to_string()).unwrap_or_default(); + assert!(tooltip().ends_with("Unsaved changes"), "{}", tooltip()); edit_structure(&workspace, id, None); wait_until(WAIT, || page.icon().is_none()).expect("the icon to go"); + assert!(!tooltip().contains("Unsaved changes"), "{}", tooltip()); } #[gtk4::test] From 77c2fa5221f742c8ae7405ec694cf164fb09122e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:13:26 +0700 Subject: [PATCH 16/23] fix(linux-a11y): name the saved queries search field --- linux/crates/app/src/ui/saved_queries_dialog.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/saved_queries_dialog.rs b/linux/crates/app/src/ui/saved_queries_dialog.rs index 5cf5faab47..65e1fe0a6d 100644 --- a/linux/crates/app/src/ui/saved_queries_dialog.rs +++ b/linux/crates/app/src/ui/saved_queries_dialog.rs @@ -82,10 +82,13 @@ impl Component for SavedQueriesDialog { header.set_title_widget(Some(&adw::WindowTitle::new(&crate::i18n::gettext("Saved Queries"), ""))); toolbar.add_top_bar(&header); + let search_label = crate::i18n::gettext("Search saved queries"); let search = gtk::SearchEntry::builder() - .placeholder_text(crate::i18n::gettext("Search saved queries")) + .placeholder_text(&search_label) .hexpand(true) .build(); + // A placeholder is gone once text is typed, so it names nothing. + search.update_property(&[gtk::accessible::Property::Label(&search_label)]); let search_bar = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .margin_start(12) @@ -608,6 +611,18 @@ mod dialog_tests { .expect("the search did not narrow the list"); } + /// No scenario opens the dialog, so its one field is checked here. + #[gtk4::test] + fn the_search_field_is_named() { + let (_root, store, local, _staging) = seeded(); + let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { + store: Some(store), + active_connection: Some(local), + }); + + assert_eq!(crate::test_support::assert_labelled(&dialog.model().search), Ok(())); + } + #[gtk4::test] fn deleting_a_query_takes_it_out_of_the_list() { let (_root, store, local, _staging) = seeded(); From 7b0d1f3ea1bbce23c1e2f71dfdff4b79f670c46a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:13:27 +0700 Subject: [PATCH 17/23] fix(linux-a11y): name the structure tab's SQL Preview --- linux/crates/app/src/ui/structure_tab/tests.rs | 13 +++++++++++++ linux/data/resources/ui/structure-tab.ui | 3 +++ 2 files changed, 16 insertions(+) diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index f67fd497b3..dc9fb2ecc9 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -877,6 +877,19 @@ fn preview(tab: &StructureTab) -> String { buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() } +/// The preview is text a reader can move through, so it takes a name. +#[gtk4::test] +fn the_sql_preview_is_named() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(keyed_users())); + let harness = Harness::open(StructureMode::Edit, script, []); + + assert_eq!( + crate::test_support::assert_labelled(&harness.tab().imp().sql_view.get()), + Ok(()) + ); +} + #[gtk4::test] fn renaming_existing_column_enables_save_and_previews_rename() { let mut script = postgres_script(); diff --git a/linux/data/resources/ui/structure-tab.ui b/linux/data/resources/ui/structure-tab.ui index 5ad071e313..54dc15c5c6 100644 --- a/linux/data/resources/ui/structure-tab.ui +++ b/linux/data/resources/ui/structure-tab.ui @@ -275,6 +275,9 @@ 6 6 6 + + SQL Preview + From 2ec0f177ac5c72225ef8b691598cbc1399f23c3c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:13:28 +0700 Subject: [PATCH 18/23] fix(linux-a11y): tie per-row controls to the item they act on --- .../app/src/ui/export/export_ledger/tests.rs | 9 +++++++ .../src/ui/export/export_operations_button.rs | 3 +++ .../crates/app/src/ui/history_dialog/tests.rs | 24 +++++++++++++++++++ linux/data/resources/ui/history-row.ui | 5 ++++ 4 files changed, 41 insertions(+) diff --git a/linux/crates/app/src/ui/export/export_ledger/tests.rs b/linux/crates/app/src/ui/export/export_ledger/tests.rs index 7ef6343470..00532ff00e 100644 --- a/linux/crates/app/src/ui/export/export_ledger/tests.rs +++ b/linux/crates/app/src/ui/export/export_ledger/tests.rs @@ -526,6 +526,15 @@ fn stop_button_cancels_job_and_removes_row() { assert!(button.is_visible()); let row = button.job_list().row_at_index(0).expect("the job's row"); let stop = first_descendant_of_type::(&row).expect("the stop button"); + let progress = first_descendant_of_type::(&row).expect("the job's bar"); + assert!(gtk::test_accessible_has_relation( + &stop, + gtk::AccessibleRelation::DescribedBy + )); + assert!(gtk::test_accessible_has_relation( + &progress, + gtk::AccessibleRelation::LabelledBy + )); stop.emit_clicked(); let outcome = harness.finish(handle); diff --git a/linux/crates/app/src/ui/export/export_operations_button.rs b/linux/crates/app/src/ui/export/export_operations_button.rs index d4d0ec8b47..f7a06880a4 100644 --- a/linux/crates/app/src/ui/export/export_operations_button.rs +++ b/linux/crates/app/src/ui/export/export_operations_button.rs @@ -103,6 +103,9 @@ fn job_row(item: &ExportJobItem) -> gtk::Widget { let stop = gtk::Button::from_icon_name(crate::ui::icons::PROCESS_STOP); crate::ui::a11y::describe_icon_button(&stop, &gettext("Stop Export"), None); + // Every job has a bar and a Stop Export, so each says which file. + progress.update_relation(&[gtk::accessible::Relation::LabelledBy(&[title.upcast_ref()])]); + stop.update_relation(&[gtk::accessible::Relation::DescribedBy(&[title.upcast_ref()])]); stop.set_valign(gtk::Align::Center); stop.add_css_class("flat"); stop.add_css_class("circular"); diff --git a/linux/crates/app/src/ui/history_dialog/tests.rs b/linux/crates/app/src/ui/history_dialog/tests.rs index e7c47cf10e..c59e5648a4 100644 --- a/linux/crates/app/src/ui/history_dialog/tests.rs +++ b/linux/crates/app/src/ui/history_dialog/tests.rs @@ -212,6 +212,30 @@ pub(super) fn menu_item(menu: &gio::MenuModel, label: &str) -> Option<(String, O None } +/// Every row has the same check box, Pin and Delete, so each says which +/// query it acts on. +#[gtk4::test] +fn each_rows_controls_name_their_query() { + let harness = Harness::new(); + harness.record("SELECT 1"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || !rows(&dialog).is_empty()).expect("the row"); + let row = rows(&dialog).remove(0); + + let controls: Vec = descendants(&row) + .into_iter() + .filter(|widget| widget.is::() || widget.is::()) + .collect(); + assert_eq!(controls.len(), 3, "the check box, Pin and Delete"); + for control in controls { + assert!( + gtk::test_accessible_has_relation(&control, gtk::AccessibleRelation::DescribedBy), + "{} does not name its query", + control.type_().name() + ); + } +} + #[gtk4::test] fn list_view_shows_pinned_section_header() { let harness = Harness::new(); diff --git a/linux/data/resources/ui/history-row.ui b/linux/data/resources/ui/history-row.ui index 91d161db30..87193c5767 100644 --- a/linux/data/resources/ui/history-row.ui +++ b/linux/data/resources/ui/history-row.ui @@ -13,6 +13,7 @@ false Select Query + title_label @@ -57,6 +58,9 @@ view-pin-symbolic center + + title_label + @@ -69,6 +73,7 @@ Delete Delete + title_label