From 9368bc047f896035a3656c95158a8a4be73368be Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:55:38 +0100 Subject: [PATCH 1/5] feat(linux): switch to a recently shown tab with Ctrl+E --- .../app/src/application/accelerators.rs | 3 + linux/crates/app/src/ui/workspace/mod.rs | 1 + .../app/src/ui/workspace/recent_tabs.rs | 270 ++++++++++++++++++ .../app/src/ui/workspace/workspace_actions.rs | 14 +- .../app/src/ui/workspace/workspace_shell.rs | 1 + .../src/ui/workspace/workspace_shell_imp.rs | 4 + linux/data/resources/gtk/menus.ui | 4 + linux/data/resources/shortcuts-dialog.ui | 6 + linux/po/POTFILES.in | 1 + 9 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 linux/crates/app/src/ui/workspace/recent_tabs.rs diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs index bd9a9bcc61..865a9650c3 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -83,6 +83,9 @@ pub const ACCELERATORS: &[Accelerator] = &[ application("win.new-editor-tab", &["t"], Tabs), application("win.close-current", &["w"], Tabs), application("win.reopen-closed-tab", &["t"], Tabs), + // Ctrl+Tab is the tab view's next tab, as across GNOME, so the tabs + // in the order they were shown take Ctrl+E. + application("win.switch-recent-tab", &["e"], Tabs), // AdwTabView's own controller, which runs in the capture phase. The // workspace removes its Ctrl+Home and Ctrl+End pairs, so the lists // and the editor inside the tabs keep those keys. diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index e2846f0a4d..200a2f17ad 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -14,6 +14,7 @@ mod health_banner; mod imp; mod import; mod import_ledger; +mod recent_tabs; mod references; mod saves; mod session_watches; diff --git a/linux/crates/app/src/ui/workspace/recent_tabs.rs b/linux/crates/app/src/ui/workspace/recent_tabs.rs new file mode 100644 index 0000000000..46681fd787 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/recent_tabs.rs @@ -0,0 +1,270 @@ +//! Recent Tabs: the window's tabs in the order they were last shown, the +//! one shown before the current first, as a list the keys walk. Ctrl+Tab +//! stays the tab view's next tab, as GNOME has it; Ctrl+E opens this list, +//! E again with Ctrl held moves down it, and letting go of Ctrl shows the +//! tab it is on. + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gdk, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::WorkspaceShell; +use crate::i18n::gettext; + +/// The open list and the tabs it shows, in its rows' order. +pub(super) struct RecentSwitcher { + dialog: adw::Dialog, + list: gtk::ListBox, + pages: Vec, +} + +impl RecentSwitcher { + fn highlighted(&self) -> Option { + self.list + .selected_row() + .and_then(|row| usize::try_from(row.index()).ok()) + } + + /// Move the highlight `by` rows, round from the last to the first. + fn step(&self, by: isize) { + let count = self.pages.len().cast_signed(); + let at = self.highlighted().map_or(0, usize::cast_signed); + let next = (at + by).rem_euclid(count.max(1)); + if let Some(row) = self.list.row_at_index(i32::try_from(next).unwrap_or(0)) { + self.list.select_row(Some(&row)); + row.grab_focus(); + } + } +} + +impl WorkspaceShell { + /// Put the tab shown now first among the recent ones. + pub(super) fn note_shown_tab(&self) { + let Some(page) = self.imp().tab_view.selected_page() else { + return; + }; + let mut recent = self.imp().recent_tabs.borrow_mut(); + recent.retain(|held| held.upgrade().is_some_and(|held| held != page)); + recent.insert(0, page.downgrade()); + } + + /// The window's tabs, the most recently shown first. A tab never shown + /// yet comes after them, in the tab bar's order. + pub(super) fn recent_pages(&self) -> Vec { + let view = self.imp().tab_view.get(); + let open: Vec = (0..view.n_pages()).map(|at| view.nth_page(at)).collect(); + let mut pages: Vec = self + .imp() + .recent_tabs + .borrow() + .iter() + .filter_map(glib::WeakRef::upgrade) + .filter(|page| open.contains(page)) + .collect(); + for page in open { + if !pages.contains(&page) { + pages.push(page); + } + } + pages + } + + /// Open the list on the tab shown before this one, or move down it + /// when it is open already. + pub(super) fn switch_recent_tab(&self) { + if let Some(switcher) = self.imp().recent_switcher.borrow().as_ref() { + switcher.step(1); + return; + } + let pages = self.recent_pages(); + if pages.len() < 2 { + self.toast_overlay() + .add_toast(crate::ui::plain_toast(&gettext("No other tab to switch to"))); + return; + } + let Some(window) = self.imp().window.upgrade() else { + return; + }; + let switcher = self.build_switcher(pages); + switcher.dialog.present(Some(&window)); + switcher.step(1); + self.imp().recent_switcher.replace(Some(switcher)); + } + + fn build_switcher(&self, pages: Vec) -> RecentSwitcher { + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::Single) + .build(); + list.add_css_class("navigation-sidebar"); + for page in &pages { + list.append(&tab_row(page)); + } + let scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .propagate_natural_height(true) + .max_content_height(420) + .child(&list) + .build(); + let header = adw::HeaderBar::builder().show_end_title_buttons(false).build(); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&scrolled)); + let dialog = adw::Dialog::builder() + .title(gettext("Recent Tabs")) + .content_width(420) + .child(&toolbar) + .build(); + let shell = self.downgrade(); + list.connect_row_activated(move |_, row| { + if let (Some(shell), Ok(at)) = (shell.upgrade(), usize::try_from(row.index())) { + shell.show_recent(at); + } + }); + // Opened with Ctrl held, as Ctrl+E does, the list shows its tab + // when Ctrl is let go; opened from a menu, it waits for Enter. + let held = control_held(&dialog); + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let shell = self.downgrade(); + keys.connect_key_pressed(move |_, keyval, _, _| { + let Some(shell) = shell.upgrade() else { + return glib::Propagation::Proceed; + }; + // Ctrl+E is the window's accelerator, which runs before this. + let by = match keyval { + gdk::Key::Down => 1, + gdk::Key::Up => -1, + _ => return glib::Propagation::Proceed, + }; + if let Some(switcher) = shell.imp().recent_switcher.borrow().as_ref() { + switcher.step(by); + } + glib::Propagation::Stop + }); + let shell = self.downgrade(); + keys.connect_key_released(move |_, keyval, _, _| { + if held + && matches!(keyval, gdk::Key::Control_L | gdk::Key::Control_R) + && let Some(shell) = shell.upgrade() + { + let at = shell + .imp() + .recent_switcher + .borrow() + .as_ref() + .and_then(RecentSwitcher::highlighted); + if let Some(at) = at { + shell.show_recent(at); + } + } + }); + dialog.add_controller(keys); + let shell = self.downgrade(); + dialog.connect_closed(move |_| { + if let Some(shell) = shell.upgrade() { + shell.imp().recent_switcher.take(); + } + }); + RecentSwitcher { dialog, list, pages } + } + + /// Show the tab on row `at` and close the list. + fn show_recent(&self, at: usize) { + let Some(switcher) = self.imp().recent_switcher.take() else { + return; + }; + if let Some(page) = switcher.pages.get(at) { + self.imp().tab_view.set_selected_page(page); + } + switcher.dialog.close(); + } +} + +/// A tab's icon and title, as its tab shows them. +fn tab_row(page: &adw::TabPage) -> gtk::ListBoxRow { + let content = gtk::Box::builder().spacing(12).margin_top(6).margin_bottom(6).build(); + // As its tab: an icon only where the tab has one. + if let Some(gicon) = page.icon() { + let icon = gtk::Image::builder() + .gicon(&gicon) + .accessible_role(gtk::AccessibleRole::Presentation) + .build(); + content.append(&icon); + } + let title = gtk::Label::builder() + .label(page.title()) + .xalign(0.0) + .hexpand(true) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + content.append(&title); + let row = gtk::ListBoxRow::builder().child(&content).build(); + row.update_property(&[gtk::accessible::Property::Label(&page.title())]); + row +} + +/// Whether Control is down on the keyboard now. +fn control_held(widget: &impl IsA) -> bool { + widget + .display() + .default_seat() + .and_then(|seat| seat.keyboard()) + .is_some_and(|keyboard| keyboard.modifier_state().contains(gdk::ModifierType::CONTROL_MASK)) +} + +#[cfg(test)] +mod tests { + use libadwaita as adw; + + use crate::test_support::drain_main_context; + use crate::ui::workspace::WorkspaceShell; + + fn shell_with_tabs(titles: &[&str]) -> (WorkspaceShell, Vec) { + crate::register_test_resources(); + let shell: WorkspaceShell = gtk4::glib::Object::new(); + let view = shell.tab_view(); + let pages = titles + .iter() + .map(|title| { + let page = view.append(>k4::Label::new(Some(title))); + page.set_title(title); + page + }) + .collect(); + (shell, pages) + } + + #[gtk4::test] + fn the_tabs_come_most_recently_shown_first() { + let (shell, pages) = shell_with_tabs(&["users", "orders", "query"]); + let view = shell.tab_view(); + for at in [0, 2, 1] { + view.set_selected_page(&pages[at]); + shell.note_shown_tab(); + } + drain_main_context(); + + let titles: Vec = shell + .recent_pages() + .iter() + .map(|page| page.title().to_string()) + .collect(); + assert_eq!(titles, ["orders", "query", "users"]); + } + + #[gtk4::test] + fn a_tab_never_shown_comes_after_the_shown_ones() { + let (shell, pages) = shell_with_tabs(&["users", "orders", "query"]); + shell.tab_view().set_selected_page(&pages[2]); + shell.note_shown_tab(); + + let titles: Vec = shell + .recent_pages() + .iter() + .map(|page| page.title().to_string()) + .collect(); + assert_eq!(titles, ["query", "users", "orders"]); + } +} diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs index d3809c32bf..73e9dddd37 100644 --- a/linux/crates/app/src/ui/workspace/workspace_actions.rs +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -2,8 +2,8 @@ //! them once and each one goes to the session in front, so a change of //! front changes where they go without taking any off the window. Their //! keys are application accelerators; a tab's own keys belong to the tab. -//! Reopen Closed Tab is the shell's own: the tab closed last may be any -//! session's. +//! Reopen Closed Tab and Recent Tabs are the shell's own: the tabs they +//! name may be any session's. use gtk4::glib; use gtk4::prelude::*; @@ -31,6 +31,7 @@ const ENTRIES: [(&str, ActionInput); 6] = [ ]; const REOPEN_CLOSED_TAB: &str = "reopen-closed-tab"; +const SWITCH_RECENT_TAB: &str = "switch-recent-tab"; /// Add the actions to the window's own action map, which GTK exports as /// `win`. Each sends its input to the session in front when activated; @@ -59,6 +60,14 @@ pub(super) fn install(window: &adw::ApplicationWindow, shell: &WorkspaceShell) { } }) .build()]); + let switching = shell.downgrade(); + window.add_action_entries([gio::ActionEntry::::builder(SWITCH_RECENT_TAB) + .activate(move |_, _, _| { + if let Some(shell) = switching.upgrade() { + shell.switch_recent_tab(); + } + }) + .build()]); // Insensitive until the query database has opened or failed to. for name in HISTORY_ACTIONS { set_enabled(window, name, false); @@ -71,6 +80,7 @@ pub(super) fn remove(window: &adw::ApplicationWindow) { window.remove_action(name); } window.remove_action(REOPEN_CLOSED_TAB); + window.remove_action(SWITCH_RECENT_TAB); } fn set_enabled(window: &adw::ApplicationWindow, name: &str, enabled: bool) { diff --git a/linux/crates/app/src/ui/workspace/workspace_shell.rs b/linux/crates/app/src/ui/workspace/workspace_shell.rs index 2e42ffca70..8972a4b8d9 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell.rs @@ -453,6 +453,7 @@ impl WorkspaceShell { let Some(page) = self.imp().tab_view.selected_page() else { return; }; + self.note_shown_tab(); if let Some((owner, _)) = self.owner_of(&page) { self.set_front(Some(&owner)); } diff --git a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs index fd19a093bc..9d0b897f9e 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs @@ -115,6 +115,10 @@ pub struct WorkspaceShell { pub(super) menu_target: RefCell>, /// The tabs closed last, the newest at the back. pub(super) closed_tabs: RefCell>, + /// The tabs the window showed, the latest first. + pub(super) recent_tabs: RefCell>>, + /// Recent Tabs, while it is open. + pub(super) recent_switcher: RefCell>, /// Flips the window's history actions as the query database opens. pub(super) history_gate: RefCell>>, /// What follows the session in front, let go of at shutdown. diff --git a/linux/data/resources/gtk/menus.ui b/linux/data/resources/gtk/menus.ui index cf47648688..7dd3b2045e 100644 --- a/linux/data/resources/gtk/menus.ui +++ b/linux/data/resources/gtk/menus.ui @@ -65,6 +65,10 @@ Reopen Closed Tab win.reopen-closed-tab + + Recent Tabs + win.switch-recent-tab + diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index 63bef131a5..df1195769e 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -86,6 +86,12 @@ win.reopen-closed-tab + + + Switch to a Recent Tab + win.switch-recent-tab + + Next Tab diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index af8dd18f8f..397e566c03 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -548,6 +548,7 @@ crates/app/src/ui/workspace/imp.rs crates/app/src/ui/workspace/import.rs crates/app/src/ui/workspace/import_ledger.rs crates/app/src/ui/workspace/mod.rs +crates/app/src/ui/workspace/recent_tabs.rs crates/app/src/ui/workspace/references.rs crates/app/src/ui/workspace/saves.rs crates/app/src/ui/workspace/session_watches.rs From aacafe9921e45b007467e0a071ab04c7ab7f0618 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:56:01 +0100 Subject: [PATCH 2/5] docs(linux): add the changelog entry --- linux/changelog.d/3883.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3883.added.md diff --git a/linux/changelog.d/3883.added.md b/linux/changelog.d/3883.added.md new file mode 100644 index 0000000000..c655d1158e --- /dev/null +++ b/linux/changelog.d/3883.added.md @@ -0,0 +1 @@ +Ctrl+E lists the window's tabs in the order they were last shown; letting go of Ctrl switches to the one highlighted. From b33ad0510278743f5bae5090ed2ff6dfa328b6bc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 22:55:31 +0700 Subject: [PATCH 3/5] refactor(linux): walk recent tabs on the tab bar and open the stock overview with Ctrl+Shift+O --- linux/changelog.d/3883.added.1.md | 1 + linux/changelog.d/3883.added.md | 2 +- .../app/src/application/accelerators.rs | 5 +- .../app/src/test_support/key_injector.rs | 30 ++ .../app/src/ui/workspace/recent_tabs.rs | 465 ++++++++++++------ .../app/src/ui/workspace/tests/shell.rs | 27 + .../app/src/ui/workspace/workspace_actions.rs | 29 +- .../app/src/ui/workspace/workspace_shell.rs | 5 + .../src/ui/workspace/workspace_shell_imp.rs | 4 +- linux/data/resources/gtk/menus.ui | 4 - linux/data/resources/shortcuts-dialog.ui | 9 +- 11 files changed, 415 insertions(+), 166 deletions(-) create mode 100644 linux/changelog.d/3883.added.1.md diff --git a/linux/changelog.d/3883.added.1.md b/linux/changelog.d/3883.added.1.md new file mode 100644 index 0000000000..84654e9141 --- /dev/null +++ b/linux/changelog.d/3883.added.1.md @@ -0,0 +1 @@ +Ctrl+Shift+O opens the overview of the window's tabs, where typing searches them. diff --git a/linux/changelog.d/3883.added.md b/linux/changelog.d/3883.added.md index c655d1158e..2d9810e22f 100644 --- a/linux/changelog.d/3883.added.md +++ b/linux/changelog.d/3883.added.md @@ -1 +1 @@ -Ctrl+E lists the window's tabs in the order they were last shown; letting go of Ctrl switches to the one highlighted. +Ctrl+E goes back to the tab shown before; hold Ctrl and press E again to go further back. diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs index 865a9650c3..cf68840238 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -83,9 +83,10 @@ pub const ACCELERATORS: &[Accelerator] = &[ application("win.new-editor-tab", &["t"], Tabs), application("win.close-current", &["w"], Tabs), application("win.reopen-closed-tab", &["t"], Tabs), - // Ctrl+Tab is the tab view's next tab, as across GNOME, so the tabs - // in the order they were shown take Ctrl+E. + // Ctrl+Tab stays the tab view's next tab, as across GNOME. application("win.switch-recent-tab", &["e"], Tabs), + // As in GNOME Web and Console. + application("win.show-tabs", &["o"], Tabs), // AdwTabView's own controller, which runs in the capture phase. The // workspace removes its Ctrl+Home and Ctrl+End pairs, so the lists // and the editor inside the tabs keep those keys. diff --git a/linux/crates/app/src/test_support/key_injector.rs b/linux/crates/app/src/test_support/key_injector.rs index b068cee045..cc52b9a88e 100644 --- a/linux/crates/app/src/test_support/key_injector.rs +++ b/linux/crates/app/src/test_support/key_injector.rs @@ -72,6 +72,23 @@ impl KeyInjector { Ok(()) } + /// Keep `keyval` down until the guard drops, so a failed test does + /// not leave it held for the next one. + pub(crate) fn hold(&self, keyval: gdk::Key) -> Result, KeyInjectionError> { + let keycode = self.keycode(keyval)?; + self.fake(KEY_PRESS_EVENT, keycode)?; + self.connection.flush()?; + self.connection.get_input_focus()?.reply()?; + Ok(Held { keys: self, keycode }) + } + + fn let_go(&self, keycode: u8) -> Result<(), KeyInjectionError> { + self.fake(KEY_RELEASE_EVENT, keycode)?; + self.connection.flush()?; + self.connection.get_input_focus()?.reply()?; + Ok(()) + } + /// Click the primary button `count` times at `at` in `widget`'s own /// coordinates, in the window that has the keyboard. A widget not laid /// out yet sits at the window's corner, so it is refused. @@ -141,6 +158,19 @@ impl KeyInjector { } } +pub(crate) struct Held<'a> { + keys: &'a KeyInjector, + keycode: u8, +} + +impl Drop for Held<'_> { + fn drop(&mut self) { + if let Err(error) = self.keys.let_go(self.keycode) { + tracing::error!(%error, "a held key was not let go"); + } + } +} + fn require_x11(display_type: &str) -> Result<(), KeyInjectionError> { if display_type == X11_DISPLAY { Ok(()) diff --git a/linux/crates/app/src/ui/workspace/recent_tabs.rs b/linux/crates/app/src/ui/workspace/recent_tabs.rs index 46681fd787..6546cb9e00 100644 --- a/linux/crates/app/src/ui/workspace/recent_tabs.rs +++ b/linux/crates/app/src/ui/workspace/recent_tabs.rs @@ -1,57 +1,70 @@ -//! Recent Tabs: the window's tabs in the order they were last shown, the -//! one shown before the current first, as a list the keys walk. Ctrl+Tab -//! stays the tab view's next tab, as GNOME has it; Ctrl+E opens this list, -//! E again with Ctrl held moves down it, and letting go of Ctrl shows the -//! tab it is on. +//! Ctrl+E walks back through the tabs in the order they were shown, the +//! tab bar showing where it is. Ctrl+Tab stays the next tab, as in GNOME. use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{self as gtk, gdk, glib}; use libadwaita as adw; -use libadwaita::prelude::*; use super::WorkspaceShell; -use crate::i18n::gettext; -/// The open list and the tabs it shows, in its rows' order. -pub(super) struct RecentSwitcher { - dialog: adw::Dialog, - list: gtk::ListBox, - pages: Vec, +const WALK_KEYS: &str = "tablepro-recent-walk-keys"; + +pub(super) struct RecentWalk { + // Frozen at the first press, so the tabs it passes do not reorder it. + order: Vec>, + at: usize, } -impl RecentSwitcher { - fn highlighted(&self) -> Option { - self.list - .selected_row() - .and_then(|row| usize::try_from(row.index()).ok()) +impl RecentWalk { + fn step(&mut self, view: &adw::TabView) -> Option { + let count = self.order.len(); + (1..count).find_map(|offset| { + let at = (self.at + offset) % count; + let page = self.order[at].upgrade().filter(|page| holds(view, page))?; + self.at = at; + Some(page) + }) + } + + fn start(&self, view: &adw::TabView) -> Option { + self.order + .first() + .and_then(glib::WeakRef::upgrade) + .filter(|page| holds(view, page)) } +} - /// Move the highlight `by` rows, round from the last to the first. - fn step(&self, by: isize) { - let count = self.pages.len().cast_signed(); - let at = self.highlighted().map_or(0, usize::cast_signed); - let next = (at + by).rem_euclid(count.max(1)); - if let Some(row) = self.list.row_at_index(i32::try_from(next).unwrap_or(0)) { - self.list.select_row(Some(&row)); - row.grab_focus(); +pub(super) struct WalkHooks { + window: glib::WeakRef, + keys: gtk::EventControllerKey, + active: glib::SignalHandlerId, +} + +impl WalkHooks { + pub(super) fn release(self) { + if let Some(window) = self.window.upgrade() { + window.remove_controller(&self.keys); + window.disconnect(self.active); } } } impl WorkspaceShell { - /// Put the tab shown now first among the recent ones. pub(super) fn note_shown_tab(&self) { - let Some(page) = self.imp().tab_view.selected_page() else { + let imp = self.imp(); + // A walk records only the tab it ends on. + if imp.recent_walk.borrow().is_some() { + return; + } + let Some(page) = imp.tab_view.selected_page() else { return; }; - let mut recent = self.imp().recent_tabs.borrow_mut(); + let mut recent = imp.recent_tabs.borrow_mut(); recent.retain(|held| held.upgrade().is_some_and(|held| held != page)); recent.insert(0, page.downgrade()); } - /// The window's tabs, the most recently shown first. A tab never shown - /// yet comes after them, in the tab bar's order. pub(super) fn recent_pages(&self) -> Vec { let view = self.imp().tab_view.get(); let open: Vec = (0..view.n_pages()).map(|at| view.nth_page(at)).collect(); @@ -71,141 +84,85 @@ impl WorkspaceShell { pages } - /// Open the list on the tab shown before this one, or move down it - /// when it is open already. pub(super) fn switch_recent_tab(&self) { - if let Some(switcher) = self.imp().recent_switcher.borrow().as_ref() { - switcher.step(1); - return; + let imp = self.imp(); + if imp.recent_walk.borrow().is_none() { + let order = self.recent_pages().iter().map(ObjectExt::downgrade).collect(); + imp.recent_walk.replace(Some(RecentWalk { order, at: 0 })); } - let pages = self.recent_pages(); - if pages.len() < 2 { - self.toast_overlay() - .add_toast(crate::ui::plain_toast(&gettext("No other tab to switch to"))); - return; + let view = imp.tab_view.get(); + let next = imp + .recent_walk + .borrow_mut() + .as_mut() + .and_then(|walk| walk.step(&view)); + if let Some(page) = next { + view.set_selected_page(&page); } - let Some(window) = self.imp().window.upgrade() else { + // No release to wait for, as GNOME Shell's switcher checks. + if !control_held(&view) { + self.end_recent_walk(true); + } + } + + pub(super) fn end_recent_walk(&self, keep: bool) { + let Some(walk) = self.imp().recent_walk.take() else { return; }; - let switcher = self.build_switcher(pages); - switcher.dialog.present(Some(&window)); - switcher.step(1); - self.imp().recent_switcher.replace(Some(switcher)); + let view = self.imp().tab_view.get(); + if !keep && let Some(start) = walk.start(&view) { + view.set_selected_page(&start); + } + self.note_shown_tab(); } - fn build_switcher(&self, pages: Vec) -> RecentSwitcher { - let list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::Single) - .build(); - list.add_css_class("navigation-sidebar"); - for page in &pages { - list.append(&tab_row(page)); - } - let scrolled = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) - .propagate_natural_height(true) - .max_content_height(420) - .child(&list) - .build(); - let header = adw::HeaderBar::builder().show_end_title_buttons(false).build(); - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&header); - toolbar.set_content(Some(&scrolled)); - let dialog = adw::Dialog::builder() - .title(gettext("Recent Tabs")) - .content_width(420) - .child(&toolbar) - .build(); - let shell = self.downgrade(); - list.connect_row_activated(move |_, row| { - if let (Some(shell), Ok(at)) = (shell.upgrade(), usize::try_from(row.index())) { - shell.show_recent(at); - } - }); - // Opened with Ctrl held, as Ctrl+E does, the list shows its tab - // when Ctrl is let go; opened from a menu, it waits for Enter. - let held = control_held(&dialog); + pub(super) fn hook_recent_walk(&self, window: &adw::ApplicationWindow) -> WalkHooks { + // On the window, so the release reaches it wherever the focus is. let keys = gtk::EventControllerKey::new(); + keys.set_name(Some(WALK_KEYS)); keys.set_propagation_phase(gtk::PropagationPhase::Capture); let shell = self.downgrade(); keys.connect_key_pressed(move |_, keyval, _, _| { - let Some(shell) = shell.upgrade() else { + let Some(shell) = shell.upgrade().filter(|_| keyval == gdk::Key::Escape) else { return glib::Propagation::Proceed; }; - // Ctrl+E is the window's accelerator, which runs before this. - let by = match keyval { - gdk::Key::Down => 1, - gdk::Key::Up => -1, - _ => return glib::Propagation::Proceed, - }; - if let Some(switcher) = shell.imp().recent_switcher.borrow().as_ref() { - switcher.step(by); + let walking = shell.imp().recent_walk.borrow().is_some(); + if !walking { + return glib::Propagation::Proceed; } + shell.end_recent_walk(false); glib::Propagation::Stop }); let shell = self.downgrade(); keys.connect_key_released(move |_, keyval, _, _| { - if held - && matches!(keyval, gdk::Key::Control_L | gdk::Key::Control_R) + if matches!(keyval, gdk::Key::Control_L | gdk::Key::Control_R) && let Some(shell) = shell.upgrade() { - let at = shell - .imp() - .recent_switcher - .borrow() - .as_ref() - .and_then(RecentSwitcher::highlighted); - if let Some(at) = at { - shell.show_recent(at); - } + shell.end_recent_walk(true); } }); - dialog.add_controller(keys); + window.add_controller(keys.clone()); + // The release goes to another window once this one loses the keyboard. let shell = self.downgrade(); - dialog.connect_closed(move |_| { - if let Some(shell) = shell.upgrade() { - shell.imp().recent_switcher.take(); + let active = window.connect_is_active_notify(move |window| { + if !window.is_active() + && let Some(shell) = shell.upgrade() + { + shell.end_recent_walk(true); } }); - RecentSwitcher { dialog, list, pages } - } - - /// Show the tab on row `at` and close the list. - fn show_recent(&self, at: usize) { - let Some(switcher) = self.imp().recent_switcher.take() else { - return; - }; - if let Some(page) = switcher.pages.get(at) { - self.imp().tab_view.set_selected_page(page); + WalkHooks { + window: window.downgrade(), + keys, + active, } - switcher.dialog.close(); } } -/// A tab's icon and title, as its tab shows them. -fn tab_row(page: &adw::TabPage) -> gtk::ListBoxRow { - let content = gtk::Box::builder().spacing(12).margin_top(6).margin_bottom(6).build(); - // As its tab: an icon only where the tab has one. - if let Some(gicon) = page.icon() { - let icon = gtk::Image::builder() - .gicon(&gicon) - .accessible_role(gtk::AccessibleRole::Presentation) - .build(); - content.append(&icon); - } - let title = gtk::Label::builder() - .label(page.title()) - .xalign(0.0) - .hexpand(true) - .ellipsize(gtk::pango::EllipsizeMode::End) - .build(); - content.append(&title); - let row = gtk::ListBoxRow::builder().child(&content).build(); - row.update_property(&[gtk::accessible::Property::Label(&page.title())]); - row +fn holds(view: &adw::TabView, page: &adw::TabPage) -> bool { + (0..view.n_pages()).any(|at| &view.nth_page(at) == page) } -/// Whether Control is down on the keyboard now. fn control_held(widget: &impl IsA) -> bool { widget .display() @@ -216,9 +173,10 @@ fn control_held(widget: &impl IsA) -> bool { #[cfg(test)] mod tests { + use gtk4::prelude::*; + use gtk4::subclass::prelude::*; use libadwaita as adw; - use crate::test_support::drain_main_context; use crate::ui::workspace::WorkspaceShell; fn shell_with_tabs(titles: &[&str]) -> (WorkspaceShell, Vec) { @@ -236,35 +194,234 @@ mod tests { (shell, pages) } + fn titles(pages: &[adw::TabPage]) -> Vec { + pages.iter().map(|page| page.title().to_string()).collect() + } + + fn selected(shell: &WorkspaceShell) -> String { + shell + .tab_view() + .selected_page() + .map(|page| page.title().to_string()) + .unwrap_or_default() + } + + fn show(shell: &WorkspaceShell, pages: &[adw::TabPage], order: &[usize]) { + for at in order { + shell.tab_view().set_selected_page(&pages[*at]); + shell.note_shown_tab(); + } + } + #[gtk4::test] fn the_tabs_come_most_recently_shown_first() { let (shell, pages) = shell_with_tabs(&["users", "orders", "query"]); - let view = shell.tab_view(); - for at in [0, 2, 1] { - view.set_selected_page(&pages[at]); + show(&shell, &pages, &[0, 2, 1]); + + assert_eq!(titles(&shell.recent_pages()), ["orders", "query", "users"]); + } + + #[gtk4::test] + fn a_tab_never_shown_comes_after_the_shown_ones() { + let (shell, pages) = shell_with_tabs(&["users", "orders", "query"]); + show(&shell, &pages, &[2]); + + assert_eq!(titles(&shell.recent_pages()), ["query", "users", "orders"]); + } + + #[gtk4::test] + fn a_closed_tab_leaves_the_order() { + let (shell, pages) = shell_with_tabs(&["users", "orders", "query"]); + show(&shell, &pages, &[0, 1, 2]); + + shell.tab_view().close_page(&pages[1]); + + assert_eq!(titles(&shell.recent_pages()), ["query", "users"]); + } + + #[gtk4::test] + fn without_ctrl_held_each_switch_goes_to_the_tab_shown_before() { + let (shell, pages) = shell_with_tabs(&["users", "orders", "query"]); + show(&shell, &pages, &[0, 1, 2]); + + shell.switch_recent_tab(); + assert_eq!(selected(&shell), "orders"); + assert!(shell.imp().recent_walk.borrow().is_none(), "a walk waits for a release"); + shell.switch_recent_tab(); + assert_eq!(selected(&shell), "query"); + assert_eq!(titles(&shell.recent_pages()), ["query", "orders", "users"]); + } + + #[gtk4::test] + fn a_walk_records_only_the_tab_it_ends_on() { + let (shell, pages) = shell_with_tabs(&["users", "orders", "query"]); + show(&shell, &pages, &[0, 1, 2]); + let imp = shell.imp(); + imp.recent_walk.replace(Some(super::RecentWalk { + order: shell.recent_pages().iter().map(ObjectExt::downgrade).collect(), + at: 0, + })); + + for _ in 0..2 { + let next = imp + .recent_walk + .borrow_mut() + .as_mut() + .and_then(|walk| walk.step(&shell.tab_view())); + shell.tab_view().set_selected_page(&next.expect("a tab to go to")); shell.note_shown_tab(); } - drain_main_context(); + assert_eq!(selected(&shell), "users"); + assert_eq!(titles(&shell.recent_pages()), ["query", "orders", "users"]); - let titles: Vec = shell - .recent_pages() - .iter() - .map(|page| page.title().to_string()) - .collect(); - assert_eq!(titles, ["orders", "query", "users"]); + shell.end_recent_walk(true); + assert_eq!(titles(&shell.recent_pages()), ["users", "query", "orders"]); } #[gtk4::test] - fn a_tab_never_shown_comes_after_the_shown_ones() { + fn a_walk_skips_a_tab_closed_on_the_way_and_escape_returns_to_the_start() { let (shell, pages) = shell_with_tabs(&["users", "orders", "query"]); - shell.tab_view().set_selected_page(&pages[2]); - shell.note_shown_tab(); + show(&shell, &pages, &[0, 1, 2]); + let imp = shell.imp(); + imp.recent_walk.replace(Some(super::RecentWalk { + order: shell.recent_pages().iter().map(ObjectExt::downgrade).collect(), + at: 0, + })); + shell.tab_view().close_page(&pages[1]); - let titles: Vec = shell - .recent_pages() - .iter() - .map(|page| page.title().to_string()) - .collect(); - assert_eq!(titles, ["query", "users", "orders"]); + let next = imp + .recent_walk + .borrow_mut() + .as_mut() + .and_then(|walk| walk.step(&shell.tab_view())); + assert_eq!(next.as_ref(), Some(&pages[0]), "the walk went to a closed tab"); + shell.tab_view().set_selected_page(&pages[0]); + + shell.end_recent_walk(false); + assert_eq!(selected(&shell), "query"); + assert_eq!(titles(&shell.recent_pages()), ["query", "users"]); + } + + #[gtk4::test] + fn the_window_lets_go_of_the_walk_keys_with_the_shell() { + use crate::ui::SessionWindowInput; + use crate::ui::session_window::tests::TestWindow; + let test = TestWindow::connected("RecentWalkKeys"); + let walk_keys = || { + let controllers = test.window.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|at| controllers.item(at).and_downcast::()) + .filter(|controller| controller.name().as_deref() == Some(super::WALK_KEYS)) + .count() + }; + assert_eq!(walk_keys(), 1); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + + assert_eq!(walk_keys(), 0, "the picker kept the walk's keys"); + } + + /// These press real keys on the X display every test shares; nextest + /// runs them with no other test beside them. + mod real_keys { + use gtk4::gdk; + use gtk4::prelude::*; + use gtk4::subclass::prelude::*; + use libadwaita as adw; + + use crate::test_support::{KeyInjector, wait_until}; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use crate::ui::workspace::WorkspaceShell; + use crate::ui::workspace::tests::{open_editor, shell_of}; + + struct Walking { + test: TestWindow, + shell: WorkspaceShell, + pages: Vec, + keys: KeyInjector, + } + + /// Three editor tabs, opened and so shown in order: the recent + /// order is the third, the second, the first. + fn walking(name: &str) -> Walking { + let test = TestWindow::connected(name); + let workspace = test.workspace(); + let shell = shell_of(&workspace); + let pages: Vec = (0..3).map(|_| open_editor(&workspace)).collect(); + assert_eq!(shell.recent_pages(), [pages[2].clone(), pages[1].clone(), pages[0].clone()]); + KeyInjector::focus_only(&test.window); + wait_until(WAIT, || test.window.is_active()).expect("the window to take the focus"); + let keys = KeyInjector::connect().expect("an X11 display"); + Walking { + test, + shell, + pages, + keys, + } + } + + impl Walking { + fn selected(&self) -> Option { + self.shell.tab_view().selected_page() + } + + fn wait_for(&self, page: usize, what: &str) { + wait_until(WAIT, || self.selected().as_ref() == Some(&self.pages[page])).expect(what); + } + + fn press_e(&self) { + self.keys + .press(gdk::Key::e, gdk::ModifierType::empty()) + .expect("the key press"); + } + + fn is_walking(&self) -> bool { + self.shell.imp().recent_walk.borrow().is_some() + } + } + + #[gtk4::test] + fn holding_ctrl_walks_back_and_letting_go_keeps_the_tab() { + let walk = walking("RecentWalkKeep"); + { + let _ctrl = walk.keys.hold(gdk::Key::Control_L).expect("Ctrl held"); + wait_until(WAIT, || super::super::control_held(&walk.test.window)).expect("GDK to see Ctrl down"); + walk.press_e(); + walk.wait_for(1, "Ctrl+E to show the tab shown before"); + walk.press_e(); + walk.wait_for(0, "E again to go one further back"); + assert!(walk.is_walking(), "the walk ended with Ctrl still held"); + } + wait_until(WAIT, || !walk.is_walking()).expect("letting go of Ctrl to end the walk"); + + assert_eq!(walk.selected().as_ref(), Some(&walk.pages[0])); + let order = [walk.pages[0].clone(), walk.pages[2].clone(), walk.pages[1].clone()]; + assert_eq!(walk.shell.recent_pages(), order, "the tabs passed on the way were recorded"); + + walk.keys + .press(gdk::Key::e, gdk::ModifierType::CONTROL_MASK) + .expect("the key press"); + walk.wait_for(2, "a tap of Ctrl+E to go back to the tab before"); + } + + #[gtk4::test] + fn escape_ends_a_walk_on_the_tab_it_started_on() { + let walk = walking("RecentWalkEscape"); + { + let _ctrl = walk.keys.hold(gdk::Key::Control_L).expect("Ctrl held"); + wait_until(WAIT, || super::super::control_held(&walk.test.window)).expect("GDK to see Ctrl down"); + walk.press_e(); + walk.wait_for(1, "Ctrl+E to show the tab shown before"); + walk.keys + .press(gdk::Key::Escape, gdk::ModifierType::empty()) + .expect("the key press"); + walk.wait_for(2, "Escape to go back to the start"); + assert!(!walk.is_walking(), "Escape left the walk on"); + } + + let order = [walk.pages[2].clone(), walk.pages[1].clone(), walk.pages[0].clone()]; + assert_eq!(walk.shell.recent_pages(), order); + } } } diff --git a/linux/crates/app/src/ui/workspace/tests/shell.rs b/linux/crates/app/src/ui/workspace/tests/shell.rs index a266b853f8..8197dfbeb4 100644 --- a/linux/crates/app/src/ui/workspace/tests/shell.rs +++ b/linux/crates/app/src/ui/workspace/tests/shell.rs @@ -142,6 +142,33 @@ fn show_sidebar_action_toggles_split_view() { assert!(split.shows_sidebar()); } +#[gtk4::test] +fn show_tabs_opens_and_closes_the_overview_while_a_tab_is_open() { + let test = TestWindow::connected("ShowTabs"); + let workspace = test.workspace(); + let shell = shell_of(&workspace); + let overview = shell.imp().tab_overview.get(); + let enabled = |name: &str| { + test.window + .lookup_action(name) + .is_some_and(|action| action.is_enabled()) + }; + assert_eq!(shell.tab_view().n_pages(), 0); + assert!(!enabled("show-tabs"), "the overview warns and stays shut with no tab"); + assert!(!enabled("switch-recent-tab")); + + super::open_editor(&workspace); + assert!(enabled("show-tabs")); + assert!(!enabled("switch-recent-tab"), "one tab has no other to go to"); + WidgetExt::activate_action(&test.window, "win.show-tabs", None).expect("win.show-tabs"); + assert!(overview.is_open()); + WidgetExt::activate_action(&test.window, "win.show-tabs", None).expect("win.show-tabs"); + assert!(!overview.is_open()); + + super::open_editor(&workspace); + assert!(enabled("switch-recent-tab")); +} + #[gtk4::test] fn sidebar_toggle_visible_only_when_collapsed_or_hidden() { let test = TestWindow::connected("SidebarToggle"); diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs index 73e9dddd37..5a724c5ea9 100644 --- a/linux/crates/app/src/ui/workspace/workspace_actions.rs +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -2,11 +2,12 @@ //! them once and each one goes to the session in front, so a change of //! front changes where they go without taking any off the window. Their //! keys are application accelerators; a tab's own keys belong to the tab. -//! Reopen Closed Tab and Recent Tabs are the shell's own: the tabs they -//! name may be any session's. +//! Reopen Closed Tab, Switch to Recent Tab and View Open Tabs are the +//! shell's own: the tabs they name may be any session's. use gtk4::glib; use gtk4::prelude::*; +use gtk4::subclass::prelude::*; use libadwaita as adw; use super::{WorkspaceInput, WorkspaceShell}; @@ -32,6 +33,7 @@ const ENTRIES: [(&str, ActionInput); 6] = [ const REOPEN_CLOSED_TAB: &str = "reopen-closed-tab"; const SWITCH_RECENT_TAB: &str = "switch-recent-tab"; +const SHOW_TABS: &str = "show-tabs"; /// Add the actions to the window's own action map, which GTK exports as /// `win`. Each sends its input to the session in front when activated; @@ -68,6 +70,26 @@ pub(super) fn install(window: &adw::ApplicationWindow, shell: &WorkspaceShell) { } }) .build()]); + let overview = shell.imp().tab_overview.downgrade(); + window.add_action_entries([gio::ActionEntry::::builder(SHOW_TABS) + .activate(move |_, _, _| { + if let Some(overview) = overview.upgrade() { + overview.set_open(!overview.is_open()); + } + }) + .build()]); + // The overview refuses to open or close with no tab, and with one tab + // Ctrl+E goes on to the tab. + for (name, least) in [(SWITCH_RECENT_TAB, 2), (SHOW_TABS, 1)] { + if let Some(action) = window.lookup_action(name) { + shell + .tab_view() + .bind_property("n-pages", &action, "enabled") + .transform_to(move |_, pages: i32| Some(pages >= least)) + .sync_create() + .build(); + } + } // Insensitive until the query database has opened or failed to. for name in HISTORY_ACTIONS { set_enabled(window, name, false); @@ -81,6 +103,7 @@ pub(super) fn remove(window: &adw::ApplicationWindow) { } window.remove_action(REOPEN_CLOSED_TAB); window.remove_action(SWITCH_RECENT_TAB); + window.remove_action(SHOW_TABS); } fn set_enabled(window: &adw::ApplicationWindow, name: &str, enabled: bool) { @@ -158,5 +181,7 @@ mod tests { remove(&window); assert!(window.lookup_action("new-editor-tab").is_none()); assert!(window.lookup_action(REOPEN_CLOSED_TAB).is_none()); + assert!(window.lookup_action(SWITCH_RECENT_TAB).is_none()); + assert!(window.lookup_action(SHOW_TABS).is_none()); } } diff --git a/linux/crates/app/src/ui/workspace/workspace_shell.rs b/linux/crates/app/src/ui/workspace/workspace_shell.rs index 8972a4b8d9..c6d16a2802 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell.rs @@ -105,6 +105,7 @@ impl WorkspaceShell { )); follow_sidebar(&imp.split_view, &imp.sidebar_toggle); window.add_action(&assistant_action(&shell)); + imp.walk_hooks.replace(Some(shell.hook_recent_walk(window))); imp.tab_view .bind_property("n-pages", &imp.tab_stack.get(), "visible-child-name") @@ -631,6 +632,10 @@ impl WorkspaceShell { } } imp.closed_tabs.borrow_mut().clear(); + imp.recent_walk.take(); + if let Some(hooks) = imp.walk_hooks.take() { + hooks.release(); + } if let Some(window) = imp.window.upgrade() { window.remove_action(SHOW_SIDEBAR); window.remove_action(SHOW_ASSISTANT); diff --git a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs index 9d0b897f9e..b0759fce2d 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs @@ -117,8 +117,8 @@ pub struct WorkspaceShell { pub(super) closed_tabs: RefCell>, /// The tabs the window showed, the latest first. pub(super) recent_tabs: RefCell>>, - /// Recent Tabs, while it is open. - pub(super) recent_switcher: RefCell>, + pub(super) recent_walk: RefCell>, + pub(super) walk_hooks: RefCell>, /// Flips the window's history actions as the query database opens. pub(super) history_gate: RefCell>>, /// What follows the session in front, let go of at shutdown. diff --git a/linux/data/resources/gtk/menus.ui b/linux/data/resources/gtk/menus.ui index 7dd3b2045e..cf47648688 100644 --- a/linux/data/resources/gtk/menus.ui +++ b/linux/data/resources/gtk/menus.ui @@ -65,10 +65,6 @@ Reopen Closed Tab win.reopen-closed-tab - - Recent Tabs - win.switch-recent-tab - diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index df1195769e..083348479f 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -88,10 +88,17 @@ - Switch to a Recent Tab + Switch to Recent Tab + Hold Ctrl and press E again to go further back win.switch-recent-tab + + + View Open Tabs + win.show-tabs + + Next Tab From d59b05c42d56d8119e0c5fe2a013037630a299d0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 23:15:48 +0700 Subject: [PATCH 4/5] fix(linux): end a recent-tab walk past a dialog boundary and on another command --- .../app/src/ui/workspace/recent_tabs.rs | 72 +++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/recent_tabs.rs b/linux/crates/app/src/ui/workspace/recent_tabs.rs index 6546cb9e00..e078d24d84 100644 --- a/linux/crates/app/src/ui/workspace/recent_tabs.rs +++ b/linux/crates/app/src/ui/workspace/recent_tabs.rs @@ -9,6 +9,7 @@ use libadwaita as adw; use super::WorkspaceShell; const WALK_KEYS: &str = "tablepro-recent-walk-keys"; +const SWITCH_RECENT_TAB: &str = "win.switch-recent-tab"; pub(super) struct RecentWalk { // Frozen at the first press, so the tabs it passes do not reorder it. @@ -117,21 +118,35 @@ impl WorkspaceShell { } pub(super) fn hook_recent_walk(&self, window: &adw::ApplicationWindow) -> WalkHooks { - // On the window, so the release reaches it wherever the focus is. + // On the window and past the event boundary of a dialog or a + // popover, so the release reaches it wherever the focus is. let keys = gtk::EventControllerKey::new(); keys.set_name(Some(WALK_KEYS)); keys.set_propagation_phase(gtk::PropagationPhase::Capture); + keys.set_propagation_limit(gtk::PropagationLimit::None); let shell = self.downgrade(); - keys.connect_key_pressed(move |_, keyval, _, _| { - let Some(shell) = shell.upgrade().filter(|_| keyval == gdk::Key::Escape) else { + keys.connect_key_pressed(move |controller, keyval, _, state| { + let Some(shell) = shell.upgrade() else { return glib::Propagation::Proceed; }; let walking = shell.imp().recent_walk.borrow().is_some(); if !walking { return glib::Propagation::Proceed; } - shell.end_recent_walk(false); - glib::Propagation::Stop + if keyval == gdk::Key::Escape { + shell.end_recent_walk(false); + return glib::Propagation::Stop; + } + let modifier = controller + .current_event() + .and_then(|event| event.downcast::().ok()) + .is_some_and(|event| event.is_modifier()); + // This controller runs before the window's accelerators, so + // another command acts on the tab the walk reached. + if !modifier && !crate::application::component_matches(SWITCH_RECENT_TAB, keyval, state) { + shell.end_recent_walk(true); + } + glib::Propagation::Proceed }); let shell = self.downgrade(); keys.connect_key_released(move |_, keyval, _, _| { @@ -329,6 +344,7 @@ mod tests { use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; + use libadwaita::prelude::*; use crate::test_support::{KeyInjector, wait_until}; use crate::ui::session_window::tests::{TestWindow, WAIT}; @@ -405,6 +421,52 @@ mod tests { walk.wait_for(2, "a tap of Ctrl+E to go back to the tab before"); } + #[gtk4::test] + fn another_command_ends_the_walk_on_the_tab_it_reached() { + let walk = walking("RecentWalkCommand"); + let workspace = walk.test.workspace(); + { + let _ctrl = walk.keys.hold(gdk::Key::Control_L).expect("Ctrl held"); + wait_until(WAIT, || super::super::control_held(&walk.test.window)).expect("GDK to see Ctrl down"); + walk.press_e(); + walk.wait_for(1, "Ctrl+E to show the tab shown before"); + walk.keys + .press(gdk::Key::t, gdk::ModifierType::empty()) + .expect("the key press"); + wait_until(WAIT, || workspace.imp().index.get().map_or(0, |index| index.pages().len()) == 4) + .expect("Ctrl+T to open a tab"); + assert!(!walk.is_walking(), "the walk went on past Ctrl+T"); + } + + let new = walk.selected().expect("the new tab in front"); + assert!(!walk.pages.contains(&new)); + let order = [new, walk.pages[1].clone(), walk.pages[2].clone(), walk.pages[0].clone()]; + assert_eq!(walk.shell.recent_pages(), order, "the tab the walk reached was not recorded"); + } + + #[gtk4::test] + fn letting_go_of_ctrl_in_a_dialog_ends_the_walk() { + let walk = walking("RecentWalkDialog"); + let dialog = adw::AlertDialog::new(Some("Connection Lost"), None); + dialog.add_response("close", "Close"); + { + let _ctrl = walk.keys.hold(gdk::Key::Control_L).expect("Ctrl held"); + wait_until(WAIT, || super::super::control_held(&walk.test.window)).expect("GDK to see Ctrl down"); + walk.press_e(); + walk.wait_for(1, "Ctrl+E to show the tab shown before"); + dialog.present(Some(&walk.test.window)); + wait_until(WAIT, || { + gtk4::prelude::RootExt::focus(&walk.test.window).is_some_and(|focus| focus.is_ancestor(&dialog)) + }) + .expect("the dialog to take the focus"); + } + wait_until(WAIT, || !walk.is_walking()).expect("letting go of Ctrl in the dialog to end the walk"); + + let order = [walk.pages[1].clone(), walk.pages[2].clone(), walk.pages[0].clone()]; + assert_eq!(walk.shell.recent_pages(), order); + dialog.close(); + } + #[gtk4::test] fn escape_ends_a_walk_on_the_tab_it_started_on() { let walk = walking("RecentWalkEscape"); From eaf51663aa4780b0a4c55111ff60cd98eca9ae6f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 23:42:12 +0700 Subject: [PATCH 5/5] fix(linux): step a recent-tab walk by GTK's own key match --- .../app/src/ui/workspace/recent_tabs.rs | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/recent_tabs.rs b/linux/crates/app/src/ui/workspace/recent_tabs.rs index e078d24d84..a67b6aee15 100644 --- a/linux/crates/app/src/ui/workspace/recent_tabs.rs +++ b/linux/crates/app/src/ui/workspace/recent_tabs.rs @@ -92,11 +92,7 @@ impl WorkspaceShell { imp.recent_walk.replace(Some(RecentWalk { order, at: 0 })); } let view = imp.tab_view.get(); - let next = imp - .recent_walk - .borrow_mut() - .as_mut() - .and_then(|walk| walk.step(&view)); + let next = imp.recent_walk.borrow_mut().as_mut().and_then(|walk| walk.step(&view)); if let Some(page) = next { view.set_selected_page(&page); } @@ -125,7 +121,7 @@ impl WorkspaceShell { keys.set_propagation_phase(gtk::PropagationPhase::Capture); keys.set_propagation_limit(gtk::PropagationLimit::None); let shell = self.downgrade(); - keys.connect_key_pressed(move |controller, keyval, _, state| { + keys.connect_key_pressed(move |controller, keyval, _, _| { let Some(shell) = shell.upgrade() else { return glib::Propagation::Proceed; }; @@ -137,13 +133,18 @@ impl WorkspaceShell { shell.end_recent_walk(false); return glib::Propagation::Stop; } - let modifier = controller + let Some(event) = controller .current_event() .and_then(|event| event.downcast::().ok()) - .is_some_and(|event| event.is_modifier()); + else { + return glib::Propagation::Proceed; + }; + // GTK's own match, so Ctrl+E steps on a layout without E too. + let steps = crate::application::component_trigger(SWITCH_RECENT_TAB) + .is_some_and(|trigger| trigger.trigger(&event, false) != gdk::KeyMatch::None); // This controller runs before the window's accelerators, so // another command acts on the tab the walk reached. - if !modifier && !crate::application::component_matches(SWITCH_RECENT_TAB, keyval, state) { + if !event.is_modifier() && !steps { shell.end_recent_walk(true); } glib::Propagation::Proceed @@ -365,7 +366,10 @@ mod tests { let workspace = test.workspace(); let shell = shell_of(&workspace); let pages: Vec = (0..3).map(|_| open_editor(&workspace)).collect(); - assert_eq!(shell.recent_pages(), [pages[2].clone(), pages[1].clone(), pages[0].clone()]); + assert_eq!( + shell.recent_pages(), + [pages[2].clone(), pages[1].clone(), pages[0].clone()] + ); KeyInjector::focus_only(&test.window); wait_until(WAIT, || test.window.is_active()).expect("the window to take the focus"); let keys = KeyInjector::connect().expect("an X11 display"); @@ -413,7 +417,11 @@ mod tests { assert_eq!(walk.selected().as_ref(), Some(&walk.pages[0])); let order = [walk.pages[0].clone(), walk.pages[2].clone(), walk.pages[1].clone()]; - assert_eq!(walk.shell.recent_pages(), order, "the tabs passed on the way were recorded"); + assert_eq!( + walk.shell.recent_pages(), + order, + "the tabs passed on the way were recorded" + ); walk.keys .press(gdk::Key::e, gdk::ModifierType::CONTROL_MASK) @@ -433,15 +441,21 @@ mod tests { walk.keys .press(gdk::Key::t, gdk::ModifierType::empty()) .expect("the key press"); - wait_until(WAIT, || workspace.imp().index.get().map_or(0, |index| index.pages().len()) == 4) - .expect("Ctrl+T to open a tab"); + wait_until(WAIT, || { + workspace.imp().index.get().map_or(0, |index| index.pages().len()) == 4 + }) + .expect("Ctrl+T to open a tab"); assert!(!walk.is_walking(), "the walk went on past Ctrl+T"); } let new = walk.selected().expect("the new tab in front"); assert!(!walk.pages.contains(&new)); let order = [new, walk.pages[1].clone(), walk.pages[2].clone(), walk.pages[0].clone()]; - assert_eq!(walk.shell.recent_pages(), order, "the tab the walk reached was not recorded"); + assert_eq!( + walk.shell.recent_pages(), + order, + "the tab the walk reached was not recorded" + ); } #[gtk4::test]