diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index 96f7c2c1..076eb330 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -111,6 +111,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul frozen_enabled: frozen_supported, preferred_output_identity: output_prefs.preferred_output_identity, xdg_fullscreen: output_prefs.xdg_fullscreen, + main_surface_uses_overlay_layer: output_prefs.main_surface_uses_overlay_layer, pending_freeze_on_start: freeze_on_start, screencopy_manager: setup.screencopy_manager, #[cfg(tablet)] diff --git a/src/backend/wayland/backend/state_init/output.rs b/src/backend/wayland/backend/state_init/output.rs index 600f53b8..22deb78e 100644 --- a/src/backend/wayland/backend/state_init/output.rs +++ b/src/backend/wayland/backend/state_init/output.rs @@ -6,6 +6,7 @@ use crate::config::Config; pub(super) struct OutputPreferences { pub(super) preferred_output_identity: Option, pub(super) xdg_fullscreen: bool, + pub(super) main_surface_uses_overlay_layer: bool, } pub(super) fn resolve(config: &Config) -> OutputPreferences { @@ -24,6 +25,8 @@ pub(super) fn resolve(config: &Config) -> OutputPreferences { .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(config.ui.xdg_fullscreen); let desktop_env = env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + let session_env = env::var("XDG_SESSION_DESKTOP").unwrap_or_default(); + let desktop_session = env::var("DESKTOP_SESSION").unwrap_or_default(); let force_fullscreen = env::var("WAYSCRIBER_XDG_FULLSCREEN_FORCE") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); @@ -33,9 +36,69 @@ pub(super) fn resolve(config: &Config) -> OutputPreferences { ); xdg_fullscreen = false; } + let main_surface_uses_overlay_layer = + main_surface_uses_overlay_layer_with_env(&desktop_env, &session_env, &desktop_session); + if main_surface_uses_overlay_layer { + info!( + "Niri detected; mapping the main overlay surface in the overlay layer so fullscreen windows cannot cover Wayscriber" + ); + } OutputPreferences { preferred_output_identity, xdg_fullscreen, + main_surface_uses_overlay_layer, + } +} + +fn main_surface_uses_overlay_layer_with_env( + desktop_env: &str, + session_env: &str, + desktop_session: &str, +) -> bool { + desktop_matches(desktop_env, "niri") + || desktop_matches(session_env, "niri") + || desktop_matches(desktop_session, "niri") +} + +fn desktop_matches(value: &str, target: &str) -> bool { + value + .split(':') + .map(str::trim) + .any(|entry| entry.eq_ignore_ascii_case(target)) +} + +#[cfg(test)] +mod tests { + use super::main_surface_uses_overlay_layer_with_env; + + #[test] + fn main_surface_uses_overlay_layer_for_niri_desktop() { + assert!(main_surface_uses_overlay_layer_with_env("niri", "", "")); + assert!(main_surface_uses_overlay_layer_with_env( + "Hyprland:Niri", + "", + "" + )); + } + + #[test] + fn main_surface_uses_overlay_layer_for_niri_session() { + assert!(main_surface_uses_overlay_layer_with_env("", "NIRI", "")); + } + + #[test] + fn main_surface_uses_overlay_layer_for_niri_desktop_session() { + assert!(main_surface_uses_overlay_layer_with_env("", "", "niri")); + } + + #[test] + fn main_surface_stays_on_top_layer_for_other_desktops() { + assert!(!main_surface_uses_overlay_layer_with_env( + "Hyprland", "", "" + )); + assert!(!main_surface_uses_overlay_layer_with_env( + "KDE", "plasma", "" + )); } } diff --git a/src/backend/wayland/backend/surface.rs b/src/backend/wayland/backend/surface.rs index fcba46d9..e14c5e56 100644 --- a/src/backend/wayland/backend/surface.rs +++ b/src/backend/wayland/backend/surface.rs @@ -1,9 +1,7 @@ use anyhow::Result; use log::info; use smithay_client_toolkit::shell::{ - WaylandSurface, - wlr_layer::{Anchor, Layer}, - xdg::window::WindowDecorations, + WaylandSurface, wlr_layer::Anchor, xdg::window::WindowDecorations, }; use crate::app_id::runtime_app_id; @@ -17,11 +15,12 @@ pub(super) fn create_overlay_surface( // Create surface using layer-shell when available, otherwise fall back to xdg-shell let wl_surface = state.compositor_state.create_surface(qh); if let Some(layer_shell) = state.layer_shell.as_ref() { - info!("Creating layer shell surface"); + let layer = state.main_surface_layer(); + info!("Creating layer shell surface in {:?} layer", layer); let layer_surface = layer_shell.create_layer_surface( qh, wl_surface, - Layer::Top, + layer, Some("wayscriber"), None, // Default output ); diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 6b0858c6..e3f83a56 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -123,6 +123,7 @@ pub(in crate::backend::wayland) struct WaylandStateInit { pub frozen_enabled: bool, pub preferred_output_identity: Option, pub xdg_fullscreen: bool, + pub main_surface_uses_overlay_layer: bool, pub pending_freeze_on_start: bool, pub screencopy_manager: Option, #[cfg(tablet)] diff --git a/src/backend/wayland/state/core/accessors.rs b/src/backend/wayland/state/core/accessors.rs index b61b8ea5..ebbba3c1 100644 --- a/src/backend/wayland/state/core/accessors.rs +++ b/src/backend/wayland/state/core/accessors.rs @@ -1,3 +1,5 @@ +use smithay_client_toolkit::shell::wlr_layer::Layer; + use super::super::*; use std::time::{Duration, Instant}; @@ -176,6 +178,14 @@ impl WaylandState { self.data.xdg_fullscreen } + pub(in crate::backend::wayland) fn main_surface_layer(&self) -> Layer { + if self.data.main_surface_uses_overlay_layer { + Layer::Overlay + } else { + Layer::Top + } + } + pub(in crate::backend::wayland) fn xdg_focus_loss_exits_overlay(&self) -> bool { matches!( self.config.ui.xdg_focus_loss_behavior, diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index c4c73176..4302f3a1 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -15,6 +15,7 @@ impl WaylandState { frozen_enabled, preferred_output_identity, xdg_fullscreen, + main_surface_uses_overlay_layer, pending_freeze_on_start, screencopy_manager, #[cfg(tablet)] @@ -53,13 +54,20 @@ impl WaylandState { data.startup_activation_token = startup_activation_token; data.preferred_output_identity = preferred_output_identity; data.xdg_fullscreen = xdg_fullscreen; + data.main_surface_uses_overlay_layer = main_surface_uses_overlay_layer; let force_inline_toolbars = force_inline_toolbars_requested(&config); - data.inline_toolbars = layer_shell.is_none() || force_inline_toolbars; + data.inline_toolbars = + layer_shell.is_none() || force_inline_toolbars || main_surface_uses_overlay_layer; if force_inline_toolbars { info!( "Forcing inline toolbars (config/ui.toolbar.force_inline or WAYSCRIBER_FORCE_INLINE_TOOLBARS)" ); } + if main_surface_uses_overlay_layer { + info!( + "Using inline toolbars because the main overlay surface runs above fullscreen windows" + ); + } data.toolbar_top_offset = config.ui.toolbar.top_offset; data.toolbar_top_offset_y = config.ui.toolbar.top_offset_y; data.toolbar_side_offset = config.ui.toolbar.side_offset; diff --git a/src/backend/wayland/state/core/output.rs b/src/backend/wayland/state/core/output.rs index f6860d57..8de09ad8 100644 --- a/src/backend/wayland/state/core/output.rs +++ b/src/backend/wayland/state/core/output.rs @@ -1,8 +1,5 @@ use log::{info, warn}; -use smithay_client_toolkit::shell::{ - WaylandSurface, - wlr_layer::{Anchor, Layer}, -}; +use smithay_client_toolkit::shell::{WaylandSurface, wlr_layer::Anchor}; use std::time::Instant; use super::super::*; @@ -285,10 +282,11 @@ impl WaylandState { let wl_surface = self.compositor_state.create_surface(qh); wl_surface.set_buffer_scale(self.surface.scale().max(1)); + let layer = self.main_surface_layer(); let layer_surface = layer_shell.create_layer_surface( qh, wl_surface, - Layer::Top, + layer, Some("wayscriber"), Some(output), ); diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index 8248b25d..a33b72c6 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -76,6 +76,7 @@ pub struct StateData { pub(super) has_seen_surface_enter: bool, pub(super) preferred_output_identity: Option, pub(super) xdg_fullscreen: bool, + pub(super) main_surface_uses_overlay_layer: bool, pub(super) overlay_suppression: OverlaySuppression, /// True when surface is configured and has keyboard focus; keys are blocked until ready. pub(super) overlay_ready: bool, @@ -139,6 +140,7 @@ impl StateData { has_seen_surface_enter: false, preferred_output_identity: None, xdg_fullscreen: false, + main_surface_uses_overlay_layer: false, overlay_suppression: OverlaySuppression::None, overlay_ready: false, suppress_next_release: false, diff --git a/src/backend/wayland/state/toolbar/visibility/mod.rs b/src/backend/wayland/state/toolbar/visibility/mod.rs index 2e6e3b83..6a991315 100644 --- a/src/backend/wayland/state/toolbar/visibility/mod.rs +++ b/src/backend/wayland/state/toolbar/visibility/mod.rs @@ -7,8 +7,9 @@ mod sync; fn desired_keyboard_interactivity_for( layer_shell_available: bool, toolbar_visible: bool, + inline_toolbars_active: bool, ) -> KeyboardInteractivity { - if layer_shell_available && toolbar_visible { + if layer_shell_available && toolbar_visible && !inline_toolbars_active { KeyboardInteractivity::OnDemand } else { KeyboardInteractivity::Exclusive diff --git a/src/backend/wayland/state/toolbar/visibility/sync.rs b/src/backend/wayland/state/toolbar/visibility/sync.rs index 38135e68..dada4a7c 100644 --- a/src/backend/wayland/state/toolbar/visibility/sync.rs +++ b/src/backend/wayland/state/toolbar/visibility/sync.rs @@ -7,7 +7,11 @@ impl WaylandState { if self.overlay_suppressed() { return KeyboardInteractivity::None; } - desired_keyboard_interactivity_for(self.layer_shell.is_some(), self.toolbar.is_visible()) + desired_keyboard_interactivity_for( + self.layer_shell.is_some(), + self.toolbar.is_visible(), + self.inline_toolbars_active(), + ) } fn log_toolbar_layer_shell_missing_once(&mut self) { diff --git a/src/backend/wayland/state/toolbar/visibility/tests.rs b/src/backend/wayland/state/toolbar/visibility/tests.rs index e1f1f46d..ec9cdb73 100644 --- a/src/backend/wayland/state/toolbar/visibility/tests.rs +++ b/src/backend/wayland/state/toolbar/visibility/tests.rs @@ -1,17 +1,21 @@ use super::*; #[test] -fn desired_keyboard_interactivity_requires_layer_shell_and_visibility() { +fn desired_keyboard_interactivity_requires_layer_shell_and_layer_toolbars() { assert_eq!( - desired_keyboard_interactivity_for(true, true), + desired_keyboard_interactivity_for(true, true, false), KeyboardInteractivity::OnDemand ); assert_eq!( - desired_keyboard_interactivity_for(true, false), + desired_keyboard_interactivity_for(true, false, false), KeyboardInteractivity::Exclusive ); assert_eq!( - desired_keyboard_interactivity_for(false, true), + desired_keyboard_interactivity_for(false, true, false), + KeyboardInteractivity::Exclusive + ); + assert_eq!( + desired_keyboard_interactivity_for(true, true, true), KeyboardInteractivity::Exclusive ); } diff --git a/src/session/snapshot/apply.rs b/src/session/snapshot/apply.rs index 48f48a6d..fae3bc25 100644 --- a/src/session/snapshot/apply.rs +++ b/src/session/snapshot/apply.rs @@ -21,7 +21,15 @@ pub fn apply_snapshot(input: &mut InputState, snapshot: SessionSnapshot, options } } - input.switch_board_force(&snapshot.active_board_id); + if input.boards.has_board(&snapshot.active_board_id) { + input.switch_board_force(&snapshot.active_board_id); + } else { + log::warn!( + "Session active board '{}' missing after restore; keeping current board '{}'", + snapshot.active_board_id, + input.board_id() + ); + } if options.restore_tool_state { if let Some(tool_state) = snapshot.tool_state { diff --git a/src/session/snapshot/load.rs b/src/session/snapshot/load.rs index 51a1372b..c8641ad8 100644 --- a/src/session/snapshot/load.rs +++ b/src/session/snapshot/load.rs @@ -187,45 +187,20 @@ pub(crate) fn load_snapshot_inner( .. } = session_file; - let pages_from_file = |pages: Option>, - active: Option, - legacy: Option| - -> Option { - if let Some(mut pages) = pages { - if pages.is_empty() { - pages.push(Frame::new()); - } - let active = active.unwrap_or(0).min(pages.len() - 1); - return Some(BoardPagesSnapshot { pages, active }); - } - legacy.map(|frame| BoardPagesSnapshot { - pages: vec![frame], - active: 0, - }) - }; - let mut snapshot = if !boards.is_empty() || active_board_id.is_some() { let mut board_snaps = Vec::new(); for BoardFile { id, - mut pages, + pages, active_page, } in boards { - if pages.is_empty() { - pages.push(Frame::new()); - } - let active = active_page.min(pages.len().saturating_sub(1)); board_snaps.push(BoardSnapshot { id, - pages: BoardPagesSnapshot { pages, active }, + pages: normalized_board_pages_snapshot(pages, Some(active_page)), }); } - let fallback_id = board_snaps - .first() - .map(|board| board.id.clone()) - .unwrap_or_else(|| "transparent".to_string()); - let active_board_id = active_board_id.unwrap_or(fallback_id); + let active_board_id = resolved_active_board_id(active_board_id, &board_snaps); SessionSnapshot { active_board_id, boards: board_snaps, @@ -234,28 +209,31 @@ pub(crate) fn load_snapshot_inner( } else { let mut board_snaps = Vec::new(); if let Some(pages) = - pages_from_file(transparent_pages, transparent_active_page, transparent) + board_pages_from_file(transparent_pages, transparent_active_page, transparent) { board_snaps.push(BoardSnapshot { id: "transparent".to_string(), pages, }); } - if let Some(pages) = pages_from_file(whiteboard_pages, whiteboard_active_page, whiteboard) { + if let Some(pages) = + board_pages_from_file(whiteboard_pages, whiteboard_active_page, whiteboard) + { board_snaps.push(BoardSnapshot { id: "whiteboard".to_string(), pages, }); } - if let Some(pages) = pages_from_file(blackboard_pages, blackboard_active_page, blackboard) { + if let Some(pages) = + board_pages_from_file(blackboard_pages, blackboard_active_page, blackboard) + { board_snaps.push(BoardSnapshot { id: "blackboard".to_string(), pages, }); } - let active_board_id = active_mode - .unwrap_or_else(|| "transparent".to_string()) - .to_lowercase(); + let active_board_id = + resolved_active_board_id(active_mode.map(|mode| mode.to_lowercase()), &board_snaps); SessionSnapshot { active_board_id, boards: board_snaps, @@ -288,6 +266,48 @@ pub(crate) fn load_snapshot_inner( })) } +fn board_pages_from_file( + pages: Option>, + active: Option, + legacy: Option, +) -> Option { + if let Some(pages) = pages { + return Some(normalized_board_pages_snapshot(pages, active)); + } + legacy.map(|frame| BoardPagesSnapshot { + pages: vec![frame], + active: 0, + }) +} + +fn normalized_board_pages_snapshot( + mut pages: Vec, + active: Option, +) -> BoardPagesSnapshot { + if pages.is_empty() { + pages.push(Frame::new()); + } + let active = active.unwrap_or(0).min(pages.len().saturating_sub(1)); + BoardPagesSnapshot { pages, active } +} + +fn resolved_active_board_id(requested: Option, boards: &[BoardSnapshot]) -> String { + let Some(fallback_id) = boards.first().map(|board| board.id.clone()) else { + return "transparent".to_string(); + }; + + let requested = requested.unwrap_or_else(|| fallback_id.clone()); + if boards.iter().any(|board| board.id == requested) { + requested + } else { + warn!( + "Session active board '{}' missing from restored boards; falling back to '{}'", + requested, fallback_id + ); + fallback_id + } +} + fn backup_corrupt_session(session_path: &Path, options: &SessionOptions) -> Result<()> { let bytes = fs::read(session_path) .with_context(|| format!("failed to read corrupt session {}", session_path.display()))?; diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index 42842e59..905ebbb3 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -1,7 +1,7 @@ use super::compression::is_gzip; use super::load::load_snapshot_inner; use super::types::{ - BoardPagesSnapshot, BoardSnapshot, CURRENT_VERSION, SessionFile, SessionSnapshot, + BoardFile, BoardPagesSnapshot, BoardSnapshot, CURRENT_VERSION, SessionFile, SessionSnapshot, }; use super::{load_snapshot, save_snapshot}; use crate::draw::{Color, Frame, Shape}; @@ -9,7 +9,7 @@ use crate::session::options::{CompressionMode, SessionOptions}; use crate::time_utils::now_rfc3339; use tempfile::tempdir; -fn sample_snapshot() -> SessionSnapshot { +fn sample_frame() -> Frame { let mut frame = Frame::new(); frame.add_shape(Shape::Line { x1: 0, @@ -24,13 +24,16 @@ fn sample_snapshot() -> SessionSnapshot { }, thick: 2.0, }); + frame +} +fn sample_snapshot() -> SessionSnapshot { SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { id: "transparent".to_string(), pages: BoardPagesSnapshot { - pages: vec![frame], + pages: vec![sample_frame()], active: 0, }, }], @@ -269,3 +272,80 @@ fn load_snapshot_inner_migrates_legacy_frame_to_pages() { assert_eq!(pages.pages.active, 0); assert_eq!(pages.pages.pages[0].shapes.len(), 1); } + +#[test] +fn load_snapshot_inner_falls_back_when_active_board_is_missing() { + let temp = tempdir().unwrap(); + let session_path = temp.path().join("session.json"); + + let file = SessionFile { + version: CURRENT_VERSION, + last_modified: now_rfc3339(), + active_board_id: Some("missing".to_string()), + active_mode: None, + boards: vec![BoardFile { + id: "transparent".to_string(), + pages: vec![sample_frame()], + active_page: 0, + }], + transparent: None, + whiteboard: None, + blackboard: None, + transparent_pages: None, + whiteboard_pages: None, + blackboard_pages: None, + transparent_active_page: None, + whiteboard_active_page: None, + blackboard_active_page: None, + tool_state: None, + }; + let bytes = serde_json::to_vec_pretty(&file).unwrap(); + std::fs::write(&session_path, bytes).unwrap(); + + let options = SessionOptions::new(temp.path().to_path_buf(), "missing-active-board"); + let loaded = load_snapshot_inner(&session_path, &options) + .expect("load_snapshot_inner should succeed") + .expect("snapshot should be present"); + + assert_eq!(loaded.snapshot.active_board_id, "transparent"); +} + +#[test] +fn load_snapshot_inner_normalizes_empty_legacy_page_lists() { + let temp = tempdir().unwrap(); + let session_path = temp.path().join("session.json"); + + let file = SessionFile { + version: CURRENT_VERSION, + last_modified: now_rfc3339(), + active_board_id: None, + active_mode: Some("transparent".to_string()), + boards: Vec::new(), + transparent: None, + whiteboard: None, + blackboard: None, + transparent_pages: Some(Vec::new()), + whiteboard_pages: Some(vec![sample_frame()]), + blackboard_pages: None, + transparent_active_page: Some(99), + whiteboard_active_page: Some(0), + blackboard_active_page: None, + tool_state: None, + }; + let bytes = serde_json::to_vec_pretty(&file).unwrap(); + std::fs::write(&session_path, bytes).unwrap(); + + let options = SessionOptions::new(temp.path().to_path_buf(), "empty-legacy-pages"); + let loaded = load_snapshot_inner(&session_path, &options) + .expect("load_snapshot_inner should succeed") + .expect("snapshot should be present"); + let pages = loaded + .snapshot + .boards + .iter() + .find(|board| board.id == "transparent") + .expect("transparent pages"); + + assert_eq!(pages.pages.pages.len(), 1); + assert_eq!(pages.pages.active, 0); +} diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index 6eee45aa..15a39aae 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -1,6 +1,6 @@ use super::super::*; use super::helpers::dummy_input_state; -use crate::draw::{Color, Shape}; +use crate::draw::{Color, Frame, Shape}; use crate::input::{EraserMode, Tool}; use std::path::PathBuf; @@ -110,3 +110,26 @@ fn apply_snapshot_restores_tool_state() { ); assert!(!restored.show_status_bar); } + +#[test] +fn apply_snapshot_keeps_current_board_when_active_board_is_missing() { + let options = SessionOptions::new(PathBuf::from("/tmp"), "display-missing-board"); + let mut input = dummy_input_state(); + input.switch_board_force("whiteboard"); + + let snapshot = SessionSnapshot { + active_board_id: "missing".to_string(), + boards: vec![BoardSnapshot { + id: "transparent".to_string(), + pages: BoardPagesSnapshot { + pages: vec![Frame::new()], + active: 0, + }, + }], + tool_state: None, + }; + + apply_snapshot(&mut input, snapshot, &options); + + assert_eq!(input.board_id(), "whiteboard"); +}