diff --git a/linux/changelog.d/3938.fixed.md b/linux/changelog.d/3938.fixed.md new file mode 100644 index 0000000000..6fd7086f41 --- /dev/null +++ b/linux/changelog.d/3938.fixed.md @@ -0,0 +1 @@ +Menu items had no access keys, so a menu could not be used by pressing an item's letter. diff --git a/linux/crates/app/src/test_support/access_keys.rs b/linux/crates/app/src/test_support/access_keys.rs new file mode 100644 index 0000000000..d821666f01 --- /dev/null +++ b/linux/crates/app/src/test_support/access_keys.rs @@ -0,0 +1,142 @@ +use gtk4::prelude::*; +use gtk4::{gio, glib}; + +/// The items one level of a menu shows at once. GtkPopoverMenu runs the +/// sections together and slides a submenu in as a page of its own, headed +/// by its item, whose label keeps the access key. +#[derive(Debug, Default)] +pub(crate) struct MenuPage { + pub labels: Vec, +} + +/// Every page of `menu`, its top level first. +pub(crate) fn menu_pages(menu: &impl IsA) -> Vec { + let mut pages = vec![MenuPage::default()]; + collect(menu.as_ref(), 0, &mut pages); + pages +} + +fn collect(menu: &gio::MenuModel, page: usize, pages: &mut Vec) { + for position in 0..menu.n_items() { + // A section's label is a heading, which GTK shows without a key. + if let Some(section) = menu.item_link(position, gio::MENU_LINK_SECTION) { + collect(§ion, page, pages); + continue; + } + let label = menu + .item_attribute_value(position, gio::MENU_ATTRIBUTE_LABEL, Some(glib::VariantTy::STRING)) + .and_then(|label| label.get::()); + if let Some(label) = &label { + pages[page].labels.push(label.clone()); + } + if let Some(submenu) = menu.item_link(position, gio::MENU_LINK_SUBMENU) { + pages.push(MenuPage { + labels: label.into_iter().collect(), + }); + let index = pages.len() - 1; + collect(&submenu, index, pages); + } + } +} + +/// The characters `label` marks as access keys. A doubled underscore is +/// a literal one. +pub(crate) fn access_keys(label: &str) -> Vec { + let mut keys = Vec::new(); + let mut chars = label.chars(); + while let Some(c) = chars.next() { + if c == '_' { + match chars.next() { + Some('_') | None => {} + Some(key) => keys.push(key), + } + } + } + keys +} + +/// Each item that has no access key, or more than one, or the key of +/// another item on its page, as the GNOME HIG asks of every menu. +pub(crate) fn access_key_problems(pages: &[MenuPage]) -> Vec { + let mut problems = Vec::new(); + for page in pages { + let mut taken: Vec<(String, &str)> = Vec::new(); + for label in &page.labels { + let keys = access_keys(label); + let [key] = keys.as_slice() else { + problems.push(format!("{label:?} has {} access keys", keys.len())); + continue; + }; + let key: String = key.to_lowercase().collect(); + if let Some((_, other)) = taken.iter().find(|(used, _)| *used == key) { + problems.push(format!("{label:?} has the access key of {other:?}")); + } + taken.push((key, label)); + } + } + problems +} + +pub(crate) fn assert_access_keys(menu: &impl IsA) { + let problems = access_key_problems(&menu_pages(menu)); + assert!(problems.is_empty(), "{problems:#?}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn menu(labels: &[&str]) -> gio::Menu { + let menu = gio::Menu::new(); + for label in labels { + menu.append(Some(*label), Some("app.any")); + } + menu + } + + #[test] + fn a_single_underscore_marks_the_key_and_a_doubled_one_is_text() { + assert_eq!(access_keys("Sort _Descending"), ['D']); + assert_eq!(access_keys("in__stock"), Vec::::new()); + assert_eq!(access_keys("_a__b"), ['a']); + assert_eq!(access_keys("Copy"), Vec::::new()); + assert_eq!(access_keys("_One _Two"), ['O', 'T']); + } + + #[test] + fn sections_share_a_page_and_a_submenu_is_its_own() { + let whole = menu(&["_Copy"]); + whole.append_section(Some("Heading"), &menu(&["C_lose"])); + whole.append_submenu(Some("Colu_mn"), &menu(&["_Copy Name"])); + + let pages = menu_pages(&whole); + + let labels: Vec> = pages + .iter() + .map(|page| page.labels.iter().map(String::as_str).collect()) + .collect(); + assert_eq!( + labels, + [vec!["_Copy", "C_lose", "Colu_mn"], vec!["Colu_mn", "_Copy Name"]] + ); + assert_eq!(access_key_problems(&pages), Vec::::new()); + } + + #[test] + fn a_missing_a_doubled_and_a_shared_key_are_each_reported() { + let whole = menu(&["_Copy", "Paste", "_Cut", "_One _Two"]); + whole.append_submenu(Some("_More"), &menu(&["_Mode"])); + + let problems = access_key_problems(&menu_pages(&whole)); + + assert_eq!( + problems, + [ + "\"Paste\" has 0 access keys", + "\"_Cut\" has the access key of \"_Copy\"", + "\"_One _Two\" has 2 access keys", + "\"_Mode\" has the access key of \"_More\"", + ] + ); + } +} diff --git a/linux/crates/app/src/test_support/mod.rs b/linux/crates/app/src/test_support/mod.rs index 17b7bab220..310a3faaae 100644 --- a/linux/crates/app/src/test_support/mod.rs +++ b/linux/crates/app/src/test_support/mod.rs @@ -1,3 +1,4 @@ +mod access_keys; mod accessible_label; mod alert_response; mod application_end; @@ -22,6 +23,7 @@ mod unlabelled_widget; mod wait_timed_out; mod widget_lookup; +pub(crate) use access_keys::{access_key_problems, assert_access_keys, menu_pages}; pub(crate) use accessible_label::{assert_labelled, test_backend_records_properties}; pub(crate) use alert_response::{click_response, respond}; pub(crate) use application_end::end_application; diff --git a/linux/crates/app/src/ui/browse_tab/layout.rs b/linux/crates/app/src/ui/browse_tab/layout.rs index 46fb075649..69865bb3d8 100644 --- a/linux/crates/app/src/ui/browse_tab/layout.rs +++ b/linux/crates/app/src/ui/browse_tab/layout.rs @@ -65,9 +65,9 @@ pub(super) fn build(tab: &BrowseTab, init: &LayoutInit<'_>) -> BrowseTabWidgets /// room for it. fn more_menu(page_sizes: &[u64]) -> gio::Menu { let actions = gio::Menu::new(); - actions.append(Some(&gettext("Filter Rows")), Some("browse.filter")); - actions.append(Some(&gettext("Export Rows…")), Some("browse.export")); - actions.append(Some(&gettext("Count Rows")), Some("browse.count-rows")); + actions.append(Some(&gettext("_Filter Rows")), Some("browse.filter")); + actions.append(Some(&gettext("_Export Rows…")), Some("browse.export")); + actions.append(Some(&gettext("_Count Rows")), Some("browse.count-rows")); let sizes = gio::Menu::new(); for size in page_sizes { let item = gio::MenuItem::new(Some(&format_thousands(*size)), None); @@ -284,6 +284,12 @@ fn collapse_top_bars_with(tab: &BrowseTab, filter: &FilterStrip) { mod tests { use super::*; + #[test] + fn every_action_in_the_more_menu_has_an_access_key_of_its_own() { + // A page size is a number, which takes no key. + crate::test_support::assert_access_keys(&more_menu(&[])); + } + #[gtk4::test] fn the_dropdown_shows_a_page_size_set_in_preferences() { let dropdown = gtk::DropDown::default(); diff --git a/linux/crates/app/src/ui/connection_appearance.rs b/linux/crates/app/src/ui/connection_appearance.rs index d397aedefc..5b805a226d 100644 --- a/linux/crates/app/src/ui/connection_appearance.rs +++ b/linux/crates/app/src/ui/connection_appearance.rs @@ -18,6 +18,21 @@ pub(crate) fn color_name(color: ConnectionColor) -> String { } } +// A tooltip or a drop-down shows `color_name`, which has no access key. +pub(crate) fn color_menu_label(color: ConnectionColor) -> String { + match color { + ConnectionColor::Blue => crate::i18n::gettext("_Blue"), + ConnectionColor::Teal => crate::i18n::gettext("_Teal"), + ConnectionColor::Green => crate::i18n::gettext("_Green"), + ConnectionColor::Yellow => crate::i18n::gettext("_Yellow"), + ConnectionColor::Orange => crate::i18n::gettext("_Orange"), + ConnectionColor::Red => crate::i18n::gettext("Re_d"), + ConnectionColor::Pink => crate::i18n::gettext("Pin_k"), + ConnectionColor::Purple => crate::i18n::gettext("_Purple"), + ConnectionColor::Slate => crate::i18n::gettext("_Slate"), + } +} + pub(crate) fn color_class(color: ConnectionColor) -> &'static str { match color { ConnectionColor::Blue => "tp-tag-blue", diff --git a/linux/crates/app/src/ui/data_grid/grid_menu.rs b/linux/crates/app/src/ui/data_grid/grid_menu.rs index 0247373849..48fe7e9d6a 100644 --- a/linux/crates/app/src/ui/data_grid/grid_menu.rs +++ b/linux/crates/app/src/ui/data_grid/grid_menu.rs @@ -37,9 +37,9 @@ fn item(label: &str, action: &str) -> gio::MenuItem { /// Set to Empty, NULL or DEFAULT, each shown where the cell takes it. pub(crate) fn presets_section() -> gio::Menu { section([ - hidden_while_disabled(&gettext("Set to Empty"), "grid.set-empty"), - hidden_while_disabled(&gettext("Set to NULL"), "grid.set-null"), - hidden_while_disabled(&gettext("Set to DEFAULT"), "grid.set-default"), + hidden_while_disabled(&gettext("Set _to Empty"), "grid.set-empty"), + hidden_while_disabled(&gettext("Set to _NULL"), "grid.set-null"), + hidden_while_disabled(&gettext("Set to DEFAU_LT"), "grid.set-default"), ]) } @@ -60,8 +60,8 @@ impl GridMenus { popover( grid, §ion([ - item(&gettext("Insert Row"), "grid.insert-row"), - item(&gettext("Paste"), "grid.paste"), + item(&gettext("_Insert Row"), "grid.insert-row"), + item(&gettext("_Paste"), "grid.paste"), ]), ) }); @@ -112,49 +112,52 @@ fn cell_menu(grid: &DataGrid, references: &gio::Menu) -> gio::Menu { let menu = gio::Menu::new(); if editable { let edits = presets_section(); - edits.prepend_item(&hidden_while_disabled(&gettext("Edit Cell"), "grid.edit")); + edits.prepend_item(&hidden_while_disabled(&gettext("_Edit Cell"), "grid.edit")); menu.append_section(None, &edits); } let copy_as = section([ - item(&gettext("Rows"), "grid.copy-rows"), - item(&gettext("With Headers"), "grid.copy-rows-headers"), - item(&gettext("CSV"), "grid.copy-csv"), - item(&gettext("CSV With Headers"), "grid.copy-csv-headers"), + item(&gettext("_Rows"), "grid.copy-rows"), + item(&gettext("_With Headers"), "grid.copy-rows-headers"), + item(&gettext("_CSV"), "grid.copy-csv"), + item(&gettext("CSV With _Headers"), "grid.copy-csv-headers"), ]); copy_as.append_section( None, §ion([ - item(&gettext("JSON"), "grid.copy-json"), - item(&gettext("JSON Lines"), "grid.copy-json-lines"), - item(&gettext("Markdown"), "grid.copy-markdown"), - item(&gettext("Text Table"), "grid.copy-text-table"), + item(&gettext("_JSON"), "grid.copy-json"), + item(&gettext("JSON _Lines"), "grid.copy-json-lines"), + item(&gettext("_Markdown"), "grid.copy-markdown"), + item(&gettext("_Text Table"), "grid.copy-text-table"), ]), ); copy_as.append_section( None, §ion([ - hidden_while_disabled(&gettext("IN Clause"), "grid.copy-in-clause"), - hidden_while_disabled(&gettext("One Row"), "grid.copy-one-row"), - hidden_while_disabled(&gettext("INSERT Statement"), "grid.copy-insert"), + hidden_while_disabled(&gettext("_IN Clause"), "grid.copy-in-clause"), + hidden_while_disabled(&gettext("_One Row"), "grid.copy-one-row"), + hidden_while_disabled(&gettext("INSERT _Statement"), "grid.copy-insert"), hidden_while_disabled( - &gettext("INSERT Without Generated Keys"), + &gettext("INSERT Without Generated _Keys"), "grid.copy-insert-without-generated-keys", ), ]), ); - copy_as.append_section(None, §ion([item(&gettext("Column Name"), "grid.copy-column-name")])); - let clipboard = section([item(&gettext("Copy"), "grid.copy")]); - clipboard.append_submenu(Some(&gettext("Copy As")), ©_as); + copy_as.append_section( + None, + §ion([item(&gettext("Column _Name"), "grid.copy-column-name")]), + ); + let clipboard = section([item(&gettext("_Copy"), "grid.copy")]); + clipboard.append_submenu(Some(&gettext("Copy _As")), ©_as); if editable { - clipboard.append_item(&item(&gettext("Paste"), "grid.paste")); + clipboard.append_item(&item(&gettext("_Paste"), "grid.paste")); } menu.append_section(None, &clipboard); if config.is_some_and(|config| config.filter_by_value) { menu.append_section( None, §ion([ - hidden_while_disabled(&gettext("Filter by Value"), "grid.filter-by-value"), - hidden_while_disabled(&gettext("Exclude Value"), "grid.exclude-value"), + hidden_while_disabled(&gettext("_Filter by Value"), "grid.filter-by-value"), + hidden_while_disabled(&gettext("Exclude _Value"), "grid.exclude-value"), ]), ); } @@ -162,16 +165,16 @@ fn cell_menu(grid: &DataGrid, references: &gio::Menu) -> gio::Menu { menu.append_section( None, §ion([ - item(&gettext("Row Details"), "grid.show-details"), - item(&gettext("Show Row as JSON"), "grid.show-row-json"), + item(&gettext("_Row Details"), "grid.show-details"), + item(&gettext("Show Row as _JSON"), "grid.show-row-json"), ]), ); menu.append_section(None, &column_section(sorts)); - let rows = section([item(&gettext("Export…"), "grid.export")]); + let rows = section([item(&gettext("E_xport…"), "grid.export")]); if editable { - rows.append_item(&item(&gettext("Insert Row"), "grid.insert-row")); - rows.append_item(&item(&gettext("Duplicate"), "grid.duplicate-rows")); - rows.append_item(&item(&gettext("Delete"), "grid.delete-rows")); + rows.append_item(&item(&gettext("_Insert Row"), "grid.insert-row")); + rows.append_item(&item(&gettext("D_uplicate"), "grid.duplicate-rows")); + rows.append_item(&item(&gettext("_Delete"), "grid.delete-rows")); } menu.append_section(None, &rows); menu @@ -183,25 +186,25 @@ fn column_section(sorts: bool) -> gio::Menu { let column = gio::Menu::new(); if sorts { column.append_item(&hidden_while_disabled( - &gettext("Sort Ascending"), + &gettext("Sort _Ascending"), "grid.sort-ascending", )); column.append_item(&hidden_while_disabled( - &gettext("Sort Descending"), + &gettext("Sort _Descending"), "grid.sort-descending", )); - column.append_item(&item(&gettext("Clear Sort"), "grid.clear-sort")); + column.append_item(&item(&gettext("_Clear Sort"), "grid.clear-sort")); } column.append_section( None, §ion([ - item(&gettext("Hide Column"), "grid.hide-column"), - item(&gettext("Size to Fit"), "grid.size-to-fit"), - item(&gettext("Columns…"), "grid.columns"), + item(&gettext("_Hide Column"), "grid.hide-column"), + item(&gettext("_Size to Fit"), "grid.size-to-fit"), + item(&gettext("Col_umns…"), "grid.columns"), ]), ); let menu = gio::Menu::new(); - menu.append_submenu(Some(&gettext("Column")), &column); + menu.append_submenu(Some(&gettext("Colu_mn")), &column); menu } @@ -219,21 +222,21 @@ pub fn header_menu(column: &GridColumn, sorting: GridSorting) -> gio::Menu { menu.append_section( None, §ion([ - at(gettext("Sort Ascending"), "grid.sort-column-ascending"), - at(gettext("Sort Descending"), "grid.sort-column-descending"), - item(&gettext("Clear Sort"), "grid.clear-sort"), + at(gettext("Sort _Ascending"), "grid.sort-column-ascending"), + at(gettext("Sort _Descending"), "grid.sort-column-descending"), + item(&gettext("_Clear Sort"), "grid.clear-sort"), ]), ); } menu.append_section( None, §ion([ - at(gettext("Hide Column"), "grid.hide-column-at"), - at(gettext("Size to Fit"), "grid.size-column-to-fit"), - at(gettext("Copy Column Name"), "grid.copy-column-name-at"), + at(gettext("_Hide Column"), "grid.hide-column-at"), + at(gettext("_Size to Fit"), "grid.size-column-to-fit"), + at(gettext("Copy Column _Name"), "grid.copy-column-name-at"), ]), ); - menu.append_section(None, §ion([item(&gettext("Columns…"), "grid.columns")])); + menu.append_section(None, §ion([item(&gettext("Col_umns…"), "grid.columns")])); menu } @@ -241,13 +244,13 @@ pub fn header_menu(column: &GridColumn, sorting: GridSorting) -> gio::Menu { /// sorting, so the header selects the rows from here. The key is /// GtkListBase's `list.select-all`, out of a header menu's reach. pub fn row_numbers_header_menu() -> gio::Menu { - let select_all = item(&gettext("Select All Rows"), "grid.select-all"); + let select_all = item(&gettext("Select _All Rows"), "grid.select-all"); if let Some(key) = crate::application::triggers_for("list.select-all").first() { select_all.set_attribute_value("accel", Some(&key.to_variant())); } let menu = gio::Menu::new(); menu.append_section(None, §ion([select_all])); - menu.append_section(None, §ion([item(&gettext("Columns…"), "grid.columns")])); + menu.append_section(None, §ion([item(&gettext("Col_umns…"), "grid.columns")])); menu } diff --git a/linux/crates/app/src/ui/data_grid/references/mod.rs b/linux/crates/app/src/ui/data_grid/references/mod.rs index 3d7f16b93f..a5203f1b90 100644 --- a/linux/crates/app/src/ui/data_grid/references/mod.rs +++ b/linux/crates/app/src/ui/data_grid/references/mod.rs @@ -128,7 +128,7 @@ impl DataGrid { for (index, label) in labelled(open.collect()) { choices.append_item(&choice(&label, OPEN_CHOICE, index)); } - let open_label = gettext("Open Referenced Row"); + let open_label = gettext("_Open Referenced Row"); match armed.offers.open.as_slice() { [] => {} // The item shows Ctrl+J but acts on the key it offered, as a key @@ -150,7 +150,7 @@ impl DataGrid { referencing.append_item(&choice(&label, REFERENCING, index)); } if referencing.n_items() > 0 { - section.append_submenu(Some(&gettext("Show Referencing Rows")), &referencing); + section.append_submenu(Some(&gettext("_Show Referencing Rows")), &referencing); } } diff --git a/linux/crates/app/src/ui/data_grid/references/tests.rs b/linux/crates/app/src/ui/data_grid/references/tests.rs index 397b7f1f17..be15694f0d 100644 --- a/linux/crates/app/src/ui/data_grid/references/tests.rs +++ b/linux/crates/app/src/ui/data_grid/references/tests.rs @@ -8,7 +8,7 @@ use tablepro_core::{ColumnInfo, ForeignKeyInfo, Value}; use crate::application::ShortcutOwner; use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; -use crate::test_support::wait_until; +use crate::test_support::{access_key_problems, menu_pages, wait_until}; use crate::ui::data_grid::test_grid::{Grid, GridSpec, WAIT, named, persisted}; use crate::ui::data_grid::{GridRequest, KeysHandle, TableKeys}; @@ -168,7 +168,7 @@ fn a_key_cell_offers_open_referenced_row_and_sends_the_rows_it_names() { assert_eq!( offered, - [item("Open Referenced Row", Some("grid.open-reference"), Some(0))] + [item("_Open Referenced Row", Some("grid.open-reference"), Some(0))] ); assert_eq!(followed(&grid), [(public("customers"), filter(&[("id", "42")]))]); } @@ -244,7 +244,7 @@ fn a_column_in_two_keys_offers_each_and_follows_the_one_chosen() { assert_eq!( offered, [ - item("Open Referenced Row", None, None), + item("_Open Referenced Row", None, None), item("tenants (tenant__id)", Some("grid.open-reference"), Some(0)), item( "crm.customers (tenant__id, customer__id)", @@ -273,7 +273,7 @@ fn keys_from_the_same_columns_into_one_table_name_their_constraints() { assert_eq!( offered, [ - item("Open Referenced Row", None, None), + item("_Open Referenced Row", None, None), item( "customers (customer__id) through by__id", Some("grid.open-reference"), @@ -354,7 +354,7 @@ fn referencing_rows_are_offered_for_a_saved_row_and_not_for_a_draft() { assert_eq!( offered, [ - item("Show Referencing Rows", None, None), + item("_Show Referencing Rows", None, None), item( "sales.orders (customer__id)", Some("grid.show-referencing-rows"), @@ -366,6 +366,45 @@ fn referencing_rows_are_offered_for_a_saved_row_and_not_for_a_draft() { assert_eq!(on_draft, [], "a draft is offered rows that point at it"); } +/// The reference items join the cell menu's own page, so their keys +/// must be free there. +#[gtk4::test] +fn the_reference_items_keep_an_access_key_of_their_own() { + let grid = with_keys( + vec![named("id", "integer", true), named("customer_id", "integer", false)], + vec![vec![Value::Int(1), Value::Int(42)]], + TableKeys { + table: public("orders"), + outbound: vec![OutboundForeignKey { + key: key(&["customer_id"], public("customers"), &["id"]), + parent_compares_alike: true, + }], + inbound: vec![InboundForeignKey { + child_compares_alike: true, + name: "invoices_order_id_fkey".to_owned(), + table: public("invoices"), + columns: vec!["order_id".to_owned()], + referenced_columns: vec!["id".to_owned()], + }], + }, + ); + + open_menu(&grid, 1, 1); + let menu = grid + .grid + .menus() + .and_then(|menus| menus.cell.menu_model()) + .expect("the cell menu"); + let pages = menu_pages(&menu); + close_menus(&grid); + + let cell = &pages[0].labels; + assert!(cell.iter().any(|label| label == "_Open Referenced Row"), "{cell:?}"); + assert!(cell.iter().any(|label| label == "_Show Referencing Rows"), "{cell:?}"); + // The referencing tables' names fill the submenu, and a name takes no key. + assert_eq!(access_key_problems(&pages[..1]), Vec::::new()); +} + #[gtk4::test] fn a_grid_given_no_keys_offers_no_reference_items() { let grid = Grid::open( diff --git a/linux/crates/app/src/ui/data_grid/tests/menus.rs b/linux/crates/app/src/ui/data_grid/tests/menus.rs index c1e324efe1..65235213f1 100644 --- a/linux/crates/app/src/ui/data_grid/tests/menus.rs +++ b/linux/crates/app/src/ui/data_grid/tests/menus.rs @@ -8,7 +8,7 @@ use super::{ accel_of, action_names, cell_menu, close_menus, data_column, keyed, menu_actions, none, press, selected_ids, submenu, text, }; -use crate::test_support::{FocusThief, after_next_frame, drain_main_context, wait_until}; +use crate::test_support::{FocusThief, after_next_frame, assert_access_keys, drain_main_context, wait_until}; use crate::ui::data_grid::test_grid::{ Grid, GridSpec, WAIT, focus_in_grid, key, named, results, two_text_columns, two_text_values, }; @@ -61,7 +61,7 @@ fn every_header_action_is_in_the_cell_menus_column_submenu() { let grid = people(); let header = data_column(&grid, 1).header_menu().expect("the header's menu"); let menu = cell_menu(&grid); - let in_column = action_names(&submenu(&menu, "Column").expect("the cell menu's Column submenu")); + let in_column = action_names(&submenu(&menu, "Colu_mn").expect("the cell menu's Column submenu")); let in_cell = action_names(&menu); let header_actions = menu_actions(&header); @@ -81,6 +81,21 @@ fn every_header_action_is_in_the_cell_menus_column_submenu() { } } +#[gtk4::test] +fn every_grid_menu_gives_each_item_its_own_access_key() { + let grid = people(); + let empty = grid + .grid + .menus() + .and_then(|menus| menus.empty.as_ref()?.menu_model()) + .expect("the menu below the rows"); + + assert_access_keys(&cell_menu(&grid)); + assert_access_keys(&empty); + assert_access_keys(&data_column(&grid, 1).header_menu().expect("the header's menu")); + assert_access_keys(&crate::ui::data_grid::grid_menu::row_numbers_header_menu()); +} + #[gtk4::test] fn the_cell_menus_column_items_act_on_its_cell_not_the_focused_one() { let grid = people(); diff --git a/linux/crates/app/src/ui/diagram/canvas_input.rs b/linux/crates/app/src/ui/diagram/canvas_input.rs index 214fa0d9c6..b8626663ef 100644 --- a/linux/crates/app/src/ui/diagram/canvas_input.rs +++ b/linux/crates/app/src/ui/diagram/canvas_input.rs @@ -226,13 +226,7 @@ pub(super) fn popup_menu(canvas: &DiagramCanvas, at: gdk::Rectangle) { .popover .borrow_mut() .get_or_insert_with(|| { - let menu = gio::Menu::new(); - menu.append_item(&menu_item(&crate::i18n::gettext("Open Table"), "diagram.open-table")); - menu.append_item(&menu_item( - &crate::i18n::gettext("Show Structure"), - "diagram.show-structure", - )); - let popover = gtk::PopoverMenu::from_model(Some(&menu)); + let popover = gtk::PopoverMenu::from_model(Some(&table_menu())); popover.set_has_arrow(true); popover.set_parent(canvas); popover @@ -241,3 +235,23 @@ pub(super) fn popup_menu(canvas: &DiagramCanvas, at: gdk::Rectangle) { popover.set_pointing_to(Some(&at)); popover.popup(); } + +fn table_menu() -> gio::Menu { + let menu = gio::Menu::new(); + menu.append_item(&menu_item(&crate::i18n::gettext("_Open Table"), "diagram.open-table")); + menu.append_item(&menu_item( + &crate::i18n::gettext("Show _Structure"), + "diagram.show-structure", + )); + menu +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_item_has_an_access_key_of_its_own() { + crate::test_support::assert_access_keys(&table_menu()); + } +} diff --git a/linux/crates/app/src/ui/editor/assistant_actions.rs b/linux/crates/app/src/ui/editor/assistant_actions.rs index 2ef1794b84..e9999a5df8 100644 --- a/linux/crates/app/src/ui/editor/assistant_actions.rs +++ b/linux/crates/app/src/ui/editor/assistant_actions.rs @@ -263,7 +263,31 @@ pub(super) mod tests { let labels = menu_labels(&menu); assert!(labels.iter().any(|label| label == "C_hange Case"), "{labels:?}"); - assert!(labels.iter().any(|label| label == "Explain with AI"), "{labels:?}"); + assert!(labels.iter().any(|label| label == "Explain _with AI"), "{labels:?}"); + } + + /// GtkTextView 4.22 shows its own items on the context menu's first + /// page, before the extra menu. + const TEXT_VIEW_ITEMS: [&str; 8] = [ + "Cu_t", + "_Copy", + "_Paste", + "_Delete", + "_Undo", + "_Redo", + "Select _All", + "Insert _Emoji", + ]; + + #[gtk4::test] + fn the_context_menu_leaves_the_text_views_access_keys_alone() { + let harness = editor_with_assistant("SELECT 1;", AiPolicy::Ask); + let menu = harness.editor.imp().source_view.extra_menu().expect("the context menu"); + + let mut pages = crate::test_support::menu_pages(&menu); + pages[0].labels.extend(TEXT_VIEW_ITEMS.map(str::to_owned)); + + assert_eq!(crate::test_support::access_key_problems(&pages), Vec::::new()); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/history_dialog/mod.rs b/linux/crates/app/src/ui/history_dialog/mod.rs index c625690fa0..b804278e5f 100644 --- a/linux/crates/app/src/ui/history_dialog/mod.rs +++ b/linux/crates/app/src/ui/history_dialog/mod.rs @@ -1090,18 +1090,18 @@ fn row_menu(entry: &HistoryEntryObject) -> gio::Menu { item }; let open = gio::Menu::new(); - open.append_item(&item(gettext("Open in New Tab"), "history.open")); - open.append_item(&item(gettext("Replace Current Tab"), "history.replace")); + open.append_item(&item(gettext("_Open in New Tab"), "history.open")); + open.append_item(&item(gettext("_Replace Current Tab"), "history.replace")); let keep = gio::Menu::new(); let pin = if entry.is_pinned() { - gettext("Unpin") + gettext("_Unpin") } else { - gettext("Pin") + gettext("_Pin") }; keep.append_item(&item(pin, "history.pin")); - keep.append_item(&item(gettext("Copy SQL"), "history.copy")); + keep.append_item(&item(gettext("_Copy SQL"), "history.copy")); let remove = gio::Menu::new(); - remove.append_item(&item(gettext("Delete"), "history.delete")); + remove.append_item(&item(gettext("_Delete"), "history.delete")); let menu = gio::Menu::new(); menu.append_section(None, &open); menu.append_section(None, &keep); diff --git a/linux/crates/app/src/ui/history_dialog/tests.rs b/linux/crates/app/src/ui/history_dialog/tests.rs index 1cf6dffb38..fa310d94ac 100644 --- a/linux/crates/app/src/ui/history_dialog/tests.rs +++ b/linux/crates/app/src/ui/history_dialog/tests.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use super::*; use crate::runtime::AppRuntime; use crate::services::history_preferences::HistoryPreferences; -use crate::test_support::{descendants, drain_main_context, test_runtime, wait_until}; +use crate::test_support::{assert_access_keys, descendants, drain_main_context, test_runtime, wait_until}; use crate::ui::file_reveal::RecordingRevealer; /// Long enough that a loaded machine cannot decide the outcome; a @@ -359,7 +359,7 @@ fn row_context_menu_open_emits_output_for_clicked_id() { let menu = dialog.imp().parts.get().expect("parts").menu.clone(); assert!(menu.is_visible(), "the menu did not open"); let model = menu.menu_model().expect("a menu model"); - let (action, target) = menu_item(&model, "Open in New Tab").expect("an Open item"); + let (action, target) = menu_item(&model, "_Open in New Tab").expect("an Open item"); menu.popdown(); dialog.activate_action(&action, target.as_ref()).expect("the action"); @@ -601,6 +601,29 @@ fn the_open_search_bar_is_labelled() { ); } +#[test] +fn every_row_menu_item_has_an_access_key_of_its_own() { + let entry = |pinned: bool| { + HistoryEntryObject::new(tablepro_storage::query_history::Entry { + id: 1, + query: "SELECT 1".to_owned(), + driver_id: "sqlite".to_owned(), + connection_id: Uuid::new_v4(), + connection_name: "Local".to_owned(), + executed_at: SystemTime::UNIX_EPOCH, + duration_ms: None, + rows_affected: None, + success: true, + cancelled: false, + pinned, + error: None, + }) + }; + + assert_access_keys(&row_menu(&entry(false))); + assert_access_keys(&row_menu(&entry(true))); +} + #[gtk4::test] fn history_dialog_show_in_files_reveals_the_database_file() { let harness = Harness::new(); @@ -611,7 +634,7 @@ fn history_dialog_show_in_files_reveals_the_database_file() { .filter_map(|widget| widget.downcast::().ok()) .find_map(|button| button.menu_model()) .expect("the dialog's menu"); - let (action, target) = menu_item(&menu, "Show in Files").expect("a Show in Files item"); + let (action, target) = menu_item(&menu, "_Show in Files").expect("a Show in Files item"); dialog.activate_action(&action, target.as_ref()).expect("the action"); @@ -658,7 +681,7 @@ fn menu_key_opens_the_menu_of_the_focused_row() { let menu = dialog.imp().parts.get().expect("parts").menu.clone(); assert!(menu.is_visible(), "the menu did not open"); let model = menu.menu_model().expect("a menu model"); - let (_, target) = menu_item(&model, "Delete").expect("a Delete item"); + let (_, target) = menu_item(&model, "_Delete").expect("a Delete item"); assert_eq!(target.and_then(|target| target.get::()), Some(first)); menu.popdown(); } diff --git a/linux/crates/app/src/ui/menu_label.rs b/linux/crates/app/src/ui/menu_label.rs index f3862c1307..e31af1da0a 100644 --- a/linux/crates/app/src/ui/menu_label.rs +++ b/linux/crates/app/src/ui/menu_label.rs @@ -7,7 +7,11 @@ pub(crate) fn menu_label(text: &str) -> String { #[cfg(test)] mod tests { + use gtk4::{self as gtk, gio}; + use super::menu_label; + use crate::config::RESOURCE_BASE_PATH; + use crate::test_support::{access_key_problems, menu_pages}; #[test] fn underscores_are_doubled_and_nothing_else_changes() { @@ -15,4 +19,59 @@ mod tests { assert_eq!(menu_label("__x"), "____x"); assert_eq!(menu_label("Sales Q1"), "Sales Q1"); } + + fn ui_files(folder: &str) -> Vec { + let names = gio::resources_enumerate_children(folder, gio::ResourceLookupFlags::NONE).expect("the folder"); + names + .iter() + .flat_map(|name| match name.strip_suffix('/') { + Some(inner) => ui_files(&format!("{folder}/{inner}")), + None if name.ends_with(".ui") => vec![format!("{folder}/{name}")], + None => Vec::new(), + }) + .collect() + } + + // A template's class is not registered here, so only the menus are built. + fn menus(ui: &str) -> Vec<(&str, &str)> { + let mut found = Vec::new(); + let mut rest = ui; + while let Some(start) = rest.find("").expect("a closed menu") + "".len(); + found.push((&rest[id_start..id_end], &rest[start..end])); + rest = &rest[end..]; + } + found + } + + #[gtk4::test] + fn every_menu_a_ui_file_holds_gives_each_item_its_own_access_key() { + crate::register_test_resources(); + let mut checked = Vec::new(); + + for path in ui_files(RESOURCE_BASE_PATH) { + let data = gio::resources_lookup_data(&path, gio::ResourceLookupFlags::NONE).expect("the file"); + let found = menus(std::str::from_utf8(&data).expect("UTF-8")); + if found.is_empty() { + continue; + } + let elements: String = found.iter().map(|(_, element)| *element).collect(); + let builder = gtk::Builder::new(); + builder + .add_from_string(&format!("{elements}")) + .expect("the menus build"); + for (id, _) in found { + let menu = builder.object::(id).expect("the menu"); + let problems = access_key_problems(&menu_pages(&menu)); + assert!(problems.is_empty(), "{path} {id}: {problems:#?}"); + checked.push(id.to_owned()); + } + } + + for id in ["primary_menu", "tab_overview_menu", "editor_menu", "options_menu"] { + assert!(checked.iter().any(|read| read == id), "{id} was not read: {checked:?}"); + } + } } diff --git a/linux/crates/app/src/ui/picker/connection_menu.rs b/linux/crates/app/src/ui/picker/connection_menu.rs index fcca13cc7e..851da8acc4 100644 --- a/linux/crates/app/src/ui/picker/connection_menu.rs +++ b/linux/crates/app/src/ui/picker/connection_menu.rs @@ -3,7 +3,7 @@ use gtk4::{gio, glib}; use tablepro_storage::{ConnectionColor, ConnectionGroupName, ConnectionTag}; use uuid::Uuid; -use crate::ui::connection_appearance::color_name; +use crate::ui::connection_appearance::color_menu_label; use crate::ui::menu_label; pub(super) const PICKER: &str = "picker"; @@ -44,7 +44,7 @@ pub(crate) fn connect_action(id: Uuid) -> (String, glib::Variant) { /// The Connect item of the connection `id`'s menu. pub(crate) fn connect_item(id: Uuid) -> gio::MenuItem { item( - &crate::i18n::gettext("Connect"), + &crate::i18n::gettext("_Connect"), PICKER, CONNECT, &id.to_string().to_variant(), @@ -74,20 +74,20 @@ pub(crate) fn connection_menu_with( let menu = gio::Menu::new(); menu.append_section(None, lead); let change = gio::Menu::new(); - change.append_item(&item(&crate::i18n::gettext("Edit…"), PICKER, EDIT, &target)); - change.append_item(&item(&crate::i18n::gettext("Duplicate"), PICKER, DUPLICATE, &target)); + change.append_item(&item(&crate::i18n::gettext("_Edit…"), PICKER, EDIT, &target)); + change.append_item(&item(&crate::i18n::gettext("D_uplicate"), PICKER, DUPLICATE, &target)); menu.append_section(None, &change); let file = gio::Menu::new(); - file.append_submenu(Some(&crate::i18n::gettext("Colour")), &color_menu()); - file.append_submenu(Some(&crate::i18n::gettext("Tags")), &tag_menu(&target, tags)); + file.append_submenu(Some(&crate::i18n::gettext("Colou_r")), &color_menu()); + file.append_submenu(Some(&crate::i18n::gettext("Tag_s")), &tag_menu(&target, tags)); file.append_submenu( - Some(&crate::i18n::gettext("Move to Group")), + Some(&crate::i18n::gettext("_Move to Group")), &group_menu(&target, groups), ); menu.append_section(None, &file); let danger = gio::Menu::new(); // Named as the dialog it opens names it. - danger.append_item(&item(&crate::i18n::gettext("Delete…"), PICKER, DELETE, &target)); + danger.append_item(&item(&crate::i18n::gettext("Dele_te…"), PICKER, DELETE, &target)); menu.append_section(None, &danger); menu } @@ -103,14 +103,19 @@ fn item(label: &str, group: &str, action: &str, target: &glib::Variant) -> gio:: fn color_menu() -> gio::Menu { let menu = gio::Menu::new(); menu.append_item(&item( - &crate::i18n::gettext("No Colour"), + &crate::i18n::gettext("_No Colour"), ROW, ROW_COLOR, &NONE.to_variant(), )); let colors = gio::Menu::new(); for color in ConnectionColor::ALL { - colors.append_item(&item(&color_name(color), ROW, ROW_COLOR, &color.id().to_variant())); + colors.append_item(&item( + &color_menu_label(color), + ROW, + ROW_COLOR, + &color.id().to_variant(), + )); } menu.append_section(None, &colors); menu @@ -121,7 +126,7 @@ fn color_menu() -> gio::Menu { fn group_menu(target: &glib::Variant, groups: &[ConnectionGroupName]) -> gio::Menu { let menu = gio::Menu::new(); menu.append_item(&item( - &crate::i18n::gettext("No Group"), + &crate::i18n::gettext("_No Group"), ROW, ROW_GROUP, &NONE.to_variant(), @@ -139,7 +144,7 @@ fn group_menu(target: &glib::Variant, groups: &[ConnectionGroupName]) -> gio::Me menu.append_section(None, &existing); } let new = gio::Menu::new(); - new.append_item(&item(&crate::i18n::gettext("New Group…"), PICKER, NEW_GROUP, target)); + new.append_item(&item(&crate::i18n::gettext("New _Group…"), PICKER, NEW_GROUP, target)); menu.append_section(None, &new); menu } @@ -159,9 +164,9 @@ fn tag_menu(target: &glib::Variant, tags: &[ConnectionTag]) -> gio::Menu { menu.append_section(None, &existing); } let change = gio::Menu::new(); - change.append_item(&item(&crate::i18n::gettext("New Tag…"), PICKER, NEW_TAG, target)); + change.append_item(&item(&crate::i18n::gettext("_New Tag…"), PICKER, NEW_TAG, target)); change.append_item(&gio::MenuItem::new( - Some(&crate::i18n::gettext("Manage Tags…")), + Some(&crate::i18n::gettext("_Manage Tags…")), Some(MANAGE_TAGS), )); menu.append_section(None, &change); @@ -171,6 +176,7 @@ fn tag_menu(target: &glib::Variant, tags: &[ConnectionTag]) -> gio::Menu { #[cfg(test)] mod tests { use super::*; + use crate::test_support::assert_access_keys; /// Each item's label, action and target, in order, sections and /// submenus flattened. @@ -206,10 +212,10 @@ mod tests { let found = items(connection_menu(id, &[], &[]).upcast_ref()); let first = found.first().expect("an item"); - assert_eq!(first.0, "Connect"); + assert_eq!(first.0, "_Connect"); assert_eq!(first.1.as_deref(), Some("picker.connect")); let last = found.last().expect("an item"); - assert_eq!(last.0, "Delete…"); + assert_eq!(last.0, "Dele_te…"); assert_eq!(last.1.as_deref(), Some("picker.delete")); for (label, action, target) in &found { if action.as_deref().is_some_and(|action| action.starts_with("picker.")) { @@ -222,6 +228,11 @@ mod tests { } } + #[test] + fn every_item_has_an_access_key_of_its_own() { + assert_access_keys(&connection_menu(Uuid::new_v4(), &[], &[])); + } + fn group(name: &str) -> ConnectionGroupName { ConnectionGroupName::new(name).expect("a group name") } @@ -237,13 +248,13 @@ mod tests { }; let order = [ - shown("Connect"), - shown("Edit…"), - shown("Duplicate"), - shown("Colour"), - shown("Tags"), - shown("Move to Group"), - shown("Delete…"), + shown("_Connect"), + shown("_Edit…"), + shown("D_uplicate"), + shown("Colou_r"), + shown("Tag_s"), + shown("_Move to Group"), + shown("Dele_te…"), ]; assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{labels:?}"); @@ -258,8 +269,8 @@ mod tests { let labels = labels(&connection_menu_with(&lead, id, &[], &[])); assert_eq!(labels.first().map(String::as_str), Some("Disconnect")); - assert!(!labels.contains(&"Connect".to_owned()), "{labels:?}"); - assert_eq!(labels.last().map(String::as_str), Some("Delete…")); + assert!(!labels.contains(&"_Connect".to_owned()), "{labels:?}"); + assert_eq!(labels.last().map(String::as_str), Some("Dele_te…")); let (action, target) = connect_action(id); assert_eq!(action, "picker.connect"); assert_eq!(target.get::(), Some(id.to_string())); @@ -275,8 +286,8 @@ mod tests { assert!(labels.contains(&"Archive".to_owned()), "{labels:?}"); assert!(labels.contains(&"Work".to_owned()), "{labels:?}"); - assert!(labels.contains(&"No Group".to_owned()), "{labels:?}"); - assert!(labels.contains(&"New Group…".to_owned()), "{labels:?}"); + assert!(labels.contains(&"_No Group".to_owned()), "{labels:?}"); + assert!(labels.contains(&"New _Group…".to_owned()), "{labels:?}"); } #[test] @@ -308,11 +319,11 @@ mod tests { .expect("the tag's item"); assert_eq!(check.1.as_deref(), Some(format!("picker-row.tag-{}", tag.id).as_str())); assert_eq!(check.2, None, "a check item carries no target"); - let new = found.iter().find(|(label, ..)| label == "New Tag…").expect("New Tag…"); + let new = found.iter().find(|(label, ..)| label == "_New Tag…").expect("New Tag…"); assert_eq!(new.1.as_deref(), Some("picker.new-tag")); let manage = found .iter() - .find(|(label, ..)| label == "Manage Tags…") + .find(|(label, ..)| label == "_Manage Tags…") .expect("Manage Tags…"); assert_eq!(manage.1.as_deref(), Some(MANAGE_TAGS)); } diff --git a/linux/crates/app/src/ui/safe_mode.rs b/linux/crates/app/src/ui/safe_mode.rs index 8af5d5b742..7251d886e5 100644 --- a/linux/crates/app/src/ui/safe_mode.rs +++ b/linux/crates/app/src/ui/safe_mode.rs @@ -8,6 +8,15 @@ pub(crate) fn label(level: SafeModeLevel) -> String { } } +// The window title and the connect dialog show `label`, which has no access key. +pub(crate) fn menu_label(level: SafeModeLevel) -> String { + match level { + SafeModeLevel::Off => crate::i18n::gettext("_Off"), + SafeModeLevel::ConfirmWrites => crate::i18n::gettext("_Confirm Writes"), + SafeModeLevel::ReadOnly => crate::i18n::gettext("_Read-Only"), + } +} + /// What the level does on this engine. Read-Only's guarantee differs by /// engine, so the row says which one the user is getting, naming an /// engine that cannot hold a connection to reads, an engine of keys is diff --git a/linux/crates/app/src/ui/saved_queries_dialog.rs b/linux/crates/app/src/ui/saved_queries_dialog.rs index 77f055c7b6..bbf3e74ee6 100644 --- a/linux/crates/app/src/ui/saved_queries_dialog.rs +++ b/linux/crates/app/src/ui/saved_queries_dialog.rs @@ -329,21 +329,7 @@ impl SavedQueriesDialog { let sender_for_activate = sender.clone(); row.connect_activated(move |_| sender_for_activate.deliver(SavedQueriesDialogInput::Activate(id))); - let menu = gio::Menu::new(); - let open_section = gio::Menu::new(); - open_section.append(Some(&crate::i18n::gettext("Open in New Tab")), Some("saved.open")); - open_section.append( - Some(&crate::i18n::gettext("Replace Current Tab")), - Some("saved.replace"), - ); - open_section.append(Some(&crate::i18n::gettext("Copy SQL")), Some("saved.copy")); - menu.append_section(None, &open_section); - let edit_section = gio::Menu::new(); - edit_section.append(Some(&crate::i18n::gettext("Rename…")), Some("saved.rename")); - menu.append_section(None, &edit_section); - let danger_section = gio::Menu::new(); - danger_section.append(Some(&crate::i18n::gettext("Delete")), Some("saved.delete")); - menu.append_section(None, &danger_section); + let menu = row_menu(); let actions = gio::SimpleActionGroup::new(); let entry = |name: &str, message: fn(i64) -> SavedQueriesDialogInput, sender: ComponentSender| { @@ -480,6 +466,25 @@ fn matches(saved: &SavedQuery, needle: &str) -> bool { || saved.connection_name.to_lowercase().contains(needle) } +fn row_menu() -> gio::Menu { + let menu = gio::Menu::new(); + let open_section = gio::Menu::new(); + open_section.append(Some(&crate::i18n::gettext("_Open in New Tab")), Some("saved.open")); + open_section.append( + Some(&crate::i18n::gettext("_Replace Current Tab")), + Some("saved.replace"), + ); + open_section.append(Some(&crate::i18n::gettext("_Copy SQL")), Some("saved.copy")); + menu.append_section(None, &open_section); + let edit_section = gio::Menu::new(); + edit_section.append(Some(&crate::i18n::gettext("Re_name…")), Some("saved.rename")); + menu.append_section(None, &edit_section); + let danger_section = gio::Menu::new(); + danger_section.append(Some(&crate::i18n::gettext("_Delete")), Some("saved.delete")); + menu.append_section(None, &danger_section); + menu +} + #[cfg(test)] mod tests { use super::*; @@ -572,6 +577,11 @@ mod tests { assert!(!matches(&query, "invoices")); } + #[test] + fn every_row_menu_item_has_an_access_key_of_its_own() { + crate::test_support::assert_access_keys(&row_menu()); + } + #[test] fn a_search_ignores_case_and_surrounding_space() { assert!(matches(&saved("Daily", "SELECT 1", "local"), " daily ")); diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs index 048b0aabce..2bae0311cd 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs @@ -25,24 +25,24 @@ pub(super) const GROUP: &str = "sidebar"; pub(crate) fn object_menu(key: &ObjectKey, kind: ObjectKind, affordances: SidebarAffordances) -> gio::Menu { let menu = gio::Menu::new(); let open = gio::Menu::new(); - open.append_item(&item(&crate::i18n::gettext("Open in New Tab"), OPEN_IN_NEW_TAB, key)); + open.append_item(&item(&crate::i18n::gettext("_Open in New Tab"), OPEN_IN_NEW_TAB, key)); // A key has no SQL to show, no structure to change and no diagram. if !kind.is_browsable() { menu.append_section(None, &open); return menu; } if affordances.speaks_sql { - open.append_item(&item(&crate::i18n::gettext("Show ER Diagram"), SHOW_DIAGRAM, key)); + open.append_item(&item(&crate::i18n::gettext("Show ER _Diagram"), SHOW_DIAGRAM, key)); } menu.append_section(None, &open); let table = kind.is_structure_editable(); let structure = gio::Menu::new(); if table && affordances.can_edit_structure { - structure.append_item(&item(&crate::i18n::gettext("Edit Structure"), EDIT_STRUCTURE, key)); + structure.append_item(&item(&crate::i18n::gettext("_Edit Structure"), EDIT_STRUCTURE, key)); } if affordances.speaks_sql { structure.append_item(&item( - &crate::i18n::gettext("Show CREATE TABLE"), + &crate::i18n::gettext("Show _CREATE TABLE"), SHOW_CREATE_TABLE, key, )); @@ -52,11 +52,11 @@ pub(crate) fn object_menu(key: &ObjectKey, kind: ObjectKind, affordances: Sideba return menu; } let data = gio::Menu::new(); - data.append_item(&item(&crate::i18n::gettext("Import CSV…"), IMPORT_CSV, key)); + data.append_item(&item(&crate::i18n::gettext("_Import CSV…"), IMPORT_CSV, key)); menu.append_section(None, &data); if affordances.can_edit_structure { let danger = gio::Menu::new(); - danger.append_item(&item(&crate::i18n::gettext("Drop Table…"), DROP_TABLE, key)); + danger.append_item(&item(&crate::i18n::gettext("Drop _Table…"), DROP_TABLE, key)); menu.append_section(None, &danger); } menu @@ -74,12 +74,12 @@ pub(crate) fn schema_menu(schema: Option<&str>, affordances: SidebarAffordances, let menu = gio::Menu::new(); let target = schema.map(str::to_owned).to_variant(); if affordances.offers_new_table() { - let item = gio::MenuItem::new(Some(&crate::i18n::gettext("New Table…")), None); + let item = gio::MenuItem::new(Some(&crate::i18n::gettext("_New Table…")), None); item.set_action_and_target_value(Some(&format!("{GROUP}.{NEW_TABLE}")), Some(&target)); menu.append_item(&item); } if affordances.speaks_sql { - let item = gio::MenuItem::new(Some(&crate::i18n::gettext("Show ER Diagram")), None); + let item = gio::MenuItem::new(Some(&crate::i18n::gettext("Show ER _Diagram")), None); item.set_action_and_target_value(Some(&format!("{GROUP}.{SHOW_SCHEMA_DIAGRAM}")), Some(&target)); menu.append_item(&item); } @@ -113,6 +113,7 @@ mod tests { use tablepro_core::meta::TableRef; use super::*; + use crate::test_support::assert_access_keys; /// Each item's action and target, in menu order, sections flattened. fn items(menu: &gio::MenuModel) -> Vec<(String, Option)> { @@ -159,6 +160,14 @@ mod tests { } } + #[test] + fn every_item_has_an_access_key_of_its_own() { + let key = ObjectKey(TableRef::new(Some("sales".to_owned()), "orders")); + + assert_access_keys(&object_menu(&key, ObjectKind::Table, all())); + assert_access_keys(&schema_menu(Some("sales"), all(), Holds::Tables).expect("a menu")); + } + #[test] fn a_read_only_connection_is_offered_nothing_that_writes() { let key = ObjectKey(TableRef::unqualified("orders")); diff --git a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs index 34d4c8a2b0..9af84cd7dc 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs @@ -39,27 +39,67 @@ pub(super) fn session_section(key: &SessionKey, state: InWindow, opens_databases }; match state { InWindow::Open => { - add(crate::i18n::gettext("New Query"), NEW_QUERY_IN); - add(crate::i18n::gettext("Refresh"), REFRESH_IN); + add(crate::i18n::gettext("_New Query"), NEW_QUERY_IN); + add(crate::i18n::gettext("Re_fresh"), REFRESH_IN); if opens_databases { - add(crate::i18n::gettext("Open Database…"), OPEN_DATABASE_IN); + add(crate::i18n::gettext("_Open Database…"), OPEN_DATABASE_IN); } - add(crate::i18n::gettext("Disconnect"), DISCONNECT_SESSION); + add(crate::i18n::gettext("_Disconnect"), DISCONNECT_SESSION); } // A connect can wait for a host that never answers. - InWindow::Connecting => add(crate::i18n::gettext("Stop Connecting"), STOP_CONNECTING), + InWindow::Connecting => add(crate::i18n::gettext("Stop _Connecting"), STOP_CONNECTING), InWindow::Closed { failed, stored } => { if stored && let SessionKey::Saved(id) = key { menu.append_item(&crate::ui::picker::connect_item(*id)); } if failed { - add(crate::i18n::gettext("Show Error"), SHOW_CONNECT_FAILURE); + add(crate::i18n::gettext("S_how Error"), SHOW_CONNECT_FAILURE); } // Its row is only the failure's; nothing else removes it. if failed && !stored { - add(crate::i18n::gettext("Remove"), FORGET_CONNECT_FAILURE); + add(crate::i18n::gettext("_Remove"), FORGET_CONNECT_FAILURE); } } } menu } + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + use crate::test_support::assert_access_keys; + + #[test] + fn every_state_keeps_the_access_keys_of_the_connections_menu_apart() { + let id = Uuid::new_v4(); + let key = SessionKey::Saved(id); + let stored = [ + InWindow::Open, + InWindow::Connecting, + InWindow::Closed { + failed: true, + stored: true, + }, + ]; + + for state in stored { + assert_access_keys(&crate::ui::picker::connection_menu_with( + &session_section(&key, state, true), + id, + &[], + &[], + )); + } + // No saved connection names this one, so the section is the menu. + assert_access_keys(&session_section( + &key, + InWindow::Closed { + failed: true, + stored: false, + }, + true, + )); + } +} diff --git a/linux/crates/app/src/ui/workspace/workspace_shell.rs b/linux/crates/app/src/ui/workspace/workspace_shell.rs index a44182b629..3e43714b71 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell.rs @@ -202,15 +202,7 @@ impl WorkspaceShell { shell.dim_tab_menu(view, page); } }); - let others = gio::Menu::new(); - others.append(Some(&gettext("Close Other Tabs")), Some("tab.close-others")); - others.append(Some(&gettext("Close Tabs After This One")), Some("tab.close-after")); - let close = gio::Menu::new(); - close.append(Some(&gettext("Close")), Some("tab.close")); - let menu = gio::Menu::new(); - menu.append_section(None, &others); - menu.append_section(None, &close); - imp.tab_view.set_menu_model(Some(&menu)); + imp.tab_view.set_menu_model(Some(&tab_menu())); } /// Dim what would close nothing for `page`, whose menu opens. @@ -718,12 +710,24 @@ fn assistant_action(shell: &WorkspaceShell) -> gio::SimpleAction { action } +fn tab_menu() -> gio::Menu { + let others = gio::Menu::new(); + others.append(Some(&gettext("Close _Other Tabs")), Some("tab.close-others")); + others.append(Some(&gettext("Close Tabs _After This One")), Some("tab.close-after")); + let close = gio::Menu::new(); + close.append(Some(&gettext("_Close")), Some("tab.close")); + let menu = gio::Menu::new(); + menu.append_section(None, &others); + menu.append_section(None, &close); + menu +} + /// The main menu with the session's Safe Mode levels on top, as radio /// items of the window's stateful `safe-mode` action. fn with_safe_mode(menu: &gio::MenuModel) -> gio::Menu { let levels = gio::Menu::new(); for level in tablepro_core::SafeModeLevel::ALL { - let item = gio::MenuItem::new(Some(&crate::ui::safe_mode::label(level)), None); + let item = gio::MenuItem::new(Some(&crate::ui::safe_mode::menu_label(level)), None); item.set_action_and_target_value( Some("win.safe-mode"), Some(&crate::ui::safe_mode::action_target(level).to_variant()), @@ -731,7 +735,7 @@ fn with_safe_mode(menu: &gio::MenuModel) -> gio::Menu { levels.append_item(&item); } let section = gio::Menu::new(); - section.append_submenu(Some(&gettext("Safe Mode")), &levels); + section.append_submenu(Some(&gettext("_Safe Mode")), &levels); let whole = gio::Menu::new(); whole.append_section(None, §ion); whole.append_section(None, menu); @@ -758,3 +762,20 @@ fn follow_sidebar(split_view: &adw::OverlaySplitView, toggle: >k::ToggleButton ); visible.bind(toggle, "visible", Some(split_view)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::RESOURCE_BASE_PATH; + use crate::test_support::assert_access_keys; + + #[gtk4::test] + fn the_tab_menu_and_the_main_menu_give_each_item_its_own_access_key() { + crate::register_test_resources(); + let builder = gtk::Builder::from_resource(&format!("{RESOURCE_BASE_PATH}/gtk/menus.ui")); + let main: gio::MenuModel = builder.object(PRIMARY_MENU).expect("the main menu"); + + assert_access_keys(&tab_menu()); + assert_access_keys(&with_safe_mode(&main)); + } +} diff --git a/linux/data/resources/gtk/menus.ui b/linux/data/resources/gtk/menus.ui index 9517677366..bb552aa9fb 100644 --- a/linux/data/resources/gtk/menus.ui +++ b/linux/data/resources/gtk/menus.ui @@ -5,11 +5,11 @@
- New Connection… + _New Connection… win.new-connection - New Window + New _Window app.new-window
@@ -17,48 +17,48 @@ - Query History + Query _History win.show-history action-missing - Saved Queries + Saved _Queries win.show-saved-queries action-missing - Disconnect + _Disconnect win.disconnect action-disabled
- Import Connections… + _Import Connections… win.import-connections - Export Connections… + _Export Connections… win.export-connections - Manage Tags… + _Manage Tags… win.manage-tags
- Preferences + _Preferences app.preferences - Keyboard Shortcuts + _Keyboard Shortcuts app.shortcuts - About TablePro + _About TablePro app.about
@@ -66,11 +66,11 @@
- Reopen Closed Tab + _Reopen Closed Tab win.reopen-closed-tab - Close All Tabs + Close _All Tabs win.close-all-tabs
diff --git a/linux/data/resources/ui/assistant-pane.ui b/linux/data/resources/ui/assistant-pane.ui index 794c4e87ec..2d7515d046 100644 --- a/linux/data/resources/ui/assistant-pane.ui +++ b/linux/data/resources/ui/assistant-pane.ui @@ -5,7 +5,7 @@
- New Conversation + _New Conversation assistant.new-conversation
diff --git a/linux/data/resources/ui/diagram-tab.ui b/linux/data/resources/ui/diagram-tab.ui index c9b0d8d12d..ca347d82b5 100644 --- a/linux/data/resources/ui/diagram-tab.ui +++ b/linux/data/resources/ui/diagram-tab.ui @@ -5,18 +5,18 @@
- Reset Layout + _Reset Layout diagram.reset-layout
- Export as PNG… + Export as _PNG… diagram.export png - Export as SVG… + Export as _SVG… diagram.export svg diff --git a/linux/data/resources/ui/find-bar.ui b/linux/data/resources/ui/find-bar.ui index 2372f4aaf3..6e4bd8ccf5 100644 --- a/linux/data/resources/ui/find-bar.ui +++ b/linux/data/resources/ui/find-bar.ui @@ -5,15 +5,15 @@
- Match Case + Match _Case find.case-sensitive - Match Whole Word + Match _Whole Word find.at-word-boundaries - Regular Expressions + _Regular Expressions find.regex-enabled
diff --git a/linux/data/resources/ui/history-dialog.ui b/linux/data/resources/ui/history-dialog.ui index 6ac5c71284..c5bc8a783d 100644 --- a/linux/data/resources/ui/history-dialog.ui +++ b/linux/data/resources/ui/history-dialog.ui @@ -156,13 +156,13 @@
- Show in Files + _Show in Files history.show-in-files
- Clear All History… + _Clear All History… history.clear-all
@@ -170,11 +170,11 @@
- Export as SQL… + Export as _SQL… history.export-sql - Export as CSV… + Export as _CSV… history.export-csv
diff --git a/linux/data/resources/ui/row-detail-pane.ui b/linux/data/resources/ui/row-detail-pane.ui index cc1c2f8c1d..a0465b692c 100644 --- a/linux/data/resources/ui/row-detail-pane.ui +++ b/linux/data/resources/ui/row-detail-pane.ui @@ -6,24 +6,24 @@
- Set to Empty + Set _to Empty grid.set-empty action-disabled - Set to NULL + Set to _NULL grid.set-null action-disabled - Set to DEFAULT + Set to DEFAU_LT grid.set-default action-disabled
- Copy Value + _Copy Value grid.copy
diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index 49da15b076..915c431572 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -6,57 +6,57 @@
- Open SQL File… + _Open SQL File… editor.open-file <Primary>o - Save + _Save editor.save <Primary>s - Save As… + Save _As… editor.save-as <Primary><Shift>s
- Save Query… + Save _Query… win.save-query - Format + Forma_t editor.format <Primary><Shift>f
- Explain with AI + Explain _with AI editor.ai-explain action-disabled - Review with AI + Re_view with AI editor.ai-review action-disabled - Optimize with AI + Opti_mize with AI editor.ai-optimize action-disabled
- Find… + _Find… editor.find <Primary>f - Find and Replace… + Find and _Replace… editor.find-replace <Primary>h @@ -66,17 +66,17 @@
- Explain with AI + Explain _with AI editor.ai-explain action-disabled - Review with AI + Re_view with AI editor.ai-review action-disabled - Optimize with AI + Opti_mize with AI editor.ai-optimize action-disabled diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index fbe9b45667..291befa8f1 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -126,6 +126,7 @@ crates/app/src/services/tracker_delta.rs crates/app/src/services/tracker_edit.rs crates/app/src/services/undo_op.rs crates/app/src/storage.rs +crates/app/src/test_support/access_keys.rs crates/app/src/test_support/accessible_label.rs crates/app/src/test_support/alert_response.rs crates/app/src/test_support/application_end.rs diff --git a/linux/po/vi.po b/linux/po/vi.po index bd48f3856e..608e7b216e 100644 --- a/linux/po/vi.po +++ b/linux/po/vi.po @@ -180,6 +180,10 @@ msgstr "Chùm khóa không lưu mật khẩu." msgid "Insert Row" msgstr "Thêm dòng" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_Insert Row" +msgstr "Thê_m dòng" + #: crates/app/src/ui/browse_tab/browse_actions.rs #: crates/app/src/ui/browse_tab/selection_handlers.rs msgid "Delete Selected Rows" @@ -190,6 +194,10 @@ msgstr "Xóa các dòng đã chọn" msgid "Export Rows…" msgstr "Xuất các dòng…" +#: crates/app/src/ui/browse_tab/layout.rs +msgid "_Export Rows…" +msgstr "_Xuất các dòng…" + #: crates/app/src/ui/browse_tab/clipboard_handlers.rs #: crates/app/src/ui/editor/mod.rs #: crates/app/src/ui/structure_tab/save_handlers.rs @@ -254,6 +262,12 @@ msgstr "Hủy" msgid "Delete" msgstr "Xóa" +#: crates/app/src/ui/data_grid/grid_menu.rs +#: crates/app/src/ui/history_dialog/mod.rs +#: crates/app/src/ui/saved_queries_dialog.rs +msgid "_Delete" +msgstr "_Xóa" + #: crates/app/src/ui/browse_tab/edit_handlers.rs msgid "" "The table's primary key changed, so no rows were marked for deletion. Select " @@ -268,11 +282,19 @@ msgstr "" msgid "Filter Rows" msgstr "Lọc dòng" +#: crates/app/src/ui/browse_tab/layout.rs +msgid "_Filter Rows" +msgstr "_Lọc dòng" + #: crates/app/src/ui/browse_tab/layout.rs #: crates/app/src/ui/browse_tab/pager_bar.rs data/resources/ui/pager-bar.ui msgid "Count Rows" msgstr "Đếm dòng" +#: crates/app/src/ui/browse_tab/layout.rs +msgid "_Count Rows" +msgstr "Đế_m dòng" + #: crates/app/src/ui/browse_tab/layout.rs msgid "Rows per Page" msgstr "Số dòng mỗi trang" @@ -667,6 +689,10 @@ msgstr "Nhập mật khẩu cơ sở dữ liệu cho {target}." msgid "Connect" msgstr "Kết nối" +#: crates/app/src/ui/picker/connection_menu.rs +msgid "_Connect" +msgstr "_Kết nối" + #: crates/app/src/ui/credential_prompt/prompt_text.rs #, rust-format msgid "Enter the SSH password for {target}." @@ -2547,14 +2573,26 @@ msgstr "Tùy chọn CSV" msgid "Rows" msgstr "Dòng" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_Rows" +msgstr "Dòn_g" + #: crates/app/src/ui/export/dialog.rs data/resources/ui/sql-editor.ui msgid "Format" msgstr "Định dạng" +#: data/resources/ui/sql-editor.ui +msgid "Forma_t" +msgstr "Định _dạng" + #: crates/app/src/ui/export/dialog.rs crates/app/src/ui/grid/grid_menus.rs msgid "Export…" msgstr "Xuất…" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "E_xport…" +msgstr "Xuấ_t…" + #: crates/app/src/ui/export/dialog.rs msgid "Reset to Defaults" msgstr "Khôi phục mặc định" @@ -2706,6 +2744,10 @@ msgstr "Không tạo được tệp." msgid "Show in Files" msgstr "Hiện trong Tệp" +#: data/resources/ui/history-dialog.ui +msgid "_Show in Files" +msgstr "_Hiện trong Tệp" + #: crates/app/src/ui/export/export_failure_text.rs #: crates/app/src/ui/history_dialog/history_export.rs msgid "Couldn't Export" @@ -2899,78 +2941,81 @@ msgstr[0] "… (thêm {count} byte)" msgid "Required" msgstr "Bắt buộc" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Edit Cell" -msgstr "Sửa ô" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_Edit Cell" +msgstr "Sử_a ô" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Set to Empty" -msgstr "Đặt thành rỗng" +#: crates/app/src/ui/data_grid/grid_menu.rs +#: data/resources/ui/row-detail-pane.ui +msgid "Set _to Empty" +msgstr "Đặt thành rỗn_g" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Set to NULL" -msgstr "Đặt thành NULL" +#: crates/app/src/ui/data_grid/grid_menu.rs +#: data/resources/ui/row-detail-pane.ui +msgid "Set to _NULL" +msgstr "Đặt thành _NULL" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Set to DEFAULT" -msgstr "Đặt thành DEFAULT" +#: crates/app/src/ui/data_grid/grid_menu.rs +#: data/resources/ui/row-detail-pane.ui +msgid "Set to DEFAU_LT" +msgstr "Đặt thành DEFAU_LT" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "With Headers" -msgstr "Kèm tiêu đề" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_With Headers" +msgstr "Kè_m tiêu đề" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "JSON" -msgstr "JSON" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_JSON" +msgstr "_JSON" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "CSV" -msgstr "CSV" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_CSV" +msgstr "_CSV" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "CSV With Headers" -msgstr "CSV kèm tiêu đề" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "CSV With _Headers" +msgstr "C_SV kèm tiêu đề" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Markdown" -msgstr "Markdown" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_Markdown" +msgstr "Ma_rkdown" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "IN Clause" -msgstr "Mệnh đề IN" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_IN Clause" +msgstr "Mệnh đề _IN" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "INSERT Statement" -msgstr "Câu lệnh INSERT" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "INSERT _Statement" +msgstr "Câu lện_h INSERT" #: crates/app/src/ui/grid/grid_menus.rs #: crates/app/src/ui/grid/row_json_dialog.rs msgid "Copy" msgstr "Sao chép" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Copy As" -msgstr "Sao chép dưới dạng" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_Copy" +msgstr "Sao c_hép" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Copy Column Name" -msgstr "Sao chép tên cột" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "Copy _As" +msgstr "Sao chép _dưới dạng" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Show Row as JSON" -msgstr "Hiện dòng dạng JSON" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "Copy Column _Name" +msgstr "Sao chép tên _cột" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Sort Ascending" -msgstr "Sắp xếp tăng dần" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "Show Row as _JSON" +msgstr "Hiện dòng dạng _JSON" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Sort Descending" -msgstr "Sắp xếp giảm dần" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "Sort _Ascending" +msgstr "Sắp xếp _tăng dần" -#: crates/app/src/ui/grid/grid_menus.rs -msgid "Clear Sort" -msgstr "Bỏ sắp xếp" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "_Clear Sort" +msgstr "_Bỏ sắp xếp" #: crates/app/src/ui/grid/grid_menus.rs data/resources/shortcuts-dialog.ui msgid "Widen Column" @@ -2984,10 +3029,10 @@ msgstr "Thu hẹp cột" msgid "Reset Column Width" msgstr "Đặt lại độ rộng cột" -#: crates/app/src/ui/grid/grid_menus.rs +#: crates/app/src/ui/data_grid/grid_menu.rs #: crates/app/src/ui/picker/connection_menu.rs -msgid "Duplicate" -msgstr "Nhân bản" +msgid "D_uplicate" +msgstr "Nhân _bản" #: crates/app/src/ui/grid/row_json_dialog.rs #, rust-format @@ -3116,11 +3161,19 @@ msgstr[0] "{n} ngày trước" msgid "Unpin" msgstr "Bỏ ghim" +#: crates/app/src/ui/history_dialog/mod.rs +msgid "_Unpin" +msgstr "_Bỏ ghim" + #: crates/app/src/ui/history_dialog/history_row.rs #: crates/app/src/ui/history_dialog/mod.rs msgid "Pin" msgstr "Ghim" +#: crates/app/src/ui/history_dialog/mod.rs +msgid "_Pin" +msgstr "_Ghim" + #: crates/app/src/ui/history_dialog/mod.rs #, rust-format msgid "{n} selected" @@ -3214,13 +3267,13 @@ msgstr "Mọi truy vấn" #: crates/app/src/ui/history_dialog/mod.rs #: crates/app/src/ui/saved_queries_dialog.rs #: crates/app/src/ui/workspace/sidebar/sidebar_menu.rs -msgid "Open in New Tab" -msgstr "Mở trong tab mới" +msgid "_Open in New Tab" +msgstr "_Mở trong tab mới" #: crates/app/src/ui/history_dialog/mod.rs #: crates/app/src/ui/saved_queries_dialog.rs -msgid "Replace Current Tab" -msgstr "Thay thế tab hiện tại" +msgid "_Replace Current Tab" +msgstr "_Thay thế tab hiện tại" #: crates/app/src/ui/history_dialog/mod.rs #: crates/app/src/ui/saved_queries_dialog.rs @@ -3228,6 +3281,11 @@ msgstr "Thay thế tab hiện tại" msgid "Copy SQL" msgstr "Sao chép SQL" +#: crates/app/src/ui/history_dialog/mod.rs +#: crates/app/src/ui/saved_queries_dialog.rs +msgid "_Copy SQL" +msgstr "Sao _chép SQL" + #: crates/app/src/ui/host_key_review.rs #: crates/app/src/ui/session_window/connections.rs #: crates/app/src/ui/tab_load/stale_banner.rs @@ -3490,69 +3548,109 @@ msgid "No values in the file" msgstr "Không có giá trị nào trong tệp" #: crates/app/src/ui/picker/connection_menu.rs -msgid "Edit…" -msgstr "Sửa…" +msgid "_Edit…" +msgstr "Sử_a…" #: crates/app/src/ui/picker/connection_menu.rs msgid "Colour" msgstr "Màu" #: crates/app/src/ui/picker/connection_menu.rs -msgid "Move to Group" -msgstr "Di chuyển vào nhóm" +msgid "Colou_r" +msgstr "_Màu" #: crates/app/src/ui/picker/connection_menu.rs -msgid "Delete…" -msgstr "Xóa…" +msgid "_Move to Group" +msgstr "_Di chuyển vào nhóm" + +#: crates/app/src/ui/picker/connection_menu.rs +msgid "Dele_te…" +msgstr "_Xóa…" #: crates/app/src/ui/picker/connection_menu.rs -msgid "No Colour" -msgstr "Không màu" +msgid "_No Colour" +msgstr "_Không màu" #: crates/app/src/ui/picker/connection_menu.rs -msgid "No Group" -msgstr "Không nhóm" +msgid "_No Group" +msgstr "_Không nhóm" #: crates/app/src/ui/picker/connection_menu.rs -msgid "New Group…" -msgstr "Nhóm mới…" +msgid "New _Group…" +msgstr "_Nhóm mới…" #: crates/app/src/ui/picker/connection_menu.rs msgid "Blue" msgstr "Xanh dương" +#: crates/app/src/ui/connection_appearance.rs +msgid "_Blue" +msgstr "Xanh _dương" + #: crates/app/src/ui/picker/connection_menu.rs msgid "Teal" msgstr "Xanh mòng két" +#: crates/app/src/ui/connection_appearance.rs +msgid "_Teal" +msgstr "Xanh mòn_g két" + #: crates/app/src/ui/picker/connection_menu.rs msgid "Green" msgstr "Xanh lá" +#: crates/app/src/ui/connection_appearance.rs +msgid "_Green" +msgstr "Xanh _lá" + #: crates/app/src/ui/picker/connection_menu.rs msgid "Yellow" msgstr "Vàng" +#: crates/app/src/ui/connection_appearance.rs +msgid "_Yellow" +msgstr "_Vàng" + #: crates/app/src/ui/picker/connection_menu.rs msgid "Orange" msgstr "Cam" +#: crates/app/src/ui/connection_appearance.rs +msgid "_Orange" +msgstr "_Cam" + #: crates/app/src/ui/picker/connection_menu.rs msgid "Red" msgstr "Đỏ" +#: crates/app/src/ui/connection_appearance.rs +msgid "Re_d" +msgstr "_Đỏ" + #: crates/app/src/ui/picker/connection_menu.rs msgid "Pink" msgstr "Hồng" +#: crates/app/src/ui/connection_appearance.rs +msgid "Pin_k" +msgstr "_Hồng" + #: crates/app/src/ui/picker/connection_menu.rs msgid "Purple" msgstr "Tím" +#: crates/app/src/ui/connection_appearance.rs +msgid "_Purple" +msgstr "_Tím" + #: crates/app/src/ui/picker/connection_menu.rs msgid "Slate" msgstr "Xám đá" +#: crates/app/src/ui/connection_appearance.rs +msgid "_Slate" +msgstr "_Xám đá" + #: crates/app/src/ui/picker/connection_row.rs msgid "Connection Options" msgstr "Tùy chọn kết nối" @@ -3648,14 +3746,26 @@ msgstr "Kết quả {n} ({rows})" msgid "Off" msgstr "Tắt" +#: crates/app/src/ui/safe_mode.rs +msgid "_Off" +msgstr "_Tắt" + #: crates/app/src/ui/safe_mode.rs msgid "Confirm Writes" msgstr "Xác nhận ghi" +#: crates/app/src/ui/safe_mode.rs +msgid "_Confirm Writes" +msgstr "_Xác nhận ghi" + #: crates/app/src/ui/safe_mode.rs msgid "Read-Only" msgstr "Chỉ đọc" +#: crates/app/src/ui/safe_mode.rs +msgid "_Read-Only" +msgstr "C_hỉ đọc" + #: crates/app/src/ui/safe_mode.rs msgid "Asks only before DROP, TRUNCATE, and DELETE or UPDATE without WHERE." msgstr "Chỉ hỏi trước DROP, TRUNCATE, và DELETE hoặc UPDATE không có WHERE." @@ -3688,6 +3798,10 @@ msgstr "" msgid "Saved Queries" msgstr "Truy vấn đã lưu" +#: data/resources/gtk/menus.ui +msgid "Saved _Queries" +msgstr "Truy _vấn đã lưu" + #: crates/app/src/ui/saved_queries_dialog.rs msgid "Search saved queries" msgstr "Tìm truy vấn đã lưu" @@ -3713,8 +3827,8 @@ msgid "Try a different search term." msgstr "Hãy thử từ khóa tìm kiếm khác." #: crates/app/src/ui/saved_queries_dialog.rs -msgid "Rename…" -msgstr "Đổi tên…" +msgid "Re_name…" +msgstr "Đổ_i tên…" #: crates/app/src/ui/saved_queries_dialog.rs msgid "Saved Query Options" @@ -4671,6 +4785,10 @@ msgstr "Hiện thanh bên" msgid "New Query" msgstr "Truy vấn mới" +#: crates/app/src/ui/workspace/sidebar/tree_menu.rs +msgid "_New Query" +msgstr "_Truy vấn mới" + #: crates/app/src/ui/workspace/shell.rs msgid "View Open Tabs" msgstr "Xem các tab đang mở" @@ -4679,6 +4797,10 @@ msgstr "Xem các tab đang mở" msgid "Safe Mode" msgstr "Chế độ an toàn" +#: crates/app/src/ui/workspace/workspace_shell.rs +msgid "_Safe Mode" +msgstr "_Chế độ an toàn" + #: crates/app/src/ui/workspace/sidebar/mod.rs #, rust-format msgid "Could not refresh the table list: {error}" @@ -4689,27 +4811,31 @@ msgstr "Không làm mới được danh sách bảng: {error}" msgid "New Table in {schema}…" msgstr "Bảng mới trong {schema}…" -#: crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs -msgid "New Table…" -msgstr "Bảng mới…" +#: crates/app/src/ui/workspace/sidebar/sidebar_menu.rs +msgid "_New Table…" +msgstr "_Bảng mới…" #: crates/app/src/ui/workspace/sidebar/sidebar_menu.rs -msgid "Edit Structure" -msgstr "Sửa cấu trúc" +msgid "_Edit Structure" +msgstr "_Sửa cấu trúc" #: crates/app/src/ui/workspace/sidebar/sidebar_menu.rs -msgid "Show CREATE TABLE" -msgstr "Hiện CREATE TABLE" +msgid "Show _CREATE TABLE" +msgstr "_Hiện CREATE TABLE" #: crates/app/src/ui/workspace/sidebar/sidebar_menu.rs -msgid "Import CSV…" -msgstr "Nhập CSV…" +msgid "_Import CSV…" +msgstr "_Nhập CSV…" #: crates/app/src/ui/workspace/sidebar/sidebar_menu.rs #: data/resources/ui/structure-tab.ui msgid "Drop Table…" msgstr "Xóa bảng…" +#: crates/app/src/ui/workspace/sidebar/sidebar_menu.rs +msgid "Drop _Table…" +msgstr "_Xóa bảng…" + #: crates/app/src/ui/workspace/sidebar/sidebar_object.rs msgctxt "object kind" msgid "View" @@ -4927,9 +5053,9 @@ msgstr "" msgid "Discard and Refresh" msgstr "Loại bỏ và làm mới" -#: crates/app/src/ui/workspace/workspace_tabs.rs -msgid "Close Other Tabs" -msgstr "Đóng các tab khác" +#: crates/app/src/ui/workspace/workspace_shell.rs +msgid "Close _Other Tabs" +msgstr "Đóng các tab _khác" #: crates/app/src/ui/workspace/workspace_tabs.rs msgid "Close Tabs to the Right" @@ -5209,6 +5335,10 @@ msgstr "" msgid "New Window" msgstr "Cửa sổ mới" +#: data/resources/gtk/menus.ui +msgid "New _Window" +msgstr "Cửa _sổ mới" + #: data/app.tablepro.TablePro.gschema.xml.in msgid "Window width" msgstr "Độ rộng cửa sổ" @@ -5517,34 +5647,51 @@ msgstr "Xuất kết quả và bảng sang CSV, JSON, SQL và các định dạn msgid "Query History" msgstr "Lịch sử truy vấn" +#: data/resources/gtk/menus.ui +msgid "Query _History" +msgstr "_Lịch sử truy vấn" + #: data/resources/gtk/menus.ui msgid "Disconnect" msgstr "Ngắt kết nối" +#: crates/app/src/ui/workspace/sidebar/tree_menu.rs +#: data/resources/gtk/menus.ui +msgid "_Disconnect" +msgstr "Ngắt _kết nối" + #: data/resources/gtk/menus.ui -msgid "Import Connections…" -msgstr "Nhập kết nối…" +msgid "_Import Connections…" +msgstr "N_hập kết nối…" #: data/resources/gtk/menus.ui -msgid "Export Connections…" -msgstr "Xuất kết nối…" +msgid "_Export Connections…" +msgstr "_Xuất kết nối…" #: data/resources/gtk/menus.ui data/resources/shortcuts-dialog.ui #: data/resources/ui/preferences-dialog.ui msgid "Preferences" msgstr "Tùy chỉnh" +#: data/resources/gtk/menus.ui +msgid "_Preferences" +msgstr "_Tùy chỉnh" + #: data/resources/gtk/menus.ui data/resources/shortcuts-dialog.ui msgid "Keyboard Shortcuts" msgstr "Phím tắt" #: data/resources/gtk/menus.ui -msgid "About TablePro" -msgstr "Giới thiệu TablePro" +msgid "_Keyboard Shortcuts" +msgstr "_Phím tắt" #: data/resources/gtk/menus.ui -msgid "Reopen Closed Tab" -msgstr "Mở lại tab đã đóng" +msgid "_About TablePro" +msgstr "_Giới thiệu TablePro" + +#: data/resources/gtk/menus.ui +msgid "_Reopen Closed Tab" +msgstr "_Mở lại tab đã đóng" #: data/resources/shortcuts-dialog.ui #: data/resources/ui/preferences-general-page.ui @@ -5619,6 +5766,10 @@ msgstr "Làm lại thay đổi gần nhất" msgid "Select All Rows" msgstr "Chọn tất cả các dòng" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "Select _All Rows" +msgstr "_Chọn tất cả các dòng" + #: data/resources/shortcuts-dialog.ui msgid "Clear the Selection" msgstr "Bỏ chọn" @@ -5828,6 +5979,10 @@ msgstr "Các bản xuất đang chạy" msgid "Column" msgstr "Cột" +#: crates/app/src/ui/data_grid/grid_menu.rs +msgid "Colu_mn" +msgstr "_Cột" + #: data/resources/ui/filter-rule-row.ui msgid "Operator" msgstr "Toán tử" @@ -5871,16 +6026,16 @@ msgid "Add Rule" msgstr "Thêm quy tắc" #: data/resources/ui/find-bar.ui -msgid "Match Case" -msgstr "Phân biệt hoa thường" +msgid "Match _Case" +msgstr "Phân biệt _hoa thường" #: data/resources/ui/find-bar.ui -msgid "Match Whole Word" -msgstr "Khớp nguyên từ" +msgid "Match _Whole Word" +msgstr "_Khớp nguyên từ" #: data/resources/ui/find-bar.ui -msgid "Regular Expressions" -msgstr "Biểu thức chính quy" +msgid "_Regular Expressions" +msgstr "_Biểu thức chính quy" #: data/resources/ui/find-bar.ui msgid "Replace with" @@ -5919,16 +6074,16 @@ msgid "Export" msgstr "Xuất" #: data/resources/ui/history-dialog.ui -msgid "Clear All History…" -msgstr "Xóa toàn bộ lịch sử…" +msgid "_Clear All History…" +msgstr "_Xóa toàn bộ lịch sử…" #: data/resources/ui/history-dialog.ui -msgid "Export as SQL…" -msgstr "Xuất dưới dạng SQL…" +msgid "Export as _SQL…" +msgstr "Xuất dưới dạng _SQL…" #: data/resources/ui/history-dialog.ui -msgid "Export as CSV…" -msgstr "Xuất dưới dạng CSV…" +msgid "Export as _CSV…" +msgstr "Xuất dưới dạng _CSV…" #: data/resources/ui/history-row.ui msgid "Select Query" @@ -6062,24 +6217,24 @@ msgid "Run a query to see what it returns." msgstr "Chạy một truy vấn để xem nó trả về gì." #: data/resources/ui/sql-editor.ui -msgid "Open SQL File…" -msgstr "Mở tệp SQL…" +msgid "_Open SQL File…" +msgstr "Mở tệp _SQL…" #: data/resources/ui/sql-editor.ui -msgid "Save As…" -msgstr "Lưu thành…" +msgid "Save _As…" +msgstr "Lư_u thành…" #: data/resources/ui/sql-editor.ui -msgid "Save Query…" -msgstr "Lưu truy vấn…" +msgid "Save _Query…" +msgstr "Lưu t_ruy vấn…" #: data/resources/ui/sql-editor.ui -msgid "Find…" -msgstr "Tìm…" +msgid "_Find…" +msgstr "_Tìm…" #: data/resources/ui/sql-editor.ui -msgid "Find and Replace…" -msgstr "Tìm và thay thế…" +msgid "Find and _Replace…" +msgstr "Tìm và t_hay thế…" #: data/resources/ui/sql-editor.ui msgid "Editor Options"