diff --git a/book/src/control-center.md b/book/src/control-center.md index 001576892..4c7022303 100644 --- a/book/src/control-center.md +++ b/book/src/control-center.md @@ -64,6 +64,9 @@ LIBEI_SOCKET Workspace Display Order : Dropdown to select how workspaces are ordered in the bar +Workspace Empty Behavior +: Dropdown to select what happens to empty workspaces when they are left or become inactive + Log Level : Dropdown to change the active log level at runtime (shown when the logger is available) diff --git a/book/src/workspaces.md b/book/src/workspaces.md index 100e13b7f..3104079d0 100644 --- a/book/src/workspaces.md +++ b/book/src/workspaces.md @@ -110,6 +110,50 @@ workspace-display-order = "sorted" You can also change this at runtime in the control center. +## Empty Workspace Behavior + +Jay creates workspaces on demand. When a workspace becomes empty, Jay can +optionally hide or destroy it automatically so your workspace list does not +accumulate unused entries. + +Configure this with the `workspace-empty-behavior` top-level setting (or at +runtime in the control center, in the Compositor pane): + +```toml +workspace-empty-behavior = "hide-on-leave" +``` + +> [!NOTE] +> This behavior is evaluated per output. +> +> - "leave" means the workspace stops being the active workspace on its output +> because you showed another workspace on that same output. +> - "inactive" means the workspace is currently not the active workspace on its +> output. + +Supported values: + +`preserve` +: Never destroy or hide empty workspaces automatically. + +`destroy-on-leave` +: Destroy an empty workspace when you leave it (default). + +`hide-on-leave` +: Hide an empty workspace when you leave it. + +`destroy` +: Destroy an empty workspace whenever it is empty and inactive. + +`hide` +: Hide an empty workspace whenever it is empty and inactive. + +> [!TIP] +> Hidden workspaces are not listed in the bar or workspace lists, but you can +> restore them by showing the workspace by name (for example via the +> `show-workspace` action). When restoring a hidden workspace, Jay prefers the +> output it was last shown on if that output is still connected. + ## Hot-Plug and Hot-Unplug Jay handles monitor connections gracefully: diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index ca37dc82f..c88a4d67d 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -37,7 +37,7 @@ use { ContentType, MatchedWindow, TileState, Window, WindowCriterion, WindowMatcher, WindowType, }, - workspace::WorkspaceDisplayOrder, + workspace::{WorkspaceDisplayOrder, WorkspaceEmptyBehavior}, xwayland::XScalingMode, }, bincode::Options, @@ -1078,6 +1078,10 @@ impl ConfigClient { self.send(&ClientMessage::SetWorkspaceDisplayOrder { order }); } + pub fn set_workspace_empty_behavior(&self, behavior: WorkspaceEmptyBehavior) { + self.send(&ClientMessage::SetWorkspaceEmptyBehavior { behavior }); + } + pub fn seat_create_mark(&self, seat: Seat, kc: Option) { self.send(&ClientMessage::SeatCreateMark { seat, kc }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 6dad42164..11e974862 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -17,7 +17,7 @@ use { Transform, VrrMode, connector_type::ConnectorType, }, window::{ContentType, TileState, Window, WindowMatcher, WindowType}, - workspace::WorkspaceDisplayOrder, + workspace::{WorkspaceDisplayOrder, WorkspaceEmptyBehavior}, xwayland::XScalingMode, }, serde::{Deserialize, Serialize}, @@ -875,6 +875,9 @@ pub enum ClientMessage<'a> { seat: Seat, enabled: bool, }, + SetWorkspaceEmptyBehavior { + behavior: WorkspaceEmptyBehavior, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/workspace.rs b/jay-config/src/workspace.rs index 5a63fc983..c7735781b 100644 --- a/jay-config/src/workspace.rs +++ b/jay-config/src/workspace.rs @@ -17,3 +17,25 @@ pub enum WorkspaceDisplayOrder { pub fn set_workspace_display_order(order: WorkspaceDisplayOrder) { get!().set_workspace_display_order(order); } + +/// Configures what happens to empty workspaces when they are left or become inactive. +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)] +pub enum WorkspaceEmptyBehavior { + /// Never destroy or hide empty workspaces automatically. + Preserve, + /// Destroy an empty workspace when switching away from it. + DestroyOnLeave, + /// Hide an empty workspace when switching away from it. + HideOnLeave, + /// Destroy an empty workspace whenever it is empty and inactive. + Destroy, + /// Hide an empty workspace whenever it is empty and inactive. + Hide, +} + +/// Sets what should happen to empty workspaces. +/// +/// The default is `WorkspaceEmptyBehavior::DestroyOnLeave`. +pub fn set_workspace_empty_behavior(behavior: WorkspaceEmptyBehavior) { + get!().set_workspace_empty_behavior(behavior); +} diff --git a/src/compositor.rs b/src/compositor.rs index 4509b46ad..39ed88ce5 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -60,9 +60,9 @@ use { tracy::enable_profiler, tree::{ DisplayNode, NodeIds, OutputNode, TearingMode, Transform, VrrMode, - WorkspaceDisplayOrder, WorkspaceNode, container_layout, container_render_positions, - container_render_titles, float_layout, float_titles, output_render_data, - placeholder_render_textures, + WorkspaceDisplayOrder, WorkspaceEmptyBehavior, WorkspaceNode, container_layout, + container_render_positions, container_render_titles, float_layout, float_titles, + output_render_data, placeholder_render_textures, }, user_session::import_environment, utils::{ @@ -398,6 +398,7 @@ fn start_compositor2( control_centers: Default::default(), virtual_outputs: Default::default(), clean_logs_older_than: Default::default(), + workspace_empty_behavior: Cell::new(WorkspaceEmptyBehavior::DestroyOnLeave), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); diff --git a/src/config/handler.rs b/src/config/handler.rs index 1d07e1c61..4754ea582 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -72,7 +72,7 @@ use { VrrMode as ConfigVrrMode, }, window::{TileState as ConfigTileState, Window, WindowMatcher}, - workspace::WorkspaceDisplayOrder, + workspace::{WorkspaceDisplayOrder, WorkspaceEmptyBehavior}, xwayland::XScalingMode, }, kbvm::Keycode, @@ -579,6 +579,9 @@ impl ConfigProxyHandler { fn handle_get_workspaces(&self) { let mut workspaces = vec![]; for ws in self.state.workspaces.lock().values() { + if ws.hidden.get() { + continue; + } workspaces.push(self.get_workspace_by_name(&ws.name)); } self.respond(Response::GetWorkspaces { workspaces }); @@ -1443,6 +1446,10 @@ impl ConfigProxyHandler { self.state.set_workspace_display_order(order.into()); } + fn handle_set_workspace_empty_behavior(&self, behavior: WorkspaceEmptyBehavior) { + self.state.set_workspace_empty_behavior(behavior.into()); + } + fn handle_get_seat_float_pinned(&self, seat: Seat) -> Result<(), CphError> { let seat = self.get_seat(seat)?; self.respond(Response::GetFloatPinned { @@ -3412,6 +3419,9 @@ impl ConfigProxyHandler { } => self .handle_window_resize(window, dx1, dy1, dx2, dy2) .wrn("window_resize")?, + ClientMessage::SetWorkspaceEmptyBehavior { behavior } => { + self.handle_set_workspace_empty_behavior(behavior) + } } Ok(()) } diff --git a/src/control_center/cc_compositor.rs b/src/control_center/cc_compositor.rs index 5091b3792..e02ed6cce 100644 --- a/src/control_center/cc_compositor.rs +++ b/src/control_center/cc_compositor.rs @@ -57,6 +57,12 @@ impl CompositorPane { s.workspace_display_order.get(), |o| s.set_workspace_display_order(o), ); + combo_box( + ui, + "Workspace Empty Behavior", + s.workspace_empty_behavior.get(), + |b| s.set_workspace_empty_behavior(b), + ); if let Some(logger) = &s.logger { combo_box(ui, "Log Level", logger.level(), |l| s.set_log_level(l)); row(ui, "Log File", |ui| { diff --git a/src/ifs/workspace_manager/ext_workspace_handle_v1.rs b/src/ifs/workspace_manager/ext_workspace_handle_v1.rs index be90a5521..28b57fe6c 100644 --- a/src/ifs/workspace_manager/ext_workspace_handle_v1.rs +++ b/src/ifs/workspace_manager/ext_workspace_handle_v1.rs @@ -20,7 +20,6 @@ use { const STATE_ACTIVE: u32 = 1; const STATE_URGENT: u32 = 2; -#[expect(dead_code)] const STATE_HIDDEN: u32 = 4; const CAP_ACTIVATE: u32 = 1; @@ -85,6 +84,9 @@ impl ExtWorkspaceHandleV1 { if ws.attention_requests.active() { state |= STATE_URGENT; } + if ws.hidden.get() { + state |= STATE_HIDDEN; + } self.send_state(state); } diff --git a/src/ifs/workspace_manager/ext_workspace_manager_v1.rs b/src/ifs/workspace_manager/ext_workspace_manager_v1.rs index 2f7d5b3af..b9276632b 100644 --- a/src/ifs/workspace_manager/ext_workspace_manager_v1.rs +++ b/src/ifs/workspace_manager/ext_workspace_manager_v1.rs @@ -77,6 +77,17 @@ impl ExtWorkspaceManagerV1Global { obj.announce_workspace(&dummy_output, &ws); } } + let hidden_workspaces: Vec<_> = client + .state + .workspaces + .lock() + .values() + .filter(|ws| ws.hidden.get() && ws.ext_workspaces.get(&obj.manager_id).is_none()) + .cloned() + .collect(); + for ws in hidden_workspaces { + obj.announce_workspace(&dummy_output, &ws); + } for output in client.state.root.outputs.lock().values() { obj.announce_output(output); } diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 56ee52724..fd6400722 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -21,6 +21,7 @@ use { sized::{BAR_SEPARATOR_WIDTH, Resizable}, }, video::{Connector, Transform}, + workspace::WorkspaceEmptyBehavior, }, std::{cell::Cell, ops::Deref, ptr, rc::Rc, time::Duration}, }; @@ -331,6 +332,10 @@ impl TestConfig { pub fn set_show_titles(&self, show: bool) -> TestResult { self.send(ClientMessage::SetShowTitles { show }) } + + pub fn set_workspace_empty_behavior(&self, behavior: WorkspaceEmptyBehavior) -> TestResult { + self.send(ClientMessage::SetWorkspaceEmptyBehavior { behavior }) + } } impl Drop for TestConfig { diff --git a/src/it/tests.rs b/src/it/tests.rs index dc28888c1..9ed826cd5 100644 --- a/src/it/tests.rs +++ b/src/it/tests.rs @@ -85,6 +85,7 @@ mod t0051_pointer_warp; mod t0052_bar; mod t0053_theme; mod t0054_subsurface_already_attached; +mod t0055_workspace_empty_behavior; pub trait TestCase: Sync { fn name(&self) -> &'static str; @@ -158,5 +159,6 @@ pub fn tests() -> Vec<&'static dyn TestCase> { t0052_bar, t0053_theme, t0054_subsurface_already_attached, + t0055_workspace_empty_behavior, } } diff --git a/src/it/tests/t0055_workspace_empty_behavior.rs b/src/it/tests/t0055_workspace_empty_behavior.rs new file mode 100644 index 000000000..7d1654224 --- /dev/null +++ b/src/it/tests/t0055_workspace_empty_behavior.rs @@ -0,0 +1,258 @@ +use { + crate::{ + backend::{ + BackendConnectorState, BackendEvent, ConnectorEvent, ConnectorKernelId, MonitorInfo, + }, + cmm::cmm_primaries::Primaries, + format::XRGB8888, + ifs::wl_output::OutputId, + it::{ + test_backend::TestConnector, + test_error::TestResult, + testrun::{DefaultSetup, TestRun}, + }, + utils::numcell::NumCell, + video::drm::ConnectorType, + }, + jay_config::workspace::WorkspaceEmptyBehavior, + std::{cell::RefCell, rc::Rc}, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + let ds = run.create_default_setup().await?; + test_preserve(&run, &ds).await?; + test_destroy_on_leave_timing(&run, &ds).await?; + test_hide_on_leave_timing(&run, &ds).await?; + test_hide_on_leave(&run, &ds).await?; + test_destroy(&run, &ds).await?; + test_hide(&run, &ds).await?; + test_restore_output_preference(&run, &ds).await?; + Ok(()) +} + +// preserve: switching away from an empty workspace keeps it listed and alive. +async fn test_preserve(run: &Rc, ds: &DefaultSetup) -> TestResult { + run.cfg.show_workspace(ds.seat.id(), "p1")?; + let after_p1 = run.state.workspaces.len(); + run.cfg + .set_workspace_empty_behavior(WorkspaceEmptyBehavior::Preserve)?; + run.cfg.show_workspace(ds.seat.id(), "p2")?; + tassert_eq!(run.state.workspaces.len(), after_p1 + 1); + tassert!(run.state.workspaces.contains("p1")); + tassert!(run.state.workspaces.contains("p2")); + tassert!(ds.output.workspaces.iter().any(|ws| ws.name == "p1")); + tassert!(ds.output.workspaces.iter().any(|ws| ws.name == "p2")); + Ok(()) +} + +// destroy-on-leave timing: leaving a non-empty workspace must not destroy it later if it becomes +// empty while inactive. It is destroyed only if it is empty at the moment you switch away. +async fn test_destroy_on_leave_timing(run: &Rc, ds: &DefaultSetup) -> TestResult { + run.cfg.show_workspace(ds.seat.id(), "dol1")?; + let client = run.create_client().await?; + let win = client.create_window().await?; + win.map().await?; + run.cfg + .set_workspace_empty_behavior(WorkspaceEmptyBehavior::DestroyOnLeave)?; + run.cfg.show_workspace(ds.seat.id(), "dol2")?; + tassert!(run.state.workspaces.contains("dol1")); + win.tl.core.destroy()?; + win.xdg.destroy()?; + win.surface.viewport.destroy()?; + win.surface.surface.destroy()?; + client.sync().await; + tassert!(run.state.workspaces.contains("dol1")); + tassert!(ds.output.workspaces.iter().any(|ws| ws.name == "dol1")); + run.cfg.show_workspace(ds.seat.id(), "dol1")?; + tassert!(run.state.workspaces.contains("dol1")); + run.cfg.show_workspace(ds.seat.id(), "dol2")?; + tassert!(run.state.workspaces.not_contains("dol1")); + tassert!(!ds.output.workspaces.iter().any(|ws| ws.name == "dol1")); + Ok(()) +} + +// hide-on-leave timing: leaving a non-empty workspace must not hide it later if it becomes empty +// while inactive. It is hidden only if it is empty at the moment you switch away. +async fn test_hide_on_leave_timing(run: &Rc, ds: &DefaultSetup) -> TestResult { + run.cfg.show_workspace(ds.seat.id(), "hol1")?; + let client = run.create_client().await?; + let win = client.create_window().await?; + win.map().await?; + run.cfg + .set_workspace_empty_behavior(WorkspaceEmptyBehavior::HideOnLeave)?; + run.cfg.show_workspace(ds.seat.id(), "hol2")?; + let hol1 = run.state.workspaces.get("hol1").unwrap(); + tassert!(!hol1.hidden.get()); + tassert!(ds.output.workspaces.iter().any(|ws| ws.name == "hol1")); + win.tl.core.destroy()?; + win.xdg.destroy()?; + win.surface.viewport.destroy()?; + win.surface.surface.destroy()?; + client.sync().await; + let hol1 = run.state.workspaces.get("hol1").unwrap(); + tassert!(!hol1.hidden.get()); + tassert!(run.state.workspaces.contains("hol1")); + tassert!(ds.output.workspaces.iter().any(|ws| ws.name == "hol1")); + run.cfg.show_workspace(ds.seat.id(), "hol1")?; + run.cfg.show_workspace(ds.seat.id(), "hol2")?; + let hol1 = run.state.workspaces.get("hol1").unwrap(); + tassert!(hol1.hidden.get()); + tassert!(run.state.workspaces.contains("hol1")); + tassert!(!ds.output.workspaces.iter().any(|ws| ws.name == "hol1")); + run.cfg.show_workspace(ds.seat.id(), "hol1")?; + let hol1 = run.state.workspaces.get("hol1").unwrap(); + tassert!(!hol1.hidden.get()); + tassert!(!hol1.output.get().is_dummy); + tassert!(ds.output.workspaces.iter().any(|ws| ws.name == "hol1")); + Ok(()) +} + +// hide-on-leave: switching away hides an empty workspace and showing it by name restores it. +async fn test_hide_on_leave(run: &Rc, ds: &DefaultSetup) -> TestResult { + run.cfg.show_workspace(ds.seat.id(), "h1")?; + run.cfg + .set_workspace_empty_behavior(WorkspaceEmptyBehavior::HideOnLeave)?; + run.cfg.show_workspace(ds.seat.id(), "h2")?; + let h1 = run.state.workspaces.get("h1").unwrap(); + tassert!(h1.hidden.get()); + tassert!(run.state.workspaces.contains("h1")); + tassert!(!ds.output.workspaces.iter().any(|ws| ws.name == "h1")); + run.cfg.show_workspace(ds.seat.id(), "h1")?; + let h1 = run.state.workspaces.get("h1").unwrap(); + tassert!(!h1.hidden.get()); + tassert!(!h1.output.get().is_dummy); + tassert!(ds.output.workspaces.iter().any(|ws| ws.name == "h1")); + Ok(()) +} + +// destroy: when an inactive workspace becomes empty, it is destroyed immediately. +async fn test_destroy(run: &Rc, ds: &DefaultSetup) -> TestResult { + run.cfg.show_workspace(ds.seat.id(), "d1")?; + run.cfg + .set_workspace_empty_behavior(WorkspaceEmptyBehavior::Destroy)?; + let client = run.create_client().await?; + let win = client.create_window().await?; + win.map().await?; + run.cfg.show_workspace(ds.seat.id(), "d2")?; + tassert!(run.state.workspaces.contains("d1")); + win.tl.core.destroy()?; + win.xdg.destroy()?; + win.surface.viewport.destroy()?; + win.surface.surface.destroy()?; + client.sync().await; + tassert!(run.state.workspaces.not_contains("d1")); + Ok(()) +} + +// hide: when an inactive workspace becomes empty, it becomes hidden and can be restored by name. +async fn test_hide(run: &Rc, ds: &DefaultSetup) -> TestResult { + run.cfg.show_workspace(ds.seat.id(), "hi1")?; + run.cfg + .set_workspace_empty_behavior(WorkspaceEmptyBehavior::Hide)?; + let client = run.create_client().await?; + let win = client.create_window().await?; + win.map().await?; + run.cfg.show_workspace(ds.seat.id(), "hi2")?; + win.tl.core.destroy()?; + win.xdg.destroy()?; + win.surface.viewport.destroy()?; + win.surface.surface.destroy()?; + client.sync().await; + let hi1 = run.state.workspaces.get("hi1").unwrap(); + tassert!(hi1.hidden.get()); + tassert!(run.state.workspaces.contains("hi1")); + tassert!(!ds.output.workspaces.iter().any(|ws| ws.name == "hi1")); + run.cfg.show_workspace(ds.seat.id(), "hi1")?; + let hi1 = run.state.workspaces.get("hi1").unwrap(); + tassert!(!hi1.hidden.get()); + tassert!(!hi1.output.get().is_dummy); + tassert!(ds.output.workspaces.iter().any(|ws| ws.name == "hi1")); + Ok(()) +} + +// restore output preference: hidden workspaces reopen on the connected output matching desired_output. +async fn test_restore_output_preference(run: &Rc, ds: &DefaultSetup) -> TestResult { + // Create a second output so we can set desired_output to a non-default connected output. + let bcs = BackendConnectorState { + serial: run.state.backend_connector_state_serials.next(), + enabled: true, + active: true, + mode: Default::default(), + non_desktop_override: None, + vrr: false, + tearing: false, + format: XRGB8888, + color_space: Default::default(), + eotf: Default::default(), + gamma_lut: Default::default(), + }; + let new_connector = Rc::new(TestConnector { + id: run.state.connector_ids.next(), + kernel_id: ConnectorKernelId { + ty: ConnectorType::VGA, + idx: 2, + }, + events: Default::default(), + feedback: Default::default(), + idle: Default::default(), + damage_calls: NumCell::new(0), + state: RefCell::new(bcs.clone()), + }); + let new_monitor_info = MonitorInfo { + modes: Some(vec![]), + output_id: OutputId::new("", "jay", "jay second connector", ""), + width_mm: 0, + height_mm: 0, + non_desktop: false, + non_desktop_effective: false, + vrr_capable: false, + eotfs: vec![], + color_spaces: vec![], + primaries: Primaries::SRGB, + luminance: None, + state: bcs, + }; + + // Hotplug the connector so the compositor creates an OutputNode for it. + run.backend + .state + .backend_events + .push(BackendEvent::NewConnector(new_connector.clone())); + new_connector + .events + .send_event(ConnectorEvent::Connected(new_monitor_info)); + run.sync().await; + + // Find the new OutputNode by connector id. + let output2 = run + .state + .root + .outputs + .lock() + .values() + .find(|o| o.global.connector.connector.id() == new_connector.id) + .unwrap() + .clone(); + run.cfg.show_workspace(ds.seat.id(), "r1")?; + let r1 = run.state.workspaces.get("r1").unwrap(); + + // Move the workspace to output2, updating desired_output, and ensure it is attached there. + run.state.move_ws_to_output(&r1, &output2); + run.state.show_workspace2(None, &output2, &r1); + run.cfg + .set_workspace_empty_behavior(WorkspaceEmptyBehavior::HideOnLeave)?; + + // Switching away from an empty r1 should hide it and keep desired_output pointing at output2. + let other = output2.create_workspace("other"); + run.state.show_workspace2(None, &output2, &other); + tassert!(r1.hidden.get()); + + // Restore by name via a different output argument; restoration must still prefer desired_output. + run.state.show_workspace2(None, &ds.output, &r1); + tassert!(!r1.hidden.get()); + tassert_eq!(r1.output.get().id, output2.id); + tassert!(output2.workspaces.iter().any(|ws| ws.name == "r1")); + Ok(()) +} diff --git a/src/state.rs b/src/state.rs index 30f2c5ad9..8c105e5c8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -109,8 +109,8 @@ use { ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, FoundNode, LatchListener, Node, NodeIds, NodeVisitorBase, OutputNode, PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, ToplevelNode, - ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, WorkspaceNode, - WsMoveConfig, generic_node_visitor, move_ws_to_output, + ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, WorkspaceEmptyBehavior, + WorkspaceNode, WsMoveConfig, generic_node_visitor, move_ws_to_output, }, udmabuf::UdmabufHolder, utils::{ @@ -296,6 +296,7 @@ pub struct State { pub enable_primary_selection: Cell, pub xdg_surface_configure_events: AsyncQueue, pub workspace_display_order: Cell, + pub workspace_empty_behavior: Cell, pub outputs_without_hc: NumCell, pub udmabuf: Rc, pub gfx_ctx_changed: EventSource, @@ -931,6 +932,48 @@ impl State { output: &Rc, ws: &Rc, ) { + let mut output = output.clone(); + if ws.hidden.get() { + let desired_output = ws.desired_output.get(); + let mut target = None; + for candidate in self.root.outputs.lock().values() { + if candidate.global.output_id == desired_output { + target = Some(candidate.clone()); + break; + } + } + if target.is_none() && !output.is_dummy { + target = Some(output.clone()); + } + if target.is_none() + && let Some(seat) = seat + { + let fallback = seat.get_fallback_output(); + if !fallback.is_dummy { + target = Some(fallback); + } + } + let Some(target) = target else { + return; + }; + if target.is_dummy { + return; + } + ws.hidden.set(false); + ws.set_output(&target); + let link = if self.workspace_display_order.get() == WorkspaceDisplayOrder::Sorted { + if let Some(before) = target.find_workspace_insertion_point(&ws.name) { + before.prepend(ws.clone()) + } else { + target.workspaces.add_last(ws.clone()) + } + } else { + target.workspaces.add_last(ws.clone()) + }; + *ws.output_link.borrow_mut() = Some(link); + ws.desired_output.set(target.global.output_id.clone()); + output = target; + } let mut pinned_is_focused = false; if let Some(seat) = seat { for pinned in output.pinned.iter() { @@ -1840,6 +1883,101 @@ impl State { self.trigger_cci(CCI_COMPOSITOR); } + pub fn set_workspace_empty_behavior(&self, behavior: WorkspaceEmptyBehavior) { + self.workspace_empty_behavior.set(behavior); + self.trigger_cci(CCI_COMPOSITOR); + if !matches!( + behavior, + WorkspaceEmptyBehavior::Destroy | WorkspaceEmptyBehavior::Hide + ) { + return; + } + let workspaces: Vec> = self.workspaces.lock().values().cloned().collect(); + for ws in workspaces { + self.enforce_workspace_empty_behavior(&ws); + } + } + + pub fn enforce_workspace_empty_behavior(&self, ws: &Rc) { + if ws.is_dummy { + return; + } + if !ws.is_empty() { + return; + } + if self.workspace_is_active(ws) { + return; + } + match self.workspace_empty_behavior.get() { + WorkspaceEmptyBehavior::Preserve => {} + WorkspaceEmptyBehavior::DestroyOnLeave => {} + WorkspaceEmptyBehavior::HideOnLeave => {} + WorkspaceEmptyBehavior::Destroy => self.destroy_empty_workspace(ws), + WorkspaceEmptyBehavior::Hide => self.hide_empty_workspace(ws), + } + } + + fn workspace_is_active(&self, ws: &WorkspaceNode) -> bool { + let output = ws.output.get(); + if let Some(active) = output.workspace.get() { + return active.id == ws.id; + } + false + } + + pub fn destroy_empty_workspace(&self, ws: &Rc) { + if ws.is_dummy { + return; + } + if !ws.is_empty() { + return; + } + if self.workspace_is_active(ws) { + return; + } + let output = ws.output.get(); + for jw in ws.jay_workspaces.lock().values() { + jw.send_destroyed(); + jw.workspace.set(None); + } + for wh in ws.ext_workspaces.lock().values() { + wh.handle_destroyed(); + } + ws.clear(); + self.workspaces.remove(&ws.name); + if !output.is_dummy { + output.schedule_update_render_data(); + self.tree_changed(); + } + } + + pub fn hide_empty_workspace(&self, ws: &Rc) { + if ws.is_dummy { + return; + } + if !ws.is_empty() { + return; + } + if self.workspace_is_active(ws) { + return; + } + let prev_output = ws.output.get(); + if prev_output.is_dummy { + return; + } + ws.desired_output.set(prev_output.global.output_id.clone()); + ws.output_link.borrow_mut().take(); + ws.hidden.set(true); + ws.set_visible(false); + let Some(dummy_output) = self.dummy_output.get() else { + return; + }; + ws.set_output(&dummy_output); + ws.flush_jay_workspaces(); + prev_output.schedule_update_render_data(); + self.tree_changed(); + } + fn spaces_changed(&self) { struct V; impl NodeVisitorBase for V { diff --git a/src/tree.rs b/src/tree.rs index b33abdc6a..e504a4c5f 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -28,9 +28,13 @@ use { utils::{linkedlist::NodeRef, numcell::NumCell, static_text::StaticText}, }, jay_config::{ - Direction as JayDirection, video::Transform as ConfigTransform, + Direction as JayDirection, + video::Transform as ConfigTransform, window::TileState as ConfigTileState, - workspace::WorkspaceDisplayOrder as ConfigWorkspaceDisplayOrder, + workspace::{ + WorkspaceDisplayOrder as ConfigWorkspaceDisplayOrder, + WorkspaceEmptyBehavior as ConfigWorkspaceEmptyBehavior, + }, }, linearize::{Linearize, LinearizeExt}, std::{ @@ -254,6 +258,52 @@ impl From for Direction { } } +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq, Default, Linearize)] +pub enum WorkspaceEmptyBehavior { + Preserve, + #[default] + DestroyOnLeave, + HideOnLeave, + Destroy, + Hide, +} + +impl From for WorkspaceEmptyBehavior { + fn from(value: ConfigWorkspaceEmptyBehavior) -> Self { + match value { + ConfigWorkspaceEmptyBehavior::Preserve => WorkspaceEmptyBehavior::Preserve, + ConfigWorkspaceEmptyBehavior::DestroyOnLeave => WorkspaceEmptyBehavior::DestroyOnLeave, + ConfigWorkspaceEmptyBehavior::HideOnLeave => WorkspaceEmptyBehavior::HideOnLeave, + ConfigWorkspaceEmptyBehavior::Destroy => WorkspaceEmptyBehavior::Destroy, + ConfigWorkspaceEmptyBehavior::Hide => WorkspaceEmptyBehavior::Hide, + } + } +} + +impl Into for WorkspaceEmptyBehavior { + fn into(self) -> ConfigWorkspaceEmptyBehavior { + match self { + WorkspaceEmptyBehavior::Preserve => ConfigWorkspaceEmptyBehavior::Preserve, + WorkspaceEmptyBehavior::DestroyOnLeave => ConfigWorkspaceEmptyBehavior::DestroyOnLeave, + WorkspaceEmptyBehavior::HideOnLeave => ConfigWorkspaceEmptyBehavior::HideOnLeave, + WorkspaceEmptyBehavior::Destroy => ConfigWorkspaceEmptyBehavior::Destroy, + WorkspaceEmptyBehavior::Hide => ConfigWorkspaceEmptyBehavior::Hide, + } + } +} + +impl StaticText for WorkspaceEmptyBehavior { + fn text(&self) -> &'static str { + match self { + WorkspaceEmptyBehavior::Preserve => "Preserve", + WorkspaceEmptyBehavior::DestroyOnLeave => "Destroy on Leave", + WorkspaceEmptyBehavior::HideOnLeave => "Hide on Leave", + WorkspaceEmptyBehavior::Destroy => "Destroy", + WorkspaceEmptyBehavior::Hide => "Hide", + } + } +} + pub struct NodeIds { next: NumCell, } diff --git a/src/tree/float.rs b/src/tree/float.rs index ccad4860d..7021e514b 100644 --- a/src/tree/float.rs +++ b/src/tree/float.rs @@ -904,6 +904,10 @@ impl ContainingNode for FloatNode { if self.visible.get() { self.state.damage(self.position.get()); } + let ws = self.workspace.get(); + if ws.is_empty() { + self.state.enforce_workspace_empty_behavior(&ws); + } } fn cnode_accepts_child(&self, _node: &dyn Node) -> bool { diff --git a/src/tree/output.rs b/src/tree/output.rs index 78b65c4fd..3f61aa9e7 100644 --- a/src/tree/output.rs +++ b/src/tree/output.rs @@ -48,8 +48,8 @@ use { tree::{ Direction, FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, NodeLocation, PinnedNode, StackedNode, TddType, TileDragDestination, Transform, - WorkspaceDisplayOrder, WorkspaceDragDestination, WorkspaceNode, WorkspaceNodeId, - walker::NodeVisitor, + WorkspaceDisplayOrder, WorkspaceDragDestination, WorkspaceEmptyBehavior, WorkspaceNode, + WorkspaceNodeId, walker::NodeVisitor, }, utils::{ asyncevent::AsyncEvent, @@ -699,15 +699,19 @@ impl OutputNode { pinned.deref().clone().set_workspace(ws, false); } if old.is_empty() { - for jw in old.jay_workspaces.lock().values() { - jw.send_destroyed(); - jw.workspace.set(None); - } - for wh in old.ext_workspaces.lock().values() { - wh.handle_destroyed(); + match self.state.workspace_empty_behavior.get() { + WorkspaceEmptyBehavior::Preserve => { + old.set_visible(false); + old.flush_jay_workspaces(); + } + WorkspaceEmptyBehavior::DestroyOnLeave => { + self.state.destroy_empty_workspace(&old) + } + WorkspaceEmptyBehavior::HideOnLeave => self.state.hide_empty_workspace(&old), + WorkspaceEmptyBehavior::Destroy | WorkspaceEmptyBehavior::Hide => { + self.state.enforce_workspace_empty_behavior(&old); + } } - old.clear(); - self.state.workspaces.remove(&old.name); } else { old.set_visible(false); old.flush_jay_workspaces(); diff --git a/src/tree/workspace.rs b/src/tree/workspace.rs index f60354a4b..e56252dea 100644 --- a/src/tree/workspace.rs +++ b/src/tree/workspace.rs @@ -58,6 +58,7 @@ pub struct WorkspaceNode { pub name: String, pub output_link: RefCell>>>, pub visible: Cell, + pub hidden: Cell, pub fullscreen: CloneCell>>, pub visible_on_desired_output: Cell, pub desired_output: CloneCell>, @@ -86,6 +87,7 @@ impl WorkspaceNode { name: name.to_string(), output_link: Default::default(), visible: Default::default(), + hidden: Default::default(), fullscreen: Default::default(), visible_on_desired_output: Default::default(), desired_output: CloneCell::new(output.global.output_id.clone()), @@ -105,6 +107,7 @@ impl WorkspaceNode { pub fn clear(&self) { self.container.set(None); *self.output_link.borrow_mut() = None; + self.hidden.set(false); self.fullscreen.set(None); self.jay_workspaces.clear(); self.ext_workspaces.clear(); @@ -464,12 +467,18 @@ impl ContainingNode for WorkspaceNode { self.discard_child_properties(&*container); self.container.set(None); self.state.damage(self.position.get()); + if self.is_empty() { + self.state.enforce_workspace_empty_behavior(&self); + } return; } if let Some(fs) = self.fullscreen.get() && fs.node_id() == child.node_id() { self.remove_fullscreen_node(); + if self.is_empty() { + self.state.enforce_workspace_empty_behavior(&self); + } return; } log::error!("Trying to remove child that's not a child"); @@ -567,6 +576,7 @@ pub fn move_ws_to_output( if target.node_visible() { target.state.damage(target.global.pos.get()); } + ws.state.enforce_workspace_empty_behavior(&ws); } pub struct WorkspaceDragDestination { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 90488e957..343baa712 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -35,7 +35,7 @@ use { theme::{BarPosition, Color}, video::{BlendSpace, ColorSpace, Eotf, Format, GfxApi, TearingMode, Transform, VrrMode}, window::{ContentType, TileState, WindowType}, - workspace::WorkspaceDisplayOrder, + workspace::{WorkspaceDisplayOrder, WorkspaceEmptyBehavior}, xwayland::XScalingMode, }, std::{ @@ -564,6 +564,7 @@ pub struct Config { pub simple_im: Option, pub fallback_output_mode: Option, pub mouse_follows_focus: Option, + pub workspace_empty_behavior: Option, } #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index 448c26b49..0651c3fad 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -53,6 +53,7 @@ mod window_match; mod window_rule; mod window_type; mod workspace_display_order; +mod workspace_empty_behavior; mod xwayland; #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index 9a150ecb5..ea57b51f6 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -40,6 +40,7 @@ use { vrr::VrrParser, window_rule::WindowRulesParser, workspace_display_order::WorkspaceDisplayOrderParser, + workspace_empty_behavior::WorkspaceEmptyBehaviorParser, xwayland::XwaylandParser, }, spanned::SpannedErrorExt, @@ -156,6 +157,7 @@ impl Parser for ConfigParser<'_> { clean_logs_older_than_val, mouse_follows_focus, ), + (workspace_empty_behavior_val,), ) = ext.extract(( ( opt(val("keymap")), @@ -217,6 +219,7 @@ impl Parser for ConfigParser<'_> { opt(val("clean-logs-older-than")), recover(opt(bol("unstable-mouse-follows-focus"))), ), + (opt(val("workspace-empty-behavior")),), ))?; let mut keymap = None; if let Some(value) = keymap_val { @@ -568,6 +571,18 @@ impl Parser for ConfigParser<'_> { } } } + let mut workspace_empty_behavior = None; + if let Some(value) = workspace_empty_behavior_val { + match value.parse(&mut WorkspaceEmptyBehaviorParser) { + Ok(v) => workspace_empty_behavior = Some(v), + Err(e) => { + log::warn!( + "Could not parse the workspace empty behavior: {}", + self.0.error(e) + ); + } + } + } Ok(Config { keymap, repeat_rate, @@ -618,6 +633,7 @@ impl Parser for ConfigParser<'_> { simple_im, fallback_output_mode, mouse_follows_focus: mouse_follows_focus.despan(), + workspace_empty_behavior, }) } } diff --git a/toml-config/src/config/parsers/workspace_empty_behavior.rs b/toml-config/src/config/parsers/workspace_empty_behavior.rs new file mode 100644 index 000000000..0afd38072 --- /dev/null +++ b/toml-config/src/config/parsers/workspace_empty_behavior.rs @@ -0,0 +1,35 @@ +use { + crate::{ + config::parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + toml::toml_span::{Span, SpannedExt}, + }, + jay_config::workspace::WorkspaceEmptyBehavior, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum WorkspaceEmptyBehaviorParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error("Unknown workspace empty behavior {0}")] + Unknown(String), +} + +pub struct WorkspaceEmptyBehaviorParser; + +impl Parser for WorkspaceEmptyBehaviorParser { + type Value = WorkspaceEmptyBehavior; + type Error = WorkspaceEmptyBehaviorParserError; + const EXPECTED: &'static [DataType] = &[DataType::String]; + + fn parse_string(&mut self, span: Span, string: &str) -> ParseResult { + match string { + "preserve" => Ok(WorkspaceEmptyBehavior::Preserve), + "destroy-on-leave" => Ok(WorkspaceEmptyBehavior::DestroyOnLeave), + "hide-on-leave" => Ok(WorkspaceEmptyBehavior::HideOnLeave), + "destroy" => Ok(WorkspaceEmptyBehavior::Destroy), + "hide" => Ok(WorkspaceEmptyBehavior::Hide), + _ => Err(WorkspaceEmptyBehaviorParserError::Unknown(string.to_string()).spanned(span)), + } + } +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 171361d99..ee05caf93 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -56,7 +56,7 @@ use { set_gfx_api, set_tearing_mode, set_vrr_cursor_hz, set_vrr_mode, }, window::Window, - workspace::set_workspace_display_order, + workspace::{set_workspace_display_order, set_workspace_empty_behavior}, xwayland::{set_x_scaling_mode, set_x_wayland_enabled}, }, run_on_drop::on_drop, @@ -1670,6 +1670,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Command { diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index b93db67a4..1e29c2373 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -1177,6 +1177,10 @@ "egui": { "description": "Sets the egui settings of the compositor.\n", "$ref": "#/$defs/Egui" + }, + "workspace-empty-behavior": { + "description": "Configures what happens to empty workspaces when you leave them or when they become inactive.\n\nThe default is `destroy-on-leave`.\n\n- Example:\n\n ```toml\n workspace-empty-behavior = \"hide-on-leave\"\n ```\n", + "$ref": "#/$defs/WorkspaceEmptyBehavior" } }, "required": [] @@ -2506,6 +2510,17 @@ "sorted" ] }, + "WorkspaceEmptyBehavior": { + "type": "string", + "description": "Configures what happens to empty workspaces when they are left or become inactive.\n\nThis is evaluated per-output.\n\n- \"leave\" means that the workspace stops being the active workspace on its output\n because another workspace was shown on that same output.\n- \"inactive\" means the workspace is currently not the active workspace on its output.\n", + "enum": [ + "preserve", + "destroy-on-leave", + "hide-on-leave", + "destroy", + "hide" + ] + }, "XScalingMode": { "type": "string", "description": "The scaling mode of X windows.\n\n- Example:\n\n ```toml\n xwayland = { scaling-mode = \"downscaled\" }\n ```\n", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 55aa4668f..be66d4571 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -2452,6 +2452,20 @@ The table has the following fields: The value of this field should be a [Egui](#types-Egui). +- `workspace-empty-behavior` (optional): + + Configures what happens to empty workspaces when you leave them or when they become inactive. + + The default is `destroy-on-leave`. + + - Example: + + ```toml + workspace-empty-behavior = "hide-on-leave" + ``` + + The value of this field should be a [WorkspaceEmptyBehavior](#types-WorkspaceEmptyBehavior). + ### `Connector` @@ -5613,6 +5627,67 @@ The string should have one of the following values: + +### `WorkspaceEmptyBehavior` + +Configures what happens to empty workspaces when they are left or become inactive. + +This is evaluated per-output. + +- "leave" means that the workspace stops being the active workspace on its output + because another workspace was shown on that same output. +- "inactive" means the workspace is currently not the active workspace on its output. + +Values of this type should be strings. + +The string should have one of the following values: + +- `preserve`: + + Never destroy or hide empty workspaces automatically. + + Empty workspaces remain present and keep showing up in workspace lists. + +- `destroy-on-leave`: + + Destroy an empty workspace when leaving it. + + The workspace is destroyed only if it is empty at the moment you switch to another + workspace on the same output. + +- `hide-on-leave`: + + Hide an empty workspace when leaving it. + + The workspace is hidden only if it is empty at the moment you switch to another + workspace on the same output. + + Hidden workspaces are not listed, but they can be restored by showing the workspace + by name. When restoring, the compositor prefers the output the workspace was last + shown on if it is still connected. + +- `destroy`: + + Destroy an empty workspace whenever it is empty and inactive. + + For example, if the last window on an inactive workspace is closed (or moved away), + the workspace is destroyed immediately. + + An active empty workspace is not destroyed until it becomes inactive. + +- `hide`: + + Hide an empty workspace whenever it is empty and inactive. + + For example, if the last window on an inactive workspace is closed (or moved away), + the workspace is hidden immediately. + + Hidden workspaces are not listed, but they can be restored by showing the workspace + by name. When restoring, the compositor prefers the output the workspace was last + shown on if it is still connected. + + + ### `XScalingMode` diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index c863177de..89ee10cce 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -3222,6 +3222,19 @@ Config: required: false description: | Sets the egui settings of the compositor. + workspace-empty-behavior: + ref: WorkspaceEmptyBehavior + required: false + description: | + Configures what happens to empty workspaces when you leave them or when they become inactive. + + The default is `destroy-on-leave`. + + - Example: + + ```toml + workspace-empty-behavior = "hide-on-leave" + ``` Idle: @@ -4697,3 +4710,55 @@ Egui: The list of monospace fonts. The default is `["monospace", "Noto Sans Mono", "Noto Color Emoji"]`. + + +WorkspaceEmptyBehavior: + kind: string + description: | + Configures what happens to empty workspaces when they are left or become inactive. + + This is evaluated per-output. + + - "leave" means that the workspace stops being the active workspace on its output + because another workspace was shown on that same output. + - "inactive" means the workspace is currently not the active workspace on its output. + values: + - value: preserve + description: | + Never destroy or hide empty workspaces automatically. + + Empty workspaces remain present and keep showing up in workspace lists. + - value: destroy-on-leave + description: | + Destroy an empty workspace when leaving it. + + The workspace is destroyed only if it is empty at the moment you switch to another + workspace on the same output. + - value: hide-on-leave + description: | + Hide an empty workspace when leaving it. + + The workspace is hidden only if it is empty at the moment you switch to another + workspace on the same output. + + Hidden workspaces are not listed, but they can be restored by showing the workspace + by name. When restoring, the compositor prefers the output the workspace was last + shown on if it is still connected. + - value: destroy + description: | + Destroy an empty workspace whenever it is empty and inactive. + + For example, if the last window on an inactive workspace is closed (or moved away), + the workspace is destroyed immediately. + + An active empty workspace is not destroyed until it becomes inactive. + - value: hide + description: | + Hide an empty workspace whenever it is empty and inactive. + + For example, if the last window on an inactive workspace is closed (or moved away), + the workspace is hidden immediately. + + Hidden workspaces are not listed, but they can be restored by showing the workspace + by name. When restoring, the compositor prefers the output the workspace was last + shown on if it is still connected.