From d5baaaddce95178f1e2c7d358eee97a26ffb0c45 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:21:53 +0200 Subject: [PATCH] feat(session): return to the home session from the Session menu The Session popover gains a way back to the home session: "Back to " when the overlay or its daemon started with a session file, or "Default session" without one. It is disabled while home is already active, and the side.session.home toolbar item hides it. The label and destination come from the launch's home input, not from the session the overlay is in. Returning home is an explicit session command. Like Open, it saves dirty current data first, refuses if the canvas or interaction changes while it is pending, and replaces nothing unless home loads. Home loads the way a launch would, from its own options for the current output, so recovery, clear-boundary and tool-restore rules apply, and its load errors stay those of a startup session file rather than becoming an empty session. A home with persistence disabled continues unsaved on an empty canvas. Once home is committed the overlay reports it, so the daemon forgets the session it remembered. The overlay keeps whether it is home with its reported target, so the menu needs no file system check on redraw. --- README.md | 2 +- docs/CONFIG.md | 3 +- docs/codebase-overview.md | 14 +-- examples/session-manager.md | 3 + src/backend/wayland/session.rs | 16 ++- src/backend/wayland/session/driver/tests.rs | 98 +++++++++++++++++++ src/backend/wayland/session/home.rs | 50 ++++++---- src/backend/wayland/session/home/tests.rs | 43 ++++++-- src/backend/wayland/session/runtime.rs | 10 ++ .../wayland/session/runtime/transaction.rs | 22 +++++ .../session/runtime/transaction/phases.rs | 28 ++++++ .../wayland/state/core/output/session_ops.rs | 2 +- .../wayland/state/core/session_home.rs | 2 +- src/backend/wayland/state/toolbar/events.rs | 2 +- .../wayland/state/toolbar/events/session.rs | 38 ++++++- .../state/toolbar/events/session/home.rs | 76 ++++++++++++++ .../wayland/state/toolbar/events/tests.rs | 2 + src/backend/wayland/toolbar/view/top/menus.rs | 17 ++++ src/backend/wayland/toolbar/view/top/tests.rs | 25 +++++ src/config/types/toolbar/ids.rs | 1 + src/config/types/toolbar/items/definitions.rs | 7 ++ src/input/state/core/toolbar/apply/mod.rs | 1 + src/toolbar_gtk/view/sections/session_pane.rs | 22 ++++- src/toolbar_gtk/view/top_bar.rs | 4 + .../view/top_bar/tests/pane_popovers.rs | 20 +++- src/ui/toolbar/events.rs | 3 + src/ui/toolbar/model/event_policy.rs | 3 + src/ui/toolbar/model/mod.rs | 14 +++ src/ui/toolbar/model/session.rs | 58 ++++++++++- src/ui/toolbar/snapshot/build.rs | 2 + src/ui/toolbar/snapshot/types.rs | 5 + 31 files changed, 553 insertions(+), 40 deletions(-) create mode 100644 src/backend/wayland/state/toolbar/events/session/home.rs diff --git a/README.md b/README.md index 17f1002e..4b998b46 100644 --- a/README.md +++ b/README.md @@ -1212,7 +1212,7 @@ See [Session manager examples](examples/session-manager.md) for complete CLI, ov - When a fresh launch restores ink onto the transparent overlay board, a toast such as "Restored 7 annotations from last session" offers **Clear** (undoable, like Clear Canvas), because that ink now sits over whatever is on screen. Daemon toggles, empty restores, and solid boards stay quiet. - Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement and the starting Spotlight magnification) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools. - `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session. The daemon's overlay keeps the session it last opened or saved as across hide and show, while the daemon runs. -- The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. +- The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, return to the home session (**Back to** the startup session file, or **Default session**), show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. - The configurator Session tab manages recent named sessions recorded when named-session targets are opened or saved from the CLI, daemon, or overlay. It can rename catalog labels, reveal files, and forget metadata without touching files. Clear Tool State removes only the saved tool layer; Clear Saved Data removes session files. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ccb1843a..727cece2 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -2114,7 +2114,7 @@ Use the CLI helpers for quick maintenance: - `wayscriber --daemon --session-file ~/Documents/lecture-04.wayscriber-session` starts a daemon whose overlay activations use that named session target, the daemon's home session. - `wayscriber --daemon-toggle --session-file ~/Documents/meeting.wayscriber-session` asks the running daemon to launch a hidden overlay with that named session target. It applies to that activation only. If the overlay is already visible with a different target, hide it before switching. -The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved or deleted, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home. +The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. The Session popover's **Back to ** or **Default session** button returns home. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved or deleted, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home. - `wayscriber --session-info --session-file `, `wayscriber --clear-session --session-file `, and `wayscriber --clear-tool-state --session-file ` target only that named file. Config values seed startup defaults. When `restore_tool_state = true`, the saved session tool state is applied after those defaults, so edits such as `[arrow] head_at_end = true` can appear ignored if the session snapshot still stores an older arrow setting. Run `wayscriber --clear-tool-state` (or add `--session-file ` for a named session) to make config defaults apply on the next startup without deleting saved boards. In a running overlay, Command Palette -> Reset Tool Defaults clears the saved layer for the active session and immediately applies config defaults to the current tools so the next autosave keeps those defaults. @@ -2127,6 +2127,7 @@ The overlay Session panel lives in the top strip's overflow **"Session..."** pop - `Save As` writes the current overlay to another named session and switches the active target. It appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. - `Info` reports the active session file size, board shape counts, and history status. - `Clear` writes a durable empty session boundary for the active target. +- `Back to ` returns to the home session: the file the overlay or daemon started with. Without one it reads `Default session` and returns to the configured default session. Like `Open`, it saves dirty current data first and switches only once home has loaded. It is disabled while home is already active. Hide it with the `side.session.home` toolbar item. - Recent session rows reopen other named sessions. If a recent target is missing, Wayscriber removes that stale catalog entry after the failed open. - `Manager` opens the configurator. Overlay Open/Save As dialogs use `zenity` or `kdialog`. diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 26032b38..2ff908cf 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -539,18 +539,20 @@ capture suppression operates on the paired resources without runtime pairing che **Modules:** - `src/session/`: target options, primary-file validation, snapshot load/save, sidecars, clear/recovery markers, saved tool-state reset, locks, catalog metadata, and inactive file operations. - `src/backend/wayland/session/`: runtime Open, Save As, Clear, and saved tool-state reset transactions for the active overlay. -- `src/backend/wayland/state/toolbar/events/session.rs`: overlay Session popover routing for Open, Save As, Info, Clear, recent sessions, and configurator launch. -- `src/daemon/`: accepts daemon-toggle requests that carry an optional named session target. +- `src/backend/wayland/state/toolbar/events/session.rs`: overlay Session popover routing for Open, Save As, return home, Info, Clear, recent sessions, and configurator launch. +- `src/backend/wayland/session/home.rs`: the overlay's home session, the remembered session a daemon launch carries, and the session reports sent back to the daemon. +- `src/daemon/`: accepts daemon-toggle requests that carry an optional named session target, and remembers the session its overlay last reported across hide and show. **Flow:** 1. CLI `--session-file` creates a named target instead of using configured storage. Named targets force persistence for that run, reject `--no-resume-session`, require an existing parent directory for foreground/open flows, and reject directories, symlinks, and special files. 2. Backend startup builds `SessionOptions` from config plus any named target, then session loading restores boards/history/tool state before rendering begins. 3. Runtime Open first saves dirty current data when needed, loads the candidate named session without mutating it, replaces board state only after a valid load, and records the open in the named-session catalog. 4. Runtime Save As validates the target, prompts before replacing existing artifacts, writes the snapshot, switches the active target, and records the save in the catalog. -5. Runtime Clear writes a durable empty-session boundary so older backup or recovery artifacts do not restore stale drawings. -6. Runtime saved tool-state reset clears the persisted tool layer for the active session and applies config-derived tool defaults in memory so autosave does not restore stale values. -7. Offline CLI maintenance can inspect sessions, clear all saved data, or clear only persisted tool state so config defaults seed the next startup without deleting boards. -8. The configurator reads the same catalog for inactive-session management: rename/reveal/forget metadata, duplicate primary files, move non-lock sidecars, clear saved tool state, and clear saved data when daemon/overlay locks are absent. +5. Returning home saves dirty current data the same way, then loads the home session as a launch would. A daemon overlay reports each committed target change, so the daemon starts the next overlay in that session; a remembered session that no longer exists falls back to home. +6. Runtime Clear writes a durable empty-session boundary so older backup or recovery artifacts do not restore stale drawings. +7. Runtime saved tool-state reset clears the persisted tool layer for the active session and applies config-derived tool defaults in memory so autosave does not restore stale values. +8. Offline CLI maintenance can inspect sessions, clear all saved data, or clear only persisted tool state so config defaults seed the next startup without deleting boards. +9. The configurator reads the same catalog for inactive-session management: rename/reveal/forget metadata, duplicate primary files, move non-lock sidecars, clear saved tool state, and clear saved data when daemon/overlay locks are absent. --- diff --git a/examples/session-manager.md b/examples/session-manager.md index 5bd5c755..c5676451 100644 --- a/examples/session-manager.md +++ b/examples/session-manager.md @@ -47,6 +47,9 @@ Open Wayscriber with any persisted session target, then use the top strip overfl - `Info` reports the active session file size, board shape counts, and history status. - `Clear` writes a durable empty session boundary for the active target. +- `Back to `, or `Default session` without a startup session file, + saves the current session and returns to the session the overlay or daemon + started with. It is disabled while that session is already active. - Recent session rows reopen other named sessions. - `Manager` opens the configurator. diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index 4d3dea38..b1aa6571 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -227,6 +227,16 @@ impl SessionState { self.notified_failure = false; } + /// Continues without persistence: home is a default session that is not + /// saved. + pub(in crate::backend::wayland) fn commit_without_persistence(&mut self) { + self.advance_target_epoch(); + self.options = None; + self.loaded = true; + self.loaded_board_data = false; + self.mark_clean_after_load(); + } + pub(in crate::backend::wayland) fn commit_runtime_clear(&mut self, now: Instant) { self.loaded = true; self.loaded_board_data = false; @@ -595,6 +605,8 @@ mod home; mod persistence; mod runtime; +#[cfg(test)] +pub(in crate::backend::wayland) use home::HomeSession; pub(in crate::backend::wayland) use home::{SessionHome, SessionLaunch, session_target}; pub(in crate::backend::wayland) use persistence::{ PersistenceCompletion, PersistenceController, PersistenceOperation, PersistenceOutcome, @@ -602,8 +614,8 @@ pub(in crate::backend::wayland) use persistence::{ }; pub(in crate::backend::wayland) use runtime::{ - ExplicitSessionTransaction, SessionCommand, SessionCommandReport, SessionTransaction, - TransactionStep, + ExplicitSessionTransaction, RuntimeHomeSessionReport, SessionCommand, SessionCommandReport, + SessionTransaction, TransactionStep, }; #[cfg(test)] pub(in crate::backend::wayland) use runtime::{ diff --git a/src/backend/wayland/session/driver/tests.rs b/src/backend/wayland/session/driver/tests.rs index dacdd887..481049f1 100644 --- a/src/backend/wayland/session/driver/tests.rs +++ b/src/backend/wayland/session/driver/tests.rs @@ -707,6 +707,104 @@ fn failed_open_and_save_as_announce_nothing() { } } +fn configured_home(base: &std::path::Path) -> stored_session::SessionOptions { + let mut options = stored_session::SessionOptions::new(base.join("configured"), "home"); + options.persist_transparent = true; + options +} + +#[test] +fn returning_home_saves_the_current_session_then_loads_home_like_a_launch() { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let home = configured_home(temp.path()); + stored_session::save_snapshot(&sample_snapshot(), &home).unwrap(); + let mut input = test_input_state(); + add_line(&mut input, 51); + input.mark_session_dirty(); + let mut session = SessionState::new(Some(current.clone())); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command( + &mut runtime, + SessionCommand::OpenHome(Some(Box::new(home.clone()))), + ) + .unwrap(); + worker.complete_next(); // save the current session + runtime.receive(); + assert_eq!(loaded_line_x2(¤t), 51); + assert!(runtime.reports.is_empty()); + worker.complete_next(); // load home + runtime.receive(); + + assert!(runtime.errors.is_empty()); + let [SessionCommandReport::Home(report)] = runtime.reports.as_slice() else { + panic!( + "expected a home report, got {} reports", + runtime.reports.len() + ); + }; + assert_eq!( + report.options.as_ref().map(|options| &options.target), + Some(&home.target) + ); + assert!(matches!( + report.outcome, + Some(stored_session::LoadSnapshotOutcome::Loaded(_)) + )); + // The runtime applies home; until then the canvas and target stay put. + assert_eq!(runtime.session.options().unwrap().target, current.target); + assert_eq!(runtime.input.boards.active_frame().shapes.len(), 1); +} + +#[test] +fn returning_home_is_refused_when_the_canvas_changes_while_home_loads() { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let home = configured_home(temp.path()); + let mut input = test_input_state(); + let mut session = SessionState::new(Some(current.clone())); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command(&mut runtime, SessionCommand::OpenHome(Some(Box::new(home)))).unwrap(); + add_line(runtime.input, 77); + runtime.input.mark_session_dirty(); + worker.complete_next(); // load home + runtime.receive(); + + assert!(runtime.reports.is_empty()); + assert!( + runtime.errors[0] + .to_string() + .contains("session was edited while the command was pending"), + "{:?}", + runtime.errors + ); + assert_eq!(runtime.session.options().unwrap().target, current.target); +} + +#[test] +fn returning_to_a_home_without_persistence_loads_nothing() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut input = test_input_state(); + let mut session = SessionState::new(Some(named_options(temp.path(), "current"))); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command(&mut runtime, SessionCommand::OpenHome(None)).unwrap(); + + assert!(!worker.has_request()); + let [SessionCommandReport::Home(report)] = runtime.reports.as_slice() else { + panic!("expected a home report"); + }; + assert!(report.options.is_none() && report.outcome.is_none()); +} + #[test] fn clear_completion_refreshes_consumer_seeds() { let temp = crate::test_temp::tempdir().unwrap(); diff --git a/src/backend/wayland/session/home.rs b/src/backend/wayland/session/home.rs index 42da452f..02e4e2a0 100644 --- a/src/backend/wayland/session/home.rs +++ b/src/backend/wayland/session/home.rs @@ -90,6 +90,9 @@ pub(in crate::backend::wayland) struct SessionHome { /// The target the daemon last learned this overlay is in, by launching it /// there or from a report. reported: SessionTarget, + /// Whether `reported` is home. Kept rather than worked out when asked, + /// since the Session menu asks on every redraw. + at_home: bool, } impl SessionHome { @@ -100,6 +103,7 @@ impl SessionHome { startup: SessionTarget, ) -> Self { Self { + at_home: is_home(&launch.home, &startup), home: launch.home, options, unchecked_preferred: launch.preferred, @@ -117,38 +121,50 @@ impl SessionHome { self.unchecked_preferred.take() } - /// How the Session menu and notices name home. - pub(in crate::backend::wayland) fn label(&self) -> String { - match &self.home { - HomeSession::Default => "the default session".to_owned(), - HomeSession::Named(path) => session_display_name(path), - } + /// The name of a named home session; `None` for the default session. + pub(in crate::backend::wayland) fn name(&self) -> Option { + self.home.file().map(session_display_name) } - /// What to report now that the overlay is in `target`, or `None` when the - /// daemon already knows. Home is reported as such even when it is a named - /// file, so the daemon never remembers home as a session of its own. - pub(in crate::backend::wayland) fn report_for( + /// How notices name home. + pub(in crate::backend::wayland) fn label(&self) -> String { + self.name() + .unwrap_or_else(|| "the default session".to_owned()) + } + + /// Whether the overlay is in its home session, as of the last commit. + pub(in crate::backend::wayland) fn is_at_home(&self) -> bool { + self.at_home + } + + /// Records that the overlay is now in `target`, returning what to report, + /// or `None` when the daemon already knows. Home is reported as such even + /// when it is a named file, so the daemon never remembers home as a + /// session of its own. + pub(in crate::backend::wayland) fn commit_target( &mut self, target: SessionTarget, ) -> Option { if target == self.reported { return None; } + self.at_home = is_home(&self.home, &target); let report = match &target { - SessionTarget::NamedFile(path) if !self.is_home_file(path) => { - ReportedSession::Named(path.clone()) - } + SessionTarget::NamedFile(path) if !self.at_home => ReportedSession::Named(path.clone()), _ => ReportedSession::Home, }; self.reported = target; Some(report) } +} - fn is_home_file(&self, path: &Path) -> bool { - self.home - .file() - .is_some_and(|home| session_paths_match(home, path)) +fn is_home(home: &HomeSession, target: &SessionTarget) -> bool { + match (home, target) { + (HomeSession::Default, SessionTarget::Configured) => true, + (HomeSession::Named(home), SessionTarget::NamedFile(path)) => { + session_paths_match(home, path) + } + _ => false, } } diff --git a/src/backend/wayland/session/home/tests.rs b/src/backend/wayland/session/home/tests.rs index 7fb3c99e..afb6ad8d 100644 --- a/src/backend/wayland/session/home/tests.rs +++ b/src/backend/wayland/session/home/tests.rs @@ -133,14 +133,14 @@ fn only_the_first_load_checks_the_preferred_session() { fn only_a_changed_session_is_reported() { let mut home = home_session(named(HOME), Some(PREFERRED), file(PREFERRED)); - assert_eq!(home.report_for(file(PREFERRED)), None); + assert_eq!(home.commit_target(file(PREFERRED)), None); let other = "/sessions/d.wayscriber-session"; assert_eq!( - home.report_for(file(other)), + home.commit_target(file(other)), Some(ReportedSession::Named(PathBuf::from(other))) ); - assert_eq!(home.report_for(file(other)), None); + assert_eq!(home.commit_target(file(other)), None); } #[test] @@ -150,17 +150,17 @@ fn home_is_reported_as_home_even_as_a_named_file() { let alias = "/sessions/./home.wayscriber-session"; assert_eq!( - named_home.report_for(file(alias)), + named_home.commit_target(file(alias)), Some(ReportedSession::Home) ); let mut default_home = home_session(HomeSession::Default, None, file(PREFERRED)); assert_eq!( - default_home.report_for(SessionTarget::Configured), + default_home.commit_target(SessionTarget::Configured), Some(ReportedSession::Home) ); assert_eq!( - default_home.report_for(file(HOME)), + default_home.commit_target(file(HOME)), Some(ReportedSession::Named(PathBuf::from(HOME))) ); } @@ -169,3 +169,34 @@ fn home_is_reported_as_home_even_as_a_named_file() { fn a_run_without_persistence_is_in_the_default_session() { assert_eq!(session_target(None), SessionTarget::Configured); } + +#[test] +fn the_overlay_knows_whether_it_is_home() { + let mut named_home = home_session(named(HOME), Some(PREFERRED), file(PREFERRED)); + assert!(!named_home.is_at_home()); + named_home.commit_target(file("/sessions/./home.wayscriber-session")); + assert!(named_home.is_at_home()); + named_home.commit_target(file(PREFERRED)); + assert!(!named_home.is_at_home()); + assert!(home_session(named(HOME), None, file(HOME)).is_at_home()); + + let mut default_home = home_session(HomeSession::Default, None, file(PREFERRED)); + assert!(!default_home.is_at_home()); + default_home.commit_target(SessionTarget::Configured); + assert!(default_home.is_at_home()); + assert!(home_session(HomeSession::Default, None, SessionTarget::Configured).is_at_home()); +} + +#[test] +fn only_a_named_home_has_a_name() { + assert_eq!( + home_session(named(HOME), None, file(HOME)) + .name() + .as_deref(), + Some("home.wayscriber-session") + ); + assert_eq!( + home_session(HomeSession::Default, None, SessionTarget::Configured).name(), + None + ); +} diff --git a/src/backend/wayland/session/runtime.rs b/src/backend/wayland/session/runtime.rs index 5a2f2bb2..206531eb 100644 --- a/src/backend/wayland/session/runtime.rs +++ b/src/backend/wayland/session/runtime.rs @@ -11,6 +11,16 @@ pub(in crate::backend::wayland) struct RuntimeOpenSessionReport { pub catalog_error: Option, } +/// A return to the home session that saved the current session and loaded +/// home, for the runtime to apply. +#[derive(Debug)] +pub(in crate::backend::wayland) struct RuntimeHomeSessionReport { + /// Home's options; `None` when home has persistence disabled. + pub options: Option, + /// What loading home found; `None` without persistence. + pub outcome: Option, +} + #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::backend::wayland) struct RuntimeSaveAsSessionReport { diff --git a/src/backend/wayland/session/runtime/transaction.rs b/src/backend/wayland/session/runtime/transaction.rs index 218dd98d..89a1b757 100644 --- a/src/backend/wayland/session/runtime/transaction.rs +++ b/src/backend/wayland/session/runtime/transaction.rs @@ -6,6 +6,9 @@ mod phases; #[derive(Debug)] pub(in crate::backend::wayland) enum SessionCommand { Open(PathBuf), + /// Return to the home session, whose options for the current output this + /// carries; `None` when home has persistence disabled. + OpenHome(Option>), SaveAs(PathBuf, SaveAsOverwrite), CheckOverwrite(PathBuf), Clear, @@ -16,6 +19,7 @@ pub(in crate::backend::wayland) enum SessionCommand { pub(in crate::backend::wayland) enum SessionCommandReport { Open(RuntimeOpenSessionReport), + Home(RuntimeHomeSessionReport), SaveAs(RuntimeSaveAsSessionReport), Overwrite(PathBuf, bool), Clear(RuntimeClearSessionReport), @@ -36,6 +40,7 @@ enum Phase { SaveCurrent, Load, RecordOpen, + LoadHome, SaveAsPreflight, SaveAs, Clear, @@ -117,6 +122,7 @@ impl ExplicitSessionTransaction { Phase::SaveCurrent => self.complete_save_current(context, outcome), Phase::Load => self.complete_load(context, outcome), Phase::RecordOpen => self.complete_record_open(context, outcome), + Phase::LoadHome => self.complete_load_home(context, outcome), Phase::SaveAsPreflight => self.complete_save_as_preflight(context, outcome), Phase::SaveAs => self.complete_save_as(context, outcome), Phase::Clear => self.complete_clear(context, outcome), @@ -196,6 +202,22 @@ impl ExplicitSessionTransaction { }, ) } + // Home loads the way a launch would, so its recovery, clear and + // tool-restore rules apply rather than those of a runtime Open. + SessionCommand::OpenHome(Some(options)) => { + let options = (**options).clone(); + self.capture_input_generation(context); + self.work( + Phase::LoadHome, + PersistenceOperation::LoadConfigured { options }, + ) + } + SessionCommand::OpenHome(None) => Ok(TransactionStep::Complete(Box::new( + SessionCommandReport::Home(RuntimeHomeSessionReport { + options: None, + outcome: None, + }), + ))), SessionCommand::SaveAs(_, _) => Ok(TransactionStep::Complete(Box::new( SessionCommandReport::SaveAs(RuntimeSaveAsSessionReport { previous_path: self.current_path(), diff --git a/src/backend/wayland/session/runtime/transaction/phases.rs b/src/backend/wayland/session/runtime/transaction/phases.rs index 99e80d0a..93037b27 100644 --- a/src/backend/wayland/session/runtime/transaction/phases.rs +++ b/src/backend/wayland/session/runtime/transaction/phases.rs @@ -57,6 +57,13 @@ impl ExplicitSessionTransaction { }, ) } + SessionCommand::OpenHome(_) => { + cancel_pending_output_transition_for_explicit_target( + context.session, + "Return to the home session", + ); + self.save_current_or_continue(context) + } SessionCommand::Clear => { self.capture_input_generation(context); self.work( @@ -165,6 +172,27 @@ impl ExplicitSessionTransaction { ))) } + /// Home is applied by the runtime, which loads it like a launch would; the + /// transaction only hands over what the load found. + pub(super) fn complete_load_home( + &mut self, + _context: &mut SessionTransaction<'_>, + outcome: Option, + ) -> Result { + let PersistenceOutcome::Load(load) = required_outcome(outcome)? else { + return Err(anyhow!("unexpected home-session load outcome")); + }; + let SessionCommand::OpenHome(Some(options)) = &self.command else { + unreachable!() + }; + Ok(TransactionStep::Complete(Box::new( + SessionCommandReport::Home(RuntimeHomeSessionReport { + options: Some((**options).clone()), + outcome: Some(load), + }), + ))) + } + pub(super) fn complete_save_as_preflight( &mut self, context: &mut SessionTransaction<'_>, diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs index 9ac37522..ce3c3091 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -64,7 +64,7 @@ impl WaylandState { }) } - pub(super) fn handle_session_load_outcome_for_options( + pub(in crate::backend::wayland::state) fn handle_session_load_outcome_for_options( &mut self, outcome: session::LoadSnapshotOutcome, options: &session::SessionOptions, diff --git a/src/backend/wayland/state/core/session_home.rs b/src/backend/wayland/state/core/session_home.rs index 5d3f00d6..fc1f960e 100644 --- a/src/backend/wayland/state/core/session_home.rs +++ b/src/backend/wayland/state/core/session_home.rs @@ -65,7 +65,7 @@ impl WaylandState { /// switch stands. pub(in crate::backend::wayland) fn session_target_committed(&mut self) { let target = session_target(self.session.options()); - let Some(session) = self.session_home.report_for(target) else { + let Some(session) = self.session_home.commit_target(target) else { return; }; if let Err(error) = publish_session_from_environment(&session) { diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index db386b47..f0bac09f 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -110,7 +110,7 @@ impl WaylandState { // toolbar snapshot is built between canvas renders, and before the // first one, so a published value would lag or not exist yet. snapshot.spotlight_magnifier_source = Some(self.current_spotlight_magnifier_source()); - populate_session_snapshot(&mut snapshot, self.session.options()); + populate_session_snapshot(&mut snapshot, self.session.options(), &self.session_home); snapshot.runtime_ui_persistence = self .preferences .runtime_ui() diff --git a/src/backend/wayland/state/toolbar/events/session.rs b/src/backend/wayland/state/toolbar/events/session.rs index ef2f9e16..c56f20cf 100644 --- a/src/backend/wayland/state/toolbar/events/session.rs +++ b/src/backend/wayland/state/toolbar/events/session.rs @@ -10,7 +10,10 @@ use wayland_client::{Connection, QueueHandle}; pub(super) fn populate_session_snapshot( snapshot: &mut ToolbarSnapshot, options: Option<&crate::session::SessionOptions>, + home: &crate::backend::wayland::session::SessionHome, ) { + snapshot.home_session_name = home.name(); + snapshot.at_home_session = home.is_at_home(); let active_path = options.map(|options| options.session_file_path()); snapshot.active_session_name = active_path.as_deref().map(session_display_name); // Recents are only read (from the catalog on disk) while the top strip's @@ -104,6 +107,7 @@ fn recent_session_snapshots( } mod dialog; +mod home; pub(in crate::backend::wayland::state) use dialog::SessionFileDialogController; pub(super) use dialog::{SessionFileDialogMode, ensure_save_as_extension}; @@ -128,6 +132,10 @@ impl WaylandState { self.handle_toolbar_open_session_path(path); true } + ToolbarEvent::OpenHomeSession => { + self.handle_toolbar_open_home_session(); + true + } ToolbarEvent::SaveSessionAs => { self.handle_toolbar_save_session_as(conn, qh); true @@ -419,6 +427,7 @@ impl WaylandState { self.set_session_toolbar_info(format!("Opened session {name}")); } } + SessionCommandReport::Home(report) => self.finish_open_home_session(report), SessionCommandReport::SaveAs(report) => { self.clear_toolbar_save_as_overwrite_prompt(); self.set_session_toolbar_info(format!( @@ -503,6 +512,7 @@ impl WaylandState { } let prefix = match command { SessionCommand::Open(_) => "Open session failed", + SessionCommand::OpenHome(_) => "Return to the home session failed", SessionCommand::SaveAs(..) | SessionCommand::CheckOverwrite(_) => "Save session failed", SessionCommand::Clear => "Clear session failed", SessionCommand::ClearTools(_) => "Failed to reset tool defaults", @@ -612,8 +622,34 @@ fn dialog_frame_accepted(phase: DialogFramePhase, outcome: RenderOutcome) -> boo #[cfg(test)] mod tests { - use super::{DialogFramePhase, dialog_frame_accepted}; + use super::{DialogFramePhase, dialog_frame_accepted, populate_session_snapshot}; + use crate::backend::wayland::session::{SessionHome, SessionLaunch}; use crate::backend::wayland::state::RenderOutcome; + use crate::backend::wayland::state::toolbar::ToolbarSnapshot; + use crate::session::SessionTarget; + + #[test] + fn the_session_menu_learns_home_and_whether_it_is_active() { + let input = crate::input::state::test_support::make_test_input_state(); + let mut snapshot = ToolbarSnapshot::from_input_with_bindings(&input, Default::default()); + let home = std::path::PathBuf::from("/sessions/home.wayscriber-session"); + let away = SessionHome::new( + SessionLaunch { + home: crate::backend::wayland::session::HomeSession::Named(home), + preferred: None, + }, + None, + SessionTarget::NamedFile("/sessions/b.wayscriber-session".into()), + ); + + populate_session_snapshot(&mut snapshot, None, &away); + + assert_eq!( + snapshot.home_session_name.as_deref(), + Some("home.wayscriber-session") + ); + assert!(!snapshot.at_home_session); + } #[test] fn dialog_entry_requires_a_committed_frame() { diff --git a/src/backend/wayland/state/toolbar/events/session/home.rs b/src/backend/wayland/state/toolbar/events/session/home.rs new file mode 100644 index 00000000..c08cc98e --- /dev/null +++ b/src/backend/wayland/state/toolbar/events/session/home.rs @@ -0,0 +1,76 @@ +use super::*; +use crate::backend::wayland::session::RuntimeHomeSessionReport; +use crate::session::{SessionOptions, SessionSnapshot}; + +impl WaylandState { + /// Returns to the home session the way Open switches session: the current + /// session is saved first, and nothing changes unless home loads. + pub(super) fn handle_toolbar_open_home_session(&mut self) { + self.clear_toolbar_save_as_overwrite_prompt(); + let command = SessionCommand::OpenHome(self.home_session_options().map(Box::new)); + if let Err(error) = self.start_session_command(command) { + self.report_session_command_error("Return to the home session failed", &error); + } + } + + /// Home's options for the output the overlay is on now, not those of the + /// named session it is leaving. + fn home_session_options(&self) -> Option { + let mut options = self.session_home.options()?.clone(); + let output_identity = self + .surface + .current_output() + .as_ref() + .and_then(|output| self.output_identity_for(output)); + options.set_output_identity(output_identity.as_deref()); + Some(options) + } + + pub(super) fn finish_open_home_session(&mut self, report: RuntimeHomeSessionReport) { + let applied = match (report.options, report.outcome) { + (Some(options), Some(outcome)) => { + let loaded_board_data = outcome.has_board_data(); + self.handle_session_load_outcome_for_options(outcome, &options, "home session") + .map(|()| { + self.input_state + .set_session_preflight_options(Some(options.clone())); + self.session + .commit_output_options(options, loaded_board_data); + }) + } + _ => self.leave_persistence_for_home(), + }; + if let Err(error) = applied { + self.report_session_command_error("Return to the home session failed", &error); + return; + } + + self.session_target_committed(); + self.set_session_toolbar_info(format!("Returned to {}", self.session_home.label())); + } + + /// Home has persistence disabled: the run continues on an empty canvas + /// that is not saved, as it would have started. + fn leave_persistence_for_home(&mut self) -> Result<()> { + let current = self + .session_options() + .cloned() + .context("no session to return home from")?; + let empty = SessionSnapshot { + active_board_id: self.input_state.board_id().to_string(), + boards: Vec::new(), + tool_state: None, + }; + crate::session::apply_snapshot_replacing_boards( + &mut self.input_state, + self.render.text_measurer(), + empty, + ¤t, + )?; + self.input_state.set_session_preflight_options(None); + self.input_state.clear_session_dirty(); + self.session.commit_without_persistence(); + self.refresh_runtime_ui_config_seeds(); + Ok(()) + } +} diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index 8827cca2..aeb0bc55 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -210,6 +210,7 @@ fn runtime_toolbar_events_do_not_directly_save_config() { ToolbarEvent::ApplyPreset(1), ToolbarEvent::OpenSession, ToolbarEvent::OpenRecentSession(std::path::PathBuf::from("/tmp/recent.wayscriber-session")), + ToolbarEvent::OpenHomeSession, ToolbarEvent::SaveSessionAs, ToolbarEvent::SaveSessionAsConfirm(std::path::PathBuf::from( "/tmp/existing.wayscriber-session", @@ -1023,6 +1024,7 @@ fn session_popover_survives_its_own_controls_and_dismisses_on_everything_else() for spared in [ ToolbarEvent::OpenSession, ToolbarEvent::OpenRecentSession(PathBuf::from("/tmp/recent.wayscriber-session")), + ToolbarEvent::OpenHomeSession, ToolbarEvent::SaveSessionAs, ToolbarEvent::SaveSessionAsConfirm(PathBuf::from("/tmp/existing.wayscriber-session")), ToolbarEvent::SaveSessionAsCancel, diff --git a/src/backend/wayland/toolbar/view/top/menus.rs b/src/backend/wayland/toolbar/view/top/menus.rs index 80bb71f2..81882dec 100644 --- a/src/backend/wayland/toolbar/view/top/menus.rs +++ b/src/backend/wayland/toolbar/view/top/menus.rs @@ -362,6 +362,23 @@ fn session_menu_content(snapshot: &ToolbarSnapshot) -> Option> { y += grid.height; } + if let Some(home) = model.home.as_ref() { + y += MENU_GAP; + nodes.push(text_button( + "top.menu.session.home".to_owned(), + (0.0, y, MENU_CONTENT_W, MENU_BUTTON_H), + LabelSpec::new(truncate_middle(&home.label, 30), MENU_LABEL_FONT, true), + if home.enabled { + ButtonStyle::plain() + } else { + ButtonStyle::disabled() + }, + home.enabled + .then(|| Interaction::click(home.event(), Some(home.label.clone()))), + )); + y += MENU_BUTTON_H; + } + for (index, recent) in model.recents.iter().enumerate() { y += MENU_GAP; let tooltip_path = recent.path.display().to_string(); diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index f5b199b0..6f05f956 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -1931,6 +1931,13 @@ fn session_popover_re_hosts_the_session_pane_content() { assert_session_nodes_inside_panel(&tree); assert_session_popover_input_rect(&snapshot, &tree, w, h); + // Away from home, the way back is offered. + snapshot.home_session_name = Some("home.wayscriber-session".to_string()); + snapshot.at_home_session = false; + let tree = build(&snapshot); + assert_session_popover_model(&tree, &snapshot); + assert_session_nodes_inside_panel(&tree); + // A pending Save-As overwrite swaps the button grid for the // confirmation, exactly like the pane. snapshot.pending_save_as_overwrite_path = @@ -1974,6 +1981,24 @@ fn assert_session_popover_model(tree: &WidgetTree, snapshot: &ToolbarSnapshot) { .expect("recent row"); assert_eq!(node.interact.as_ref().unwrap().event, recent.event()); } + let home = model.home.as_ref().expect("home row"); + let node = tree + .node_by_id(&"top.menu.session.home".into()) + .expect("home row"); + match &node.kind { + WidgetKind::TextButton { label, style } => { + assert_eq!( + label.text, + crate::ui::toolbar::session_format::truncate_middle(&home.label, 30) + ); + assert_eq!(style.disabled, !home.enabled); + } + other => panic!("home row kind, got {other:?}"), + } + assert_eq!( + node.interact.as_ref().map(|interact| &interact.event), + home.enabled.then(|| home.event()).as_ref() + ); // Meta labels are decor; this popover has no collapsible header. assert!(tree.node_by_id(&"top.menu.session.name".into()).is_some()); assert!(tree.node_by_id(&"top.menu.session.path".into()).is_some()); diff --git a/src/config/types/toolbar/ids.rs b/src/config/types/toolbar/ids.rs index 3ade29a4..68544e06 100644 --- a/src/config/types/toolbar/ids.rs +++ b/src/config/types/toolbar/ids.rs @@ -126,6 +126,7 @@ pub const SIDE_SETTINGS_ABOUT: ToolbarItemId = ToolbarItemId::from_known("side.s pub const SIDE_SESSION_OPEN: ToolbarItemId = ToolbarItemId::from_known("side.session.open"); pub const SIDE_SESSION_SAVE_AS: ToolbarItemId = ToolbarItemId::from_known("side.session.save-as"); +pub const SIDE_SESSION_HOME: ToolbarItemId = ToolbarItemId::from_known("side.session.home"); pub const SIDE_SESSION_INFO: ToolbarItemId = ToolbarItemId::from_known("side.session.info"); pub const SIDE_SESSION_CLEAR: ToolbarItemId = ToolbarItemId::from_known("side.session.clear"); pub const SIDE_SESSION_MANAGER: ToolbarItemId = ToolbarItemId::from_known("side.session.manager"); diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs index 8bdff410..dd3288c5 100644 --- a/src/config/types/toolbar/items/definitions.rs +++ b/src/config/types/toolbar/items/definitions.rs @@ -452,6 +452,13 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[ Session, Some(ToolbarGroupId::Session), ), + item( + ids::SIDE_SESSION_HOME, + "Return to home session", + Popover, + Session, + Some(ToolbarGroupId::Session), + ), item( ids::SIDE_SESSION_INFO, "Session info", diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 4d8e7f50..9a3f520f 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -362,6 +362,7 @@ impl InputState { ToolbarEvent::ClearPreset(slot) => self.apply_toolbar_clear_preset(slot), ToolbarEvent::OpenSession | ToolbarEvent::OpenRecentSession(_) + | ToolbarEvent::OpenHomeSession | ToolbarEvent::SaveSessionAs | ToolbarEvent::SaveSessionAsConfirm(_) | ToolbarEvent::SaveSessionAsCancel diff --git a/src/toolbar_gtk/view/sections/session_pane.rs b/src/toolbar_gtk/view/sections/session_pane.rs index 6120b509..f4dedbb5 100644 --- a/src/toolbar_gtk/view/sections/session_pane.rs +++ b/src/toolbar_gtk/view/sections/session_pane.rs @@ -1,6 +1,7 @@ //! Session pane: active-session meta labels, the Open / Save As / Info / //! Clear / Manager grid (replaced by the Save-As overwrite confirmation -//! while one is pending), and the recent-session list. +//! while one is pending), the way back to the home session, and the +//! recent-session list. use gtk4::prelude::*; @@ -54,6 +55,9 @@ pub(in crate::toolbar_gtk) fn build_popover_content( column.append(&grid); } + if let Some(home) = session.home.as_ref() { + column.append(&home_button(ctx, home)); + } for recent in &session.recents { column.append(&recent_row(ctx, recent)); } @@ -131,6 +135,22 @@ fn overwrite_confirmation_rows( rows } +fn home_button(ctx: &SectionCtx, home: &model::session::ToolbarSessionHome) -> gtk4::Button { + let button = gtk4::Button::new(); + button.set_size_request(-1, ctx.px(24.0)); + let label = gtk4::Label::new(Some(&home.label)); + label.set_ellipsize(gtk4::pango::EllipsizeMode::Middle); + button.set_child(Some(&label)); + button.set_tooltip_text(Some(&home.label)); + button.set_sensitive(home.enabled); + let sender = ctx.feedback.clone(); + let event = home.event(); + button.connect_clicked(move |_| { + send_event(&sender, event.clone()); + }); + button +} + fn recent_row(ctx: &SectionCtx, recent: &model::ToolbarSessionRecent) -> gtk4::Button { let button = gtk4::Button::new(); button.set_size_request(-1, ctx.px(22.0)); diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index f88617d7..ab0c527d 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -222,6 +222,8 @@ struct SessionMenuContentKey { active_session_name: Option, active_session_path: Option, recent_sessions: Vec, + home_session_name: Option, + at_home_session: bool, pending_save_as_overwrite_path: Option, use_icons: bool, } @@ -233,6 +235,8 @@ impl SessionMenuContentKey { active_session_name: snapshot.active_session_name.clone(), active_session_path: snapshot.active_session_path.clone(), recent_sessions: snapshot.recent_sessions.clone(), + home_session_name: snapshot.home_session_name.clone(), + at_home_session: snapshot.at_home_session, pending_save_as_overwrite_path: snapshot.pending_save_as_overwrite_path.clone(), use_icons: snapshot.use_icons, } diff --git a/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs b/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs index 8081741e..f4424021 100644 --- a/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs +++ b/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs @@ -21,6 +21,8 @@ pub(super) fn assert_menu_popover_contracts(regular: &ToolbarSnapshot) { display_name: "recent-0.wayscriber-session".to_string(), path: std::path::PathBuf::from("/tmp/recent-0.wayscriber-session"), }]; + session_snapshot.home_session_name = Some("home.wayscriber-session".to_string()); + session_snapshot.at_home_session = false; let (tx, menu_rx) = std::sync::mpsc::channel(); let mut menu_top = TopBar::new_for_test(FeedbackSender::new(tx)); // Building the strip creates the two overflow-anchored native popovers. @@ -52,9 +54,10 @@ fn assert_session_popover_contract( find_widget_named(&content, "top.menu.session.panel").expect("session popover panel box"); let mut buttons: Vec = Vec::new(); collect_descendants(&panel, &mut buttons); + let home = model.home.as_ref().expect("home row"); assert_eq!( buttons.len(), - model.buttons.len() + model.recents.len(), + model.buttons.len() + 1 + model.recents.len(), "the popover exposes exactly the pane's controls" ); for (button, button_model) in buttons.iter().zip(model.buttons.iter()) { @@ -70,6 +73,21 @@ fn assert_session_popover_contract( rebind_requested: false, } ); + let home_button = &buttons[model.buttons.len()]; + assert_eq!( + home_button.tooltip_text().as_deref(), + Some(home.label.as_str()) + ); + assert_eq!(home_button.is_sensitive(), home.enabled); + home_button.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK home event"), + GtkToolbarFeedback::Event { + event: home.event(), + rebind_requested: false, + } + ); buttons.last().expect("recent row button").emit_clicked(); assert_eq!( rx.recv_timeout(Duration::from_secs(1)) diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 73463ef9..f8748045 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -166,6 +166,9 @@ pub enum ToolbarEvent { ClearPreset(usize), OpenSession, OpenRecentSession(PathBuf), + /// Return to the home session: the daemon's startup session file, or the + /// configured default session. + OpenHomeSession, SaveSessionAs, SaveSessionAsConfirm(PathBuf), SaveSessionAsCancel, diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 13c4f258..196fc30f 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -236,6 +236,7 @@ pub(crate) fn action_for_event(event: &ToolbarEvent) -> Option { // and Settings preferences: toolbar state rather than drawing actions. ToolbarEvent::OpenSession | ToolbarEvent::OpenRecentSession(_) + | ToolbarEvent::OpenHomeSession | ToolbarEvent::SaveSessionAs | ToolbarEvent::SaveSessionAsConfirm(_) | ToolbarEvent::SaveSessionAsCancel @@ -464,6 +465,7 @@ pub(crate) fn popovers_for_event(event: &ToolbarEvent) -> &'static [ToolbarPopov // Session hosts the session controls. ToolbarEvent::OpenSession | ToolbarEvent::OpenRecentSession(_) + | ToolbarEvent::OpenHomeSession | ToolbarEvent::SaveSessionAs | ToolbarEvent::SaveSessionAsConfirm(_) | ToolbarEvent::SaveSessionAsCancel @@ -747,6 +749,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::ClearPreset(_) | ToolbarEvent::OpenSession | ToolbarEvent::OpenRecentSession(_) + | ToolbarEvent::OpenHomeSession | ToolbarEvent::SaveSessionAs | ToolbarEvent::SaveSessionAsConfirm(_) | ToolbarEvent::SaveSessionAsCancel diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index d643860c..75aab0a5 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -684,6 +684,20 @@ mod tests { .any(|button| button.event == ToolbarEvent::SessionInfo), "hiding side.session.info removes the Session Info control" ); + assert!( + ToolbarSessionModel::for_popover(&snapshot) + .expect("session") + .home + .is_some() + ); + hide(&mut snapshot, ids::SIDE_SESSION_HOME); + assert!( + ToolbarSessionModel::for_popover(&snapshot) + .expect("session") + .home + .is_none(), + "hiding side.session.home removes the way back to the home session" + ); snapshot = self::snapshot(); let about_visible = |snapshot: &ToolbarSnapshot| { diff --git a/src/ui/toolbar/model/session.rs b/src/ui/toolbar/model/session.rs index 24631ebb..aaff8aab 100644 --- a/src/ui/toolbar/model/session.rs +++ b/src/ui/toolbar/model/session.rs @@ -12,6 +12,7 @@ pub(crate) struct ToolbarSessionModel { pub(crate) active_name: String, pub(crate) active_path_label: String, pub(crate) buttons: Vec, + pub(crate) home: Option, pub(crate) recents: Vec, pub(crate) overwrite_confirmation: Option, } @@ -44,6 +45,8 @@ impl ToolbarSessionModel { .into_iter() .filter(|button| session_button_visible(snapshot, &button.event)) .collect(); + let home = (!snapshot.toolbar_item_hidden(ids::SIDE_SESSION_HOME)) + .then(|| ToolbarSessionHome::from_snapshot(snapshot)); let recents = if target_active { snapshot .recent_sessions @@ -62,10 +65,11 @@ impl ToolbarSessionModel { path: path.clone(), }); - (!buttons.is_empty() || !recents.is_empty()).then_some(Self { + (!buttons.is_empty() || home.is_some() || !recents.is_empty()).then_some(Self { active_name, active_path_label, buttons, + home, recents, overwrite_confirmation, }) @@ -109,6 +113,32 @@ impl ToolbarSessionButton { } } +/// The way back to the home session: the daemon's startup session file, or +/// the configured default session. +#[derive(Debug, Clone)] +pub(crate) struct ToolbarSessionHome { + /// "Back to ", or "Default session". + pub(crate) label: String, + /// Off while home is already active: there is nothing to return to. + pub(crate) enabled: bool, +} + +impl ToolbarSessionHome { + fn from_snapshot(snapshot: &ToolbarSnapshot) -> Self { + Self { + label: snapshot.home_session_name.as_ref().map_or_else( + || "Default session".to_owned(), + |name| format!("Back to {name}"), + ), + enabled: !snapshot.at_home_session, + } + } + + pub(crate) fn event(&self) -> ToolbarEvent { + ToolbarEvent::OpenHomeSession + } +} + #[derive(Debug, Clone)] pub(crate) struct ToolbarSessionRecent { pub(crate) label: String, @@ -220,6 +250,32 @@ mod tests { assert_eq!(model.active_path_label, "No persisted session target"); } + #[test] + fn session_model_offers_the_way_home_only_away_from_home() { + let mut snapshot = app_snapshot(); + snapshot.active_session_path = Some(PathBuf::from("/tmp/b.wayscriber-session")); + + for (name, label) in [ + (None, "Default session"), + ( + Some("home.wayscriber-session"), + "Back to home.wayscriber-session", + ), + ] { + snapshot.home_session_name = name.map(str::to_owned); + for at_home in [false, true] { + snapshot.at_home_session = at_home; + + let model = ToolbarSessionModel::for_popover(&snapshot).expect("session model"); + let home = model.home.expect("home row"); + + assert_eq!(home.label, label); + assert_eq!(home.enabled, !at_home, "{label}, at home {at_home}"); + assert_eq!(home.event(), ToolbarEvent::OpenHomeSession); + } + } + } + #[test] fn session_model_exposes_pending_save_as_overwrite_confirmation() { let mut snapshot = app_snapshot(); diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index af3e88e1..0a06dfc1 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -241,6 +241,8 @@ impl ToolbarSnapshot { active_session_name: None, active_session_path: None, recent_sessions: Vec::new(), + home_session_name: None, + at_home_session: true, pending_save_as_overwrite_path: state.pending_save_as_overwrite().map(PathBuf::from), runtime_ui_persistence: None, } diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index 38d81aa8..2c91808d 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -465,6 +465,11 @@ pub struct ToolbarSnapshot { pub active_session_path: Option, /// Recent persisted sessions from the catalog. pub recent_sessions: Vec, + /// The named session the Session menu returns home to; `None` is the + /// configured default session. + pub home_session_name: Option, + /// Whether the active session is home, leaving nothing to return to. + pub at_home_session: bool, /// Save Session As target waiting for explicit overwrite confirmation. pub pending_save_as_overwrite_path: Option, /// Generated runtime UI preference persistence and recovery state.