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 new file mode 100644 index 0000000000..2d9810e22f --- /dev/null +++ b/linux/changelog.d/3883.added.md @@ -0,0 +1 @@ +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 bd9a9bcc61..cf68840238 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -83,6 +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 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/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..a67b6aee15 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/recent_tabs.rs @@ -0,0 +1,503 @@ +//! 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 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. + order: Vec>, + at: usize, +} + +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)) + } +} + +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 { + pub(super) fn note_shown_tab(&self) { + 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 = imp.recent_tabs.borrow_mut(); + recent.retain(|held| held.upgrade().is_some_and(|held| held != page)); + recent.insert(0, page.downgrade()); + } + + 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 + } + + pub(super) fn switch_recent_tab(&self) { + 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 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); + } + // 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 view = self.imp().tab_view.get(); + if !keep && let Some(start) = walk.start(&view) { + view.set_selected_page(&start); + } + self.note_shown_tab(); + } + + pub(super) fn hook_recent_walk(&self, window: &adw::ApplicationWindow) -> WalkHooks { + // 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 |controller, keyval, _, _| { + 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; + } + if keyval == gdk::Key::Escape { + shell.end_recent_walk(false); + return glib::Propagation::Stop; + } + let Some(event) = controller + .current_event() + .and_then(|event| event.downcast::().ok()) + 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 !event.is_modifier() && !steps { + shell.end_recent_walk(true); + } + glib::Propagation::Proceed + }); + let shell = self.downgrade(); + keys.connect_key_released(move |_, keyval, _, _| { + if matches!(keyval, gdk::Key::Control_L | gdk::Key::Control_R) + && let Some(shell) = shell.upgrade() + { + shell.end_recent_walk(true); + } + }); + window.add_controller(keys.clone()); + // The release goes to another window once this one loses the keyboard. + let shell = self.downgrade(); + let active = window.connect_is_active_notify(move |window| { + if !window.is_active() + && let Some(shell) = shell.upgrade() + { + shell.end_recent_walk(true); + } + }); + WalkHooks { + window: window.downgrade(), + keys, + active, + } + } +} + +fn holds(view: &adw::TabView, page: &adw::TabPage) -> bool { + (0..view.n_pages()).any(|at| &view.nth_page(at) == page) +} + +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 gtk4::prelude::*; + use gtk4::subclass::prelude::*; + use libadwaita as adw; + + 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) + } + + 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"]); + 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(); + } + assert_eq!(selected(&shell), "users"); + assert_eq!(titles(&shell.recent_pages()), ["query", "orders", "users"]); + + shell.end_recent_walk(true); + assert_eq!(titles(&shell.recent_pages()), ["users", "query", "orders"]); + } + + #[gtk4::test] + 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"]); + 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 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 libadwaita::prelude::*; + + 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 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"); + { + 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 d3809c32bf..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 is the shell's own: the tab closed last 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}; @@ -31,6 +32,8 @@ 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; @@ -59,6 +62,34 @@ 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()]); + 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); @@ -71,6 +102,8 @@ pub(super) fn remove(window: &adw::ApplicationWindow) { window.remove_action(name); } 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) { @@ -148,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 2e42ffca70..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") @@ -453,6 +454,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)); } @@ -630,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 fd19a093bc..b0759fce2d 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>>, + 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/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index 63bef131a5..083348479f 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -86,6 +86,19 @@ win.reopen-closed-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 diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index ba56cfe121..5fab1859c7 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -554,6 +554,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