From e85fc1e0325de2023f869af015b00053e9782dea Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 09:15:07 +0100 Subject: [PATCH] advance shell integration and session support --- README.md | 13 +- compositor/src/config.rs | 49 ++- compositor/src/handlers/xdg_shell.rs | 22 ++ compositor/src/input.rs | 51 ++- compositor/src/ipc.rs | 36 +- compositor/src/state.rs | 99 +++++- compositor/src/winit.rs | 91 ++---- crates/ferese-animation/src/lib.rs | 107 +++++- crates/ferese-core/src/workspace.rs | 2 +- crates/ferese-layout/src/lib.rs | 10 +- docs/ferese-theme-ui-spec.md | 8 +- docs/ferese-window-manager-spec.md | 2 +- docs/installation.md | 53 +++ docs/nested-resize-testing.md | 61 ++++ feresectl/src/main.rs | 13 +- packaging/config.toml | 14 + packaging/ferese-session | 41 +++ packaging/ferese.desktop | 6 + scripts/install-session.sh | 57 ++++ shell/ferese-shell/Cargo.toml | 4 +- shell/ferese-shell/assets/icons/battery.svg | 5 + shell/ferese-shell/assets/icons/overview.svg | 6 + shell/ferese-shell/assets/icons/volume.svg | 5 + shell/ferese-shell/assets/icons/wifi.svg | 6 + shell/ferese-shell/src/config.rs | 251 +++++++++++++- shell/ferese-shell/src/control.rs | 29 +- shell/ferese-shell/src/main.rs | 325 ++++++++++++++----- 27 files changed, 1165 insertions(+), 201 deletions(-) create mode 100644 docs/installation.md create mode 100644 docs/nested-resize-testing.md create mode 100644 packaging/config.toml create mode 100644 packaging/ferese-session create mode 100644 packaging/ferese.desktop create mode 100644 scripts/install-session.sh create mode 100644 shell/ferese-shell/assets/icons/battery.svg create mode 100644 shell/ferese-shell/assets/icons/overview.svg create mode 100644 shell/ferese-shell/assets/icons/volume.svg create mode 100644 shell/ferese-shell/assets/icons/wifi.svg diff --git a/README.md b/README.md index a492719..4ec07ca 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # Ferese -Ferese is an experimental Wayland scrolling compositor written in Rust with +Ferese is a Wayland scrolling compositor written in Rust with Smithay. Scrolling columns are the default, tree tiling remains available as an optional per-workspace mode, and geometry changes use timestamp-driven animation. See the [window-manager specification](docs/ferese-window-manager-spec.md) for the current architecture and milestones. +For a versioned installation alongside Plasma in SDDM, see +[installation and recovery](docs/installation.md). `Super+Shift+E` or +`feresectl exit` logs out immediately; save your work first. + ## Development Install the native dependencies required by Smithay's winit and GLES backends, @@ -24,6 +28,10 @@ cargo run -p ferese -- foot Set `RUST_LOG=ferese=debug` for detailed compositor logging. +`Super+F` toggles decorated maximization inside the workspace (bar and outer +gaps retained). `Super+Shift+F` toggles true fullscreen (no bar or window +decorations). Leaving fullscreen restores the previous maximized/tiled state. + Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under the `ferese::render` tracing target. Summaries include rendered frames, damaged pixels, average and longest render time, DRM missed-deadline totals, blur-active @@ -76,6 +84,7 @@ them for a lone tiled window: ```toml [layout] +outer_gap = 4.0 smart_gaps = true ``` @@ -245,7 +254,7 @@ cargo run -p feresectl -- get-outputs ``` The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, -`toggle-floating`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, +`toggle-floating`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `close`, `get-focused-window`, and `get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The runtime directory and socket are restricted to modes `0700` and `0600`, and the diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 38d2825..6280ee3 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -110,6 +110,7 @@ pub struct ThemeSettings { pub border_color: RgbaColor, pub accent_color: RgbaColor, pub shadow_color: RgbaColor, + pub surface_base_color: RgbaColor, pub window_radius: f64, pub shadow_offset_y: f64, pub shadow_blur: f64, @@ -146,6 +147,8 @@ struct ThemeMaterialConfig { #[derive(Debug, Deserialize)] struct ThemeColorsConfig { + #[serde(default = "default_surface_base_color")] + surface_base: String, #[serde(default = "default_border_color")] border: String, #[serde(default = "default_accent_color")] @@ -157,6 +160,7 @@ struct ThemeColorsConfig { impl Default for ThemeColorsConfig { fn default() -> Self { Self { + surface_base: default_surface_base_color(), border: default_border_color(), accent: default_accent_color(), shadow: default_shadow_color(), @@ -235,12 +239,14 @@ struct BindingModifiers { pub enum BindingAction { Spawn(Vec), Close, + Exit, Focus(ferese_layout::Direction), Move(ferese_layout::Direction), Resize(ferese_layout::Direction), SwitchWorkspace(u8), MoveToWorkspace(u8), ToggleFullscreen, + ToggleMaximized, ToggleLayout, CycleColumnWidth, CenterColumn, @@ -803,6 +809,10 @@ impl Config { border_color: parse_color(&self.theme.colors.border, "colors.border")?, accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?, shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?, + surface_base_color: parse_color( + &self.theme.colors.surface_base, + "colors.surface_base", + )?, window_radius, shadow_offset_y, shadow_blur, @@ -1059,6 +1069,10 @@ fn parse_action( no_argument()?; Ok(BindingAction::Close) } + "exit" => { + no_argument()?; + Ok(BindingAction::Exit) + } "focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)), "move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)), "resize" => Ok(BindingAction::Resize( @@ -1070,6 +1084,10 @@ fn parse_action( "move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace( required_argument()?, )?)), + "toggle-maximized" => { + no_argument()?; + Ok(BindingAction::ToggleMaximized) + } "toggle-fullscreen" => { no_argument()?; Ok(BindingAction::ToggleFullscreen) @@ -1133,7 +1151,8 @@ fn default_bindings() -> Vec { let mut bindings = vec![ binding("Super+Enter", "spawn", Some("terminal")), binding("Super+Q", "close", None), - binding("Super+F", "toggle-fullscreen", None), + binding("Super+F", "toggle-maximized", None), + binding("Super+Shift+F", "toggle-fullscreen", None), binding("Super+M", "toggle-layout", None), binding("Super+R", "cycle-column-width", None), binding("Super+C", "center-column", None), @@ -1141,6 +1160,7 @@ fn default_bindings() -> Vec { binding("Super+]", "expel", None), binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Tab", "toggle-overview", None), + binding("Super+Shift+E", "exit", None), ]; for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] { @@ -1283,13 +1303,17 @@ const fn default_inner_gap() -> f64 { } const fn default_outer_gap() -> f64 { - 10.0 + 4.0 } fn default_border_color() -> String { "#FFFFFF18".to_owned() } +fn default_surface_base_color() -> String { + "#111821".to_owned() +} + fn default_accent_color() -> String { "#5B8CFF".to_owned() } @@ -1574,6 +1598,7 @@ mod tests { border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]), accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]), shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]), + surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]), window_radius: 12.0, shadow_offset_y: -2.0, shadow_blur: 24.0, @@ -1628,7 +1653,21 @@ mod tests { let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 40); + assert_eq!(bindings.len(), 42); + for (shift, action) in [ + (false, BindingAction::ToggleMaximized), + (true, BindingAction::ToggleFullscreen), + ] { + assert!(bindings.iter().any(|binding| binding.modifiers.logo + && binding.modifiers.shift == shift + && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_f) + && binding.action == action)); + } + assert!( + bindings + .iter() + .any(|binding| binding.action == BindingAction::Exit) + ); assert!(bindings.iter().any(|binding| { binding.modifiers.logo && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return) @@ -1650,7 +1689,7 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 40); + assert_eq!(bindings.len(), 42); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec![ @@ -1662,7 +1701,7 @@ mod tests { let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 39); + assert_eq!(bindings.len(), 41); assert!( !bindings .iter() diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 75c1398..9f45f3f 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -198,6 +198,28 @@ impl XdgShellHandler for Ferese { ); } + fn maximize_request(&mut self, surface: ToplevelSurface) { + if let Some(id) = self.window_ids.iter().find_map(|(window, id)| { + window + .toplevel() + .is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface()) + .then_some(*id) + }) { + self.set_window_maximized(id, true); + } + } + + fn unmaximize_request(&mut self, surface: ToplevelSurface) { + if let Some(id) = self.window_ids.iter().find_map(|(window, id)| { + window + .toplevel() + .is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface()) + .then_some(*id) + }) { + self.set_window_maximized(id, false); + } + } + fn fullscreen_request( &mut self, surface: ToplevelSurface, diff --git a/compositor/src/input.rs b/compositor/src/input.rs index fc937fb..584acd8 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -44,6 +44,14 @@ impl Ferese { } let symbol = keysym.modified_sym().raw(); + if overview_escape(symbol, data.overview.is_active()) { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + data.set_overview_active(false); + } + + return FilterResult::Intercept(()); + } if emergency_shortcut_escape(symbol, modifiers.ctrl, modifiers.alt) { if state == KeyState::Pressed { data.intercepted_keys.insert(keycode); @@ -168,11 +176,29 @@ impl Ferese { let serial = SERIAL_COUNTER.next_serial(); if self.overview.is_active() { - if event.state() == ButtonState::Pressed - && let Some(window) = self.window_under_visual(pointer.current_location()) - && let Some(id) = self.window_ids.get(&window).copied() - { - self.select_overview_window(id); + let position = pointer.current_location(); + if self.layer_under(position).is_some() { + pointer.button( + self, + &ButtonEvent { + button: event.button_code(), + state: event.state(), + serial, + time: event.time() as u32, + }, + ); + pointer.frame(self); + return; + } + + if event.state() == ButtonState::Pressed { + if let Some(window) = self.window_under_visual(position) + && let Some(id) = self.window_ids.get(&window).copied() + { + self.select_overview_window(id); + } else { + self.set_overview_active(false); + } } return; @@ -465,6 +491,7 @@ impl Ferese { } } BindingAction::Close => self.close_focused_window(), + BindingAction::Exit => self.loop_signal.stop(), BindingAction::Focus(direction) => self.focus_direction(direction), BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction), @@ -475,6 +502,7 @@ impl Ferese { self.move_focused_to_workspace(u32::from(workspace)); } BindingAction::ToggleFullscreen => self.toggle_focused_fullscreen(), + BindingAction::ToggleMaximized => self.toggle_focused_maximized(), BindingAction::ToggleLayout => self.toggle_layout_mode(), BindingAction::CycleColumnWidth => self.cycle_focused_column_width(), BindingAction::CenterColumn => self.center_focused_column(), @@ -503,9 +531,20 @@ fn emergency_shortcut_escape(symbol: u32, ctrl: bool, alt: bool) -> bool { ctrl && alt && symbol == keysyms::KEY_Escape } +fn overview_escape(symbol: u32, overview_active: bool) -> bool { + overview_active && symbol == keysyms::KEY_Escape +} + #[cfg(test)] mod tests { - use super::{emergency_shortcut_escape, virtual_terminal}; + use super::{emergency_shortcut_escape, overview_escape, virtual_terminal}; + + #[test] + fn escape_closes_only_an_active_overview() { + assert!(overview_escape(keysyms::KEY_Escape, true)); + assert!(!overview_escape(keysyms::KEY_Escape, false)); + assert!(!overview_escape(keysyms::KEY_q, true)); + } #[test] fn emergency_escape_requires_control_alt_escape() { diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index bbfda9f..3aea5f6 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -16,7 +16,7 @@ use ferese_core::LayoutMode; use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; use ferese_layout::Direction; use serde_json::{Value, json}; -use smithay::reexports::calloop::{EventLoop, channel}; +use smithay::reexports::calloop::{EventLoop, LoopSignal, channel}; use smithay::utils::Transform; use crate::{Ferese, config::OutputTransform}; @@ -84,15 +84,20 @@ pub(crate) fn init( } })?; + let signal = event_loop.get_signal(); thread::Builder::new() .name("ferese-ipc-listener".to_owned()) - .spawn(move || accept_connections(listener, sender))?; + .spawn(move || accept_connections(listener, sender, signal))?; tracing::info!(path = %path.display(), "Ferese IPC is accepting connections"); Ok(guard) } -fn accept_connections(listener: UnixListener, sender: channel::SyncSender) { +fn accept_connections( + listener: UnixListener, + sender: channel::SyncSender, + signal: LoopSignal, +) { let active_connections = Arc::new(AtomicUsize::new(0)); loop { @@ -125,11 +130,12 @@ fn accept_connections(listener: UnixListener, sender: channel::SyncSender) -> Option }) } -fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender) { +fn serve_connection( + mut stream: UnixStream, + sender: channel::SyncSender, + signal: LoopSignal, +) { loop { - let request = match read_frame(&mut stream) { + let request: Request = match read_frame(&mut stream) { Ok(request) => request, Err(ferese_ipc::FrameError::Io(error)) if matches!( @@ -174,6 +184,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender } }; let (response, receiver) = sync_channel(1); + let exit_requested = request.command == "exit"; let call = IpcCall { request, response }; if sender.try_send(call).is_err() { tracing::warn!("disconnecting IPC client because the request queue is full"); @@ -182,10 +193,17 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender let Ok(response) = receiver.recv() else { return; }; + let exit_accepted = exit_requested && response.error.is_none(); if let Err(error) = write_frame(&mut stream, &response) { tracing::debug!(%error, "IPC client disconnected before receiving its response"); return; } + if exit_accepted { + // Acknowledge before stopping so feresectl never races process exit. + signal.stop(); + signal.wakeup(); + return; + } } } @@ -203,6 +221,7 @@ impl Ferese { fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result { match command { + "exit" => {} // The IPC worker stops the loop after writing the response. "focus" => self.focus_direction(direction_arg(args)?), "move" => self.move_direction(direction_arg(args)?), "resize" => self.resize_direction(direction_arg(args)?), @@ -210,6 +229,7 @@ impl Ferese { "move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?), "toggle-floating" => self.toggle_focused_floating(), "toggle-fullscreen" => self.toggle_focused_fullscreen(), + "toggle-maximized" => self.toggle_focused_maximized(), "toggle-layout" => self.toggle_layout_mode(), "cycle-column-width" => self.cycle_focused_column_width(), "center-column" => self.center_focused_column(), @@ -608,7 +628,9 @@ mod tests { fn malformed_ipc_connection_is_closed_without_reaching_the_request_queue() { let (mut client, server) = UnixStream::pair().unwrap(); let (sender, _receiver) = channel::sync_channel(1); - let worker = thread::spawn(move || serve_connection(server, sender)); + let event_loop = EventLoop::<()>::try_new().unwrap(); + let signal = event_loop.get_signal(); + let worker = thread::spawn(move || serve_connection(server, sender, signal)); let invalid = b"not-json"; client diff --git a/compositor/src/state.rs b/compositor/src/state.rs index bc03d9c..a08f869 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -6,7 +6,7 @@ use std::{ time::{Duration, Instant}, }; -use ferese_animation::{AnimatedValue, ClientSize, SpringConfig, WindowGeometry}; +use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry}; use ferese_core::{ LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, WorkspaceSet, @@ -118,6 +118,7 @@ pub struct Ferese { output_identity_ids: HashMap, pub window_ids: HashMap, pub window_geometry: HashMap, + maximized_windows: HashSet, window_stack: crate::stacking::WindowStack, pub(crate) window_borders: HashMap, pub(crate) window_shadows: HashMap, @@ -446,6 +447,7 @@ impl Ferese { output_identity_ids: HashMap::new(), window_ids: HashMap::new(), window_geometry: HashMap::new(), + maximized_windows: HashSet::new(), window_stack: crate::stacking::WindowStack::default(), window_borders: HashMap::new(), window_shadows: HashMap::new(), @@ -619,6 +621,9 @@ impl Ferese { let map = layer_map_for_output(output); for requested in layers { + if *requested == Layer::Top && self.output_has_fullscreen(output) { + continue; + } for layer in map.layers_on(*requested).rev() { let geometry = map.layer_geometry(layer)?; let layer_position = output_position - geometry.loc.to_f64(); @@ -901,6 +906,7 @@ impl Ferese { self.space.unmap_elem(window); self.window_geometry.remove(&id); + self.maximized_windows.remove(&id); self.window_stack.remove(id); self.window_borders.remove(&id); self.window_shadows.remove(&id); @@ -996,8 +1002,12 @@ impl Ferese { continue; } + let is_maximized = + self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id); let rect = if workspace_fullscreen == Some(*id) { fullscreen_bounds + } else if is_maximized { + maximized_rect(bounds, self.gap_config.outer) } else { match self.workspaces.placement(*id) { Some(WindowPlacement::Tiled) => { @@ -1017,7 +1027,7 @@ impl Ferese { ); visible.insert(*id); - let scrolling = if !is_fullscreen && !is_floating { + let scrolling = if !is_fullscreen && !is_maximized && !is_floating { viewport_target .zip(viewport_current) .map(|(target, current)| (workspace_id, rect.x + target, current)) @@ -1030,6 +1040,7 @@ impl Ferese { *id, rect, is_fullscreen, + is_maximized, is_floating, scrolling, pending_column_width_cycles.contains(id) && viewport_target_changed, @@ -1053,7 +1064,9 @@ impl Ferese { let mut scrolling_world_x = HashMap::new(); placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id)); - for (window, id, rect, is_fullscreen, is_floating, scrolling, couple_width) in placements { + for (window, id, rect, is_fullscreen, is_maximized, is_floating, scrolling, couple_width) in + placements + { // A viewport-coupled width must never override fullscreen/floating geometry. if scrolling.is_none() { self.viewport_coupled_widths.remove(&id); @@ -1064,7 +1077,14 @@ impl Ferese { .window_geometry .entry(id) .or_insert_with(|| WindowGeometry::new(rect, committed_size)); - let mut requested_size = geometry.set_presentation_target(rect, is_fullscreen, now); + let mode = if is_fullscreen { + PresentationMode::Fullscreen + } else if is_maximized { + PresentationMode::Maximized + } else { + PresentationMode::Normal + }; + let mut requested_size = geometry.set_presentation_mode(rect, mode, now); if !self.animations_enabled { geometry.advance(Duration::ZERO, self.spring_config, false); requested_size = geometry.presentation_size_request(now).or(requested_size); @@ -1132,6 +1152,11 @@ impl Ferese { state.states.unset(xdg_toplevel::State::Fullscreen) }; + let maximized_changed = if is_maximized { + state.states.set(xdg_toplevel::State::Maximized) + } else { + state.states.unset(xdg_toplevel::State::Maximized) + }; let tiled = !is_floating && !is_fullscreen; let tiled_changed = if tiled { state.states.set(xdg_toplevel::State::TiledLeft) @@ -1145,7 +1170,7 @@ impl Ferese { | state.states.unset(xdg_toplevel::State::TiledBottom) }; - let decoration_mode = if is_floating && !is_fullscreen { + let decoration_mode = if is_floating && !is_fullscreen && !is_maximized { DecorationMode::ClientSide } else { DecorationMode::ServerSide @@ -1153,7 +1178,7 @@ impl Ferese { let decoration_changed = state.decoration_mode != Some(decoration_mode); state.decoration_mode = Some(decoration_mode); - fullscreen_changed || tiled_changed || decoration_changed + fullscreen_changed || maximized_changed || tiled_changed || decoration_changed }); if requested_size.is_some() || state_changed { @@ -1600,6 +1625,42 @@ impl Ferese { self.relayout(); } + pub fn toggle_focused_maximized(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + // Super+F from true fullscreen enters decorated maximization. + let fullscreen = self + .workspaces + .workspace_for_window(window) + .and_then(|workspace| self.workspaces.workspace(workspace)) + .is_some_and(|workspace| workspace.fullscreen == Some(window)); + let enabled = fullscreen || !self.maximized_windows.contains(&window); + self.set_window_maximized(window, enabled); + } + + pub fn set_window_maximized(&mut self, window: WindowId, enabled: bool) { + if self.workspaces.workspace_for_window(window).is_none() { + return; + } + if enabled { + let _ = self.workspaces.set_fullscreen(window, false); + self.maximized_windows.insert(window); + } else { + self.maximized_windows.remove(&window); + } + self.relayout(); + } + + pub(crate) fn output_has_fullscreen(&self, output: &Output) -> bool { + !self.overview.is_presenting() + && self + .output_id(output) + .and_then(|id| self.output_workspaces.active_workspace(id)) + .and_then(|workspace| self.workspaces.workspace(workspace)) + .is_some_and(|workspace| workspace.fullscreen.is_some()) + } + pub fn close_focused_window(&mut self) { let Some(focused) = self.focused_window else { return; @@ -1950,6 +2011,18 @@ impl Ferese { } } +fn maximized_rect(bounds: Rect, gap: f64) -> Rect { + let gap = gap + .max(0.0) + .min(((bounds.width.min(bounds.height) - 1.0) / 2.0).max(0.0)); + Rect::new( + bounds.x + gap, + bounds.y + gap, + (bounds.width - 2.0 * gap).max(1.0), + (bounds.height - 2.0 * gap).max(1.0), + ) +} + fn centered_floating_rect(bounds: Rect) -> Rect { let width = (bounds.width * 0.6).max(1.0); let height = (bounds.height * 0.6).max(1.0); @@ -2017,6 +2090,20 @@ impl ClientData for ClientState { mod tests { use super::*; + #[test] + fn maximization_respects_layer_exclusion_and_outer_gaps() { + assert_eq!( + maximized_rect(Rect::new(0.0, 48.0, 1280.0, 752.0), 10.0), + Rect::new(10.0, 58.0, 1260.0, 732.0) + ); + assert_eq!( + maximized_rect(Rect::new(1600.0, 36.0, 1280.0, 764.0), 6.0), + Rect::new(1606.0, 42.0, 1268.0, 752.0) + ); + let tiny = maximized_rect(Rect::new(0.0, 0.0, 5.0, 3.0), 10.0); + assert!(tiny.width >= 1.0 && tiny.height >= 1.0); + } + #[test] fn transient_geometry_is_centered_and_bounded_by_its_parent() { let parent = Rect::new(100.0, 50.0, 1_000.0, 800.0); diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 22527f8..bc99b5a 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -40,9 +40,9 @@ use smithay::{ }, input::pointer::{CursorImageStatus, CursorImageSurfaceData}, output::{Mode, Output, PhysicalProperties, Scale, Subpixel}, - reexports::calloop::EventLoop, reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind, reexports::wayland_server::Resource, + reexports::calloop::EventLoop, utils::{ Buffer, Clock, Logical, Monotonic, Physical, Point, Rectangle, Scale as RenderScale, Size, Transform, @@ -871,12 +871,12 @@ fn output_elements( } else { Vec::new() }; - elements.extend(layer_elements( - state, - renderer, - output, - &[Layer::Overlay, Layer::Top], - )); + let upper_layers: &[Layer] = if state.output_has_fullscreen(output) { + &[Layer::Overlay] + } else { + &[Layer::Overlay, Layer::Top] + }; + elements.extend(layer_elements(state, renderer, output, upper_layers)); let windows = state .space .elements() @@ -1452,30 +1452,26 @@ fn layer_elements( let mut elements = Vec::new(); for (geometry, layer) in layers { - let target = geometry.to_physical_precise_round(scale); - let surface_elements = AsRenderElements::::render_elements::< - WaylandSurfaceRenderElement, - >(&layer, renderer, target.loc, scale.into(), 1.0); - let source_bounds = surface_elements - .iter() - .map(|element| element.geometry(scale.into())) - .reduce(Rectangle::merge); - let content_scale = source_bounds - .map(|source| layer_content_scale(source.size, target.size)) - .unwrap_or_else(|| RenderScale::from(1.0)); - let scale_origin = source_bounds.map_or(target.loc, |source| source.loc); - let crop = target.intersection(output_crop).unwrap_or_default(); + elements.extend( + AsRenderElements::::render_elements::< + WaylandSurfaceRenderElement, + >( + &layer, + renderer, + geometry.loc.to_physical_precise_round(scale), + scale.into(), + 1.0, + ) + .into_iter() + .filter_map(|element| { + let origin = Point::::default(); + let element = RescaleRenderElement::from_element(element, origin, 1.0); + let element = + RelocateRenderElement::from_element(element, origin, Relocate::Relative); - elements.extend(surface_elements.into_iter().filter_map(|element| { - let element = RescaleRenderElement::from_element(element, scale_origin, content_scale); - let element = RelocateRenderElement::from_element( - element, - Point::::default(), - Relocate::Relative, - ); - - CropRenderElement::from_element(element, scale, crop).map(Into::into) - })); + CropRenderElement::from_element(element, scale, output_crop).map(Into::into) + }), + ); if let Some(material) = material_element(state, renderer, output, &layer, geometry) { elements.push(material); @@ -1485,20 +1481,6 @@ fn layer_elements( elements } -fn layer_content_scale( - source: Size, - target: Size, -) -> RenderScale { - if source.w <= 0 || source.h <= 0 || target.w <= 0 || target.h <= 0 { - return RenderScale::from(1.0); - } - - RenderScale::from(( - f64::from(target.w) / f64::from(source.w), - f64::from(target.h) / f64::from(source.h), - )) -} - fn material_element( state: &mut Ferese, renderer: &mut GlesRenderer, @@ -1519,7 +1501,8 @@ fn material_element( expanded_blur_region(geometry, material.blur.ceil() as i32, output_logical); let sample_physical = sample_geometry.to_physical_precise_round(scale); let transform = output.current_transform().invert(); - let tint = [0.067, 0.094, 0.129, material.opacity]; + let [red, green, blue, _] = state.theme_settings.surface_base_color.0; + let tint = [red, green, blue, material.opacity]; let parameters = MaterialParameters { geometry, tint, @@ -1831,8 +1814,7 @@ mod tests { use super::{ color_with_alpha, expanded_blur_region, framebuffer_capture_rect, framebuffer_clip_rect, - layer_content_scale, normalized_scale, rounded_visual_rect, scaled_visual_rect, - shadow_bounds, + normalized_scale, rounded_visual_rect, scaled_visual_rect, shadow_bounds, }; #[derive(Debug)] @@ -1885,21 +1867,6 @@ mod tests { assert_eq!(normalized_scale(f64::INFINITY), 1.0); } - #[test] - fn layer_content_tracks_new_allocation_before_the_client_repaints() { - let scale = layer_content_scale((1_000, 40).into(), (1_600, 40).into()); - - assert_eq!(scale, Scale::from((1.6, 1.0))); - } - - #[test] - fn layer_content_scale_ignores_empty_allocations() { - assert_eq!( - layer_content_scale((0, 40).into(), (1_600, 40).into()), - Scale::from(1.0) - ); - } - #[test] fn clip_rect_matches_smithay_gles_projection() { let geometry = Rectangle::::new((10, 5).into(), (30, 40).into()); diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs index ea3cfd8..cbb8a42 100644 --- a/crates/ferese-animation/src/lib.rs +++ b/crates/ferese-animation/src/lib.rs @@ -267,6 +267,13 @@ impl ClientGeometry { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PresentationMode { + Normal, + Maximized, + Fullscreen, +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct WindowGeometry { pub logical: Rect, @@ -274,11 +281,11 @@ pub struct WindowGeometry { pub client: ClientGeometry, pub decorations: f64, pub presentation_changed: bool, - fullscreen: bool, + mode: PresentationMode, zoom: Option, } -/// Fullscreen geometry and decorations use one clock, including on reversal. +/// Maximized/fullscreen geometry and decorations share a clock, including on reversal. #[derive(Clone, Copy, Debug, PartialEq)] struct ZoomTransition { from: Rect, @@ -300,7 +307,7 @@ impl WindowGeometry { }, decorations: 1.0, presentation_changed: true, - fullscreen: false, + mode: PresentationMode::Normal, zoom: None, } } @@ -319,9 +326,26 @@ impl WindowGeometry { rect: Rect, fullscreen: bool, now: Duration, + ) -> Option { + self.set_presentation_mode( + rect, + if fullscreen { + PresentationMode::Fullscreen + } else { + PresentationMode::Normal + }, + now, + ) + } + + pub fn set_presentation_mode( + &mut self, + rect: Rect, + mode: PresentationMode, + now: Duration, ) -> Option { let rect = normalized_rect(rect); - if self.fullscreen != fullscreen || (self.zoom.is_some() && self.logical != rect) { + if self.mode != mode || (self.zoom.is_some() && self.logical != rect) { let mut progress = AnimatedValue::new(0.0); progress.set_target(1.0); let from = self.visual.current; @@ -350,7 +374,7 @@ impl WindowGeometry { progress, }); } - self.fullscreen = fullscreen; + self.mode = mode; self.logical = rect; self.visual.set_target(rect); self.presentation_size_request(now) @@ -368,14 +392,14 @@ impl WindowGeometry { } pub fn is_fullscreen(&self) -> bool { - self.fullscreen + self.mode == PresentationMode::Fullscreen } pub fn follow_pointer(&mut self, rect: Rect, now: Duration) -> Option { self.zoom = None; let requested_size = self.set_logical_target(rect, now); self.visual.snap(); - self.decorations = if self.fullscreen { 0.0 } else { 1.0 }; + self.decorations = if self.is_fullscreen() { 0.0 } else { 1.0 }; self.presentation_changed = true; requested_size } @@ -400,7 +424,7 @@ impl WindowGeometry { ) -> bool { if !animations_enabled { self.visual.snap(); - self.decorations = if self.fullscreen { 0.0 } else { 1.0 }; + self.decorations = if self.is_fullscreen() { 0.0 } else { 1.0 }; self.zoom = None; return false; } @@ -437,7 +461,11 @@ impl WindowGeometry { }; self.decorations = lerp( zoom.decorations_from, - if self.fullscreen { 0.0 } else { 1.0 }, + if self.mode == PresentationMode::Fullscreen { + 0.0 + } else { + 1.0 + }, ); if !active { self.zoom = None; @@ -544,6 +572,67 @@ fn finite_or(value: f64, fallback: f64) -> f64 { mod tests { use super::*; + #[test] + fn maximized_zoom_keeps_decorations_and_restores_through_fullscreen() { + let normal = Rect::new(800.0, 50.0, 600.0, 700.0); + let maximized = Rect::new(10.0, 50.0, 1580.0, 740.0); + let fullscreen = Rect::new(0.0, 0.0, 1600.0, 800.0); + let mut geometry = WindowGeometry::new(normal, None); + for (mode, rect) in [ + (PresentationMode::Maximized, maximized), + (PresentationMode::Fullscreen, fullscreen), + (PresentationMode::Maximized, maximized), + (PresentationMode::Normal, normal), + ] { + let before = geometry.visual.current; + let decorations = geometry.decorations; + geometry.set_presentation_mode(rect, mode, Duration::ZERO); + assert_eq!(geometry.visual.current, before); + assert_eq!(geometry.decorations, decorations); + for _ in 0..1000 { + if !geometry.advance(Duration::from_millis(8), SpringConfig::default(), true) { + break; + } + if mode != PresentationMode::Fullscreen && decorations == 1.0 { + assert_eq!(geometry.decorations, 1.0); + } + } + assert_eq!(geometry.visual.current, rect); + assert_eq!( + geometry.decorations, + if mode == PresentationMode::Fullscreen { + 0.0 + } else { + 1.0 + } + ); + assert!(!geometry.is_zooming()); + assert_eq!( + geometry.is_fullscreen(), + mode == PresentationMode::Fullscreen + ); + } + } + + #[test] + fn reversing_maximization_preserves_visual_position_and_decoration() { + let normal = Rect::new(800.0, 50.0, 600.0, 700.0); + let mut geometry = WindowGeometry::new(normal, None); + geometry.set_presentation_mode( + Rect::new(10.0, 50.0, 1580.0, 740.0), + PresentationMode::Maximized, + Duration::ZERO, + ); + geometry.advance(Duration::from_millis(80), SpringConfig::default(), true); + let before = geometry.visual.current; + geometry.set_presentation_mode(normal, PresentationMode::Normal, Duration::from_millis(80)); + assert_eq!(geometry.visual.current, before); + assert_eq!(geometry.decorations, 1.0); + geometry.advance(Duration::ZERO, SpringConfig::default(), false); + assert_eq!(geometry.visual.current, normal); + assert_eq!(geometry.decorations, 1.0); + } + #[test] fn zoom_finishes_without_a_visible_final_edge_jump() { let tiled = Rect::new(12.0, 12.0, 942.0, 1056.0); diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index dc7bf0d..0160542 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -1031,7 +1031,7 @@ mod tests { assert_eq!( first.geometry[&WindowId(1)], - Rect::new(10.0, 10.0, 980.0, 780.0) + Rect::new(4.0, 4.0, 992.0, 792.0) ); workspaces.switch_to_numeric(2).unwrap(); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling); diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index 216504b..3a19f20 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -119,7 +119,7 @@ impl Default for GapConfig { fn default() -> Self { Self { inner: 10.0, - outer: 10.0, + outer: 4.0, smart: false, } } @@ -1178,8 +1178,8 @@ mod tests { let first = geometry[&WindowId(1)]; let second = geometry[&WindowId(2)]; - assert_eq!(first.x, 10.0); - assert_eq!(second.x + second.width, 90.0); + assert_eq!(first.x, 4.0); + assert_eq!(second.x + second.width, 96.0); assert_eq!(second.x - (first.x + first.width), 10.0); } @@ -1213,7 +1213,7 @@ mod tests { .geometry_with_gaps(Rect::new(0.0, 0.0, 100.0, 80.0), GapConfig::default()) .unwrap(); - assert_eq!(geometry[&WindowId(1)], Rect::new(10.0, 10.0, 80.0, 60.0)); + assert_eq!(geometry[&WindowId(1)], Rect::new(4.0, 4.0, 92.0, 72.0)); } #[test] @@ -1571,7 +1571,7 @@ mod tests { assert_eq!( result.geometry[&WindowId(1)], - Rect::new(10.0, 10.0, 60.0, 40.0) + Rect::new(4.0, 4.0, 60.0, 40.0) ); assert_eq!(result.warnings.len(), 2); } diff --git a/docs/ferese-theme-ui-spec.md b/docs/ferese-theme-ui-spec.md index cc29f33..1e822cf 100644 --- a/docs/ferese-theme-ui-spec.md +++ b/docs/ferese-theme-ui-spec.md @@ -240,10 +240,10 @@ base_unit = 8.0 window_radius = 14.0 -top_bar_height = 32.0 +top_bar_height = 38.0 top_bar_margin_top = 4.0 top_bar_margin_horizontal = 10.0 -top_bar_radius = 16.0 +top_bar_radius = 19.0 launcher_width = 560.0 launcher_radius = 20.0 @@ -724,8 +724,8 @@ noise = 0.012 [theme.geometry] window_radius = 14.0 -top_bar_height = 32.0 -top_bar_radius = 16.0 +top_bar_height = 38.0 +top_bar_radius = 19.0 launcher_width = 560.0 launcher_radius = 20.0 diff --git a/docs/ferese-window-manager-spec.md b/docs/ferese-window-manager-spec.md index ab1cb3c..ec27b57 100644 --- a/docs/ferese-window-manager-spec.md +++ b/docs/ferese-window-manager-spec.md @@ -1127,7 +1127,7 @@ mass = 1.0 [layout] mode = "scrolling" inner_gap = 10.0 -outer_gap = 10.0 +outer_gap = 4.0 smart_gaps = false [scrolling] diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..3bff658 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,53 @@ +# Fedora / SDDM installation + +Build and test in the checkout: + +```sh +cargo test -p ferese -p feresectl -p ferese-animation -p ferese-core -p ferese-layout -p ferese-shell --locked +cargo build --release --locked -p ferese -p ferese-shell -p feresectl +sudo bash scripts/install-session.sh YOUR_RELEASE_ID +``` + +The installer adds **Ferese** to SDDM without changing Plasma or +the default session. It installs immutable, versioned directories in +`/usr/local/lib/ferese/releases/` and launchers in `/usr/local/bin/`. +`current` selects the installed build; `previous` records the prior selection +on upgrades. Installation refuses to replace unmanaged launchers/session files. + +Keep your existing `~/.config/ferese/config.toml`. For a new configuration, copy +`/usr/local/lib/ferese/current/config.example.toml` there. The existing compositor +defaults handle connected monitors automatically; add output profiles only after +checking `feresectl get-outputs` on the actual DRM session. + +Use `ferese-session --nested` for an installed-build preview. Log out of Plasma +and select Ferese in SDDM for real hardware use. `ferese-session` can also run +from a spare logged-in TTY. It must run as your normal user, never as root. +Foot and `dbus-run-session` must be installed. + +Session logs are written under `${XDG_STATE_HOME:-~/.local/state}/ferese/`. +The launcher sets Wayland desktop variables, starts D-Bus if none is available, +and grants shell capabilities only to the compositor-launched shell process. +It does not enable public screencopy, privileged input methods, or shortcut +inhibition by default. + +## Logout and recovery + +Save your work, then press **Super+Shift+E** or run `feresectl exit` to log out. +Ferese stops its event loop and terminates its shell child during shutdown. +This is immediate logout, without a save-confirmation dialog. + +If the compositor becomes unresponsive, switch to another TTY using +Ctrl+Alt+F1–F12 and terminate that specific Ferese process. Select Plasma on the +next login. Do not restart SDDM while a desktop contains unsaved work. + +For a binary rollback, inspect `/usr/local/lib/ferese/previous` and select that +release with the `current` symlink while Ferese is stopped. Existing releases +are retained. To hide the login option, move +`/usr/share/wayland-sessions/ferese.desktop` out of that directory. + +## First hardware session + +Verify terminal/editor/browser, clipboard, fullscreen, fractional scaling, +monitor disconnect/reconnect, VT switching away/back, and logout. Suspend/resume +and session locking are not certified by the nested smoke test. Keep Plasma +available until real development sessions and these checks pass. diff --git a/docs/nested-resize-testing.md b/docs/nested-resize-testing.md new file mode 100644 index 0000000..16fef0a --- /dev/null +++ b/docs/nested-resize-testing.md @@ -0,0 +1,61 @@ +# Nested live-resize regression check + +The shell enables libcosmic's `wgpu` feature. Without it, iced uses tiny-skia +to resample and rasterize the wallpaper on the CPU. In an unoptimized build, +this blocks the UI/render thread shared with the top bar for seconds. Prompt +layer configures, buffer stretching, and compositor vsync cannot fix that +client-side bottleneck. Software rendering remains available as a fallback. + +## Reproduce and compare + +```sh +cargo build --locked -p ferese -p ferese-shell +WAYLAND_DEBUG=1 FERESE_TRACE_PERFORMANCE=1 \ + target/debug/ferese --backend nested --grant-effects --grant-shell-control -- \ + target/debug/ferese-shell 2>/tmp/ferese-resize.log +``` + +Use a configured wallpaper. Drag the host window's edges in both directions, +then maximize/restore it repeatedly. Check that the wallpaper and bar follow +the window, including at fractional host scaling. Repeat with +`ICED_BACKEND=wgpu` to require GPU rendering (rather than silently accepting +fallback), and with `ICED_BACKEND=tiny-skia` to compare software rendering. +Use a release build if software fallback is needed for regular use. + +Trace the chain, not just the compositor's frame rate: + +1. Host `xdg_toplevel.configure` becomes Winit `Resized`. +2. Ferese updates the output, arranges layers, and sends + `zwlr_layer_surface_v1.configure` for the new logical dimensions. +3. The shell acknowledges configure and eventually attaches/commits a new + buffer. A fast acknowledgement does **not** imply a finished render. +4. Committed buffers change surface damage; Ferese renders the damaged output + and submits through EGL to the host compositor. + +`FERESE_TRACE_PERFORMANCE` measures Ferese's rendering/submission, not shell +rasterization. Wayland traces distinguish software SHM buffers from GPU +buffers (Mesa Vulkan on the tested system). + +## Measured validation (KDE Wayland, 175% scale) + +With temporary instrumentation, 24 host maximize/restore requests spaced +250 ms apart generated 26 Winit resize events including initialization. The +same debug binaries were tested with each renderer forced: + +| Measurement | tiny-skia | wgpu | +| --- | ---: | ---: | +| Median configure acknowledgement | 1.35 ms | 2.27 ms | +| Median Ferese render + submit | 0.82 ms | 1.10 ms | +| Wallpaper buffer interval | 5919 ms (only two buffers) | 9.85 ms median | +| Bar buffers observed | 1 | 996 | + +The CPU capture ran for 14 seconds and the GPU capture for 18 seconds; +counts are diagnostic, not equal-duration throughput benchmarks. GPU maximum +buffer intervals were about 200 ms for wallpaper and 158 ms for the bar, so +the median does not imply every frame met a refresh deadline. These figures +include protocol tracing overhead. The temporary resize/timing hooks were +removed after testing; no synthetic resizing runs in production. + +The fix does not alter output coordinates, layer exclusion, damage tracking, +frame scheduling, or EGL swap settings. It also applies to the same shell +running under DRM, though this validation used the nested backend. diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index eb87a76..5d23457 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -47,9 +47,9 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), .map_err(|_| format!("{} requires a positive workspace index", command))?; json!({ "index": index }) } - "toggle-floating" | "toggle-fullscreen" | "toggle-layout" | "cycle-column-width" - | "center-column" | "consume" | "expel" | "close" | "get-focused-window" - | "get-workspaces" | "get-outputs" => { + "toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" + | "cycle-column-width" | "center-column" | "consume" | "expel" | "close" + | "get-focused-window" | "get-workspaces" | "get-outputs" | "exit" => { if !positional.is_empty() { return Err(format!("{command} does not accept arguments")); } @@ -78,7 +78,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] @@ -107,6 +107,11 @@ mod tests { #[test] fn rejects_unknown_or_missing_arguments() { + assert_eq!( + parse_args(["exit".to_owned()]).unwrap(), + ("exit".to_owned(), json!({})) + ); + assert!(parse_args(["exit".to_owned(), "extra".to_owned()]).is_err()); assert!(parse_args(["focus".to_owned()]).is_err()); assert!(parse_args(["reload-config".to_owned()]).is_err()); } diff --git a/packaging/config.toml b/packaging/config.toml new file mode 100644 index 0000000..df3b4f6 --- /dev/null +++ b/packaging/config.toml @@ -0,0 +1,14 @@ +# User configuration: ~/.config/ferese/config.toml +# Unspecified monitors use automatic placement and preferred modes. +[commands] +terminal = ["foot"] + +[input] +xkb_layout = "us" +focus_follows_mouse = false + +[layout] +smart_gaps = false + +# Super+Enter opens Foot. Super+Shift+E logs out. +# See README.md for monitor profiles and additional bindings. diff --git a/packaging/ferese-session b/packaging/ferese-session new file mode 100644 index 0000000..4b7bd8a --- /dev/null +++ b/packaging/ferese-session @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +backend=drm +if [[ ${1:-} == --nested ]]; then + backend=nested + shift +fi +if (($#)); then + echo 'usage: ferese-session [--nested]' >&2 + exit 2 +fi +: "${XDG_RUNTIME_DIR:?A login session with XDG_RUNTIME_DIR is required}" + +# Resolve once, so upgrading current cannot mix compositor and shell versions. +release_dir=$(dirname -- "$(readlink -f -- "$0")") +state_dir=${XDG_STATE_HOME:-$HOME/.local/state}/ferese +mkdir -p -- "$state_dir" +log_file=$state_dir/session-$(date -u +%Y%m%dT%H%M%SZ)-$$.log +echo "Ferese session log: $log_file" >&2 +exec >>"$log_file" 2>&1 + +export XDG_CURRENT_DESKTOP=Ferese +export XDG_SESSION_DESKTOP=ferese +export XDG_SESSION_TYPE=wayland +export MOZ_ENABLE_WAYLAND=1 +export QT_QPA_PLATFORM=wayland +export PATH="$release_dir:$PATH" +unset WAYLAND_SOCKET +if [[ $backend == drm ]]; then + unset DISPLAY WAYLAND_DISPLAY +fi + +command=("$release_dir/ferese" --backend "$backend" --grant-effects + --grant-shell-control -- "$release_dir/ferese-shell") +if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then + exec dbus-run-session -- "${command[@]}" +else + exec "${command[@]}" +fi diff --git a/packaging/ferese.desktop b/packaging/ferese.desktop new file mode 100644 index 0000000..3956d06 --- /dev/null +++ b/packaging/ferese.desktop @@ -0,0 +1,6 @@ +[Desktop Entry] +Name=Ferese +Comment=Animation-first scrolling Wayland desktop +Exec=/usr/local/bin/ferese-session +TryExec=/usr/local/bin/ferese-session +Type=Application diff --git a/scripts/install-session.sh b/scripts/install-session.sh new file mode 100644 index 0000000..57e94de --- /dev/null +++ b/scripts/install-session.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Install already-built binaries. Run from any directory with sudo. +set -euo pipefail + +repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +release_id=${1:?usage: install-session.sh RELEASE_ID} +[[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 2 +[[ $EUID == 0 ]] || { echo 'Run this installer with sudo.' >&2; exit 1; } + +install_root=/usr/local/lib/ferese +release_dir=$install_root/releases/$release_id +session_entry=/usr/share/wayland-sessions/ferese.desktop +[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; } + +# Refuse to overwrite unrelated installations. +for name in ferese ferese-shell feresectl ferese-session; do + link=/usr/local/bin/$name + if [[ -e $link || -L $link ]]; then + [[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || { + echo "Unmanaged path exists: $link" >&2; exit 1; + } + fi +done +for link in current previous; do + if [[ -e $install_root/$link || -L $install_root/$link ]]; then + [[ -L $install_root/$link && $(readlink -- "$install_root/$link") == releases/* ]] || { + echo "Unmanaged path exists: $install_root/$link" >&2; exit 1; + } + fi +done +if [[ -e $session_entry ]]; then + cmp -s -- "$repo_dir/packaging/ferese.desktop" "$session_entry" || { + echo "Different session entry exists: $session_entry" >&2; exit 1; + } +fi +for name in ferese ferese-shell feresectl; do + [[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; } +done +desktop-file-validate "$repo_dir/packaging/ferese.desktop" + +install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions +for name in ferese ferese-shell feresectl; do + install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name" +done +install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" +install -m 0644 -- "$repo_dir/packaging/config.toml" "$release_dir/config.example.toml" + +if [[ -L $install_root/current ]]; then + ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous" +fi +ln -s -- "releases/$release_id" "$install_root/current-$release_id" +mv -Tf -- "$install_root/current-$release_id" "$install_root/current" +for name in ferese ferese-shell feresectl ferese-session; do + ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name" +done +install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry" +echo "Installed Ferese $release_id. Select Ferese in SDDM." diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 13efd1a..2a18c84 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -8,7 +8,9 @@ rust-version = "1.93" [dependencies] ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } jiff.workspace = true -libcosmic.workspace = true +# Wallpaper resampling must not block the bar on iced's shared UI thread. +# Keep tiny-skia available as libcosmic's fallback, but prefer GPU rendering. +libcosmic = { workspace = true, features = ["wgpu"] } serde.workspace = true toml.workspace = true wayland-client.workspace = true diff --git a/shell/ferese-shell/assets/icons/battery.svg b/shell/ferese-shell/assets/icons/battery.svg new file mode 100644 index 0000000..ea2b996 --- /dev/null +++ b/shell/ferese-shell/assets/icons/battery.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/overview.svg b/shell/ferese-shell/assets/icons/overview.svg new file mode 100644 index 0000000..2cbd4c9 --- /dev/null +++ b/shell/ferese-shell/assets/icons/overview.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shell/ferese-shell/assets/icons/volume.svg b/shell/ferese-shell/assets/icons/volume.svg new file mode 100644 index 0000000..7fa4a7f --- /dev/null +++ b/shell/ferese-shell/assets/icons/volume.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/wifi.svg b/shell/ferese-shell/assets/icons/wifi.svg new file mode 100644 index 0000000..742a4a9 --- /dev/null +++ b/shell/ferese-shell/assets/icons/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index cdc1b2a..986c966 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -10,6 +10,48 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; pub(crate) struct ShellConfig { pub(crate) font_family: Option, pub(crate) wallpaper: WallpaperConfig, + pub(crate) theme: ShellTheme, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ShellTheme { + pub(crate) surface_base: [u8; 4], + pub(crate) text_primary: [u8; 4], + pub(crate) text_muted: [u8; 4], + pub(crate) accent: [u8; 4], + pub(crate) border: [u8; 4], + pub(crate) shadow: [u8; 4], + pub(crate) bar_height: f32, + pub(crate) bar_margin_top: i32, + pub(crate) bar_margin_horizontal: i32, + pub(crate) bar_radius: f32, + pub(crate) panel_padding: f32, + pub(crate) control_gap: f32, + pub(crate) shadow_offset_y: f32, + pub(crate) shadow_blur: f32, + pub(crate) shadow_opacity: f32, +} + +impl Default for ShellTheme { + fn default() -> Self { + Self { + surface_base: [17, 24, 33, 255], + text_primary: [244, 247, 251, 255], + text_muted: [127, 138, 152, 255], + accent: [91, 140, 255, 255], + border: [255, 255, 255, 24], + shadow: [0, 0, 0, 85], + bar_height: 38.0, + bar_margin_top: 4, + bar_margin_horizontal: 10, + bar_radius: 19.0, + panel_padding: 12.0, + control_gap: 8.0, + shadow_offset_y: 4.0, + shadow_blur: 18.0, + shadow_opacity: 0.20, + } + } } #[derive(Clone, Debug, Default, Deserialize)] @@ -35,6 +77,12 @@ struct FereseConfig { #[derive(Debug, Default, Deserialize)] struct ThemeConfig { + #[serde(default)] + colors: ThemeColorsConfig, + #[serde(default)] + geometry: ThemeGeometryConfig, + #[serde(default)] + shadow: ThemeShadowConfig, #[serde(default)] typography: TypographyConfig, #[serde(default)] @@ -46,6 +94,90 @@ struct TypographyConfig { font_family: Option, } +#[derive(Debug, Deserialize)] +struct ThemeColorsConfig { + #[serde(default = "default_surface_base")] + surface_base: String, + #[serde(default = "default_text_primary")] + text_primary: String, + #[serde(default = "default_text_muted")] + text_muted: String, + #[serde(default = "default_accent")] + accent: String, + #[serde(default = "default_border")] + border: String, + #[serde(default = "default_shadow")] + shadow: String, +} + +impl Default for ThemeColorsConfig { + fn default() -> Self { + Self { + surface_base: default_surface_base(), + text_primary: default_text_primary(), + text_muted: default_text_muted(), + accent: default_accent(), + border: default_border(), + shadow: default_shadow(), + } + } +} + +#[derive(Debug, Deserialize)] +struct ThemeGeometryConfig { + #[serde(default = "default_bar_height")] + top_bar_height: f32, + #[serde(default = "default_bar_margin_top")] + top_bar_margin_top: i32, + #[serde(default = "default_bar_margin_horizontal")] + top_bar_margin_horizontal: i32, + #[serde(default = "default_bar_radius")] + top_bar_radius: f32, + #[serde(default = "default_panel_padding")] + panel_padding: f32, + #[serde(default = "default_control_gap")] + control_gap: f32, +} + +impl Default for ThemeGeometryConfig { + fn default() -> Self { + Self { + top_bar_height: default_bar_height(), + top_bar_margin_top: default_bar_margin_top(), + top_bar_margin_horizontal: default_bar_margin_horizontal(), + top_bar_radius: default_bar_radius(), + panel_padding: default_panel_padding(), + control_gap: default_control_gap(), + } + } +} + +#[derive(Debug, Default, Deserialize)] +struct ThemeShadowConfig { + #[serde(default)] + soft: SoftShadowConfig, +} + +#[derive(Debug, Deserialize)] +struct SoftShadowConfig { + #[serde(default = "default_shadow_offset_y")] + offset_y: f32, + #[serde(default = "default_shadow_blur")] + blur: f32, + #[serde(default = "default_shadow_opacity")] + opacity: f32, +} + +impl Default for SoftShadowConfig { + fn default() -> Self { + Self { + offset_y: default_shadow_offset_y(), + blur: default_shadow_blur(), + opacity: default_shadow_opacity(), + } + } +} + pub(crate) fn load() -> ShellConfig { let Some(path) = config_path() else { return ShellConfig::default(); @@ -55,10 +187,15 @@ pub(crate) fn load() -> ShellConfig { }; match toml::from_str::(&source) { - Ok(config) => ShellConfig { - font_family: config.theme.typography.font_family, - wallpaper: config.theme.background, - }, + Ok(config) => { + let theme = shell_theme(&config.theme); + + ShellConfig { + font_family: config.theme.typography.font_family, + wallpaper: config.theme.background, + theme, + } + } Err(error) => { eprintln!("ferese-shell: failed to read {}: {error}", path.display()); ShellConfig::default() @@ -66,6 +203,112 @@ pub(crate) fn load() -> ShellConfig { } } +fn shell_theme(theme: &ThemeConfig) -> ShellTheme { + let defaults = ShellTheme::default(); + + ShellTheme { + surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base), + text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary), + text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted), + accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent), + border: parse_color(&theme.colors.border).unwrap_or(defaults.border), + shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow), + bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height), + bar_margin_top: theme.geometry.top_bar_margin_top.max(0), + bar_margin_horizontal: theme.geometry.top_bar_margin_horizontal.max(0), + bar_radius: nonnegative_or(theme.geometry.top_bar_radius, defaults.bar_radius), + panel_padding: nonnegative_or(theme.geometry.panel_padding, defaults.panel_padding), + control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap), + shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y), + shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur), + shadow_opacity: theme.shadow.soft.opacity.clamp(0.0, 1.0), + } +} + +fn parse_color(value: &str) -> Option<[u8; 4]> { + let value = value.strip_prefix('#')?; + if value.len() != 6 && value.len() != 8 { + return None; + } + + let red = u8::from_str_radix(&value[0..2], 16).ok()?; + let green = u8::from_str_radix(&value[2..4], 16).ok()?; + let blue = u8::from_str_radix(&value[4..6], 16).ok()?; + let alpha = if value.len() == 8 { + u8::from_str_radix(&value[6..8], 16).ok()? + } else { + 255 + }; + + Some([red, green, blue, alpha]) +} + +fn positive_or(value: f32, fallback: f32) -> f32 { + if value.is_finite() && value > 0.0 { + value + } else { + fallback + } +} + +fn nonnegative_or(value: f32, fallback: f32) -> f32 { + if value.is_finite() && value >= 0.0 { + value + } else { + fallback + } +} + +fn finite_or(value: f32, fallback: f32) -> f32 { + if value.is_finite() { value } else { fallback } +} + +fn default_surface_base() -> String { + "#111821".to_owned() +} +fn default_text_primary() -> String { + "#F4F7FB".to_owned() +} +fn default_text_muted() -> String { + "#7F8A98".to_owned() +} +fn default_accent() -> String { + "#5B8CFF".to_owned() +} +fn default_border() -> String { + "#FFFFFF18".to_owned() +} +fn default_shadow() -> String { + "#00000055".to_owned() +} +const fn default_bar_height() -> f32 { + 38.0 +} +const fn default_bar_margin_top() -> i32 { + 4 +} +const fn default_bar_margin_horizontal() -> i32 { + 10 +} +const fn default_bar_radius() -> f32 { + 19.0 +} +const fn default_panel_padding() -> f32 { + 12.0 +} +const fn default_control_gap() -> f32 { + 8.0 +} +const fn default_shadow_offset_y() -> f32 { + 4.0 +} +const fn default_shadow_blur() -> f32 { + 18.0 +} +const fn default_shadow_opacity() -> f32 { + 0.20 +} + pub(crate) const fn default_background() -> [u8; 3] { DEFAULT_BACKGROUND } diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 662fdc6..414d0f6 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -45,6 +45,7 @@ pub(crate) struct WindowSnapshot { pub(crate) app_id: String, pub(crate) title: String, pub(crate) focused: bool, + pub(crate) fullscreen: bool, } pub(crate) struct ShellControl { @@ -56,11 +57,13 @@ pub(crate) struct ShellControl { pub(crate) struct ControlPoll { pub(crate) snapshot: Option, + pub(crate) overview_active: Option, pub(crate) disconnected: bool, } enum ControlUpdate { Snapshot(ShellSnapshot), + OverviewState(bool), Disconnected, } @@ -93,12 +96,14 @@ impl ShellControl { pub(crate) fn poll(&self) -> ControlPoll { let mut poll = ControlPoll { snapshot: None, + overview_active: None, disconnected: false, }; loop { match self.updates.try_recv() { Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot), + Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active), Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => { poll.disconnected = true; return poll; @@ -115,8 +120,12 @@ impl ShellControl { let _ = self.connection.flush(); } - pub(crate) fn enter_overview(&self) { - self.shell.enter_overview(); + pub(crate) fn set_overview_active(&self, active: bool) { + if active { + self.shell.enter_overview(); + } else { + self.shell.exit_overview(); + } let _ = self.connection.flush(); } } @@ -214,17 +223,20 @@ impl Dispatch for ControlState { title, state: window_state, } => { - let focused = matches!( - window_state, - WEnum::Value(flags) - if flags.contains(ferese_shell_v1::WindowState::Focused) - ); + let (focused, fullscreen) = match window_state { + WEnum::Value(flags) => ( + flags.contains(ferese_shell_v1::WindowState::Focused), + flags.contains(ferese_shell_v1::WindowState::Fullscreen), + ), + WEnum::Unknown(_) => (false, false), + }; state.pending.windows.push(WindowSnapshot { workspace: join_id(workspace_hi, workspace_lo), app_id, title, focused, + fullscreen, }); } ferese_shell_v1::Event::SnapshotEnd { serial } @@ -234,6 +246,9 @@ impl Dispatch for ControlState { .sender .send(ControlUpdate::Snapshot(state.pending.clone())); } + ferese_shell_v1::Event::OverviewState { active } => { + let _ = state.sender.send(ControlUpdate::OverviewState(active != 0)); + } _ => {} } } diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 5dbbdcf..4d8def1 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -9,6 +9,7 @@ use cosmic::iced::alignment; use cosmic::iced::event::{self, PlatformSpecific, wayland}; use cosmic::iced::platform_specific::{ runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings}, + shell::commands::layer_surface::set_input_zone, shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, }; use cosmic::iced::{ @@ -16,7 +17,7 @@ use cosmic::iced::{ window, }; use cosmic::theme; -use cosmic::widget::{button, container, image, row, text}; +use cosmic::widget::{button, container, icon, image, row, text}; use ferese_protocols::effects::v1::client::{ ferese_effects_manager_v1::FereseEffectsManagerV1, ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, @@ -28,21 +29,41 @@ use wayland_client::{ protocol::{wl_registry, wl_surface}, }; -use crate::config::{ShellConfig, WallpaperMode}; +use crate::config::{ShellConfig, ShellTheme, WallpaperMode}; use crate::control::{ShellControl, ShellSnapshot}; const APP_ID: &str = "dev.ferese.Shell"; -const BAR_HEIGHT: u32 = 32; -const BAR_MARGIN: i32 = 4; -const EXCLUSIVE_ZONE: i32 = BAR_HEIGHT as i32; -const BAR_TEXT_SIZE: u16 = (BAR_HEIGHT * 3 / 8) as u16; -const BAR_ICON_SIZE: u16 = (BAR_HEIGHT / 2) as u16; -const CONTROL_HEIGHT: f32 = BAR_HEIGHT as f32 * 0.75; -const WORKSPACE_HIT_WIDTH: f32 = BAR_HEIGHT as f32 * 0.6875; -const ACTIVE_MARKER_WIDTH: f32 = BAR_HEIGHT as f32 * 0.5; -const ACTIVE_MARKER_HEIGHT: f32 = BAR_HEIGHT as f32 * 0.125; -const DOT_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.1875; -const EMPTY_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.125; + +#[derive(Clone, Copy)] +struct BarMetrics { + height: f32, + text_size: u16, + icon_size: u16, + control_height: f32, + workspace_hit_width: f32, + active_marker_width: f32, + active_marker_height: f32, + dot_marker_size: f32, + empty_marker_size: f32, +} + +impl From for BarMetrics { + fn from(theme: ShellTheme) -> Self { + let height = theme.bar_height; + + Self { + height, + text_size: (height * 0.368).round() as u16, + icon_size: (height * 0.553).round() as u16, + control_height: height * 0.79, + workspace_hit_width: height * 0.68, + active_marker_width: height * 0.47, + active_marker_height: height * 0.13, + dot_marker_size: height * 0.18, + empty_marker_size: height * 0.13, + } + } +} fn main() -> cosmic::iced::Result { let config = config::load(); @@ -66,6 +87,7 @@ struct FereseShell { config: ShellConfig, control: Option, snapshot: ShellSnapshot, + overview_active: bool, clock: String, effects: Option, } @@ -75,7 +97,7 @@ enum Message { Event(Event, window::Id), Tick, ActivateWorkspace(u64), - EnterOverview, + ToggleOverview, } impl cosmic::Application for FereseShell { @@ -96,6 +118,8 @@ impl cosmic::Application for FereseShell { fn init(core: Core, config: Self::Flags) -> (Self, Task) { let bar_surface_id = window::Id::unique(); let wallpaper_surface_id = window::Id::unique(); + let shell_theme = config.theme; + let bar = BarMetrics::from(shell_theme); let app = Self { core, bar_surface_id, @@ -106,6 +130,7 @@ impl cosmic::Application for FereseShell { }) .ok(), snapshot: ShellSnapshot::default(), + overview_active: false, clock: current_time(), effects: None, }; @@ -135,14 +160,14 @@ impl cosmic::Application for FereseShell { output: IcedOutput::Active, namespace: "ferese-shell-top-bar".to_owned(), margin: IcedMargin { - top: BAR_MARGIN, - right: 10, + top: shell_theme.bar_margin_top, + right: shell_theme.bar_margin_horizontal, bottom: 0, - left: 10, + left: shell_theme.bar_margin_horizontal, }, - size: Some((None, Some(BAR_HEIGHT))), + size: Some((None, Some(bar.height.round() as u32))), size_limits: Limits::NONE, - exclusive_zone: EXCLUSIVE_ZONE, + exclusive_zone: bar.height.round() as i32, ..Default::default() }, Some(Box::new(Self::view_layer)), @@ -173,8 +198,26 @@ impl cosmic::Application for FereseShell { if poll.disconnected { return cosmic::iced::exit(); } + if let Some(active) = poll.overview_active { + self.overview_active = active; + } if let Some(snapshot) = poll.snapshot { + let was_hidden = bar_hidden(&self.snapshot); + self.snapshot = snapshot; + + let hidden = bar_hidden(&self.snapshot); + if hidden != was_hidden { + if let Some(effects) = &self.effects + && let Err(error) = effects.set_visible(!hidden) + { + eprintln!("ferese-shell: could not update panel material: {error}"); + } + + let input_zone = if hidden { Some(Vec::new()) } else { None }; + + return set_input_zone(self.bar_surface_id, input_zone); + } } } Task::none() @@ -185,9 +228,10 @@ impl cosmic::Application for FereseShell { } Task::none() } - Message::EnterOverview => { + Message::ToggleOverview => { + self.overview_active = !self.overview_active; if let Some(control) = &self.control { - control.enter_overview(); + control.set_overview_active(self.overview_active); } Task::none() } @@ -234,13 +278,20 @@ impl FereseShell { return; } - match EffectsBinding::attach(surface) { + match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) { Ok(binding) => self.effects = Some(binding), Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"), } } fn view_layer(&self) -> Element<'_, cosmic::Action> { + if bar_hidden(&self.snapshot) { + return container(text("")) + .width(Length::Fill) + .height(Length::Fill) + .into(); + } + let focused_output = self .snapshot .outputs @@ -248,16 +299,18 @@ impl FereseShell { .find(|output| output.focused) .or_else(|| self.snapshot.outputs.first()); let focused_output_id = focused_output.map(|output| output.id); + let shell_theme = self.config.theme; + let bar = BarMetrics::from(shell_theme); let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) .spacing(1) .align_y(cosmic::iced::Alignment::Center); workspace_row = workspace_row.push( - button::suggested("F") - .font_size(BAR_TEXT_SIZE) - .height(CONTROL_HEIGHT) - .padding([2, 9]) - .on_press(cosmic::Action::App(Message::EnterOverview)), + button::custom(overview_control(bar, shell_theme)) + .height(bar.control_height) + .padding(0) + .class(theme::Button::Transparent) + .on_press(cosmic::Action::App(Message::ToggleOverview)), ); for workspace in self .snapshot @@ -270,9 +323,9 @@ impl FereseShell { .windows .iter() .any(|window| window.workspace == workspace.id); - let indicator = workspace_indicator(workspace.active, occupied); + let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme); let workspace_button = button::custom(indicator) - .height(CONTROL_HEIGHT) + .height(bar.control_height) .padding(0) .class(theme::Button::Transparent) .on_press(cosmic::Action::App(Message::ActivateWorkspace( @@ -293,16 +346,16 @@ impl FereseShell { }) .map(display_app_name) .unwrap_or_else(|| "Desktop".to_owned()); - let left = row![workspace_row, text(app_name).size(BAR_TEXT_SIZE)] - .spacing(12) + let left = row![workspace_row, text(app_name).size(bar.text_size)] + .spacing(shell_theme.control_gap) .align_y(cosmic::iced::Alignment::Center); - let center = text(&self.clock).size(BAR_TEXT_SIZE); + let center = text(&self.clock).size(bar.text_size); let right = row![ - text("◌").size(BAR_ICON_SIZE), - text("♪").size(BAR_ICON_SIZE), - text("●").size(BAR_ICON_SIZE), + symbolic_icon(include_bytes!("../assets/icons/wifi.svg"), bar.icon_size), + symbolic_icon(include_bytes!("../assets/icons/volume.svg"), bar.icon_size), + symbolic_icon(include_bytes!("../assets/icons/battery.svg"), bar.icon_size), ] - .spacing(10) + .spacing(shell_theme.control_gap) .align_y(cosmic::iced::Alignment::Center); let content = row![ container(left) @@ -321,8 +374,8 @@ impl FereseShell { container(content) .width(Length::Fill) .height(Length::Fill) - .padding([0, 12]) - .class(theme::Container::custom(bar_style)) + .padding([0, shell_theme.panel_padding.round() as u16]) + .class(theme::Container::custom(move |_| bar_style(shell_theme))) .into() } @@ -347,51 +400,98 @@ impl FereseShell { } } -fn workspace_indicator(active: bool, occupied: bool) -> Element<'static, cosmic::Action> { - let (width, height, style) = if active { +fn symbolic_icon(source: &'static [u8], size: u16) -> icon::Icon { + icon::from_svg_bytes(source) + .symbolic(true) + .icon() + .size(size) +} + +fn overview_control( + bar: BarMetrics, + shell_theme: ShellTheme, +) -> Element<'static, cosmic::Action> { + container(symbolic_icon( + include_bytes!("../assets/icons/overview.svg"), + bar.icon_size, + )) + .width(bar.control_height) + .height(bar.control_height) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + .class(theme::Container::custom(move |_| container::Style { + icon_color: Some(color(shell_theme.text_primary)), + background: Some(Background::Color(color(shell_theme.accent))), + border: Border { + color: color_with_opacity(shell_theme.text_primary, 0.14), + width: 1.0, + radius: (bar.control_height * 0.34).into(), + }, + snap: true, + ..container::Style::default() + })) + .into() +} + +fn bar_hidden(snapshot: &ShellSnapshot) -> bool { + let active_workspace = snapshot + .outputs + .iter() + .find(|output| output.focused) + .or_else(|| snapshot.outputs.first()) + .map(|output| output.active_workspace); + + active_workspace.is_some_and(|workspace| { + snapshot + .windows + .iter() + .any(|window| window.workspace == workspace && window.fullscreen) + }) +} + +fn workspace_indicator( + active: bool, + occupied: bool, + bar: BarMetrics, + shell_theme: ShellTheme, +) -> Element<'static, cosmic::Action> { + let (width, height, color, radius) = if active { ( - ACTIVE_MARKER_WIDTH, - ACTIVE_MARKER_HEIGHT, - active_workspace_style as fn(&cosmic::Theme) -> container::Style, + bar.active_marker_width, + bar.active_marker_height, + color(shell_theme.accent), + 2.0, ) } else if occupied { ( - DOT_MARKER_SIZE, - DOT_MARKER_SIZE, - occupied_workspace_style as fn(&cosmic::Theme) -> container::Style, + bar.dot_marker_size, + bar.dot_marker_size, + color_with_opacity(shell_theme.text_muted, 0.78), + 3.0, ) } else { ( - EMPTY_MARKER_SIZE, - EMPTY_MARKER_SIZE, - empty_workspace_style as fn(&cosmic::Theme) -> container::Style, + bar.empty_marker_size, + bar.empty_marker_size, + color_with_opacity(shell_theme.text_muted, 0.42), + 2.0, ) }; let marker = container(text("")) .width(width) .height(height) - .class(theme::Container::custom(style)); + .class(theme::Container::custom(move |_| { + workspace_marker_style(color, radius) + })); container(marker) - .width(WORKSPACE_HIT_WIDTH) - .height(CONTROL_HEIGHT) + .width(bar.workspace_hit_width) + .height(bar.control_height) .align_x(alignment::Horizontal::Center) .align_y(alignment::Vertical::Center) .into() } -fn active_workspace_style(_theme: &cosmic::Theme) -> container::Style { - workspace_marker_style(Color::from_rgb8(91, 140, 255), 2.0) -} - -fn occupied_workspace_style(_theme: &cosmic::Theme) -> container::Style { - workspace_marker_style(Color::from_rgba8(221, 228, 240, 0.68), 3.0) -} - -fn empty_workspace_style(_theme: &cosmic::Theme) -> container::Style { - workspace_marker_style(Color::from_rgba8(221, 228, 240, 0.25), 2.0) -} - fn workspace_marker_style(color: Color, radius: f32) -> container::Style { container::Style { background: Some(Background::Color(color)), @@ -426,25 +526,37 @@ fn current_time() -> String { Zoned::now().strftime("%H:%M").to_string() } -fn bar_style(_theme: &cosmic::Theme) -> container::Style { +fn bar_style(theme: ShellTheme) -> container::Style { + let mut shadow = color(theme.shadow); + shadow.a *= theme.shadow_opacity; + container::Style { - icon_color: Some(Color::from_rgb8(235, 239, 247)), - text_color: Some(Color::from_rgb8(235, 239, 247)), - background: Some(Background::Color(Color::from_rgba8(18, 21, 28, 0.88))), + icon_color: Some(color(theme.text_primary)), + text_color: Some(color(theme.text_primary)), + background: None, border: Border { - color: Color::from_rgba8(255, 255, 255, 0.14), + color: color(theme.border), width: 1.0, - radius: 16.0.into(), + radius: theme.bar_radius.into(), }, shadow: Shadow { - color: Color::from_rgba8(0, 0, 0, 0.32), - offset: Vector::new(0.0, 4.0), - blur_radius: 18.0, + color: shadow, + offset: Vector::new(0.0, theme.shadow_offset_y), + blur_radius: theme.shadow_blur, }, snap: true, } } +fn color([red, green, blue, alpha]: [u8; 4]) -> Color { + Color::from_rgba8(red, green, blue, f32::from(alpha) / 255.0) +} + +fn color_with_opacity(mut value: [u8; 4], opacity: f32) -> Color { + value[3] = (f32::from(value[3]) * opacity).round() as u8; + color(value) +} + fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style { let [red, green, blue] = config::default_background(); @@ -455,13 +567,17 @@ fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style { } struct EffectsBinding { + connection: Connection, _manager: FereseEffectsManagerV1, - _surface: FereseSurfaceEffectsV1, + surface: FereseSurfaceEffectsV1, _queue: EventQueue, } impl EffectsBinding { - fn attach(surface: &wl_surface::WlSurface) -> Result> { + fn attach( + surface: &wl_surface::WlSurface, + visible: bool, + ) -> Result> { let backend = surface .backend() .upgrade() @@ -472,15 +588,32 @@ impl EffectsBinding { let manager = globals.bind::(&qh, 1..=1, ())?; let effects = manager.get_surface_effects(surface, &qh, ()); - effects.set_role(ferese_surface_effects_v1::Role::Panel); + if visible { + effects.set_role(ferese_surface_effects_v1::Role::Panel); + } else { + effects.clear_role(); + } connection.flush()?; Ok(Self { + connection, _manager: manager, - _surface: effects, + surface: effects, _queue: queue, }) } + + fn set_visible(&self, visible: bool) -> Result<(), Box> { + if visible { + self.surface + .set_role(ferese_surface_effects_v1::Role::Panel); + } else { + self.surface.clear_role(); + } + + self.connection.flush()?; + Ok(()) + } } struct EffectsState; @@ -499,3 +632,41 @@ impl Dispatch for EffectsState { delegate_noop!(EffectsState: ignore FereseEffectsManagerV1); delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1); + +#[cfg(test)] +mod tests { + use super::*; + use crate::control::{OutputSnapshot, WindowSnapshot}; + + #[test] + fn fullscreen_on_the_active_workspace_hides_the_bar() { + let snapshot = snapshot_with_fullscreen_window(7); + + assert!(bar_hidden(&snapshot)); + } + + #[test] + fn fullscreen_on_an_inactive_workspace_keeps_the_bar_visible() { + let snapshot = snapshot_with_fullscreen_window(8); + + assert!(!bar_hidden(&snapshot)); + } + + fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot { + ShellSnapshot { + outputs: vec![OutputSnapshot { + id: 1, + active_workspace: 7, + focused: true, + }], + workspaces: Vec::new(), + windows: vec![WindowSnapshot { + workspace, + app_id: "dev.ferese.Test".to_owned(), + title: "Test".to_owned(), + focused: true, + fullscreen: true, + }], + } + } +}