From cbf048566ab1f52e04ec2f081c2759b2a3e25543 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 12:47:26 +0100 Subject: [PATCH] Split shell, settings, and compositor into focused modules --- compositor/src/backends/direct.rs | 10 +- compositor/src/config.rs | 1537 +-------- compositor/src/config/appearance.rs | 414 +++ compositor/src/config/bindings.rs | 621 ++++ compositor/src/config/input_motion.rs | 245 ++ compositor/src/config/layout.rs | 78 + compositor/src/config/outputs.rs | 182 + compositor/src/handlers/window_capture.rs | 2 +- compositor/src/ipc.rs | 2 +- compositor/src/main.rs | 1 + compositor/src/render/capture.rs | 120 + compositor/src/render/decorations.rs | 502 +++ compositor/src/render/materials.rs | 391 +++ compositor/src/render/mod.rs | 890 +++++ compositor/src/render/overview.rs | 314 ++ compositor/src/render/scene.rs | 494 +++ .../src/render/shaders/blur_shader.frag | 42 + .../src/render/shaders/material_shader.frag | 31 + .../render/shaders/rounded_border_shader.frag | 55 + .../shaders/rounded_texture_shader.frag | 49 + .../render/shaders/window_shadow_shader.frag | 35 + compositor/src/state.rs | 2819 +--------------- compositor/src/state/animation.rs | 352 ++ compositor/src/state/floating.rs | 434 +++ compositor/src/state/hit_testing.rs | 232 ++ compositor/src/state/layout.rs | 429 +++ compositor/src/state/navigation.rs | 577 ++++ compositor/src/state/outputs.rs | 160 + compositor/src/state/windows.rs | 642 ++++ compositor/src/winit.rs | 2917 +---------------- shell/ferese-settings/src/form_controls.rs | 247 ++ shell/ferese-settings/src/main.rs | 993 +----- shell/ferese-settings/src/pages.rs | 531 +++ shell/ferese-settings/src/theme_controls.rs | 237 ++ shell/ferese-shell/src/bar.rs | 337 ++ shell/ferese-shell/src/desktop_widgets.rs | 734 +++++ shell/ferese-shell/src/main.rs | 1603 +-------- shell/ferese-shell/src/surfaces.rs | 556 ++++ 38 files changed, 9998 insertions(+), 9817 deletions(-) create mode 100644 compositor/src/config/appearance.rs create mode 100644 compositor/src/config/bindings.rs create mode 100644 compositor/src/config/input_motion.rs create mode 100644 compositor/src/config/layout.rs create mode 100644 compositor/src/config/outputs.rs create mode 100644 compositor/src/render/capture.rs create mode 100644 compositor/src/render/decorations.rs create mode 100644 compositor/src/render/materials.rs create mode 100644 compositor/src/render/mod.rs create mode 100644 compositor/src/render/overview.rs create mode 100644 compositor/src/render/scene.rs create mode 100644 compositor/src/render/shaders/blur_shader.frag create mode 100644 compositor/src/render/shaders/material_shader.frag create mode 100644 compositor/src/render/shaders/rounded_border_shader.frag create mode 100644 compositor/src/render/shaders/rounded_texture_shader.frag create mode 100644 compositor/src/render/shaders/window_shadow_shader.frag create mode 100644 compositor/src/state/animation.rs create mode 100644 compositor/src/state/floating.rs create mode 100644 compositor/src/state/hit_testing.rs create mode 100644 compositor/src/state/layout.rs create mode 100644 compositor/src/state/navigation.rs create mode 100644 compositor/src/state/outputs.rs create mode 100644 compositor/src/state/windows.rs create mode 100644 shell/ferese-settings/src/form_controls.rs create mode 100644 shell/ferese-settings/src/pages.rs create mode 100644 shell/ferese-settings/src/theme_controls.rs create mode 100644 shell/ferese-shell/src/bar.rs create mode 100644 shell/ferese-shell/src/desktop_widgets.rs create mode 100644 shell/ferese-shell/src/surfaces.rs diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index e24617a..0907d0f 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -35,7 +35,7 @@ use smithay::wayland::presentation::Refresh; use crate::Ferese; use crate::config::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}; use crate::metrics::RenderMetrics; -use crate::winit::{animated_window_elements, cursorless_window_elements, frame_effect_metrics, redraw_output}; +use crate::render::{animated_window_elements, cursorless_window_elements, frame_effect_metrics, redraw_output}; pub struct DirectBackendState { pub session: LibSeatSession, @@ -57,7 +57,7 @@ impl DirectBackendState { geometry: smithay::utils::Rectangle, output: &Output, remaining: usize, - ) -> Result, smithay::backend::renderer::gles::GlesError> { + ) -> Result, smithay::backend::renderer::gles::GlesError> { let Some(device) = self .devices .values_mut() @@ -65,7 +65,7 @@ impl DirectBackendState { else { return Ok(None); }; - crate::winit::capture_resize_snapshot( + crate::render::capture_resize_snapshot( &mut device.renderer, window, geometry, @@ -85,7 +85,7 @@ impl DirectBackendState { .values_mut() .find(|device| device.outputs.values().any(|candidate| &candidate.output == output)) .ok_or("Window output is unavailable")?; - crate::winit::capture_window_buffer( + crate::render::capture_window_buffer( &mut device.renderer, window, geometry, @@ -104,7 +104,7 @@ impl DirectBackendState { .values_mut() .find(|device| device.outputs.values().any(|candidate| &candidate.output == output)) .ok_or("Window output is unavailable")?; - crate::winit::capture_window_frame( + crate::render::capture_window_frame( &mut device.renderer, window, window.geometry(), diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 7685d21..da28a06 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -1,11 +1,26 @@ +mod appearance; +mod bindings; +mod input_motion; +mod layout; +mod outputs; + use std::collections::{HashMap, HashSet}; use std::error::Error; use std::fmt; use std::path::PathBuf; +use appearance::*; +pub use appearance::{BorderGradient, InactiveDimSettings, MaterialStyle, ThemeSettings}; +pub(crate) use bindings::physical_keymap; +use bindings::*; +pub use bindings::{Binding, BindingAction}; use ferese_animation::SpringConfig; use ferese_core::LayoutMode; use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy}; +pub use input_motion::InputSettings; +use input_motion::*; +use outputs::*; +pub use outputs::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}; use serde::Deserialize; use smithay::input::keyboard::{Keycode, keysyms, xkb}; @@ -68,218 +83,6 @@ struct ShellStatusConfig { settings_command: Option>, } -#[derive(Clone, Debug, PartialEq)] -pub struct OutputProfile { - pub name: String, - pub outputs: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct OutputSettings { - pub auto_refresh: bool, - pub matcher: String, - pub enabled: bool, - pub mode: Option, - pub scale: f64, - pub transform: OutputTransform, - pub position: Option<[i32; 2]>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct OutputModeRequest { - pub width: u16, - pub height: u16, - pub refresh_millihertz: Option, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum OutputTransform { - #[default] - Normal, - #[serde(rename = "rotate_90")] - Rotate90, - #[serde(rename = "rotate_180")] - Rotate180, - #[serde(rename = "rotate_270")] - Rotate270, - Flipped, - #[serde(rename = "flipped_90")] - Flipped90, - #[serde(rename = "flipped_180")] - Flipped180, - #[serde(rename = "flipped_270")] - Flipped270, -} - -#[derive(Clone, Debug, Deserialize)] -struct OutputProfileConfig { - name: String, - #[serde(default)] - outputs: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct OutputConfig { - #[serde(default)] - auto_refresh: bool, - #[serde(rename = "match")] - matcher: String, - #[serde(default = "default_true")] - enabled: bool, - mode: Option, - #[serde(default = "default_output_scale")] - scale: f64, - #[serde(default)] - transform: OutputTransform, - position: Option<[i32; 2]>, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct RgbaColor(pub [f32; 4]); - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct BorderGradient { - pub from: RgbaColor, - pub to: RgbaColor, - pub angle: f64, -} - -#[derive(Debug, Default, Deserialize)] -struct BorderPaintConfig { - gradient: Option, -} - -#[derive(Debug, Deserialize)] -struct BorderGradientConfig { - from: String, - to: String, - #[serde(default)] - angle: f64, -} - -impl BorderPaintConfig { - fn settings(&self, name: &str) -> Result, ConfigError> { - let (from_name, to_name, angle_name) = if name == "focus_ring" { - ( - "focus_ring.gradient.from", - "focus_ring.gradient.to", - "focus_ring.gradient.angle", - ) - } else { - ("border.gradient.from", "border.gradient.to", "border.gradient.angle") - }; - self.gradient - .as_ref() - .map(|gradient| { - Ok(BorderGradient { - from: parse_color(&gradient.from, from_name)?, - to: parse_color(&gradient.to, to_name)?, - angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0), - }) - }) - .transpose() - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ThemeSettings { - pub border_width: f64, - pub focus_ring_width: f64, - pub border_color: RgbaColor, - pub accent_color: RgbaColor, - pub border_gradient: Option, - pub focus_ring_gradient: Option, - pub shadow_color: RgbaColor, - pub surface_base_color: RgbaColor, - pub bar_background_color: RgbaColor, - pub text_primary_color: RgbaColor, - pub shell_opacity: f64, - pub inactive_dim: InactiveDimSettings, - pub window_radius: f64, - pub shadow_offset_y: f64, - pub shadow_blur: f64, - pub shadow_opacity: f64, - pub material_style: MaterialStyle, - pub backdrop_blur: f64, - pub material_tint_strength: f64, - pub material_radius: f64, - pub panel_radius: f64, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum MaterialStyle { - Translucent, - #[default] - Solid, -} - -#[derive(Debug, Default, Deserialize)] -struct AppearanceConfig { - corner_radius: Option, - #[serde(default)] - inactive_dim: InactiveDimConfig, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct InactiveDimSettings { - pub enabled: bool, - pub amount: f64, - pub duration_ms: f64, -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -struct InactiveDimConfig { - enabled: bool, - amount: f64, - duration_ms: f64, -} - -impl Default for InactiveDimConfig { - fn default() -> Self { - Self { - enabled: false, - amount: 0.15, - duration_ms: 150.0, - } - } -} - -#[derive(Debug, Default, Deserialize)] -struct ThemeConfig { - #[serde(default)] - border: BorderPaintConfig, - #[serde(default)] - focus_ring: BorderPaintConfig, - #[serde(default)] - typography: OverviewTypographyConfig, - #[serde(default)] - background: crate::wallpaper::WallpaperConfig, - #[serde(default)] - colors: ThemeColorsConfig, - #[serde(default)] - geometry: ThemeGeometryConfig, - #[serde(default)] - shadow: ThemeShadowConfig, - #[serde(default)] - material: ThemeMaterialConfig, - #[serde(default)] - surface: ThemeSurfaceConfig, -} - -#[derive(Debug, Default, Deserialize)] -struct ThemeSurfaceConfig { - #[serde(default)] - bar: ThemeBarConfig, -} - -#[derive(Debug, Default, Deserialize)] -struct ThemeBarConfig { - background: Option, -} - impl Config { #[cfg(test)] pub(crate) fn parse_source(source: &str) -> Result { @@ -349,482 +152,6 @@ impl Config { } } -#[derive(Debug, Default, Deserialize)] -struct OverviewTypographyConfig { - font_family: Option, -} - -#[derive(Debug, Deserialize)] -struct ThemeMaterialConfig { - #[serde(default = "default_shell_opacity")] - opacity: f64, - #[serde(default)] - style: MaterialStyle, - #[serde(default = "default_backdrop_blur")] - blur_radius: f64, - #[serde(default = "default_material_tint_strength")] - tint_strength: f64, -} - -impl Default for ThemeMaterialConfig { - fn default() -> Self { - Self { - style: MaterialStyle::Solid, - opacity: default_shell_opacity(), - blur_radius: default_backdrop_blur(), - tint_strength: default_material_tint_strength(), - } - } -} - -fn default_shell_opacity() -> f64 { - ferese_config::DEFAULT_MATERIAL_OPACITY -} - -fn default_material_tint_strength() -> f64 { - 0.5 -} - -fn default_backdrop_blur() -> f64 { - 12.0 -} - -#[derive(Debug, Deserialize)] -struct ThemeColorsConfig { - #[serde(default = "default_text_primary_color")] - text_primary: String, - #[serde(default = "default_surface_base_color")] - surface_base: String, - #[serde(default = "default_border_color")] - border: String, - #[serde(default = "default_accent_color")] - accent: String, - #[serde(default = "default_shadow_color")] - shadow: String, -} - -impl Default for ThemeColorsConfig { - fn default() -> Self { - Self { - surface_base: default_surface_base_color(), - text_primary: default_text_primary_color(), - border: default_border_color(), - accent: default_accent_color(), - shadow: default_shadow_color(), - } - } -} - -#[derive(Debug, Default, Deserialize)] -struct ThemeShadowConfig { - #[serde(default)] - soft: SoftShadowConfig, -} - -#[derive(Debug, Deserialize)] -struct SoftShadowConfig { - #[serde(default = "default_shadow_offset_y")] - offset_y: f64, - #[serde(default = "default_shadow_blur")] - blur: f64, - #[serde(default = "default_shadow_opacity")] - opacity: f64, -} - -impl Default for SoftShadowConfig { - fn default() -> Self { - Self { - offset_y: default_shadow_offset_y(), - blur: default_shadow_blur(), - opacity: default_shadow_opacity(), - } - } -} - -#[derive(Debug, Deserialize)] -struct ThemeGeometryConfig { - #[serde(default = "default_border_width")] - border_width: f64, - #[serde(default = "default_focus_ring_width")] - focus_ring_width: f64, - #[serde(default = "default_window_radius")] - window_radius: f64, - #[serde(default)] - top_bar_radius: Option, - #[serde(default)] - shell_radius: Option, -} - -impl Default for ThemeGeometryConfig { - fn default() -> Self { - Self { - border_width: default_border_width(), - focus_ring_width: default_focus_ring_width(), - window_radius: default_window_radius(), - top_bar_radius: None, - shell_radius: None, - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Binding { - modifiers: BindingModifiers, - trigger: BindingTrigger, - pub action: BindingAction, -} - -#[derive(Clone, Debug, PartialEq)] -enum BindingTrigger { - Keysym(u32), - Physical(Keycode), - Swipe { - fingers: u32, - direction: crate::gestures::SwipeDirection, - }, -} - -#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] -struct BindingModifiers { - logo: bool, - ctrl: bool, - alt: bool, - shift: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum BindingAction { - None, - Spawn(Vec), - Close, - Exit, - Focus(ferese_layout::Direction), - Move(ferese_layout::Direction), - Resize(ferese_layout::Direction), - SwitchWorkspace(u8), - SwitchRelativeWorkspace(bool), - WorkspaceBackAndForth, - MoveToWorkspace(u8), - ToggleFullscreen, - ToggleMaximized, - ToggleLayout, - CycleColumnWidth, - CenterColumn, - Consume, - Expel, - ToggleFloating, - ToggleOverview, - ToggleKeybindingGuide, -} - -impl Binding { - pub(crate) fn portal_trigger(chord: &str) -> Result { - if chord.len() > 256 { - return Err(ConfigError::InvalidBinding("Shortcut is too long".into())); - } - let parts = chord.split('+').collect::>(); - if parts - .iter() - .filter(|part| part.trim().eq_ignore_ascii_case("num")) - .count() - > 1 - { - return Err(ConfigError::InvalidBinding("Duplicate NUM modifier".into())); - } - let chord = parts - .into_iter() - .filter(|part| !part.trim().eq_ignore_ascii_case("num")) - .collect::>() - .join("+"); - let (modifiers, key) = parse_chord(&chord)?; - let symbol = parse_keysym(&key)?; - if (modifiers.ctrl - && modifiers.alt - && matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12)) - || (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol) - { - return Err(ConfigError::InvalidBinding("Reserved system shortcut".into())); - } - Ok(Self { - modifiers, - trigger: BindingTrigger::Keysym(symbol), - action: BindingAction::None, - }) - } - - pub(crate) fn conflicts(&self, other: &Self, map: &xkb::Keymap) -> bool { - if self.modifiers != other.modifiers { - return false; - } - match (&self.trigger, &other.trigger) { - (BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right, - (BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code)) - | (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => (0..map.num_layouts_for_key(*code)) - .any(|layout| { - map.key_get_syms_by_level(*code, layout, 0) - .iter() - .any(|key| key.raw() == *symbol) - }), - (BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right, - _ => false, - } - } - - pub fn matches(&self, keycode: Keycode, keysyms: &[u32], logo: bool, ctrl: bool, alt: bool, shift: bool) -> bool { - self.modifiers == BindingModifiers { logo, ctrl, alt, shift } - && match self.trigger { - BindingTrigger::Keysym(expected) => keysyms.contains(&expected), - BindingTrigger::Physical(expected) => expected == keycode, - BindingTrigger::Swipe { .. } => false, - } - } - - pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option { - let description = match &self.action { - BindingAction::None => return None, - BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => { - if argv.iter().any(|arg| arg == "--full") { - "Capture whole screen" - } else { - "Capture selected area" - } - .into() - } - BindingAction::Spawn(argv) => format!( - "Launch {}", - argv.first() - .and_then(|program| std::path::Path::new(program).file_name()) - .and_then(|name| name.to_str()) - .unwrap_or("application") - ), - BindingAction::Close => "Close window".into(), - BindingAction::Exit => "Log out (with confirmation)".into(), - BindingAction::Focus(direction) => format!("Focus window {direction:?}"), - BindingAction::Move(direction) => format!("Move window {direction:?}"), - BindingAction::Resize(direction) => format!("Resize window {direction:?}"), - BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), - BindingAction::WorkspaceBackAndForth => "Return to last workspace on this monitor".into(), - BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"), - BindingAction::SwitchRelativeWorkspace(next) => { - if *next { "Next workspace" } else { "Previous workspace" }.into() - } - BindingAction::ToggleFullscreen => "Toggle fullscreen".into(), - BindingAction::ToggleMaximized => "Toggle full-width window".into(), - BindingAction::ToggleLayout => "Switch workspace layout".into(), - BindingAction::CycleColumnWidth => "Cycle column width".into(), - BindingAction::CenterColumn => "Center focused column".into(), - BindingAction::Consume => "Join window into column".into(), - BindingAction::Expel => "Move window out of column".into(), - BindingAction::ToggleFloating => "Toggle floating window".into(), - BindingAction::ToggleOverview => "Open or close overview".into(), - BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(), - }; - let mut parts = Vec::new(); - for (enabled, label) in [ - (self.modifiers.logo, "Super"), - (self.modifiers.ctrl, "Ctrl"), - (self.modifiers.alt, "Alt"), - (self.modifiers.shift, "Shift"), - ] { - if enabled { - parts.push(label.to_owned()); - } - } - parts.push(match self.trigger { - BindingTrigger::Keysym(symbol) => { - let name = xkb::keysym_get_name(xkb::Keysym::new(symbol)); - match name.as_str() { - "Return" => "Enter".into(), - "Print" => "Print Screen".into(), - "space" => "Space".into(), - _ if name.len() == 1 => name.to_uppercase(), - _ => name, - } - } - BindingTrigger::Physical(code) => keymap - .and_then(|map| map.key_get_name(code)) - .map(|name| format!("Physical {name}")) - .unwrap_or_else(|| format!("Keycode {}", u32::from(code))), - BindingTrigger::Swipe { fingers, direction } => { - format!("{fingers}-finger swipe {direction:?}") - } - }); - Some(serde_json::json!({"keys": parts.join(" + "), "description": description})) - } - - pub(crate) fn swipe_fingers(&self, count: u32) -> bool { - matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count) - } - - pub(crate) fn matches_swipe(&self, count: u32, target: crate::gestures::SwipeDirection) -> bool { - matches!(self.trigger, BindingTrigger::Swipe { fingers, direction } if fingers == count && direction == target) - } -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)] -#[serde(rename_all = "lowercase")] -enum BindingMatch { - #[default] - Keysym, - Physical, - Swipe, -} - -#[derive(Clone, Debug, Deserialize)] -struct BindingConfig { - keys: String, - #[serde(default, rename = "match")] - match_mode: BindingMatch, - action: Option, - argument: Option, - #[serde(default)] - disabled: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct InputSettings { - pub focus_follows_mouse: bool, - pub xkb_layout: String, - pub xkb_variant: String, - pub xkb_options: Vec, - pub repeat_rate: i32, - pub repeat_delay_ms: i32, - pub touchpad: TouchpadSettings, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct TouchpadSettings { - pub tap: bool, - pub natural_scroll: bool, - pub disable_while_typing: bool, - pub swipe_threshold: u16, -} - -fn default_swipe_threshold() -> u16 { - 80 -} - -#[derive(Debug, Deserialize)] -struct InputConfig { - #[serde(default)] - focus_follows_mouse: bool, - #[serde(default = "default_xkb_layout")] - xkb_layout: String, - #[serde(default)] - xkb_variant: String, - #[serde(default)] - xkb_options: Vec, - #[serde(default = "default_repeat_rate")] - repeat_rate: i32, - #[serde(default = "default_repeat_delay")] - repeat_delay_ms: i32, - #[serde(default)] - touchpad: TouchpadConfig, -} - -impl Default for InputConfig { - fn default() -> Self { - Self { - focus_follows_mouse: false, - xkb_layout: default_xkb_layout(), - xkb_variant: String::new(), - xkb_options: Vec::new(), - repeat_rate: default_repeat_rate(), - repeat_delay_ms: default_repeat_delay(), - touchpad: TouchpadConfig::default(), - } - } -} - -#[derive(Debug, Deserialize)] -struct TouchpadConfig { - #[serde(default = "enabled_by_default")] - tap: bool, - #[serde(default = "enabled_by_default")] - natural_scroll: bool, - #[serde(default = "enabled_by_default")] - disable_while_typing: bool, - #[serde(default = "default_swipe_threshold")] - swipe_threshold: u16, -} - -impl Default for TouchpadConfig { - fn default() -> Self { - Self { - tap: true, - natural_scroll: true, - disable_while_typing: true, - swipe_threshold: default_swipe_threshold(), - } - } -} - -#[derive(Debug, Deserialize)] -struct AnimationsConfig { - #[serde(default = "enabled_by_default")] - enabled: bool, - #[serde(default)] - reduced_motion: bool, - #[serde(default = "default_animation_speed")] - speed: f64, - #[serde(default)] - spring: SpringSettings, - #[serde(default)] - viewport_spring: ViewportSpringSettings, -} - -impl Default for AnimationsConfig { - fn default() -> Self { - Self { - enabled: true, - reduced_motion: false, - speed: 1.0, - spring: SpringSettings::default(), - viewport_spring: ViewportSpringSettings::default(), - } - } -} - -#[derive(Debug, Deserialize)] -struct ViewportSpringSettings { - #[serde(default = "default_viewport_mass")] - mass: f64, - #[serde(default = "default_viewport_stiffness")] - stiffness: f64, - #[serde(default = "default_viewport_damping_ratio")] - damping_ratio: f64, -} - -impl Default for ViewportSpringSettings { - fn default() -> Self { - Self { - mass: default_viewport_mass(), - stiffness: default_viewport_stiffness(), - damping_ratio: default_viewport_damping_ratio(), - } - } -} - -#[derive(Debug, Deserialize)] -struct SpringSettings { - #[serde(default = "default_spring_mass")] - mass: f64, - #[serde(default = "default_spring_stiffness")] - stiffness: f64, - #[serde(default = "default_spring_damping")] - damping: f64, -} - -impl Default for SpringSettings { - fn default() -> Self { - Self { - mass: default_spring_mass(), - stiffness: default_spring_stiffness(), - damping: default_spring_damping(), - } - } -} - #[derive(Debug, Default, Deserialize)] struct WorkspacesConfig { #[serde(default)] @@ -965,321 +292,9 @@ impl Error for ConfigError { } impl Config { - pub fn layout_mode(&self) -> LayoutMode { - match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) { - LayoutModeValue::Scrolling => LayoutMode::Scrolling, - LayoutModeValue::Tree => LayoutMode::Tree, - } - } - - pub fn gap_config(&self) -> Result { - let inner = nonnegative_layout_value(self.layout.inner_gap, "inner_gap")?; - let outer = nonnegative_layout_value(self.layout.outer_gap, "outer_gap")?; - - Ok(GapConfig { - inner, - outer, - smart: self.layout.smart_gaps, - }) - } - - pub fn default_column_width(&self) -> Result { - parse_column_width( - self.scrolling - .default_column_width - .as_ref() - .unwrap_or(&ColumnWidthValue::Proportion(0.5)), - "default_column_width", - ) - } - - pub fn width_presets(&self) -> Result, ConfigError> { - if self.scrolling.width_presets.is_empty() { - return Ok(vec![ - ColumnWidth::Proportion(1.0 / 3.0), - ColumnWidth::Proportion(0.5), - ColumnWidth::Proportion(2.0 / 3.0), - ColumnWidth::Full, - ]); - } - - self.scrolling - .width_presets - .iter() - .map(|value| parse_column_width(value, "width_presets")) - .collect() - } - - pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy { - match self.scrolling.focus_strategy.unwrap_or(FocusStrategyValue::Minimal) { - FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal, - FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center, - FocusStrategyValue::Paged => ViewportFocusStrategy::Paged, - } - } - - pub fn input_settings(&self) -> Result { - if self.input.xkb_layout.trim().is_empty() { - return Err(ConfigError::InvalidInputValue { - field: "xkb_layout", - value: self.input.xkb_layout.clone(), - }); - } - if self.input.repeat_rate <= 0 { - return Err(ConfigError::InvalidInputValue { - field: "repeat_rate", - value: self.input.repeat_rate.to_string(), - }); - } - if self.input.repeat_delay_ms < 0 { - return Err(ConfigError::InvalidInputValue { - field: "repeat_delay_ms", - value: self.input.repeat_delay_ms.to_string(), - }); - } - - if !(16..=1000).contains(&self.input.touchpad.swipe_threshold) { - return Err(ConfigError::InvalidInputValue { - field: "touchpad.swipe_threshold", - value: self.input.touchpad.swipe_threshold.to_string(), - }); - } - Ok(InputSettings { - focus_follows_mouse: self.input.focus_follows_mouse, - xkb_layout: self.input.xkb_layout.clone(), - xkb_variant: self.input.xkb_variant.clone(), - xkb_options: self.input.xkb_options.clone(), - repeat_rate: self.input.repeat_rate, - repeat_delay_ms: self.input.repeat_delay_ms, - touchpad: TouchpadSettings { - tap: self.input.touchpad.tap, - natural_scroll: self.input.touchpad.natural_scroll, - disable_while_typing: self.input.touchpad.disable_while_typing, - swipe_threshold: self.input.touchpad.swipe_threshold, - }, - }) - } - - pub fn bindings(&self, input: &InputSettings) -> Result, ConfigError> { - let mut commands = HashMap::from([ - ("terminal".to_owned(), vec!["foot".to_owned()]), - ("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]), - ( - "screenshot-full".to_owned(), - vec!["ferese-screenshot".to_owned(), "--full".to_owned()], - ), - ]); - commands.extend(self.commands.clone()); - validate_commands(&commands)?; - - let keymap = physical_keymap(input)?; - let mut bindings = default_bindings() - .into_iter() - .map(|binding| parse_binding(&binding, &commands, &keymap)) - .collect::, _>>()?; - let mut supplied = HashSet::new(); - - for configured in &self.bindings { - let identity = binding_identity(configured, &keymap)?; - if !supplied.insert(identity.clone()) { - return Err(ConfigError::InvalidBinding(format!( - "duplicate binding {:?}", - configured.keys - ))); - } - - bindings.retain(|binding| binding_identity_for_runtime(binding) != identity); - if configured.disabled { - if configured.action.is_some() || configured.argument.is_some() { - return Err(ConfigError::InvalidBinding(format!( - "disabled binding {:?} cannot have an action or argument", - configured.keys - ))); - } - } else { - bindings.push(parse_binding(configured, &commands, &keymap)?); - } - } - - Ok(bindings) - } - pub fn window_rules(&self) -> Result, ConfigError> { window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule) } - - pub fn output_profiles(&self) -> Result, ConfigError> { - let mut names = HashSet::new(); - - self.output_profiles - .iter() - .map(|profile| { - let name = profile.name.trim(); - if name.is_empty() { - return Err(ConfigError::InvalidOutputProfile( - "profile names cannot be empty".to_owned(), - )); - } - if !names.insert(name.to_owned()) { - return Err(ConfigError::InvalidOutputProfile(format!( - "duplicate profile name {name:?}" - ))); - } - if profile.outputs.is_empty() { - return Err(ConfigError::InvalidOutputProfile(format!( - "profile {name:?} has no outputs" - ))); - } - - let mut matchers = HashSet::new(); - let outputs = profile - .outputs - .iter() - .map(|output| { - let matcher = output.matcher.trim(); - if matcher.is_empty() { - return Err(ConfigError::InvalidOutputProfile(format!( - "profile {name:?} contains an empty output matcher" - ))); - } - if !matchers.insert(matcher.to_owned()) { - return Err(ConfigError::InvalidOutputProfile(format!( - "profile {name:?} repeats output matcher {matcher:?}" - ))); - } - if !output.scale.is_finite() || output.scale <= 0.0 { - return Err(ConfigError::InvalidOutputProfile(format!( - "profile {name:?} output {matcher:?} has invalid scale {}", - output.scale - ))); - } - - Ok(OutputSettings { - auto_refresh: output.auto_refresh, - matcher: matcher.to_owned(), - enabled: output.enabled, - mode: output - .mode - .as_deref() - .map(parse_output_mode) - .transpose() - .map_err(ConfigError::InvalidOutputProfile)?, - scale: output.scale, - transform: output.transform, - position: output.position, - }) - }) - .collect::, ConfigError>>()?; - - Ok(OutputProfile { - name: name.to_owned(), - outputs, - }) - }) - .collect() - } - - pub fn theme_settings(&self) -> Result { - let border_width = nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?; - let focus_ring_width = - nonnegative_theme_value(self.theme.geometry.focus_ring_width, "geometry.focus_ring_width")?; - let window_radius = nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?; - let shadow_offset_y = finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?; - let shadow_blur = nonnegative_theme_value(self.theme.shadow.soft.blur, "shadow.soft.blur")?; - let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?; - - let material_radius = nonnegative_theme_value( - self.theme - .geometry - .shell_radius - .or(self.appearance.corner_radius) - .or(self.theme.geometry.top_bar_radius) - .unwrap_or(14.0), - "geometry.shell_radius", - )?; - Ok(ThemeSettings { - border_width, - focus_ring_width, - border_color: parse_color(&self.theme.colors.border, "colors.border")?, - text_primary_color: parse_color(&self.theme.colors.text_primary, "colors.text_primary")?, - accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?, - border_gradient: self.theme.border.settings("border")?, - focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?, - shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?, - surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?, - bar_background_color: parse_color( - self.theme - .surface - .bar - .background - .as_deref() - .unwrap_or(&self.theme.colors.surface_base), - "surface.bar.background", - )?, - shell_opacity: unit_theme_value(self.theme.material.opacity, "material.opacity")?, - inactive_dim: InactiveDimSettings { - enabled: self.appearance.inactive_dim.enabled, - amount: unit_theme_value(self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount")?, - duration_ms: nonnegative_theme_value( - self.appearance.inactive_dim.duration_ms, - "appearance.inactive_dim.duration_ms", - )?, - }, - window_radius, - shadow_offset_y, - shadow_blur, - shadow_opacity, - material_style: self.theme.material.style, - backdrop_blur: nonnegative_theme_value(self.theme.material.blur_radius, "material.blur_radius")?.min(32.0), - material_tint_strength: unit_theme_value(self.theme.material.tint_strength, "material.tint_strength")?, - material_radius, - panel_radius: material_radius, - }) - } - - pub fn animations_enabled(&self) -> bool { - self.animations.enabled && !self.animations.reduced_motion - } - - pub fn animation_speed(&self) -> Result { - positive_animation_value(self.animations.speed, "speed") - } - - pub fn spring_config(&self) -> Result { - let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?; - let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?; - let damping = self.animations.spring.damping; - if !damping.is_finite() || damping < 0.0 { - return Err(ConfigError::InvalidAnimationValue { - field: "spring.damping", - value: damping, - }); - } - - Ok(SpringConfig { - mass, - stiffness, - damping, - ..SpringConfig::default() - }) - } - - pub fn viewport_spring_config(&self) -> Result { - let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?; - let stiffness = - positive_animation_value(self.animations.viewport_spring.stiffness, "viewport_spring.stiffness")?; - let damping_ratio = positive_animation_value( - self.animations.viewport_spring.damping_ratio, - "viewport_spring.damping_ratio", - )?; - - Ok(SpringConfig { - mass, - stiffness, - damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(), - ..SpringConfig::default() - }) - } } #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -1289,449 +304,6 @@ struct BindingIdentity { key: u32, } -fn validate_commands(commands: &HashMap>) -> Result<(), ConfigError> { - for (name, argv) in commands { - if name.trim().is_empty() { - return Err(ConfigError::InvalidBinding("command names cannot be empty".to_owned())); - } - if argv.is_empty() || argv[0].is_empty() { - return Err(ConfigError::InvalidBinding(format!( - "command {name:?} must contain a program" - ))); - } - } - - Ok(()) -} - -pub(crate) fn physical_keymap(input: &InputSettings) -> Result { - let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); - let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(",")); - - xkb::Keymap::new_from_names( - &context, - "", - "", - &input.xkb_layout, - &input.xkb_variant, - options, - xkb::KEYMAP_COMPILE_NO_FLAGS, - ) - .ok_or_else(|| ConfigError::InvalidBinding("failed to compile the XKB keymap".to_owned())) -} - -fn parse_binding( - configured: &BindingConfig, - commands: &HashMap>, - keymap: &xkb::Keymap, -) -> Result { - if configured.disabled { - return Err(ConfigError::InvalidBinding(format!( - "disabled binding {:?} cannot be executed", - configured.keys - ))); - } - - let identity = binding_identity(configured, keymap)?; - let action = configured - .action - .as_deref() - .ok_or_else(|| ConfigError::InvalidBinding(format!("binding {:?} has no action", configured.keys)))?; - let action = parse_action(action, configured.argument.as_deref(), commands)?; - let trigger = match identity.match_mode { - BindingMatch::Keysym => BindingTrigger::Keysym(identity.key), - BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)), - BindingMatch::Swipe => BindingTrigger::Swipe { - fingers: identity.key / 4, - direction: [ - crate::gestures::SwipeDirection::Up, - crate::gestures::SwipeDirection::Down, - crate::gestures::SwipeDirection::Left, - crate::gestures::SwipeDirection::Right, - ][(identity.key % 4) as usize], - }, - }; - - Ok(Binding { - modifiers: identity.modifiers, - trigger, - action, - }) -} - -fn binding_identity(configured: &BindingConfig, keymap: &xkb::Keymap) -> Result { - let (modifiers, key) = parse_chord(&configured.keys)?; - if let Some((fingers, direction)) = parse_swipe(&key) { - if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() { - return Err(ConfigError::InvalidBinding( - "swipes cannot use keyboard modifiers or physical key matching".into(), - )); - } - return Ok(BindingIdentity { - modifiers, - match_mode: BindingMatch::Swipe, - key: fingers * 4 + direction as u32, - }); - } - let key = match configured.match_mode { - BindingMatch::Keysym => parse_keysym(&key)?, - BindingMatch::Physical => keymap - .key_by_name(&key.to_ascii_uppercase()) - .map(Keycode::raw) - .ok_or_else(|| { - ConfigError::InvalidBinding(format!( - "unknown XKB physical key name {key:?} in {:?}", - configured.keys - )) - })?, - BindingMatch::Swipe => { - return Err(ConfigError::InvalidBinding( - "gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(), - )); - } - }; - - Ok(BindingIdentity { - modifiers, - match_mode: configured.match_mode, - key, - }) -} - -fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity { - let (match_mode, key) = match binding.trigger { - BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key), - BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()), - BindingTrigger::Swipe { fingers, direction } => (BindingMatch::Swipe, fingers * 4 + direction as u32), - }; - - BindingIdentity { - modifiers: binding.modifiers, - match_mode, - key, - } -} - -fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> { - let mut modifiers = BindingModifiers::default(); - let mut key = None; - - for component in chord.split('+').map(str::trim) { - if component.is_empty() { - return Err(ConfigError::InvalidBinding(format!("invalid key chord {chord:?}"))); - } - - let slot = match component.to_ascii_lowercase().as_str() { - "super" | "logo" | "mod4" => Some(&mut modifiers.logo), - "ctrl" | "control" => Some(&mut modifiers.ctrl), - "alt" => Some(&mut modifiers.alt), - "shift" => Some(&mut modifiers.shift), - _ => None, - }; - if let Some(slot) = slot { - if *slot { - return Err(ConfigError::InvalidBinding(format!("duplicate modifier in {chord:?}"))); - } - *slot = true; - } else if key.replace(component.to_owned()).is_some() { - return Err(ConfigError::InvalidBinding(format!( - "key chord {chord:?} contains more than one key" - ))); - } - } - - let key = key.ok_or_else(|| ConfigError::InvalidBinding(format!("key chord {chord:?} does not contain a key")))?; - Ok((modifiers, key)) -} - -fn parse_swipe(key: &str) -> Option<(u32, crate::gestures::SwipeDirection)> { - use crate::gestures::SwipeDirection; - let lower = key.to_ascii_lowercase(); - let rest = lower.strip_prefix("swipe")?; - let fingers = rest.get(..1)?.parse::().ok()?; - if !(3..=5).contains(&fingers) { - return None; - } - let direction = match rest.get(1..)? { - "up" => SwipeDirection::Up, - "down" => SwipeDirection::Down, - "left" => SwipeDirection::Left, - "right" => SwipeDirection::Right, - _ => return None, - }; - Some((fingers, direction)) -} - -fn parse_keysym(name: &str) -> Result { - let normalized = match name { - "Enter" | "enter" => "Return".to_owned(), - "Space" | "space" => "space".to_owned(), - "[" => "bracketleft".to_owned(), - "]" => "bracketright".to_owned(), - name if name.len() == 1 => name.to_ascii_lowercase(), - name => name.to_owned(), - }; - let mut symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_NO_FLAGS); - if symbol.raw() == keysyms::KEY_NoSymbol { - symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_CASE_INSENSITIVE); - } - if symbol.raw() == keysyms::KEY_NoSymbol { - Err(ConfigError::InvalidBinding(format!("unknown keysym {name:?}"))) - } else { - Ok(symbol.raw()) - } -} - -fn parse_action( - action: &str, - argument: Option<&str>, - commands: &HashMap>, -) -> Result { - let no_argument = || { - if argument.is_some() { - Err(ConfigError::InvalidBinding(format!( - "action {action:?} does not accept an argument" - ))) - } else { - Ok(()) - } - }; - let required_argument = - || argument.ok_or_else(|| ConfigError::InvalidBinding(format!("action {action:?} requires an argument"))); - - match action { - "none" => { - no_argument()?; - Ok(BindingAction::None) - } - "workspace-next" | "workspace-previous" => { - no_argument()?; - Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next")) - } - "workspace-back-and-forth" => { - no_argument()?; - Ok(BindingAction::WorkspaceBackAndForth) - } - "spawn" => { - let command = required_argument()?; - let argv = commands - .get(command) - .ok_or_else(|| ConfigError::InvalidBinding(format!("unknown command {command:?}")))?; - Ok(BindingAction::Spawn(argv.clone())) - } - "close" => { - 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(parse_direction(required_argument()?)?)), - "workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(required_argument()?)?)), - "move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(required_argument()?)?)), - "toggle-maximized" => { - no_argument()?; - Ok(BindingAction::ToggleMaximized) - } - "toggle-fullscreen" => { - no_argument()?; - Ok(BindingAction::ToggleFullscreen) - } - "toggle-layout" => { - no_argument()?; - Ok(BindingAction::ToggleLayout) - } - "cycle-column-width" => { - no_argument()?; - Ok(BindingAction::CycleColumnWidth) - } - "center-column" => { - no_argument()?; - Ok(BindingAction::CenterColumn) - } - "consume" => { - no_argument()?; - Ok(BindingAction::Consume) - } - "expel" => { - no_argument()?; - Ok(BindingAction::Expel) - } - "toggle-floating" => { - no_argument()?; - Ok(BindingAction::ToggleFloating) - } - "toggle-overview" => { - no_argument()?; - Ok(BindingAction::ToggleOverview) - } - "toggle-keybinding-guide" => { - no_argument()?; - Ok(BindingAction::ToggleKeybindingGuide) - } - _ => Err(ConfigError::InvalidBinding(format!("unknown action {action:?}"))), - } -} - -fn parse_direction(argument: &str) -> Result { - match argument { - "left" => Ok(Direction::Left), - "right" => Ok(Direction::Right), - "up" => Ok(Direction::Up), - "down" => Ok(Direction::Down), - _ => Err(ConfigError::InvalidBinding(format!("invalid direction {argument:?}"))), - } -} - -fn parse_workspace(argument: &str) -> Result { - match argument.parse() { - Ok(workspace) if workspace > 0 => Ok(workspace), - _ => Err(ConfigError::InvalidBinding(format!("invalid workspace {argument:?}"))), - } -} - -fn default_bindings() -> Vec { - let mut bindings = vec![ - binding("Super+Enter", "spawn", Some("terminal")), - binding("Super+Shift+S", "spawn", Some("screenshot")), - binding("Print", "spawn", Some("screenshot-full")), - binding("Swipe3Up", "workspace-next", None), - binding("Swipe3Down", "workspace-previous", None), - binding("Swipe3Left", "focus", Some("right")), - binding("Swipe3Right", "focus", Some("left")), - binding("Super+Q", "close", 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), - binding("Super+[", "consume", None), - binding("Super+]", "expel", None), - binding("Super+Shift+Space", "toggle-floating", None), - binding("Super+Tab", "toggle-overview", None), - binding("Super+F1", "toggle-keybinding-guide", None), - binding("Super+Escape", "workspace-back-and-forth", None), - binding("Super+Shift+E", "exit", None), - ]; - - for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] { - bindings.push(binding("Super+".to_owned() + key, "focus", Some(direction))); - bindings.push(binding("Super+Shift+".to_owned() + key, "move", Some(direction))); - bindings.push(binding("Super+Ctrl+".to_owned() + key, "resize", Some(direction))); - } - - for workspace in 1..=9 { - let workspace = workspace.to_string(); - bindings.push(binding(format!("Super+{workspace}"), "workspace", Some(&workspace))); - bindings.push(binding( - format!("Super+Shift+{workspace}"), - "move-to-workspace", - Some(&workspace), - )); - } - - bindings -} - -fn binding(keys: impl Into, action: impl Into, argument: Option<&str>) -> BindingConfig { - BindingConfig { - keys: keys.into(), - match_mode: BindingMatch::Keysym, - action: Some(action.into()), - argument: argument.map(str::to_owned), - disabled: false, - } -} - -fn positive_animation_value(value: f64, field: &'static str) -> Result { - if value.is_finite() && value > 0.0 { - Ok(value) - } else { - Err(ConfigError::InvalidAnimationValue { field, value }) - } -} - -fn nonnegative_layout_value(value: f64, field: &'static str) -> Result { - if value.is_finite() && value >= 0.0 { - Ok(value) - } else { - Err(ConfigError::InvalidLayoutValue { field, value }) - } -} - -fn nonnegative_theme_value(value: f64, field: &'static str) -> Result { - if value.is_finite() && value >= 0.0 { - Ok(value) - } else { - Err(ConfigError::InvalidThemeValue { - field, - value: value.to_string(), - }) - } -} - -fn finite_theme_value(value: f64, field: &'static str) -> Result { - if value.is_finite() { - Ok(value) - } else { - Err(ConfigError::InvalidThemeValue { - field, - value: value.to_string(), - }) - } -} - -fn unit_theme_value(value: f64, field: &'static str) -> Result { - if value.is_finite() && (0.0..=1.0).contains(&value) { - Ok(value) - } else { - Err(ConfigError::InvalidThemeValue { - field, - value: value.to_string(), - }) - } -} - -fn parse_color(value: &str, field: &'static str) -> Result { - let digits = value.strip_prefix('#').unwrap_or(value); - if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) { - return Err(ConfigError::InvalidThemeValue { - field, - value: value.to_owned(), - }); - } - - let parse_channel = |offset| u8::from_str_radix(&digits[offset..offset + 2], 16).ok(); - let Some((red, green, blue)) = parse_channel(0) - .zip(parse_channel(2)) - .zip(parse_channel(4)) - .map(|((red, green), blue)| (red, green, blue)) - else { - return Err(ConfigError::InvalidThemeValue { - field, - value: value.to_owned(), - }); - }; - let alpha = if digits.len() == 8 { - parse_channel(6).ok_or_else(|| ConfigError::InvalidThemeValue { - field, - value: value.to_owned(), - })? - } else { - u8::MAX - }; - - Ok(RgbaColor([ - f32::from(red) / 255.0, - f32::from(green) / 255.0, - f32::from(blue) / 255.0, - f32::from(alpha) / 255.0, - ])) -} - const fn enabled_by_default() -> bool { true } @@ -1744,26 +316,6 @@ const fn default_outer_gap() -> f64 { 4.0 } -fn default_border_color() -> String { - "#FFFFFF18".to_owned() -} - -fn default_text_primary_color() -> String { - "#F4F7FB".to_owned() -} - -fn default_surface_base_color() -> String { - "#111821".to_owned() -} - -fn default_accent_color() -> String { - "#3D7BE6".to_owned() -} - -fn default_shadow_color() -> String { - "#00000055".to_owned() -} - const fn default_border_width() -> f64 { 1.0 } @@ -1788,10 +340,6 @@ const fn default_shadow_opacity() -> f64 { 0.20 } -fn default_xkb_layout() -> String { - "us".to_owned() -} - const fn default_repeat_rate() -> i32 { 25 } @@ -1832,61 +380,6 @@ const fn default_true() -> bool { true } -const fn default_output_scale() -> f64 { - 1.0 -} - -fn parse_output_mode(value: &str) -> Result { - let (size, refresh) = value - .trim() - .split_once('@') - .map_or((value.trim(), None), |(size, refresh)| (size, Some(refresh))); - let (width, height) = size - .split_once('x') - .ok_or_else(|| format!("mode {value:?} must use WIDTHxHEIGHT or WIDTHxHEIGHT@REFRESH"))?; - let width = width - .parse::() - .map_err(|_| format!("mode {value:?} has an invalid width"))?; - let height = height - .parse::() - .map_err(|_| format!("mode {value:?} has an invalid height"))?; - if width == 0 || height == 0 { - return Err(format!("mode {value:?} dimensions must be positive")); - } - let refresh_millihertz = refresh - .map(|refresh| { - let hertz = refresh - .parse::() - .map_err(|_| format!("mode {value:?} has an invalid refresh rate"))?; - if !hertz.is_finite() || hertz <= 0.0 || hertz > u32::MAX as f64 / 1_000.0 { - return Err(format!("mode {value:?} has an invalid refresh rate")); - } - - Ok((hertz * 1_000.0).round() as u32) - }) - .transpose()?; - - Ok(OutputModeRequest { - width, - height, - refresh_millihertz, - }) -} - -fn parse_column_width(value: &ColumnWidthValue, field: &'static str) -> Result { - match value { - ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => Ok(ColumnWidth::Proportion(*value)), - ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => Ok(ColumnWidth::Full), - value => Err(ConfigError::InvalidColumnWidth { - field, - value: match value { - ColumnWidthValue::Proportion(value) => value.to_string(), - ColumnWidthValue::Named(value) => format!("{value:?}"), - }, - }), - } -} - pub(crate) fn config_path() -> Option { ferese_config::config_path() } diff --git a/compositor/src/config/appearance.rs b/compositor/src/config/appearance.rs new file mode 100644 index 0000000..6f52aca --- /dev/null +++ b/compositor/src/config/appearance.rs @@ -0,0 +1,414 @@ +use super::*; + +impl Config { + pub fn theme_settings(&self) -> Result { + let border_width = nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?; + let focus_ring_width = + nonnegative_theme_value(self.theme.geometry.focus_ring_width, "geometry.focus_ring_width")?; + let window_radius = nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?; + let shadow_offset_y = finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?; + let shadow_blur = nonnegative_theme_value(self.theme.shadow.soft.blur, "shadow.soft.blur")?; + let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?; + + let material_radius = nonnegative_theme_value( + self.theme + .geometry + .shell_radius + .or(self.appearance.corner_radius) + .or(self.theme.geometry.top_bar_radius) + .unwrap_or(14.0), + "geometry.shell_radius", + )?; + Ok(ThemeSettings { + border_width, + focus_ring_width, + border_color: parse_color(&self.theme.colors.border, "colors.border")?, + text_primary_color: parse_color(&self.theme.colors.text_primary, "colors.text_primary")?, + accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?, + border_gradient: self.theme.border.settings("border")?, + focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?, + shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?, + surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?, + bar_background_color: parse_color( + self.theme + .surface + .bar + .background + .as_deref() + .unwrap_or(&self.theme.colors.surface_base), + "surface.bar.background", + )?, + shell_opacity: unit_theme_value(self.theme.material.opacity, "material.opacity")?, + inactive_dim: InactiveDimSettings { + enabled: self.appearance.inactive_dim.enabled, + amount: unit_theme_value(self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount")?, + duration_ms: nonnegative_theme_value( + self.appearance.inactive_dim.duration_ms, + "appearance.inactive_dim.duration_ms", + )?, + }, + window_radius, + shadow_offset_y, + shadow_blur, + shadow_opacity, + material_style: self.theme.material.style, + backdrop_blur: nonnegative_theme_value(self.theme.material.blur_radius, "material.blur_radius")?.min(32.0), + material_tint_strength: unit_theme_value(self.theme.material.tint_strength, "material.tint_strength")?, + material_radius, + panel_radius: material_radius, + }) + } +} + +pub(super) fn nonnegative_theme_value(value: f64, field: &'static str) -> Result { + if value.is_finite() && value >= 0.0 { + Ok(value) + } else { + Err(ConfigError::InvalidThemeValue { + field, + value: value.to_string(), + }) + } +} + +pub(super) fn finite_theme_value(value: f64, field: &'static str) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(ConfigError::InvalidThemeValue { + field, + value: value.to_string(), + }) + } +} + +pub(super) fn unit_theme_value(value: f64, field: &'static str) -> Result { + if value.is_finite() && (0.0..=1.0).contains(&value) { + Ok(value) + } else { + Err(ConfigError::InvalidThemeValue { + field, + value: value.to_string(), + }) + } +} + +pub(super) fn parse_color(value: &str, field: &'static str) -> Result { + let digits = value.strip_prefix('#').unwrap_or(value); + if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) { + return Err(ConfigError::InvalidThemeValue { + field, + value: value.to_owned(), + }); + } + + let parse_channel = |offset| u8::from_str_radix(&digits[offset..offset + 2], 16).ok(); + let Some((red, green, blue)) = parse_channel(0) + .zip(parse_channel(2)) + .zip(parse_channel(4)) + .map(|((red, green), blue)| (red, green, blue)) + else { + return Err(ConfigError::InvalidThemeValue { + field, + value: value.to_owned(), + }); + }; + let alpha = if digits.len() == 8 { + parse_channel(6).ok_or_else(|| ConfigError::InvalidThemeValue { + field, + value: value.to_owned(), + })? + } else { + u8::MAX + }; + + Ok(RgbaColor([ + f32::from(red) / 255.0, + f32::from(green) / 255.0, + f32::from(blue) / 255.0, + f32::from(alpha) / 255.0, + ])) +} + +pub(super) fn default_border_color() -> String { + "#FFFFFF18".to_owned() +} + +pub(super) fn default_text_primary_color() -> String { + "#F4F7FB".to_owned() +} + +pub(super) fn default_surface_base_color() -> String { + "#111821".to_owned() +} + +pub(super) fn default_accent_color() -> String { + "#3D7BE6".to_owned() +} + +pub(super) fn default_shadow_color() -> String { + "#00000055".to_owned() +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RgbaColor(pub [f32; 4]); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BorderGradient { + pub from: RgbaColor, + pub to: RgbaColor, + pub angle: f64, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct BorderPaintConfig { + pub(super) gradient: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct BorderGradientConfig { + pub(super) from: String, + pub(super) to: String, + #[serde(default)] + pub(super) angle: f64, +} + +impl BorderPaintConfig { + fn settings(&self, name: &str) -> Result, ConfigError> { + let (from_name, to_name, angle_name) = if name == "focus_ring" { + ( + "focus_ring.gradient.from", + "focus_ring.gradient.to", + "focus_ring.gradient.angle", + ) + } else { + ("border.gradient.from", "border.gradient.to", "border.gradient.angle") + }; + self.gradient + .as_ref() + .map(|gradient| { + Ok(BorderGradient { + from: parse_color(&gradient.from, from_name)?, + to: parse_color(&gradient.to, to_name)?, + angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0), + }) + }) + .transpose() + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ThemeSettings { + pub border_width: f64, + pub focus_ring_width: f64, + pub border_color: RgbaColor, + pub accent_color: RgbaColor, + pub border_gradient: Option, + pub focus_ring_gradient: Option, + pub shadow_color: RgbaColor, + pub surface_base_color: RgbaColor, + pub bar_background_color: RgbaColor, + pub text_primary_color: RgbaColor, + pub shell_opacity: f64, + pub inactive_dim: InactiveDimSettings, + pub window_radius: f64, + pub shadow_offset_y: f64, + pub shadow_blur: f64, + pub shadow_opacity: f64, + pub material_style: MaterialStyle, + pub backdrop_blur: f64, + pub material_tint_strength: f64, + pub material_radius: f64, + pub panel_radius: f64, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum MaterialStyle { + Translucent, + #[default] + Solid, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct AppearanceConfig { + pub(super) corner_radius: Option, + #[serde(default)] + pub(super) inactive_dim: InactiveDimConfig, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct InactiveDimSettings { + pub enabled: bool, + pub amount: f64, + pub duration_ms: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub(super) struct InactiveDimConfig { + pub(super) enabled: bool, + pub(super) amount: f64, + pub(super) duration_ms: f64, +} + +impl Default for InactiveDimConfig { + fn default() -> Self { + Self { + enabled: false, + amount: 0.15, + duration_ms: 150.0, + } + } +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct ThemeConfig { + #[serde(default)] + pub(super) border: BorderPaintConfig, + #[serde(default)] + pub(super) focus_ring: BorderPaintConfig, + #[serde(default)] + pub(super) typography: OverviewTypographyConfig, + #[serde(default)] + pub(super) background: crate::wallpaper::WallpaperConfig, + #[serde(default)] + pub(super) colors: ThemeColorsConfig, + #[serde(default)] + pub(super) geometry: ThemeGeometryConfig, + #[serde(default)] + pub(super) shadow: ThemeShadowConfig, + #[serde(default)] + pub(super) material: ThemeMaterialConfig, + #[serde(default)] + pub(super) surface: ThemeSurfaceConfig, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct ThemeSurfaceConfig { + #[serde(default)] + pub(super) bar: ThemeBarConfig, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct ThemeBarConfig { + pub(super) background: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct OverviewTypographyConfig { + pub(super) font_family: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ThemeMaterialConfig { + #[serde(default = "default_shell_opacity")] + pub(super) opacity: f64, + #[serde(default)] + pub(super) style: MaterialStyle, + #[serde(default = "default_backdrop_blur")] + pub(super) blur_radius: f64, + #[serde(default = "default_material_tint_strength")] + pub(super) tint_strength: f64, +} + +impl Default for ThemeMaterialConfig { + fn default() -> Self { + Self { + style: MaterialStyle::Solid, + opacity: default_shell_opacity(), + blur_radius: default_backdrop_blur(), + tint_strength: default_material_tint_strength(), + } + } +} + +fn default_shell_opacity() -> f64 { + ferese_config::DEFAULT_MATERIAL_OPACITY +} + +fn default_material_tint_strength() -> f64 { + 0.5 +} + +fn default_backdrop_blur() -> f64 { + 12.0 +} + +#[derive(Debug, Deserialize)] +pub(super) struct ThemeColorsConfig { + #[serde(default = "default_text_primary_color")] + pub(super) text_primary: String, + #[serde(default = "default_surface_base_color")] + pub(super) surface_base: String, + #[serde(default = "default_border_color")] + pub(super) border: String, + #[serde(default = "default_accent_color")] + pub(super) accent: String, + #[serde(default = "default_shadow_color")] + pub(super) shadow: String, +} + +impl Default for ThemeColorsConfig { + fn default() -> Self { + Self { + surface_base: default_surface_base_color(), + text_primary: default_text_primary_color(), + border: default_border_color(), + accent: default_accent_color(), + shadow: default_shadow_color(), + } + } +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct ThemeShadowConfig { + #[serde(default)] + pub(super) soft: SoftShadowConfig, +} + +#[derive(Debug, Deserialize)] +pub(super) struct SoftShadowConfig { + #[serde(default = "default_shadow_offset_y")] + pub(super) offset_y: f64, + #[serde(default = "default_shadow_blur")] + pub(super) blur: f64, + #[serde(default = "default_shadow_opacity")] + pub(super) opacity: f64, +} + +impl Default for SoftShadowConfig { + fn default() -> Self { + Self { + offset_y: default_shadow_offset_y(), + blur: default_shadow_blur(), + opacity: default_shadow_opacity(), + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct ThemeGeometryConfig { + #[serde(default = "default_border_width")] + pub(super) border_width: f64, + #[serde(default = "default_focus_ring_width")] + pub(super) focus_ring_width: f64, + #[serde(default = "default_window_radius")] + pub(super) window_radius: f64, + #[serde(default)] + pub(super) top_bar_radius: Option, + #[serde(default)] + pub(super) shell_radius: Option, +} + +impl Default for ThemeGeometryConfig { + fn default() -> Self { + Self { + border_width: default_border_width(), + focus_ring_width: default_focus_ring_width(), + window_radius: default_window_radius(), + top_bar_radius: None, + shell_radius: None, + } + } +} diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs new file mode 100644 index 0000000..d5c7ef4 --- /dev/null +++ b/compositor/src/config/bindings.rs @@ -0,0 +1,621 @@ +use super::*; + +impl Config { + pub fn bindings(&self, input: &InputSettings) -> Result, ConfigError> { + let mut commands = HashMap::from([ + ("terminal".to_owned(), vec!["foot".to_owned()]), + ("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]), + ( + "screenshot-full".to_owned(), + vec!["ferese-screenshot".to_owned(), "--full".to_owned()], + ), + ]); + commands.extend(self.commands.clone()); + validate_commands(&commands)?; + + let keymap = physical_keymap(input)?; + let mut bindings = default_bindings() + .into_iter() + .map(|binding| parse_binding(&binding, &commands, &keymap)) + .collect::, _>>()?; + let mut supplied = HashSet::new(); + + for configured in &self.bindings { + let identity = binding_identity(configured, &keymap)?; + if !supplied.insert(identity.clone()) { + return Err(ConfigError::InvalidBinding(format!( + "duplicate binding {:?}", + configured.keys + ))); + } + + bindings.retain(|binding| binding_identity_for_runtime(binding) != identity); + if configured.disabled { + if configured.action.is_some() || configured.argument.is_some() { + return Err(ConfigError::InvalidBinding(format!( + "disabled binding {:?} cannot have an action or argument", + configured.keys + ))); + } + } else { + bindings.push(parse_binding(configured, &commands, &keymap)?); + } + } + + Ok(bindings) + } +} + +pub(super) fn validate_commands(commands: &HashMap>) -> Result<(), ConfigError> { + for (name, argv) in commands { + if name.trim().is_empty() { + return Err(ConfigError::InvalidBinding("command names cannot be empty".to_owned())); + } + if argv.is_empty() || argv[0].is_empty() { + return Err(ConfigError::InvalidBinding(format!( + "command {name:?} must contain a program" + ))); + } + } + + Ok(()) +} + +pub(crate) fn physical_keymap(input: &InputSettings) -> Result { + let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); + let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(",")); + + xkb::Keymap::new_from_names( + &context, + "", + "", + &input.xkb_layout, + &input.xkb_variant, + options, + xkb::KEYMAP_COMPILE_NO_FLAGS, + ) + .ok_or_else(|| ConfigError::InvalidBinding("failed to compile the XKB keymap".to_owned())) +} + +pub(super) fn parse_binding( + configured: &BindingConfig, + commands: &HashMap>, + keymap: &xkb::Keymap, +) -> Result { + if configured.disabled { + return Err(ConfigError::InvalidBinding(format!( + "disabled binding {:?} cannot be executed", + configured.keys + ))); + } + + let identity = binding_identity(configured, keymap)?; + let action = configured + .action + .as_deref() + .ok_or_else(|| ConfigError::InvalidBinding(format!("binding {:?} has no action", configured.keys)))?; + let action = parse_action(action, configured.argument.as_deref(), commands)?; + let trigger = match identity.match_mode { + BindingMatch::Keysym => BindingTrigger::Keysym(identity.key), + BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)), + BindingMatch::Swipe => BindingTrigger::Swipe { + fingers: identity.key / 4, + direction: [ + crate::gestures::SwipeDirection::Up, + crate::gestures::SwipeDirection::Down, + crate::gestures::SwipeDirection::Left, + crate::gestures::SwipeDirection::Right, + ][(identity.key % 4) as usize], + }, + }; + + Ok(Binding { + modifiers: identity.modifiers, + trigger, + action, + }) +} + +pub(super) fn binding_identity( + configured: &BindingConfig, + keymap: &xkb::Keymap, +) -> Result { + let (modifiers, key) = parse_chord(&configured.keys)?; + if let Some((fingers, direction)) = parse_swipe(&key) { + if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() { + return Err(ConfigError::InvalidBinding( + "swipes cannot use keyboard modifiers or physical key matching".into(), + )); + } + return Ok(BindingIdentity { + modifiers, + match_mode: BindingMatch::Swipe, + key: fingers * 4 + direction as u32, + }); + } + let key = match configured.match_mode { + BindingMatch::Keysym => parse_keysym(&key)?, + BindingMatch::Physical => keymap + .key_by_name(&key.to_ascii_uppercase()) + .map(Keycode::raw) + .ok_or_else(|| { + ConfigError::InvalidBinding(format!( + "unknown XKB physical key name {key:?} in {:?}", + configured.keys + )) + })?, + BindingMatch::Swipe => { + return Err(ConfigError::InvalidBinding( + "gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(), + )); + } + }; + + Ok(BindingIdentity { + modifiers, + match_mode: configured.match_mode, + key, + }) +} + +pub(super) fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity { + let (match_mode, key) = match binding.trigger { + BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key), + BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()), + BindingTrigger::Swipe { fingers, direction } => (BindingMatch::Swipe, fingers * 4 + direction as u32), + }; + + BindingIdentity { + modifiers: binding.modifiers, + match_mode, + key, + } +} + +pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> { + let mut modifiers = BindingModifiers::default(); + let mut key = None; + + for component in chord.split('+').map(str::trim) { + if component.is_empty() { + return Err(ConfigError::InvalidBinding(format!("invalid key chord {chord:?}"))); + } + + let slot = match component.to_ascii_lowercase().as_str() { + "super" | "logo" | "mod4" => Some(&mut modifiers.logo), + "ctrl" | "control" => Some(&mut modifiers.ctrl), + "alt" => Some(&mut modifiers.alt), + "shift" => Some(&mut modifiers.shift), + _ => None, + }; + if let Some(slot) = slot { + if *slot { + return Err(ConfigError::InvalidBinding(format!("duplicate modifier in {chord:?}"))); + } + *slot = true; + } else if key.replace(component.to_owned()).is_some() { + return Err(ConfigError::InvalidBinding(format!( + "key chord {chord:?} contains more than one key" + ))); + } + } + + let key = key.ok_or_else(|| ConfigError::InvalidBinding(format!("key chord {chord:?} does not contain a key")))?; + Ok((modifiers, key)) +} + +pub(super) fn parse_swipe(key: &str) -> Option<(u32, crate::gestures::SwipeDirection)> { + use crate::gestures::SwipeDirection; + let lower = key.to_ascii_lowercase(); + let rest = lower.strip_prefix("swipe")?; + let fingers = rest.get(..1)?.parse::().ok()?; + if !(3..=5).contains(&fingers) { + return None; + } + let direction = match rest.get(1..)? { + "up" => SwipeDirection::Up, + "down" => SwipeDirection::Down, + "left" => SwipeDirection::Left, + "right" => SwipeDirection::Right, + _ => return None, + }; + Some((fingers, direction)) +} + +pub(super) fn parse_keysym(name: &str) -> Result { + let normalized = match name { + "Enter" | "enter" => "Return".to_owned(), + "Space" | "space" => "space".to_owned(), + "[" => "bracketleft".to_owned(), + "]" => "bracketright".to_owned(), + name if name.len() == 1 => name.to_ascii_lowercase(), + name => name.to_owned(), + }; + let mut symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_NO_FLAGS); + if symbol.raw() == keysyms::KEY_NoSymbol { + symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_CASE_INSENSITIVE); + } + if symbol.raw() == keysyms::KEY_NoSymbol { + Err(ConfigError::InvalidBinding(format!("unknown keysym {name:?}"))) + } else { + Ok(symbol.raw()) + } +} + +pub(super) fn parse_action( + action: &str, + argument: Option<&str>, + commands: &HashMap>, +) -> Result { + let no_argument = || { + if argument.is_some() { + Err(ConfigError::InvalidBinding(format!( + "action {action:?} does not accept an argument" + ))) + } else { + Ok(()) + } + }; + let required_argument = + || argument.ok_or_else(|| ConfigError::InvalidBinding(format!("action {action:?} requires an argument"))); + + match action { + "none" => { + no_argument()?; + Ok(BindingAction::None) + } + "workspace-next" | "workspace-previous" => { + no_argument()?; + Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next")) + } + "workspace-back-and-forth" => { + no_argument()?; + Ok(BindingAction::WorkspaceBackAndForth) + } + "spawn" => { + let command = required_argument()?; + let argv = commands + .get(command) + .ok_or_else(|| ConfigError::InvalidBinding(format!("unknown command {command:?}")))?; + Ok(BindingAction::Spawn(argv.clone())) + } + "close" => { + 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(parse_direction(required_argument()?)?)), + "workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(required_argument()?)?)), + "move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(required_argument()?)?)), + "toggle-maximized" => { + no_argument()?; + Ok(BindingAction::ToggleMaximized) + } + "toggle-fullscreen" => { + no_argument()?; + Ok(BindingAction::ToggleFullscreen) + } + "toggle-layout" => { + no_argument()?; + Ok(BindingAction::ToggleLayout) + } + "cycle-column-width" => { + no_argument()?; + Ok(BindingAction::CycleColumnWidth) + } + "center-column" => { + no_argument()?; + Ok(BindingAction::CenterColumn) + } + "consume" => { + no_argument()?; + Ok(BindingAction::Consume) + } + "expel" => { + no_argument()?; + Ok(BindingAction::Expel) + } + "toggle-floating" => { + no_argument()?; + Ok(BindingAction::ToggleFloating) + } + "toggle-overview" => { + no_argument()?; + Ok(BindingAction::ToggleOverview) + } + "toggle-keybinding-guide" => { + no_argument()?; + Ok(BindingAction::ToggleKeybindingGuide) + } + _ => Err(ConfigError::InvalidBinding(format!("unknown action {action:?}"))), + } +} + +pub(super) fn parse_direction(argument: &str) -> Result { + match argument { + "left" => Ok(Direction::Left), + "right" => Ok(Direction::Right), + "up" => Ok(Direction::Up), + "down" => Ok(Direction::Down), + _ => Err(ConfigError::InvalidBinding(format!("invalid direction {argument:?}"))), + } +} + +pub(super) fn parse_workspace(argument: &str) -> Result { + match argument.parse() { + Ok(workspace) if workspace > 0 => Ok(workspace), + _ => Err(ConfigError::InvalidBinding(format!("invalid workspace {argument:?}"))), + } +} + +pub(super) fn default_bindings() -> Vec { + let mut bindings = vec![ + binding("Super+Enter", "spawn", Some("terminal")), + binding("Super+Shift+S", "spawn", Some("screenshot")), + binding("Print", "spawn", Some("screenshot-full")), + binding("Swipe3Up", "workspace-next", None), + binding("Swipe3Down", "workspace-previous", None), + binding("Swipe3Left", "focus", Some("right")), + binding("Swipe3Right", "focus", Some("left")), + binding("Super+Q", "close", 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), + binding("Super+[", "consume", None), + binding("Super+]", "expel", None), + binding("Super+Shift+Space", "toggle-floating", None), + binding("Super+Tab", "toggle-overview", None), + binding("Super+F1", "toggle-keybinding-guide", None), + binding("Super+Escape", "workspace-back-and-forth", None), + binding("Super+Shift+E", "exit", None), + ]; + + for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] { + bindings.push(binding("Super+".to_owned() + key, "focus", Some(direction))); + bindings.push(binding("Super+Shift+".to_owned() + key, "move", Some(direction))); + bindings.push(binding("Super+Ctrl+".to_owned() + key, "resize", Some(direction))); + } + + for workspace in 1..=9 { + let workspace = workspace.to_string(); + bindings.push(binding(format!("Super+{workspace}"), "workspace", Some(&workspace))); + bindings.push(binding( + format!("Super+Shift+{workspace}"), + "move-to-workspace", + Some(&workspace), + )); + } + + bindings +} + +pub(super) fn binding(keys: impl Into, action: impl Into, argument: Option<&str>) -> BindingConfig { + BindingConfig { + keys: keys.into(), + match_mode: BindingMatch::Keysym, + action: Some(action.into()), + argument: argument.map(str::to_owned), + disabled: false, + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Binding { + pub(super) modifiers: BindingModifiers, + pub(super) trigger: BindingTrigger, + pub action: BindingAction, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum BindingTrigger { + Keysym(u32), + Physical(Keycode), + Swipe { + fingers: u32, + direction: crate::gestures::SwipeDirection, + }, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub(super) struct BindingModifiers { + pub(super) logo: bool, + pub(super) ctrl: bool, + pub(super) alt: bool, + pub(super) shift: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum BindingAction { + None, + Spawn(Vec), + Close, + Exit, + Focus(ferese_layout::Direction), + Move(ferese_layout::Direction), + Resize(ferese_layout::Direction), + SwitchWorkspace(u8), + SwitchRelativeWorkspace(bool), + WorkspaceBackAndForth, + MoveToWorkspace(u8), + ToggleFullscreen, + ToggleMaximized, + ToggleLayout, + CycleColumnWidth, + CenterColumn, + Consume, + Expel, + ToggleFloating, + ToggleOverview, + ToggleKeybindingGuide, +} + +impl Binding { + pub(crate) fn portal_trigger(chord: &str) -> Result { + if chord.len() > 256 { + return Err(ConfigError::InvalidBinding("Shortcut is too long".into())); + } + let parts = chord.split('+').collect::>(); + if parts + .iter() + .filter(|part| part.trim().eq_ignore_ascii_case("num")) + .count() + > 1 + { + return Err(ConfigError::InvalidBinding("Duplicate NUM modifier".into())); + } + let chord = parts + .into_iter() + .filter(|part| !part.trim().eq_ignore_ascii_case("num")) + .collect::>() + .join("+"); + let (modifiers, key) = parse_chord(&chord)?; + let symbol = parse_keysym(&key)?; + if (modifiers.ctrl + && modifiers.alt + && matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12)) + || (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol) + { + return Err(ConfigError::InvalidBinding("Reserved system shortcut".into())); + } + Ok(Self { + modifiers, + trigger: BindingTrigger::Keysym(symbol), + action: BindingAction::None, + }) + } + + pub(crate) fn conflicts(&self, other: &Self, map: &xkb::Keymap) -> bool { + if self.modifiers != other.modifiers { + return false; + } + match (&self.trigger, &other.trigger) { + (BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right, + (BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code)) + | (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => (0..map.num_layouts_for_key(*code)) + .any(|layout| { + map.key_get_syms_by_level(*code, layout, 0) + .iter() + .any(|key| key.raw() == *symbol) + }), + (BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right, + _ => false, + } + } + + pub fn matches(&self, keycode: Keycode, keysyms: &[u32], logo: bool, ctrl: bool, alt: bool, shift: bool) -> bool { + self.modifiers == BindingModifiers { logo, ctrl, alt, shift } + && match self.trigger { + BindingTrigger::Keysym(expected) => keysyms.contains(&expected), + BindingTrigger::Physical(expected) => expected == keycode, + BindingTrigger::Swipe { .. } => false, + } + } + + pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option { + let description = match &self.action { + BindingAction::None => return None, + BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => { + if argv.iter().any(|arg| arg == "--full") { + "Capture whole screen" + } else { + "Capture selected area" + } + .into() + } + BindingAction::Spawn(argv) => format!( + "Launch {}", + argv.first() + .and_then(|program| std::path::Path::new(program).file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("application") + ), + BindingAction::Close => "Close window".into(), + BindingAction::Exit => "Log out (with confirmation)".into(), + BindingAction::Focus(direction) => format!("Focus window {direction:?}"), + BindingAction::Move(direction) => format!("Move window {direction:?}"), + BindingAction::Resize(direction) => format!("Resize window {direction:?}"), + BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), + BindingAction::WorkspaceBackAndForth => "Return to last workspace on this monitor".into(), + BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"), + BindingAction::SwitchRelativeWorkspace(next) => { + if *next { "Next workspace" } else { "Previous workspace" }.into() + } + BindingAction::ToggleFullscreen => "Toggle fullscreen".into(), + BindingAction::ToggleMaximized => "Toggle full-width window".into(), + BindingAction::ToggleLayout => "Switch workspace layout".into(), + BindingAction::CycleColumnWidth => "Cycle column width".into(), + BindingAction::CenterColumn => "Center focused column".into(), + BindingAction::Consume => "Join window into column".into(), + BindingAction::Expel => "Move window out of column".into(), + BindingAction::ToggleFloating => "Toggle floating window".into(), + BindingAction::ToggleOverview => "Open or close overview".into(), + BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(), + }; + let mut parts = Vec::new(); + for (enabled, label) in [ + (self.modifiers.logo, "Super"), + (self.modifiers.ctrl, "Ctrl"), + (self.modifiers.alt, "Alt"), + (self.modifiers.shift, "Shift"), + ] { + if enabled { + parts.push(label.to_owned()); + } + } + parts.push(match self.trigger { + BindingTrigger::Keysym(symbol) => { + let name = xkb::keysym_get_name(xkb::Keysym::new(symbol)); + match name.as_str() { + "Return" => "Enter".into(), + "Print" => "Print Screen".into(), + "space" => "Space".into(), + _ if name.len() == 1 => name.to_uppercase(), + _ => name, + } + } + BindingTrigger::Physical(code) => keymap + .and_then(|map| map.key_get_name(code)) + .map(|name| format!("Physical {name}")) + .unwrap_or_else(|| format!("Keycode {}", u32::from(code))), + BindingTrigger::Swipe { fingers, direction } => { + format!("{fingers}-finger swipe {direction:?}") + } + }); + Some(serde_json::json!({"keys": parts.join(" + "), "description": description})) + } + + pub(crate) fn swipe_fingers(&self, count: u32) -> bool { + matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count) + } + + pub(crate) fn matches_swipe(&self, count: u32, target: crate::gestures::SwipeDirection) -> bool { + matches!(self.trigger, BindingTrigger::Swipe { fingers, direction } if fingers == count && direction == target) + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) enum BindingMatch { + #[default] + Keysym, + Physical, + Swipe, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct BindingConfig { + pub(super) keys: String, + #[serde(default, rename = "match")] + pub(super) match_mode: BindingMatch, + pub(super) action: Option, + pub(super) argument: Option, + #[serde(default)] + pub(super) disabled: bool, +} diff --git a/compositor/src/config/input_motion.rs b/compositor/src/config/input_motion.rs new file mode 100644 index 0000000..0de1952 --- /dev/null +++ b/compositor/src/config/input_motion.rs @@ -0,0 +1,245 @@ +use super::*; + +impl Config { + pub fn input_settings(&self) -> Result { + if self.input.xkb_layout.trim().is_empty() { + return Err(ConfigError::InvalidInputValue { + field: "xkb_layout", + value: self.input.xkb_layout.clone(), + }); + } + if self.input.repeat_rate <= 0 { + return Err(ConfigError::InvalidInputValue { + field: "repeat_rate", + value: self.input.repeat_rate.to_string(), + }); + } + if self.input.repeat_delay_ms < 0 { + return Err(ConfigError::InvalidInputValue { + field: "repeat_delay_ms", + value: self.input.repeat_delay_ms.to_string(), + }); + } + + if !(16..=1000).contains(&self.input.touchpad.swipe_threshold) { + return Err(ConfigError::InvalidInputValue { + field: "touchpad.swipe_threshold", + value: self.input.touchpad.swipe_threshold.to_string(), + }); + } + Ok(InputSettings { + focus_follows_mouse: self.input.focus_follows_mouse, + xkb_layout: self.input.xkb_layout.clone(), + xkb_variant: self.input.xkb_variant.clone(), + xkb_options: self.input.xkb_options.clone(), + repeat_rate: self.input.repeat_rate, + repeat_delay_ms: self.input.repeat_delay_ms, + touchpad: TouchpadSettings { + tap: self.input.touchpad.tap, + natural_scroll: self.input.touchpad.natural_scroll, + disable_while_typing: self.input.touchpad.disable_while_typing, + swipe_threshold: self.input.touchpad.swipe_threshold, + }, + }) + } + + pub fn animations_enabled(&self) -> bool { + self.animations.enabled && !self.animations.reduced_motion + } + + pub fn animation_speed(&self) -> Result { + positive_animation_value(self.animations.speed, "speed") + } + + pub fn spring_config(&self) -> Result { + let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?; + let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?; + let damping = self.animations.spring.damping; + if !damping.is_finite() || damping < 0.0 { + return Err(ConfigError::InvalidAnimationValue { + field: "spring.damping", + value: damping, + }); + } + + Ok(SpringConfig { + mass, + stiffness, + damping, + ..SpringConfig::default() + }) + } + + pub fn viewport_spring_config(&self) -> Result { + let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?; + let stiffness = + positive_animation_value(self.animations.viewport_spring.stiffness, "viewport_spring.stiffness")?; + let damping_ratio = positive_animation_value( + self.animations.viewport_spring.damping_ratio, + "viewport_spring.damping_ratio", + )?; + + Ok(SpringConfig { + mass, + stiffness, + damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(), + ..SpringConfig::default() + }) + } +} + +pub(super) fn positive_animation_value(value: f64, field: &'static str) -> Result { + if value.is_finite() && value > 0.0 { + Ok(value) + } else { + Err(ConfigError::InvalidAnimationValue { field, value }) + } +} + +pub(super) fn default_xkb_layout() -> String { + "us".to_owned() +} + +#[derive(Clone, Debug, PartialEq)] +pub struct InputSettings { + pub focus_follows_mouse: bool, + pub xkb_layout: String, + pub xkb_variant: String, + pub xkb_options: Vec, + pub repeat_rate: i32, + pub repeat_delay_ms: i32, + pub touchpad: TouchpadSettings, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TouchpadSettings { + pub tap: bool, + pub natural_scroll: bool, + pub disable_while_typing: bool, + pub swipe_threshold: u16, +} + +fn default_swipe_threshold() -> u16 { + 80 +} + +#[derive(Debug, Deserialize)] +pub(super) struct InputConfig { + #[serde(default)] + pub(super) focus_follows_mouse: bool, + #[serde(default = "default_xkb_layout")] + pub(super) xkb_layout: String, + #[serde(default)] + pub(super) xkb_variant: String, + #[serde(default)] + pub(super) xkb_options: Vec, + #[serde(default = "default_repeat_rate")] + pub(super) repeat_rate: i32, + #[serde(default = "default_repeat_delay")] + pub(super) repeat_delay_ms: i32, + #[serde(default)] + pub(super) touchpad: TouchpadConfig, +} + +impl Default for InputConfig { + fn default() -> Self { + Self { + focus_follows_mouse: false, + xkb_layout: default_xkb_layout(), + xkb_variant: String::new(), + xkb_options: Vec::new(), + repeat_rate: default_repeat_rate(), + repeat_delay_ms: default_repeat_delay(), + touchpad: TouchpadConfig::default(), + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct TouchpadConfig { + #[serde(default = "enabled_by_default")] + pub(super) tap: bool, + #[serde(default = "enabled_by_default")] + pub(super) natural_scroll: bool, + #[serde(default = "enabled_by_default")] + pub(super) disable_while_typing: bool, + #[serde(default = "default_swipe_threshold")] + pub(super) swipe_threshold: u16, +} + +impl Default for TouchpadConfig { + fn default() -> Self { + Self { + tap: true, + natural_scroll: true, + disable_while_typing: true, + swipe_threshold: default_swipe_threshold(), + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct AnimationsConfig { + #[serde(default = "enabled_by_default")] + pub(super) enabled: bool, + #[serde(default)] + pub(super) reduced_motion: bool, + #[serde(default = "default_animation_speed")] + pub(super) speed: f64, + #[serde(default)] + pub(super) spring: SpringSettings, + #[serde(default)] + pub(super) viewport_spring: ViewportSpringSettings, +} + +impl Default for AnimationsConfig { + fn default() -> Self { + Self { + enabled: true, + reduced_motion: false, + speed: 1.0, + spring: SpringSettings::default(), + viewport_spring: ViewportSpringSettings::default(), + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct ViewportSpringSettings { + #[serde(default = "default_viewport_mass")] + pub(super) mass: f64, + #[serde(default = "default_viewport_stiffness")] + pub(super) stiffness: f64, + #[serde(default = "default_viewport_damping_ratio")] + pub(super) damping_ratio: f64, +} + +impl Default for ViewportSpringSettings { + fn default() -> Self { + Self { + mass: default_viewport_mass(), + stiffness: default_viewport_stiffness(), + damping_ratio: default_viewport_damping_ratio(), + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct SpringSettings { + #[serde(default = "default_spring_mass")] + pub(super) mass: f64, + #[serde(default = "default_spring_stiffness")] + pub(super) stiffness: f64, + #[serde(default = "default_spring_damping")] + pub(super) damping: f64, +} + +impl Default for SpringSettings { + fn default() -> Self { + Self { + mass: default_spring_mass(), + stiffness: default_spring_stiffness(), + damping: default_spring_damping(), + } + } +} diff --git a/compositor/src/config/layout.rs b/compositor/src/config/layout.rs new file mode 100644 index 0000000..ee756da --- /dev/null +++ b/compositor/src/config/layout.rs @@ -0,0 +1,78 @@ +use super::*; + +impl Config { + pub fn layout_mode(&self) -> LayoutMode { + match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) { + LayoutModeValue::Scrolling => LayoutMode::Scrolling, + LayoutModeValue::Tree => LayoutMode::Tree, + } + } + + pub fn gap_config(&self) -> Result { + let inner = nonnegative_layout_value(self.layout.inner_gap, "inner_gap")?; + let outer = nonnegative_layout_value(self.layout.outer_gap, "outer_gap")?; + + Ok(GapConfig { + inner, + outer, + smart: self.layout.smart_gaps, + }) + } + + pub fn default_column_width(&self) -> Result { + parse_column_width( + self.scrolling + .default_column_width + .as_ref() + .unwrap_or(&ColumnWidthValue::Proportion(0.5)), + "default_column_width", + ) + } + + pub fn width_presets(&self) -> Result, ConfigError> { + if self.scrolling.width_presets.is_empty() { + return Ok(vec![ + ColumnWidth::Proportion(1.0 / 3.0), + ColumnWidth::Proportion(0.5), + ColumnWidth::Proportion(2.0 / 3.0), + ColumnWidth::Full, + ]); + } + + self.scrolling + .width_presets + .iter() + .map(|value| parse_column_width(value, "width_presets")) + .collect() + } + + pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy { + match self.scrolling.focus_strategy.unwrap_or(FocusStrategyValue::Minimal) { + FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal, + FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center, + FocusStrategyValue::Paged => ViewportFocusStrategy::Paged, + } + } +} + +pub(super) fn nonnegative_layout_value(value: f64, field: &'static str) -> Result { + if value.is_finite() && value >= 0.0 { + Ok(value) + } else { + Err(ConfigError::InvalidLayoutValue { field, value }) + } +} + +pub(super) fn parse_column_width(value: &ColumnWidthValue, field: &'static str) -> Result { + match value { + ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => Ok(ColumnWidth::Proportion(*value)), + ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => Ok(ColumnWidth::Full), + value => Err(ConfigError::InvalidColumnWidth { + field, + value: match value { + ColumnWidthValue::Proportion(value) => value.to_string(), + ColumnWidthValue::Named(value) => format!("{value:?}"), + }, + }), + } +} diff --git a/compositor/src/config/outputs.rs b/compositor/src/config/outputs.rs new file mode 100644 index 0000000..fde8a62 --- /dev/null +++ b/compositor/src/config/outputs.rs @@ -0,0 +1,182 @@ +use super::*; + +impl Config { + pub fn output_profiles(&self) -> Result, ConfigError> { + let mut names = HashSet::new(); + + self.output_profiles + .iter() + .map(|profile| { + let name = profile.name.trim(); + if name.is_empty() { + return Err(ConfigError::InvalidOutputProfile( + "profile names cannot be empty".to_owned(), + )); + } + if !names.insert(name.to_owned()) { + return Err(ConfigError::InvalidOutputProfile(format!( + "duplicate profile name {name:?}" + ))); + } + if profile.outputs.is_empty() { + return Err(ConfigError::InvalidOutputProfile(format!( + "profile {name:?} has no outputs" + ))); + } + + let mut matchers = HashSet::new(); + let outputs = profile + .outputs + .iter() + .map(|output| { + let matcher = output.matcher.trim(); + if matcher.is_empty() { + return Err(ConfigError::InvalidOutputProfile(format!( + "profile {name:?} contains an empty output matcher" + ))); + } + if !matchers.insert(matcher.to_owned()) { + return Err(ConfigError::InvalidOutputProfile(format!( + "profile {name:?} repeats output matcher {matcher:?}" + ))); + } + if !output.scale.is_finite() || output.scale <= 0.0 { + return Err(ConfigError::InvalidOutputProfile(format!( + "profile {name:?} output {matcher:?} has invalid scale {}", + output.scale + ))); + } + + Ok(OutputSettings { + auto_refresh: output.auto_refresh, + matcher: matcher.to_owned(), + enabled: output.enabled, + mode: output + .mode + .as_deref() + .map(parse_output_mode) + .transpose() + .map_err(ConfigError::InvalidOutputProfile)?, + scale: output.scale, + transform: output.transform, + position: output.position, + }) + }) + .collect::, ConfigError>>()?; + + Ok(OutputProfile { + name: name.to_owned(), + outputs, + }) + }) + .collect() + } +} + +pub(super) fn parse_output_mode(value: &str) -> Result { + let (size, refresh) = value + .trim() + .split_once('@') + .map_or((value.trim(), None), |(size, refresh)| (size, Some(refresh))); + let (width, height) = size + .split_once('x') + .ok_or_else(|| format!("mode {value:?} must use WIDTHxHEIGHT or WIDTHxHEIGHT@REFRESH"))?; + let width = width + .parse::() + .map_err(|_| format!("mode {value:?} has an invalid width"))?; + let height = height + .parse::() + .map_err(|_| format!("mode {value:?} has an invalid height"))?; + if width == 0 || height == 0 { + return Err(format!("mode {value:?} dimensions must be positive")); + } + let refresh_millihertz = refresh + .map(|refresh| { + let hertz = refresh + .parse::() + .map_err(|_| format!("mode {value:?} has an invalid refresh rate"))?; + if !hertz.is_finite() || hertz <= 0.0 || hertz > u32::MAX as f64 / 1_000.0 { + return Err(format!("mode {value:?} has an invalid refresh rate")); + } + + Ok((hertz * 1_000.0).round() as u32) + }) + .transpose()?; + + Ok(OutputModeRequest { + width, + height, + refresh_millihertz, + }) +} + +pub(super) const fn default_output_scale() -> f64 { + 1.0 +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OutputProfile { + pub name: String, + pub outputs: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OutputSettings { + pub auto_refresh: bool, + pub matcher: String, + pub enabled: bool, + pub mode: Option, + pub scale: f64, + pub transform: OutputTransform, + pub position: Option<[i32; 2]>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutputModeRequest { + pub width: u16, + pub height: u16, + pub refresh_millihertz: Option, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum OutputTransform { + #[default] + Normal, + #[serde(rename = "rotate_90")] + Rotate90, + #[serde(rename = "rotate_180")] + Rotate180, + #[serde(rename = "rotate_270")] + Rotate270, + Flipped, + #[serde(rename = "flipped_90")] + Flipped90, + #[serde(rename = "flipped_180")] + Flipped180, + #[serde(rename = "flipped_270")] + Flipped270, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct OutputProfileConfig { + pub(super) name: String, + #[serde(default)] + pub(super) outputs: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct OutputConfig { + #[serde(default)] + pub(super) auto_refresh: bool, + #[serde(rename = "match")] + pub(super) matcher: String, + #[serde(default = "default_true")] + pub(super) enabled: bool, + pub(super) mode: Option, + #[serde(default = "default_output_scale")] + pub(super) scale: f64, + #[serde(default)] + pub(super) transform: OutputTransform, + pub(super) position: Option<[i32; 2]>, +} diff --git a/compositor/src/handlers/window_capture.rs b/compositor/src/handlers/window_capture.rs index 2597293..dd922a6 100644 --- a/compositor/src/handlers/window_capture.rs +++ b/compositor/src/handlers/window_capture.rs @@ -235,7 +235,7 @@ impl Ferese { }; let result = if let Some(backend) = self.nested_backend.clone() { let mut backend = backend.try_borrow_mut().ok()?; - crate::winit::capture_window_frame( + crate::render::capture_window_frame( backend.renderer(), &window, geometry, diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 265b199..a7986d5 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -864,7 +864,7 @@ impl Ferese { }; let result = if let Some(backend) = &self.nested_backend { match backend.try_borrow_mut() { - Ok(mut backend) => crate::winit::capture_window_buffer(backend.renderer(), &window, geometry, scale), + Ok(mut backend) => crate::render::capture_window_buffer(backend.renderer(), &window, geometry, scale), Err(_) => Err("Window renderer is busy".into()), } } else if let Some(backend) = &mut self.direct_backend { diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 080e655..c49909f 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -20,6 +20,7 @@ mod portal_shortcuts; mod presentation; mod private_client; mod reload; +mod render; mod resize_transaction; mod session_lock; mod shell_control; diff --git a/compositor/src/render/capture.rs b/compositor/src/render/capture.rs new file mode 100644 index 0000000..d6074ef --- /dev/null +++ b/compositor/src/render/capture.rs @@ -0,0 +1,120 @@ +use super::*; + +pub(crate) fn capture_resize_snapshot( + renderer: &mut GlesRenderer, + window: &smithay::desktop::Window, + geometry: Rectangle, + scale: f64, + remaining: usize, +) -> Result, GlesError> { + let Some(toplevel) = window.toplevel() else { + return Ok(None); + }; + let size: Size = geometry.size.to_physical_precise_round(scale); + let bytes = (size.w.max(0) as usize) + .saturating_mul(size.h.max(0) as usize) + .saturating_mul(4); + if bytes == 0 || bytes > remaining { + return Ok(None); + } + // Called before on_commit_buffer_handler: renderer surface state still + // owns the previous buffer. Copy only during an actual resize handoff. + let content = render_elements_from_surface_tree::>( + renderer, + toplevel.wl_surface(), + Point::::from((-geometry.loc.x, -geometry.loc.y)).to_physical_precise_round(scale), + scale, + 1.0, + RenderElementKind::Unspecified, + ); + if content.is_empty() { + return Ok(None); + } + let mut texture = Offscreen::::create_buffer(renderer, Fourcc::Abgr8888, (size.w, size.h).into())?; + { + let mut target = renderer.bind(&mut texture)?; + let mut frame = renderer.render(&mut target, size, Transform::Normal)?; + let damage = Rectangle::from_size(size); + frame.clear(Color32F::new(0.0, 0.0, 0.0, 0.0), &[damage])?; + draw_render_elements(&mut frame, scale, &content, &[damage])?; + let _ = frame.finish()?; + } + Ok(Some(ResizeSnapshot { + texture, + context: renderer.context_id().erased(), + id: Id::new(), + commit: CommitCounter::default(), + elapsed: Duration::ZERO, + last_tick: None, + scale, + })) +} + +pub(crate) fn capture_window_buffer( + renderer: &mut GlesRenderer, + window: &smithay::desktop::Window, + geometry: Rectangle, + scale: f64, +) -> Result { + capture_window_frame(renderer, window, geometry, scale, None) +} + +pub(crate) fn capture_window_frame( + renderer: &mut GlesRenderer, + window: &smithay::desktop::Window, + geometry: Rectangle, + scale: f64, + cursor: Option<(&Ferese, Rectangle)>, +) -> Result { + let mut snapshot = capture_resize_snapshot(renderer, window, geometry, scale, 128 * 1024 * 1024) + .map_err(|error| error.to_string())? + .ok_or("Window has no capturable content")?; + let size = snapshot.texture.size(); + if let Some((state, cursor_geometry)) = cursor { + let elements = cursor_elements(state, renderer, cursor_geometry, scale); + let mut target = renderer.bind(&mut snapshot.texture).map_err(|e| e.to_string())?; + let mut frame = renderer + .render(&mut target, (size.w, size.h).into(), Transform::Normal) + .map_err(|e| e.to_string())?; + draw_render_elements( + &mut frame, + scale, + &elements, + &[Rectangle::from_size((size.w, size.h).into())], + ) + .map_err(|e| e.to_string())?; + let _ = frame.finish().map_err(|e| e.to_string())?; + } + let mapping = renderer + .copy_texture(&snapshot.texture, Rectangle::from_size(size), Fourcc::Abgr8888) + .map_err(|error| error.to_string())?; + if mapping.format() != Some(Fourcc::Abgr8888) { + return Err("Unsupported window readback format".into()); + } + let bytes = (size.w as usize) + .checked_mul(size.h as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or("Window is too large")?; + let source = renderer.map_texture(&mapping).map_err(|error| error.to_string())?; + if source.len() < bytes { + return Err("Incomplete window readback".into()); + } + let mut pixels = source[..bytes].to_vec(); + for pixel in pixels.as_chunks_mut::<4>().0 { + pixel.swap(0, 2); + let alpha = pixel[3] as u16; + for channel in &mut pixel[..3] { + *channel = if alpha == 0 { + 0 + } else { + ((*channel as u16 * 255 + alpha / 2) / alpha).min(255) as u8 + }; + } + } + Ok(crate::handlers::screenshot::CaptureBuffer { + width: size.w, + height: size.h, + stride: size.w as usize * 4, + pixels, + }) +} diff --git a/compositor/src/render/decorations.rs b/compositor/src/render/decorations.rs new file mode 100644 index 0000000..ea4a9fd --- /dev/null +++ b/compositor/src/render/decorations.rs @@ -0,0 +1,502 @@ +use super::*; + +pub(super) fn rounded_clip_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { + let context = renderer.context_id().erased(); + if let Some(program) = state.rounded_clip_programs.get(&context) { + return Some(program.clone()); + } + + let texture_uniforms = [ + UniformName::new("clip_rect", UniformType::_4f), + UniformName::new("radius", UniformType::_1f), + ]; + let border_uniforms = [ + UniformName::new("clip_rect", UniformType::_4f), + UniformName::new("radius", UniformType::_1f), + UniformName::new("border_width", UniformType::_1f), + UniformName::new("border_color", UniformType::_4f), + UniformName::new("border_color_to", UniformType::_4f), + UniformName::new("gradient_line", UniformType::_4f), + UniformName::new("focus_color", UniformType::_4f), + UniformName::new("focus_color_to", UniformType::_4f), + UniformName::new("focus_gradient_line", UniformType::_4f), + UniformName::new("focus_mix", UniformType::_1f), + ]; + let shadow_uniforms = [ + UniformName::new("shadow_rect", UniformType::_4f), + UniformName::new("radius", UniformType::_1f), + UniformName::new("blur", UniformType::_1f), + UniformName::new("opacity", UniformType::_1f), + UniformName::new("shadow_color", UniformType::_4f), + ]; + let texture = renderer.compile_custom_texture_shader(ROUNDED_TEXTURE_SHADER, &texture_uniforms); + let border = renderer.compile_custom_pixel_shader(ROUNDED_BORDER_SHADER, &border_uniforms); + let shadow = renderer.compile_custom_pixel_shader(WINDOW_SHADOW_SHADER, &shadow_uniforms); + let compiled = texture.and_then(|texture| border.and_then(|border| shadow.map(|shadow| (texture, border, shadow)))); + match compiled { + Ok((texture, border, shadow)) => { + let programs = RoundedClipPrograms { + texture, + border, + shadow, + }; + state.rounded_clip_programs.insert(context, programs.clone()); + Some(programs) + } + Err(error) => { + tracing::error!(%error, "failed to compile rounded-window shader"); + None + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn window_border_element( + state: &mut Ferese, + renderer: &GlesRenderer, + id: ferese_layout::WindowId, + geometry: Rectangle, + physical: Rectangle, + scale: f64, + requested_radius: f64, + requested_width: f64, + color: [f32; 4], + gradient: Option, + focus_mix: f32, + opacity: f32, + output: &Output, + programs: &RoundedClipPrograms, +) -> Option { + let mode = output.current_mode()?; + let width = scaled_effect_value(requested_width, geometry, scale); + if width == 0.0 { + return None; + } + + let (from, to, gradient_line) = match gradient { + Some(gradient) => ( + gradient.from.0, + gradient.to.0, + border_gradient_line(physical, mode.size, output.current_transform().invert(), gradient.angle), + ), + None => (color, color, [0.0; 4]), + }; + let (focus_from, focus_to, focus_gradient_line) = match state.theme_settings.focus_ring_gradient { + Some(g) => ( + g.from.0, + g.to.0, + border_gradient_line(physical, mode.size, output.current_transform().invert(), g.angle), + ), + None => ( + state.theme_settings.accent_color.0, + state.theme_settings.accent_color.0, + [0.0; 4], + ), + }; + let parameters = BorderParameters { + geometry, + clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), + radius: scaled_effect_value(requested_radius, geometry, scale), + width, + color: color_with_alpha(from, opacity), + color_to: color_with_alpha(to, opacity), + gradient_line, + focus_color: color_with_alpha(focus_from, opacity), + focus_color_to: color_with_alpha(focus_to, opacity), + focus_gradient_line, + focus_mix, + }; + let context = renderer.context_id().erased(); + let buffers = state.window_borders.entry(id).or_default(); + if !buffers.contexts.contains_key(&context) { + let element = PixelShaderElement::new( + programs.border.clone(), + geometry, + None, + 1.0, + border_uniforms(¶meters), + RenderElementKind::Unspecified, + ); + buffers.contexts.insert( + context.clone(), + CachedBorder { + element, + parameters: parameters.clone(), + }, + ); + } + + let cached = buffers.contexts.get_mut(&context)?; + if cached.parameters != parameters { + if cached.parameters.geometry != parameters.geometry { + cached.element.resize(parameters.geometry, None); + } + cached.element.update_uniforms(border_uniforms(¶meters)); + cached.parameters = parameters; + } + + Some(PhysicalShaderElement { + inner: cached.element.clone(), + geometry: physical, + }) +} + +pub(super) fn border_uniforms(parameters: &BorderParameters) -> Vec> { + vec![ + Uniform::new("clip_rect", parameters.clip_rect).into_owned(), + Uniform::new("radius", parameters.radius).into_owned(), + Uniform::new("border_width", parameters.width).into_owned(), + Uniform::new("border_color", parameters.color).into_owned(), + Uniform::new("border_color_to", parameters.color_to).into_owned(), + Uniform::new("gradient_line", parameters.gradient_line).into_owned(), + Uniform::new("focus_color", parameters.focus_color).into_owned(), + Uniform::new("focus_color_to", parameters.focus_color_to).into_owned(), + Uniform::new("focus_gradient_line", parameters.focus_gradient_line).into_owned(), + Uniform::new("focus_mix", parameters.focus_mix).into_owned(), + ] +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn window_tint_element( + state: &mut Ferese, + renderer: &mut GlesRenderer, + id: ferese_layout::WindowId, + geometry: Rectangle, + physical: Rectangle, + scale: f64, + radius: f64, + color: [f32; 4], + resize_fill: bool, + output: &Output, +) -> Option { + if color[3] <= 0.0 { + if resize_fill { + state.window_resize_fills.remove(&id); + } else { + state.window_dims.remove(&id); + } + return None; + } + let mode = output.current_mode()?; + let program = material_program(state, renderer)?; + let parameters = BorderParameters { + geometry, + clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), + radius: scaled_effect_value(radius, geometry, scale), + width: 0.0, + color, + color_to: color, + gradient_line: [0.0; 4], + focus_color: color, + focus_color_to: color, + focus_gradient_line: [0.0; 4], + focus_mix: 0.0, + }; + let uniforms = |p: &BorderParameters| { + vec![ + Uniform::new("visible_rect", p.clip_rect).into_owned(), + Uniform::new("material_radius", p.radius).into_owned(), + Uniform::new("tint", p.color).into_owned(), + Uniform::new("paint_mode", 0.0_f32).into_owned(), + Uniform::new("shadow_rect", p.clip_rect).into_owned(), + Uniform::new("shadow_values", [0.0_f32; 2]).into_owned(), + ] + }; + let context = renderer.context_id().erased(); + let buffers = if resize_fill { + state.window_resize_fills.entry(id).or_default() + } else { + state.window_dims.entry(id).or_default() + }; + let cached = buffers.contexts.entry(context).or_insert_with(|| CachedBorder { + element: PixelShaderElement::new( + program.0, + geometry, + None, + 1.0, + uniforms(¶meters), + RenderElementKind::Unspecified, + ), + parameters: parameters.clone(), + }); + if cached.parameters != parameters { + if cached.parameters.geometry != geometry { + cached.element.resize(geometry, None); + } + // Updating uniforms advances the element's commit so unchanged client + // buffers still repaint while focus dimming animates. + cached.element.update_uniforms(uniforms(¶meters)); + cached.parameters = parameters; + } + Some(PhysicalShaderElement { + inner: cached.element.clone(), + geometry: physical, + }) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn window_shadow_element( + state: &mut Ferese, + renderer: &GlesRenderer, + id: ferese_layout::WindowId, + geometry: Rectangle, + physical: Rectangle, + scale: f64, + requested_radius: f64, + offset_y: f64, + blur: f64, + opacity: f64, + color: [f32; 4], + output: &Output, + programs: &RoundedClipPrograms, +) -> Option { + let mode = output.current_mode()?; + if opacity == 0.0 || color[3] == 0.0 { + return None; + } + + let shadow_geometry = Rectangle::new( + (physical.loc.x, physical.loc.y + (offset_y * scale).round() as i32).into(), + physical.size, + ); + let bounds = shadow_bounds(geometry, offset_y, blur); + let parameters = ShadowParameters { + blur: (blur * scale) as f32, + bounds, + shadow_rect: framebuffer_clip_rect(shadow_geometry, mode.size, output.current_transform().invert()), + radius: scaled_effect_value(requested_radius, geometry, scale), + opacity: opacity as f32, + color, + }; + let context = renderer.context_id().erased(); + let buffers = state.window_shadows.entry(id).or_default(); + if !buffers.contexts.contains_key(&context) { + let element = PixelShaderElement::new( + programs.shadow.clone(), + bounds, + None, + 1.0, + shadow_uniforms(¶meters), + RenderElementKind::Unspecified, + ); + buffers.contexts.insert( + context.clone(), + CachedShadow { + element, + parameters: parameters.clone(), + }, + ); + } + + let cached = buffers.contexts.get_mut(&context)?; + if cached.parameters != parameters { + if cached.parameters.bounds != parameters.bounds { + cached.element.resize(parameters.bounds, None); + } + cached.element.update_uniforms(shadow_uniforms(¶meters)); + cached.parameters = parameters; + } + + let extent = (blur * scale * 2.0).ceil() as i32; + Some(PhysicalShaderElement { + inner: cached.element.clone(), + geometry: Rectangle::new( + (shadow_geometry.loc.x - extent, shadow_geometry.loc.y - extent).into(), + (shadow_geometry.size.w + 2 * extent, shadow_geometry.size.h + 2 * extent).into(), + ), + }) +} + +pub(super) fn shadow_uniforms(parameters: &ShadowParameters) -> Vec> { + vec![ + Uniform::new("shadow_rect", parameters.shadow_rect).into_owned(), + Uniform::new("radius", parameters.radius).into_owned(), + Uniform::new("blur", parameters.blur).into_owned(), + Uniform::new("opacity", parameters.opacity).into_owned(), + Uniform::new("shadow_color", parameters.color).into_owned(), + ] +} + +pub(super) fn shadow_bounds(geometry: Rectangle, offset_y: f64, blur: f64) -> Rectangle { + let extent = (blur * 2.0).ceil() as i32; + let offset_y = offset_y.round() as i32; + + Rectangle::new( + (geometry.loc.x - extent, geometry.loc.y + offset_y - extent).into(), + (geometry.size.w + extent * 2, geometry.size.h + extent * 2).into(), + ) +} + +pub(super) fn scaled_effect_value(requested: f64, geometry: Rectangle, scale: f64) -> f32 { + requested + .min(f64::from(geometry.size.w.min(geometry.size.h)) / 2.0) + .max(0.0) as f32 + * scale as f32 +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn rounded_window_elements( + renderer: &mut GlesRenderer, + window: &smithay::desktop::Window, + constrain: Rectangle, + physical_constrain: Rectangle, + scale: f64, + requested_radius: f64, + alpha: f32, + clip_changed: bool, + output: &Output, + program: GlesTexProgram, + behavior: ConstrainScaleBehavior, +) -> Vec { + let Some(toplevel) = window.toplevel() else { + return Vec::new(); + }; + let Some(mode) = output.current_mode() else { + return Vec::new(); + }; + + let geometry = window.geometry(); + let reference = geometry.to_physical_precise_round(scale); + let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale); + let clip = framebuffer_clip_rect(physical_constrain, mode.size, output.current_transform().invert()); + let radius = scaled_effect_value(requested_radius, constrain, scale); + let surface = toplevel.wl_surface(); + + let mut content = PopupManager::popups_for_surface(surface) + .flat_map(|(popup, popup_offset)| { + let offset = (geometry.loc + popup_offset - popup.geometry().loc).to_physical_precise_round(scale); + + render_elements_from_surface_tree::>( + renderer, + popup.wl_surface(), + location + offset, + scale, + alpha, + RenderElementKind::Unspecified, + ) + .into_iter() + .map(WindowContentRenderElement::from) + }) + .collect::>(); + content.extend( + render_elements_from_surface_tree::>( + renderer, + surface, + location, + scale, + alpha, + RenderElementKind::Unspecified, + ) + .into_iter() + .map(|inner| { + RoundedSurfaceRenderElement { + inner, + program: program.clone(), + clip_rect: clip, + radius, + clip_changed, + } + .into() + }), + ); + + constrain_render_elements( + content, + location, + physical_constrain, + reference, + behavior, + ConstrainAlign::TOP | ConstrainAlign::LEFT, + scale, + ) + .map(Into::into) + .collect() +} + +pub(super) fn resize_content_behavior(intentional_scale: bool) -> ConstrainScaleBehavior { + if intentional_scale { + ConstrainScaleBehavior::Stretch + } else { + ConstrainScaleBehavior::CutOff + } +} + +pub(super) fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect { + let scale = scale.clamp(0.0, 1.0); + let width = rect.width * scale; + let height = rect.height * scale; + + ferese_layout::Rect::new( + rect.x + (rect.width - width) / 2.0, + rect.y + (rect.height - height) / 2.0, + width, + height, + ) +} + +pub(super) fn color_with_alpha(mut color: [f32; 4], alpha: f32) -> [f32; 4] { + color[3] *= alpha; + color +} + +pub(super) fn rounded_visual_rect( + rect: ferese_layout::Rect, + output_location: Point, +) -> Rectangle { + let left = (rect.x - f64::from(output_location.x)).round() as i32; + let top = (rect.y - f64::from(output_location.y)).round() as i32; + let right = (rect.x + rect.width - f64::from(output_location.x)).round() as i32; + let bottom = (rect.y + rect.height - f64::from(output_location.y)).round() as i32; + + Rectangle::new( + (left, top).into(), + ((right - left).max(1), (bottom - top).max(1)).into(), + ) +} + +pub(super) fn framebuffer_clip_rect( + geometry: Rectangle, + output_size: smithay::utils::Size, + transform: Transform, +) -> [f32; 4] { + // Match GlesFrame's projection: Smithay already accounts for GL's Y axis. + // An extra bottom-left conversion here mirrors shader clips independently + // of the surface geometry (notably with the nested Flipped180 output). + let element_size = transform.transform_size(output_size); + let transformed = transform.transform_rect_in(geometry, &element_size); + + [ + transformed.loc.x as f32, + transformed.loc.y as f32, + transformed.size.w as f32, + transformed.size.h as f32, + ] +} + +pub(super) fn border_gradient_line( + geometry: Rectangle, + output_size: Size, + transform: Transform, + angle: f64, +) -> [f32; 4] { + let angle = angle.to_radians(); + let direction = (angle.cos(), angle.sin()); + let width = f64::from(geometry.size.w); + let height = f64::from(geometry.size.h); + let extent = (width * direction.0.abs() + height * direction.1.abs()) * 0.5; + let center = geometry.loc.to_f64() + Point::from((width * 0.5, height * 0.5)); + let offset: Point = (direction.0 * extent, direction.1 * extent).into(); + // Apply the same output-to-framebuffer transform as the rounded clip. + // This keeps angles in window coordinates on rotated/flipped monitors. + let area = transform.transform_size(output_size).to_f64(); + let start = transform.transform_point_in(center - offset, &area); + let end = transform.transform_point_in(center + offset, &area); + let delta = end - start; + let length_squared = (delta.x * delta.x + delta.y * delta.y).max(0.000001); + [ + start.x as f32, + start.y as f32, + (delta.x / length_squared) as f32, + (delta.y / length_squared) as f32, + ] +} diff --git a/compositor/src/render/materials.rs b/compositor/src/render/materials.rs new file mode 100644 index 0000000..28abb9d --- /dev/null +++ b/compositor/src/render/materials.rs @@ -0,0 +1,391 @@ +use super::*; + +#[allow(clippy::too_many_arguments)] +pub(super) fn append_material_surface( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, + geometry: Rectangle, + content_origin: Point, + scale: f64, + output_crop: Rectangle, + elements: &mut Vec, +) { + let radius = + if crate::effects::surface_role(surface).is_some_and(|(role, _)| role == crate::effects::SemanticRole::Panel) { + state.theme_settings.panel_radius + } else { + state.theme_settings.material_radius + }; + let regions = crate::effects::surface_regions(surface); + let targets: Vec<_> = match ®ions { + None => vec![(geometry, radius as f32)], + Some(regions) => regions + .iter() + .filter_map(|r| { + Rectangle::new( + (content_origin.x + r[0], content_origin.y + r[1]).into(), + (r[2], r[3]).into(), + ) + .intersection(geometry) + .map(|rect| (rect, r[4] as f32)) + }) + .collect(), + }; + let materials: Vec<_> = targets + .iter() + .enumerate() + .filter_map(|(index, (rect, radius))| { + material_element(state, renderer, output, surface, *rect, *radius, index, geometry, 1.0) + }) + .collect(); + if let Some(buffers) = state.material_buffers.get_mut(surface) { + buffers.contexts.retain(|(_, index), _| *index < targets.len()); + } + let material = (!materials.is_empty() && regions.is_none()).then_some(()); + let clip = material.as_ref().and_then(|_| { + let mode = output.current_mode()?; + let program = rounded_clip_program(state, renderer)?; + Some(( + program.texture, + framebuffer_clip_rect( + geometry.to_physical_precise_round(scale), + mode.size, + output.current_transform().invert(), + ), + scaled_effect_value(radius, geometry, scale), + )) + }); + // Smithay element lists are front to back: content, material, shadow. + let content = render_elements_from_surface_tree::>( + renderer, + surface, + content_origin.to_physical_precise_round(scale), + scale, + crate::effects::surface_opacity(surface), + RenderElementKind::Unspecified, + ); + elements.extend(content.into_iter().filter_map(|element| { + let origin = Point::::default(); + let element = if let Some((program, clip_rect, radius)) = &clip { + WindowContentRenderElement::Rounded(RoundedSurfaceRenderElement { + inner: element, + program: program.clone(), + clip_rect: *clip_rect, + radius: *radius, + clip_changed: false, + }) + } else { + WindowContentRenderElement::Popup(element) + }; + let element = RescaleRenderElement::from_element(element, origin, 1.0); + let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); + CropRenderElement::from_element(element, scale, output_crop).map(Into::into) + })); + for (background, shadow) in materials { + elements.push(background); + elements.push(shadow); + } +} + +pub(super) fn material_element( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, + geometry: Rectangle, + radius: f32, + index: usize, + capture_geometry: Rectangle, + alpha: f32, +) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { + let (role, generation) = crate::effects::surface_role(surface)?; + let material = crate::effects::resolve_material( + role, + state.theme_settings.material_style, + state.theme_settings.shell_opacity as f32, + ); + let presentation_alpha = crate::effects::surface_opacity(surface) * alpha; + let mode = output.current_mode()?; + let scale = output.current_scale().fractional_scale(); + let transform = output.current_transform().invert(); + let background = if role == crate::effects::SemanticRole::Panel { + state.theme_settings.bar_background_color + } else { + state.theme_settings.surface_base_color + }; + let [red, green, blue, _] = background.0; + let radius = radius.min(geometry.size.w.min(geometry.size.h) as f32 * 0.5); + let [offset_y, shadow_blur, shadow_opacity] = material.shadow; + let edge_bar = role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0; + let shadow_opacity = if edge_bar { + 0.0 + } else { + shadow_opacity * (state.theme_settings.shadow_opacity / 0.2) * f64::from(presentation_alpha) + }; + let shadow_geometry = Rectangle::new( + (geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), + geometry.size, + ); + let background_opacity = material.opacity; + let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur); + let output_size = output + .current_transform() + .transform_size(mode.size) + .to_f64() + .to_logical(scale) + .to_i32_ceil(); + let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size); + let sample_physical = sample_geometry.to_physical_precise_round(scale); + let mut parameters = MaterialParameters { + presentation_alpha, + background_opacity, + blur: (blur * scale) as f32, + scene_generation: if blur > 0.0 { state.backdrop_generation } else { 0 }, + sample_geometry, + sample_physical, + sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform), + radius: radius * scale as f32, + shadow_rect: framebuffer_clip_rect(shadow_geometry.to_physical_precise_round(scale), mode.size, transform), + shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32], + shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur), + geometry, + tint: [red, green, blue, material.opacity * presentation_alpha], + generation, + opaque: material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0, + visible_framebuffer: framebuffer_clip_rect(geometry.to_physical_precise_round(scale), mode.size, transform), + }; + let context = renderer.context_id().erased(); + let program = material_program(state, renderer)?; + let blur_program = (blur > 0.0).then(|| blur_program(state, renderer)).flatten(); + let capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer); + let capture_size = Size::from((capture_rect[2], capture_rect[3])); + let buffers = state.material_buffers.entry(surface.clone()).or_default(); + let capture = if let Some(blur_program) = blur_program { + if buffers + .captures + .get(&context) + .is_none_or(|c| c.geometry != sample_geometry || c.texture.size() != capture_size) + { + let texture = Offscreen::::create_buffer(renderer, Fourcc::Abgr8888, capture_size) + .map_err(|error| { + tracing::warn!(%error, ?sample_physical, "backdrop capture allocation failed"); + error + }) + .ok(); + if let Some(texture) = texture { + buffers.captures.insert( + context.clone(), + BlurCapture { + texture, + dirty: Arc::new(AtomicBool::new(true)), + geometry: sample_geometry, + }, + ); + } else { + buffers.captures.remove(&context); + } + } + buffers + .captures + .get(&context) + .cloned() + .map(|capture| (capture, blur_program)) + } else { + buffers.captures.remove(&context); + None + }; + if capture.is_some() { + parameters.tint[3] = state.theme_settings.material_tint_strength as f32; + } + let make_element = || { + if let Some((capture, program)) = &capture { + MaterialElement::Blur(BlurRenderElement::new( + capture.texture.clone(), + program.0.clone(), + ¶meters, + capture.dirty.clone(), + )) + } else { + MaterialElement::Fill(PixelShaderElement::new( + program.0.clone(), + geometry, + parameters.opaque.then(|| vec![Rectangle::from_size(geometry.size)]), + 1.0, + material_uniforms(¶meters), + RenderElementKind::Unspecified, + )) + } + }; + let cached = buffers + .contexts + .entry((context, index)) + .or_insert_with(|| CachedMaterial { + element: make_element(), + parameters: parameters.clone(), + shadow: PixelShaderElement::new( + program.0.clone(), + parameters.shadow_bounds, + None, + 1.0, + decoration_uniforms(¶meters, 2.0), + RenderElementKind::Unspecified, + ), + }); + let replace = match &cached.element { + MaterialElement::Fill(_) => capture.is_some(), + MaterialElement::Blur(element) => capture + .as_ref() + .is_none_or(|(capture, _)| capture.texture.tex_id() != element.texture.tex_id()), + }; + if replace { + cached.element = make_element(); + } + if cached.parameters != parameters { + cached.shadow.resize(parameters.shadow_bounds, None); + cached.shadow.update_uniforms(decoration_uniforms(¶meters, 2.0)); + match &mut cached.element { + MaterialElement::Fill(element) => { + element.resize( + geometry, + parameters.opaque.then(|| vec![Rectangle::from_size(geometry.size)]), + ); + element.update_uniforms(material_uniforms(¶meters)); + } + MaterialElement::Blur(element) => element.update(¶meters), + } + cached.parameters = parameters; + } + let background = match &cached.element { + MaterialElement::Fill(element) => element.clone().into(), + MaterialElement::Blur(element) => element.clone().into(), + }; + Some((background, cached.shadow.clone().into())) +} + +pub(super) fn material_blur_radius(style: crate::config::MaterialStyle, opacity: f32, radius: f64) -> f64 { + if style == crate::config::MaterialStyle::Translucent && opacity > 0.0 { + radius + } else { + 0.0 + } +} + +pub(super) fn blur_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { + let context = renderer.context_id().erased(); + if let Some(program) = state.blur_programs.get(&context) { + return Some(program.clone()); + } + match renderer.compile_custom_texture_shader(BLUR_SHADER, &blur_uniform_names()) { + Ok(program) => { + let program = BlurProgram(program); + state.blur_programs.insert(context, program.clone()); + Some(program) + } + Err(error) => { + tracing::warn!(%error,"backdrop blur unavailable; using plain translucent fill"); + None + } + } +} + +pub(super) fn blur_uniform_names() -> [UniformName<'static>; 8] { + [ + UniformName::new("visible_rect", UniformType::_4f), + UniformName::new("material_radius", UniformType::_1f), + UniformName::new("texture_size", UniformType::_2f), + UniformName::new("capture_origin", UniformType::_2f), + UniformName::new("blur_radius", UniformType::_1f), + UniformName::new("presentation_alpha", UniformType::_1f), + UniformName::new("background_opacity", UniformType::_1f), + UniformName::new("tint", UniformType::_4f), + ] +} + +pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec> { + vec![ + Uniform::new("visible_rect", p.visible_framebuffer).into_owned(), + Uniform::new("material_radius", p.radius).into_owned(), + Uniform::new("texture_size", [p.sample_framebuffer[2], p.sample_framebuffer[3]]).into_owned(), + Uniform::new("capture_origin", [p.sample_framebuffer[0], p.sample_framebuffer[1]]).into_owned(), + Uniform::new("blur_radius", p.blur).into_owned(), + Uniform::new("presentation_alpha", p.presentation_alpha).into_owned(), + Uniform::new("background_opacity", p.background_opacity).into_owned(), + Uniform::new("tint", p.tint).into_owned(), + ] +} + +pub(super) fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] { + rect.map(|v| v.round() as i32) +} + +pub(super) fn expanded_blur_region( + visible: Rectangle, + radius: i32, + output_size: Size, +) -> Rectangle { + let left = (visible.loc.x - radius).max(0); + let top = (visible.loc.y - radius).max(0); + let right = (visible.loc.x + visible.size.w + radius).min(output_size.w); + let bottom = (visible.loc.y + visible.size.h + radius).min(output_size.h); + Rectangle::new( + (left, top).into(), + ((right - left).max(1), (bottom - top).max(1)).into(), + ) +} + +pub(super) fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { + let context = renderer.context_id().erased(); + if let Some(program) = state.material_programs.get(&context) { + return Some(program.clone()); + } + + let uniforms = [ + UniformName::new("visible_rect", UniformType::_4f), + UniformName::new("material_radius", UniformType::_1f), + UniformName::new("tint", UniformType::_4f), + UniformName::new("paint_mode", UniformType::_1f), + UniformName::new("shadow_rect", UniformType::_4f), + UniformName::new("shadow_values", UniformType::_2f), + ]; + match renderer.compile_custom_pixel_shader(MATERIAL_SHADER, &uniforms) { + Ok(program) => { + let program = MaterialProgram(program); + state.material_programs.insert(context, program.clone()); + Some(program) + } + Err(error) => { + tracing::error!(%error, "failed to compile semantic material shader"); + None + } + } +} + +pub(super) fn material_uniforms(parameters: &MaterialParameters) -> Vec> { + decoration_uniforms(parameters, 0.0) +} + +pub(super) fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f32) -> Vec> { + vec![ + Uniform::new("paint_mode", paint_mode).into_owned(), + Uniform::new("shadow_rect", parameters.shadow_rect).into_owned(), + Uniform::new("shadow_values", parameters.shadow_values).into_owned(), + Uniform::new("visible_rect", parameters.visible_framebuffer).into_owned(), + Uniform::new("material_radius", parameters.radius).into_owned(), + Uniform::new("tint", parameters.tint).into_owned(), + ] +} + +pub(super) fn blur_damage( + size: Size, + current: CommitCounter, + previous: Option, +) -> DamageSet { + if previous == Some(current) { + DamageSet::default() + } else { + // A new scene needs its entire sampling halo repainted before capture. + DamageSet::from_slice(&[Rectangle::from_size((size.w, size.h).into())]) + } +} diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs new file mode 100644 index 0000000..f9a4518 --- /dev/null +++ b/compositor/src/render/mod.rs @@ -0,0 +1,890 @@ +mod capture; +mod decorations; +mod materials; +mod overview; +mod scene; + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +pub(crate) use capture::{capture_resize_snapshot, capture_window_buffer, capture_window_frame}; +use decorations::*; +use materials::*; +use overview::*; +use scene::cursor_elements; +pub(crate) use scene::{ + animated_window_elements, cursorless_window_elements, frame_effect_metrics, layer_surfaces, output_elements, +}; +use smithay::backend::allocator::Fourcc; +use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; +use smithay::backend::renderer::element::solid::SolidColorRenderElement; +use smithay::backend::renderer::element::surface::{ + WaylandSurfaceRenderElement, WaylandSurfaceTexture, render_elements_from_surface_tree, +}; +use smithay::backend::renderer::element::utils::{ + ConstrainAlign, ConstrainScaleBehavior, CropRenderElement, Relocate, RelocateRenderElement, RescaleRenderElement, + constrain_render_elements, +}; +use smithay::backend::renderer::element::{ + Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage, render_elements, +}; +use smithay::backend::renderer::gles::element::PixelShaderElement; +use smithay::backend::renderer::gles::{ + GlesError, GlesFrame, GlesPixelProgram, GlesRenderer, GlesTexProgram, GlesTexture, Uniform, UniformName, + UniformType, +}; +use smithay::backend::renderer::utils::{CommitCounter, DamageSet, OpaqueRegions, draw_render_elements}; +use smithay::backend::renderer::{Bind, Color32F, ErasedContextId, ExportMem, Frame, Offscreen, Renderer, Texture}; +use smithay::desktop::space::{ConstrainBehavior, ConstrainReference, constrain_space_element}; +use smithay::desktop::{LayerSurface, PopupManager, layer_map_for_output}; +use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData}; +use smithay::output::Output; +use smithay::reexports::wayland_server::Resource; +use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Scale as RenderScale, Size, Transform}; +use smithay::wayland::compositor::with_states; +use smithay::wayland::shell::wlr_layer::Layer; + +use crate::Ferese; +use crate::metrics::FrameEffectMetrics; +use crate::presentation::{NativeTextureElement, PhysicalShaderElement, physical_rect}; + +type SurfaceRenderElement = + CropRenderElement>>>; + +type WindowRenderElement = CropRenderElement>>; + +type MemoryRenderElement = + CropRenderElement>>>; + +render_elements! { + pub(crate) AnimatedWindowRenderElement<=GlesRenderer>; + Window=WindowRenderElement, + Surface=SurfaceRenderElement, + Memory=MemoryRenderElement, + Solid=SolidColorRenderElement, + LockSurface=WaylandSurfaceRenderElement, + Border=PixelShaderElement, + Blur=BlurRenderElement, + Effect=PhysicalShaderElement, + Native=NativeTextureElement, +} + +#[derive(Debug)] +pub(crate) struct ResizeSnapshot { + pub texture: GlesTexture, + pub context: ErasedContextId, + pub id: Id, + pub commit: CommitCounter, + pub elapsed: Duration, + pub last_tick: Option, + pub scale: f64, +} + +impl ResizeSnapshot { + pub(crate) fn bytes(&self) -> usize { + self.texture.size().w as usize * self.texture.size().h as usize * 4 + } +} + +render_elements! { + WindowContentRenderElement<=GlesRenderer>; + Rounded=RoundedSurfaceRenderElement, + Popup=WaylandSurfaceRenderElement, +} + +const ROUNDED_TEXTURE_SHADER: &str = include_str!("shaders/rounded_texture_shader.frag"); + +const ROUNDED_BORDER_SHADER: &str = include_str!("shaders/rounded_border_shader.frag"); + +const WINDOW_SHADOW_SHADER: &str = include_str!("shaders/window_shadow_shader.frag"); + +// The material program paints either the fallback tint, the edges above client +// content, or the shadow outside the surface. All coordinates are physical px. +const MATERIAL_SHADER: &str = include_str!("shaders/material_shader.frag"); + +const BLUR_SHADER: &str = include_str!("shaders/blur_shader.frag"); + +#[derive(Clone, Debug)] +pub(crate) struct BlurProgram(GlesTexProgram); + +#[derive(Clone, Debug)] +struct BlurCapture { + texture: GlesTexture, + dirty: Arc, + geometry: Rectangle, +} + +#[derive(Clone, Debug)] +struct BlurRenderElement { + capture_dirty: Arc, + texture: GlesTexture, + program: GlesTexProgram, + id: Id, + commit: CommitCounter, + geometry: Rectangle, + capture_rect: [i32; 4], + uniforms: Vec>, +} + +impl BlurRenderElement { + fn new( + texture: GlesTexture, + program: GlesTexProgram, + parameters: &MaterialParameters, + capture_dirty: Arc, + ) -> Self { + Self { + capture_dirty, + texture, + program, + id: Id::new(), + commit: CommitCounter::default(), + geometry: parameters.sample_geometry, + capture_rect: framebuffer_capture_rect(parameters.sample_framebuffer), + uniforms: blur_uniforms(parameters), + } + } + + fn update(&mut self, parameters: &MaterialParameters) { + self.capture_dirty.store(true, Ordering::Relaxed); + self.geometry = parameters.sample_geometry; + self.capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer); + self.uniforms = blur_uniforms(parameters); + self.commit.increment(); + } +} + +impl Element for BlurRenderElement { + fn id(&self) -> &Id { + &self.id + } + + fn current_commit(&self) -> CommitCounter { + self.commit + } + + fn src(&self) -> Rectangle { + Rectangle::from_size(self.texture.size().to_f64()) + } + + fn geometry(&self, scale: RenderScale) -> Rectangle { + self.geometry.to_physical_precise_round(scale) + } + + fn damage_since(&self, scale: RenderScale, commit: Option) -> DamageSet { + blur_damage(self.geometry(scale).size, self.commit, commit) + } + + fn opaque_regions(&self, _scale: RenderScale) -> OpaqueRegions { + // Backdrop dependencies must remain visible to damage tracking even + // though the shader produces opaque pixels inside the visible region. + OpaqueRegions::default() + } + + fn alpha(&self) -> f32 { + 1.0 + } + + fn kind(&self) -> RenderElementKind { + RenderElementKind::Unspecified + } +} + +impl RenderElement for BlurRenderElement { + fn draw( + &self, + frame: &mut GlesFrame<'_, '_>, + src: Rectangle, + dst: Rectangle, + damage: &[Rectangle], + opaque_regions: &[Rectangle], + ) -> Result<(), GlesError> { + let texture = self.texture.tex_id(); + let [x, y, width, height] = self.capture_rect; + // Buffer-age restoration and unrelated partial damage must reuse the + // clean captured scene. Recapturing a partly repainted framebuffer + // would feed previous blurred pixels back into the blur. + if self.capture_dirty.load(Ordering::Relaxed) { + let error = frame.with_context(|gl| unsafe { + gl.GetError(); + let mut framebuffer = 0; + let mut previous_read_buffer = 0; + gl.GetIntegerv( + smithay::backend::renderer::gles::ffi::READ_FRAMEBUFFER_BINDING, + &mut framebuffer, + ); + gl.GetIntegerv( + smithay::backend::renderer::gles::ffi::READ_BUFFER, + &mut previous_read_buffer, + ); + gl.ReadBuffer(if framebuffer == 0 { + smithay::backend::renderer::gles::ffi::BACK + } else { + smithay::backend::renderer::gles::ffi::COLOR_ATTACHMENT0 + }); + gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, texture); + gl.TexParameteri( + smithay::backend::renderer::gles::ffi::TEXTURE_2D, + smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_S, + smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32, + ); + gl.TexParameteri( + smithay::backend::renderer::gles::ffi::TEXTURE_2D, + smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_T, + smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32, + ); + gl.CopyTexSubImage2D( + smithay::backend::renderer::gles::ffi::TEXTURE_2D, + 0, + 0, + 0, + x, + y, + width, + height, + ); + gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, 0); + let error = gl.GetError(); + gl.ReadBuffer(previous_read_buffer as u32); + error + })?; + if error != smithay::backend::renderer::gles::ffi::NO_ERROR { + tracing::warn!(error, "backdrop framebuffer capture failed"); + } + self.capture_dirty.store(false, Ordering::Relaxed); + } + + frame.render_texture_from_to( + &self.texture, + src, + dst, + damage, + opaque_regions, + Transform::Normal, + 1.0, + Some(&self.program), + &self.uniforms, + ) + } + + fn underlying_storage(&self, _renderer: &mut GlesRenderer) -> Option> { + None + } +} + +#[derive(Clone, Debug)] +pub(crate) struct RoundedClipPrograms { + texture: GlesTexProgram, + border: GlesPixelProgram, + shadow: GlesPixelProgram, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum OverviewChromePart { + Card, + Outline, + Strip, + Caption, +} + +#[derive(Debug)] +pub(crate) struct OverviewScrim { + chrome: HashMap<(u64, OverviewChromePart), HashMap>, +} + +#[derive(Clone, Debug)] +pub(crate) struct MaterialProgram(GlesPixelProgram); + +#[derive(Clone, Debug, PartialEq)] +struct MaterialParameters { + presentation_alpha: f32, + background_opacity: f32, + blur: f32, + scene_generation: u64, + sample_geometry: Rectangle, + sample_physical: Rectangle, + sample_framebuffer: [f32; 4], + radius: f32, + shadow_rect: [f32; 4], + shadow_values: [f32; 2], + shadow_bounds: Rectangle, + geometry: Rectangle, + tint: [f32; 4], + generation: u64, + opaque: bool, + visible_framebuffer: [f32; 4], +} + +#[derive(Clone, Debug)] +enum MaterialElement { + Fill(PixelShaderElement), + Blur(BlurRenderElement), +} + +#[derive(Debug)] +struct CachedMaterial { + element: MaterialElement, + parameters: MaterialParameters, + shadow: PixelShaderElement, +} + +#[derive(Debug, Default)] +pub(crate) struct MaterialBuffers { + contexts: HashMap<(ErasedContextId, usize), CachedMaterial>, + captures: HashMap, +} + +#[derive(Clone, Debug, PartialEq)] +struct BorderParameters { + geometry: Rectangle, + clip_rect: [f32; 4], + radius: f32, + width: f32, + color: [f32; 4], + color_to: [f32; 4], + gradient_line: [f32; 4], + focus_color: [f32; 4], + focus_color_to: [f32; 4], + focus_gradient_line: [f32; 4], + focus_mix: f32, +} + +#[derive(Debug)] +struct CachedBorder { + element: PixelShaderElement, + parameters: BorderParameters, +} + +#[derive(Debug, Default)] +pub(crate) struct WindowBorderBuffers { + contexts: HashMap, +} + +#[derive(Clone, Debug, PartialEq)] +struct ShadowParameters { + blur: f32, + bounds: Rectangle, + shadow_rect: [f32; 4], + radius: f32, + opacity: f32, + color: [f32; 4], +} + +#[derive(Debug)] +struct CachedShadow { + element: PixelShaderElement, + parameters: ShadowParameters, +} + +#[derive(Debug, Default)] +pub(crate) struct WindowShadowBuffers { + contexts: HashMap, +} + +#[derive(Debug)] +struct RoundedSurfaceRenderElement { + inner: WaylandSurfaceRenderElement, + program: GlesTexProgram, + clip_rect: [f32; 4], + radius: f32, + clip_changed: bool, +} + +impl Element for RoundedSurfaceRenderElement { + fn id(&self) -> &Id { + self.inner.id() + } + + fn current_commit(&self) -> CommitCounter { + self.inner.current_commit() + } + + fn geometry(&self, scale: RenderScale) -> Rectangle { + self.inner.geometry(scale) + } + + fn transform(&self) -> Transform { + self.inner.transform() + } + + fn src(&self) -> Rectangle { + self.inner.src() + } + + fn damage_since(&self, scale: RenderScale, commit: Option) -> DamageSet { + if self.clip_changed { + return DamageSet::from_slice(&[Rectangle::from_size(self.inner.geometry(scale).size)]); + } + self.inner.damage_since(scale, commit) + } + + fn opaque_regions(&self, scale: RenderScale) -> OpaqueRegions { + if self.radius == 0.0 { + self.inner.opaque_regions(scale) + } else { + OpaqueRegions::default() + } + } + + fn alpha(&self) -> f32 { + self.inner.alpha() + } + + fn kind(&self) -> RenderElementKind { + self.inner.kind() + } +} + +impl RenderElement for RoundedSurfaceRenderElement { + fn draw( + &self, + frame: &mut GlesFrame<'_, '_>, + src: Rectangle, + dst: Rectangle, + damage: &[Rectangle], + opaque_regions: &[Rectangle], + ) -> Result<(), GlesError> { + match self.inner.texture() { + WaylandSurfaceTexture::Texture(texture) => frame.render_texture_from_to( + texture, + src, + dst, + damage, + opaque_regions, + self.transform(), + self.alpha(), + Some(&self.program), + &[ + Uniform::new("clip_rect", self.clip_rect), + Uniform::new("radius", self.radius), + ], + ), + WaylandSurfaceTexture::SolidColor(_) => self.inner.draw(frame, src, dst, damage, opaque_regions), + } + } + + fn underlying_storage(&self, renderer: &mut GlesRenderer) -> Option> { + self.inner.underlying_storage(renderer) + } +} + +pub(crate) fn redraw_output( + renderer: &mut R, + framebuffer: &mut R::Framebuffer<'_>, + output: &Output, + elements: &[E], +) -> Result<(), R::Error> +where + R: Renderer, + R::TextureId: 'static, + E: RenderElement, +{ + let mode = output.current_mode().expect("output has a mode"); + let transform = output.current_transform().invert(); + let damage = Rectangle::from_size(transform.transform_size(mode.size)); + let mut frame = renderer.render(framebuffer, mode.size, transform)?; + + frame.clear(Color32F::new(0.035, 0.04, 0.055, 1.0), &[damage])?; + draw_render_elements( + &mut frame, + output.current_scale().fractional_scale(), + elements, + &[damage], + )?; + let _ = frame.finish()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use smithay::backend::renderer::damage::OutputDamageTracker; + use smithay::backend::renderer::element::{Element, Id}; + use smithay::backend::renderer::utils::CommitCounter; + use smithay::utils::{Buffer, Logical, Physical, Rectangle, Scale, Transform}; + + use super::{ + border_gradient_line, color_with_alpha, framebuffer_clip_rect, resize_content_behavior, rounded_visual_rect, + scaled_visual_rect, shadow_bounds, + }; + + #[test] + #[ignore = "requires an EGL rendering device"] + fn translucent_blur_does_not_leak_the_sharp_backdrop() { + use smithay::backend::allocator::Fourcc; + use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay}; + use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture, Uniform}; + use smithay::backend::renderer::{Bind, Color32F, ExportMem, Frame, ImportMem, Offscreen, Renderer}; + + let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device"); + let display = unsafe { EGLDisplay::new(device).unwrap() }; + let context = EGLContext::new(&display).unwrap(); + let mut renderer = unsafe { GlesRenderer::new(context).unwrap() }; + let program = renderer + .compile_custom_texture_shader(super::BLUR_SHADER, &super::blur_uniform_names()) + .unwrap(); + let size = (32, 32).into(); + let mut pixels = vec![128u8; 32 * 32 * 4]; + for pixel in pixels.chunks_exact_mut(4) { + pixel[3] = 255; + } + let blurred = renderer.import_memory(&pixels, Fourcc::Abgr8888, size, false).unwrap(); + let mut target_texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, size).unwrap(); + let damage = Rectangle::::from_size((32, 32).into()); + + for (opacity, strength, tint) in [ + (0.2f32, 0.5f32, 0.1f32), + (0.71, 0.5, 0.1), + (1.0, 0.5, 0.1), + (0.94, 1.0, 245.0 / 255.0), + ] { + let uniforms = vec![ + Uniform::new("visible_rect", [0.0f32, 0.0, 32.0, 32.0]), + Uniform::new("material_radius", 0.0f32), + Uniform::new("texture_size", [32.0f32, 32.0]), + Uniform::new("capture_origin", [0.0f32, 0.0]), + Uniform::new("blur_radius", 12.0f32), + Uniform::new("presentation_alpha", 1.0f32), + Uniform::new("background_opacity", opacity), + Uniform::new("tint", [tint, tint, tint, strength]), + ]; + let mut target = renderer.bind(&mut target_texture).unwrap(); + { + let mut frame = renderer + .render(&mut target, (32, 32).into(), Transform::Normal) + .unwrap(); + frame.clear(Color32F::new(0.0, 0.0, 0.0, 1.0), &[damage]).unwrap(); + frame + .draw_solid( + Rectangle::new((0, 0).into(), (16, 32).into()), + &[damage], + Color32F::new(1.0, 1.0, 1.0, 1.0), + ) + .unwrap(); + frame + .render_texture_from_to( + &blurred, + Rectangle::from_size(size.to_f64()), + damage, + &[damage], + &[], + Transform::Normal, + 1.0, + Some(&program), + &uniforms, + ) + .unwrap(); + let _ = frame.finish().unwrap(); + } + let mapping = renderer + .copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888) + .unwrap(); + let rendered = renderer.map_texture(&mapping).unwrap(); + let left = rendered[(16 * 32 + 8) * 4]; + let right = rendered[(16 * 32 + 24) * 4]; + assert!( + left.abs_diff(right) <= 2, + "opacity {opacity} leaked the original backdrop: {left} versus {right}" + ); + let expected = (128.0 * (1.0 - strength * opacity) + tint * 255.0 * strength * opacity).round() as u8; + assert!( + left.abs_diff(expected) <= 2, + "opacity {opacity} failed to control tint: {left} versus {expected}" + ); + } + } + + #[test] + fn gradient_direction_survives_every_output_transform() { + let mode = (2560, 1600).into(); + for transform in [ + Transform::Normal, + Transform::_90, + Transform::_180, + Transform::_270, + Transform::Flipped, + Transform::Flipped90, + Transform::Flipped180, + Transform::Flipped270, + ] { + let area = transform.transform_size(mode).to_f64(); + let rect: Rectangle = Rectangle::new((110, 60).into(), (800, 600).into()); + for (angle, first, last) in [ + (0.0, (110.0, 360.0), (910.0, 360.0)), + (90.0, (510.0, 60.0), (510.0, 660.0)), + (45.0, (110.0, 60.0), (910.0, 660.0)), + (135.0, (910.0, 60.0), (110.0, 660.0)), + ] { + let line = border_gradient_line(rect, mode, transform, angle); + let progress = |point: smithay::utils::Point| { + let point = transform.transform_point_in(point, &area); + (point.x - f64::from(line[0])) * f64::from(line[2]) + + (point.y - f64::from(line[1])) * f64::from(line[3]) + }; + assert!(progress(first.into()).abs() < 0.00001, "{transform:?}, {angle}"); + assert!((progress(last.into()) - 1.0).abs() < 0.00001, "{transform:?}, {angle}"); + } + } + } + + #[test] + fn gradient_coordinates_follow_fractional_resize_and_translation() { + for scale in [1.0, 1.25, 1.5, 1.8, 2.0] { + for width in [500.0, 733.25, 1000.0] { + let rect = crate::presentation::physical_rect( + ferese_layout::Rect::new(40.25, 60.75, width, 600.5), + (0, 0).into(), + scale, + ); + let line = border_gradient_line(rect, (3840, 2160).into(), Transform::Normal, 0.0); + assert!((f64::from(line[0]) - f64::from(rect.loc.x)).abs() < 0.001); + assert!((f64::from(line[2]) * f64::from(rect.size.w) - 1.0).abs() < 0.00001); + assert!(line.iter().all(|component| component.is_finite())); + } + } + } + + #[derive(Debug)] + struct DamageElement { + id: Id, + geometry: Rectangle, + commit: CommitCounter, + } + + impl DamageElement { + fn new(geometry: Rectangle) -> Self { + Self { + id: Id::new(), + geometry, + commit: CommitCounter::default(), + } + } + } + + impl Element for DamageElement { + fn id(&self) -> &Id { + &self.id + } + + fn current_commit(&self) -> CommitCounter { + self.commit + } + + fn src(&self) -> Rectangle { + Rectangle::from_size(self.geometry.size.to_f64().to_buffer(1.0, Transform::Normal)) + } + + fn geometry(&self, scale: Scale) -> Rectangle { + self.geometry.to_physical_precise_round(scale) + } + + fn damage_since( + &self, + scale: Scale, + commit: Option, + ) -> smithay::backend::renderer::utils::DamageSet { + if commit == Some(self.current_commit()) { + smithay::backend::renderer::utils::DamageSet::default() + } else { + smithay::backend::renderer::utils::DamageSet::from_slice(&[Rectangle::from_size( + self.geometry(scale).size, + )]) + } + } + } + + #[test] + fn resize_keeps_source_pixels_native_when_growing_and_shrinking() { + use smithay::backend::renderer::element::utils::{ConstrainAlign, constrain_render_elements}; + for scale in [1.0, 1.5, 1.8, 2.0] { + for width in [300, 600, 1200] { + let source = Rectangle::::from_size((600, 800).into()); + let destination = Rectangle::::from_size((width, 800).into()); + let element = constrain_render_elements( + [DamageElement::new(source)], + (0, 0), + destination.to_physical_precise_round(scale), + source.to_physical_precise_round(scale), + resize_content_behavior(false), + ConstrainAlign::TOP | ConstrainAlign::LEFT, + scale, + ) + .next() + .unwrap(); + // Comparing destination pixels to sampled source pixels catches + // stretching, unlike tests of configure timing alone. + let physical = element.geometry(scale.into()); + let sampled = element.src(); + assert!((f64::from(physical.size.w) / sampled.size.w - scale).abs() < 0.01); + assert!((f64::from(physical.size.h) / sampled.size.h - scale).abs() < 0.01); + } + } + } + + #[test] + fn overview_retains_intentional_content_scaling() { + assert!(matches!( + resize_content_behavior(true), + smithay::backend::renderer::element::utils::ConstrainScaleBehavior::Stretch + )); + } + + #[test] + fn clip_rect_matches_smithay_gles_projection() { + let geometry = Rectangle::::new((10, 5).into(), (30, 40).into()); + + assert_eq!( + framebuffer_clip_rect(geometry, (100, 80).into(), Transform::Normal), + [10.0, 5.0, 30.0, 40.0] + ); + } + + #[test] + fn clip_rect_follows_output_transform() { + let geometry = Rectangle::::new((10, 5).into(), (30, 40).into()); + + assert_eq!( + framebuffer_clip_rect(geometry, (100, 80).into(), Transform::Flipped180), + [10.0, 35.0, 30.0, 40.0] + ); + assert_eq!( + framebuffer_clip_rect(geometry, (100, 80).into(), Transform::_180), + [60.0, 35.0, 30.0, 40.0] + ); + } + + #[test] + fn panel_clearance_clip_matches_gles_projection_at_fractional_scales() { + for scale in [1.0, 1.25, 1.75, 2.0] { + for transform in [ + Transform::Normal, + Transform::_90, + Transform::_180, + Transform::_270, + Transform::Flipped, + Transform::Flipped90, + Transform::Flipped180, + Transform::Flipped270, + ] { + let output_size = + smithay::utils::Size::::from(((1280.0 * scale) as i32, (800.0 * scale) as i32)); + let element_size = transform.transform_size(output_size); + let logical_height = f64::from(element_size.h) / scale; + // Bar and exclusion end at 40; the 10px outer gap ends at 50. + let geometry = + Rectangle::::new((10.0, 50.0).into(), (400.0, logical_height - 60.0).into()) + .to_physical_precise_round(scale); + let clip = framebuffer_clip_rect(geometry, output_size, transform); + // Independently reproduce GlesFrame's orthographic projection, + // transform matrix, GL flip, and viewport mapping for each corner. + let matrix = transform.matrix(); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for x in [geometry.loc.x, geometry.loc.x + geometry.size.w] { + for y in [geometry.loc.y, geometry.loc.y + geometry.size.h] { + let nx = 2.0 * x as f32 / element_size.w as f32 - 1.0; + let ny = 1.0 - 2.0 * y as f32 / element_size.h as f32; + let tx = matrix[0][0] * nx + matrix[1][0] * ny; + let ty = matrix[0][1] * nx + matrix[1][1] * ny; + xs.push((tx + 1.0) * output_size.w as f32 / 2.0); + ys.push((1.0 - ty) * output_size.h as f32 / 2.0); + } + } + let min_x = xs.iter().copied().fold(f32::INFINITY, f32::min); + let max_x = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let min_y = ys.iter().copied().fold(f32::INFINITY, f32::min); + let max_y = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max); + for (actual, expected) in clip.into_iter().zip([min_x, min_y, max_x - min_x, max_y - min_y]) { + assert!( + (actual - expected).abs() < 0.001, + "{transform:?}, scale={scale}: {actual} != {expected}" + ); + } + } + } + } + + #[test] + fn shadow_bounds_include_blur_and_offset() { + let geometry = Rectangle::::new((100, 80).into(), (400, 300).into()); + + assert_eq!( + shadow_bounds(geometry, 4.0, 18.0), + Rectangle::new((64, 48).into(), (472, 372).into()) + ); + assert_eq!( + shadow_bounds(geometry, -6.0, 10.0), + Rectangle::new((80, 54).into(), (440, 340).into()) + ); + } + + #[test] + fn damage_tracks_old_and_new_expanded_shadow_bounds() { + let old_window = Rectangle::::new((100, 80).into(), (400, 300).into()); + let new_window = Rectangle::::new((300, 180).into(), (400, 300).into()); + let mut shadow = DamageElement::new(shadow_bounds(old_window, 4.0, 18.0)); + let mut tracker = OutputDamageTracker::new((1_000, 800), Scale::from(1.0), Transform::Normal); + + tracker.damage_output(0, &[&shadow]).unwrap(); + shadow.geometry = shadow_bounds(new_window, 4.0, 18.0); + let damage = tracker.damage_output(1, &[&shadow]).unwrap().0.cloned().unwrap(); + + assert!(damage.iter().any(|rect| rect.contains((64, 48)))); + assert!(damage.iter().any(|rect| rect.contains((735, 519)))); + + let removal_damage = tracker + .damage_output::<&DamageElement>(1, &[]) + .unwrap() + .0 + .cloned() + .unwrap(); + assert!(removal_damage.iter().any(|rect| rect.contains((300, 180)))); + } + + #[test] + fn translucent_materials_blur_at_full_opacity() { + use crate::config::MaterialStyle; + assert_eq!(super::material_blur_radius(MaterialStyle::Translucent, 0.0, 18.0), 0.0); + assert_eq!(super::material_blur_radius(MaterialStyle::Translucent, 1.0, 18.0), 18.0); + assert_eq!( + super::material_blur_radius(MaterialStyle::Translucent, 0.79, 18.0), + 18.0 + ); + assert_eq!(super::material_blur_radius(MaterialStyle::Solid, 0.79, 18.0), 0.0); + } + + #[test] + fn close_transform_scales_about_the_window_center() { + let rect = ferese_layout::Rect::new(100.0, 50.0, 400.0, 300.0); + + assert_eq!( + scaled_visual_rect(rect, 0.98), + ferese_layout::Rect::new(104.0, 53.0, 392.0, 294.0) + ); + assert_eq!(scaled_visual_rect(rect, 1.0), rect); + } + + #[test] + fn close_opacity_only_changes_the_alpha_channel() { + assert_eq!(color_with_alpha([0.2, 0.4, 0.6, 0.8], 0.5), [0.2, 0.4, 0.6, 0.4]); + } + + #[test] + fn visual_rect_rounds_shared_edges_instead_of_size() { + let rect = ferese_layout::Rect::new(100.5, 50.25, 399.5, 299.75); + let rounded = rounded_visual_rect(rect, (0, 0).into()); + + assert_eq!(rounded, Rectangle::new((101, 50).into(), (399, 300).into())); + assert_eq!(rounded.loc.x + rounded.size.w, 500); + assert_eq!(rounded.loc.y + rounded.size.h, 350); + + for tenth in 0..=10 { + let left = 100.0 + f64::from(tenth) / 10.0; + let frame = rounded_visual_rect(ferese_layout::Rect::new(left, 50.0, 500.0 - left, 300.0), (0, 0).into()); + + assert_eq!(frame.loc.x + frame.size.w, 500); + } + } +} diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs new file mode 100644 index 0000000..f259571 --- /dev/null +++ b/compositor/src/render/overview.rs @@ -0,0 +1,314 @@ +use super::*; + +#[allow(clippy::too_many_arguments)] +pub(super) fn overview_chrome_element( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + output_geometry: Rectangle, + scale: f64, + key: (u64, OverviewChromePart), + rect: ferese_layout::Rect, + color: [f32; 4], + width: f64, + radius: f64, +) -> Option { + let output_id = state.output_id(output)?; + let geometry = rounded_visual_rect(rect, output_geometry.loc); + let physical = physical_rect(rect, output_geometry.loc, scale); + let mode = output.current_mode()?; + let parameters = BorderParameters { + geometry, + clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), + radius: (radius * scale) as f32, + width: (width * scale) as f32, + color, + color_to: color, + gradient_line: [0.0; 4], + focus_color: color, + focus_color_to: color, + focus_gradient_line: [0.0; 4], + focus_mix: 0.0, + }; + let program = if key.1 == OverviewChromePart::Outline { + rounded_clip_program(state, renderer)?.border + } else { + material_program(state, renderer)?.0 + }; + let uniforms = |p: &BorderParameters| { + if key.1 == OverviewChromePart::Outline { + border_uniforms(p) + } else { + vec![ + Uniform::new("visible_rect", p.clip_rect).into_owned(), + Uniform::new("material_radius", p.radius).into_owned(), + Uniform::new("tint", p.color).into_owned(), + Uniform::new("paint_mode", 0.0_f32).into_owned(), + Uniform::new("shadow_rect", p.clip_rect).into_owned(), + Uniform::new("shadow_values", [0.0_f32; 2]).into_owned(), + ] + } + }; + let context = renderer.context_id().erased(); + let chrome = &mut state + .overview_scrims + .entry(output_id) + .or_insert_with(|| OverviewScrim { chrome: HashMap::new() }) + .chrome; + let cached = chrome + .entry(key) + .or_default() + .entry(context) + .or_insert_with(|| CachedBorder { + element: PixelShaderElement::new( + program, + geometry, + None, + 1.0, + uniforms(¶meters), + RenderElementKind::Unspecified, + ), + parameters: parameters.clone(), + }); + if cached.parameters != parameters { + if cached.parameters.geometry != geometry { + cached.element.resize(geometry, None); + } + cached.element.update_uniforms(uniforms(¶meters)); + cached.parameters = parameters; + } + Some(PhysicalShaderElement { + inner: cached.element.clone(), + geometry: physical, + }) +} + +pub(super) fn overview_strip_elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + output_geometry: Rectangle, + scale: f64, +) -> Vec { + let alpha = state.overview.opacity(); + if alpha <= 0.001 { + return Vec::new(); + } + let Some(bounds) = state.output_bounds_for(output) else { + return Vec::new(); + }; + let cards = state.overview_workspace_cards(output); + let strip = crate::overview::workspace_strip(bounds); + let panel = match (cards.first(), cards.last()) { + (Some(first), Some(last)) => ferese_layout::Rect::new( + first.rect.x - 12.0, + strip.y, + last.rect.x + last.rect.width - first.rect.x + 24.0, + strip.height, + ), + _ => strip, + }; + let radius = state.theme_settings.material_radius; + let material_opacity = crate::effects::resolve_material( + crate::effects::SemanticRole::Panel, + state.theme_settings.material_style, + state.theme_settings.shell_opacity as f32, + ) + .opacity; + let mut elements = Vec::new(); + let programs = rounded_clip_program(state, renderer); + // Resolve IDs once per strip, rather than scanning all managed windows + // separately for every miniature. Own the handles so rendering can mutate state. + let windows_by_id: std::collections::HashMap<_, _> = state + .window_ids + .iter() + .map(|(window, id)| (*id, window.clone())) + .collect(); + if let Some(output_id) = state.output_id(output) + && let Some(cache) = state.overview_scrims.get_mut(&output_id) + { + cache.chrome.retain(|(id, part), _| match part { + OverviewChromePart::Caption => windows_by_id.keys().any(|window| window.0 == *id), + OverviewChromePart::Card | OverviewChromePart::Outline => cards.iter().any(|card| card.workspace.0 == *id), + OverviewChromePart::Strip => true, + }); + } + // Cached title textures are independent of window content and stay readable + // when thumbnails are small. Their pills use the shell's material and radius. + for (id, window) in &windows_by_id { + if !state.window_belongs_to_output(*id, output) { + continue; + } + let Some(rect) = state.presented_window_rect(*id) else { + continue; + }; + let Some((buffer, size)) = state.overview_window_label(window, scale, rect.width) else { + continue; + }; + let width = (f64::from(size.w) / scale + 16.0).min(rect.width); + let caption = ferese_layout::Rect::new( + rect.x + (rect.width - width) * 0.5, + rect.y + rect.height + 8.0, + width, + 26.0, + ); + let location = Point::::from(( + ((caption.x - f64::from(output_geometry.loc.x) + caption.width * 0.5) * scale).round() as i32 - size.w / 2, + ((caption.y - f64::from(output_geometry.loc.y) + 4.0) * scale).round() as i32, + )); + if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( + renderer, + location.to_f64(), + &buffer, + Some(alpha), + Some(Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into())), + Some( + ( + (f64::from(size.w) / scale).round() as i32, + (f64::from(size.h) / scale).round() as i32, + ) + .into(), + ), + RenderElementKind::Unspecified, + ) { + let element = RescaleRenderElement::from_element(element, location, 1.0); + let element = RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative); + if let Some(element) = + CropRenderElement::from_element(element, scale, physical_rect(caption, output_geometry.loc, scale)) + { + elements.push(element.into()); + } + } + let mut fill = state.theme_settings.surface_base_color.0; + fill[3] = alpha * material_opacity; + if let Some(background) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (id.0, OverviewChromePart::Caption), + caption, + fill, + 0.0, + radius.min(13.0), + ) { + elements.push(background.into()); + } + } + for card in cards { + // Front-to-back: outline and live miniatures above each card, all above + // the strip. Nothing is painted behind the main overview window grid. + if card.selected { + let mut color = state.theme_settings.accent_color.0; + color[3] *= alpha; + if let Some(border) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (card.workspace.0, OverviewChromePart::Outline), + card.rect, + color, + 1.0, + radius, + ) { + elements.push(border.into()); + } + } + if let Some(programs) = &programs { + for (id, rect) in &card.windows { + if let Some(window) = windows_by_id.get(id) { + elements.extend(rounded_window_elements( + renderer, + window, + rounded_visual_rect(*rect, output_geometry.loc), + physical_rect(*rect, output_geometry.loc, scale), + scale, + 4.0, + alpha, + true, + output, + programs.texture.clone(), + ConstrainScaleBehavior::Stretch, + )); + } + } + } + if let Some((buffer, size)) = state.overview_workspace_label(card.workspace, scale) { + let location = Point::::from(( + ((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale).round() as i32 + - size.w / 2, + ((card.rect.y - f64::from(output_geometry.loc.y) + card.rect.height - 20.0) * scale).round() as i32, + )); + if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( + renderer, + location.to_f64(), + &buffer, + Some(alpha), + Some(Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into())), + Some( + ( + (f64::from(size.w) / scale).round() as i32, + (f64::from(size.h) / scale).round() as i32, + ) + .into(), + ), + RenderElementKind::Unspecified, + ) { + let element = RescaleRenderElement::from_element(element, location, 1.0); + let element = RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative); + if let Some(element) = CropRenderElement::from_element( + element, + scale, + physical_rect(card.rect, output_geometry.loc, scale), + ) { + elements.push(element.into()); + } + } + } + let mut card_color = state.theme_settings.surface_base_color.0; + for component in &mut card_color[..3] { + *component = (*component + 0.035).min(1.0); + } + card_color[3] = alpha * material_opacity; + if let Some(card_fill) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (card.workspace.0, OverviewChromePart::Card), + card.rect, + card_color, + 0.0, + radius, + ) { + elements.push(card_fill.into()); + } + } + let mut color = state.theme_settings.surface_base_color.0; + color[3] = alpha + * crate::effects::resolve_material( + crate::effects::SemanticRole::Panel, + state.theme_settings.material_style, + state.theme_settings.shell_opacity as f32, + ) + .opacity; + if let Some(strip) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (0, OverviewChromePart::Strip), + panel, + color, + 0.0, + radius, + ) { + elements.push(strip.into()); + } + elements +} diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs new file mode 100644 index 0000000..a7af773 --- /dev/null +++ b/compositor/src/render/scene.rs @@ -0,0 +1,494 @@ +use super::*; + +pub(crate) fn animated_window_elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, +) -> Vec { + output_elements(state, renderer, output, true) +} + +pub(crate) fn cursorless_window_elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, +) -> Vec { + output_elements(state, renderer, output, false) +} + +pub(crate) fn frame_effect_metrics(_elements: &[AnimatedWindowRenderElement], _scale: f64) -> FrameEffectMetrics { + FrameEffectMetrics +} + +pub(crate) fn output_elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + include_cursor: bool, +) -> Vec { + if state.session_lock.active { + state.configure_lock_surfaces(); + let Some(geometry) = state.space.output_geometry(output) else { + return Vec::new(); + }; + let scale = output.current_scale().fractional_scale(); + let background = state.session_lock.backgrounds.entry(output.clone()).or_insert_with(|| { + smithay::backend::renderer::element::solid::SolidColorBuffer::new(geometry.size, [0.0, 0.0, 0.0, 1.0]) + }); + background.resize(geometry.size); + let mut elements = Vec::new(); + let opacity = state.session_lock.idle_opacity; + if opacity > 0.0 { + let overlay = state + .session_lock + .idle_overlays + .entry(output.clone()) + .or_insert_with(|| { + smithay::backend::renderer::element::solid::SolidColorBuffer::new( + geometry.size, + [0.0, 0.0, 0.0, 1.0], + ) + }); + overlay.resize(geometry.size); + elements.push( + SolidColorRenderElement::from_buffer(overlay, (0, 0), scale, opacity, RenderElementKind::Unspecified) + .into(), + ); + } + if let Some(surface) = state + .session_lock + .surfaces + .get(output) + .filter(|surface| surface.alive()) + { + elements.extend( + render_elements_from_surface_tree::>( + renderer, + surface.wl_surface(), + (0, 0), + scale, + 1.0, + RenderElementKind::Unspecified, + ) + .into_iter() + .map(AnimatedWindowRenderElement::from), + ); + } + elements.push( + SolidColorRenderElement::from_buffer(background, (0, 0), scale, 1.0, RenderElementKind::Unspecified).into(), + ); + return elements; + } + let Some(output_geometry) = state.space.output_geometry(output) else { + return Vec::new(); + }; + let scale = output.current_scale().fractional_scale(); + let rounded_clip_program = rounded_clip_program(state, renderer); + + let mut elements = if include_cursor { + cursor_elements(state, renderer, output_geometry, scale) + } else { + Vec::new() + }; + 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)); + if state.overview.is_presenting() { + elements.extend(overview_strip_elements(state, renderer, output, output_geometry, scale)); + } + let mut candidates = if state.overview.is_active() { + state.window_ids.keys().cloned().collect::>() + } else { + state.space.elements().rev().cloned().collect::>() + }; + if state.overview.is_active() { + candidates.sort_by_key(|window| std::cmp::Reverse(state.window_ids.get(window).map_or(0, |id| id.0))); + } + let windows = candidates + .iter() + .filter_map(|window| { + // Configure immediately, but present the frame/shadow only after + // the first buffer commit has settled the actual client geometry. + if !state.window_content_ready(window) { + return None; + } + let id = *state.window_ids.get(window)?; + // Scrolling columns may sit outside their monitor's rectangle. + // They must not reappear on a neighboring output just because + // their global animated coordinates overlap it. + if !state.window_belongs_to_output(id, output) { + return None; + } + let visual = state.presented_window_rect(id)?; + let (close_scale, close_alpha) = state.closing_visual(id); + let visual = scaled_visual_rect(visual, close_scale); + let decoration_progress = state + .window_geometry + .get(&id) + .map_or(1.0, |geometry| geometry.decorations.clamp(0.0, 1.0)); + + Some((window.clone(), id, visual, decoration_progress, close_alpha)) + }) + .collect::>(); + + for (window, id, visual, decoration_progress, close_alpha) in windows { + let constrain = rounded_visual_rect(visual, output_geometry.loc); + let pixels = physical_rect(visual, output_geometry.loc, scale); + let material_surface = window + .toplevel() + .map(|toplevel| toplevel.wl_surface()) + .filter(|surface| { + crate::effects::surface_role(surface) + .is_some_and(|(role, _)| role == crate::effects::SemanticRole::Modal) + }); + let window_radius = if material_surface.is_some() { + state.theme_settings.material_radius + } else { + state.theme_settings.window_radius + } * decoration_progress; + // Only overview/close intentionally scale the complete application. + let scale_content = state.overview.is_presenting() || state.closing_visual(id).0 != 1.0; + let behavior = resize_content_behavior(scale_content); + + let dim = state.window_dimming.get(&id).map_or(0.0, |dim| dim.current); + if let Some(overlay) = window_tint_element( + state, + renderer, + id, + constrain, + pixels, + scale, + window_radius, + [0.0, 0.0, 0.0, dim as f32 * close_alpha], + false, + output, + ) { + // Front-to-back: dim the application and its border, not its shadow + // or other windows/layer surfaces. The overlay is input-transparent. + elements.push(overlay.into()); + } + + if let Some(programs) = rounded_clip_program.clone() { + let shadow_offset_y = state.theme_settings.shadow_offset_y; + let shadow_blur = state.theme_settings.shadow_blur; + let shadow_opacity = state.theme_settings.shadow_opacity; + let shadow_color = state.theme_settings.shadow_color.0; + let focused = if state.overview.is_active() { + state.overview_selected(id) + } else { + state.focused_window == Some(id) + }; + let focus = state + .window_focus + .get(&id) + .map_or(if focused { 1.0 } else { 0.0 }, |v| v.current); + let border_width = state.theme_settings.border_width + + (state.theme_settings.focus_ring_width - state.theme_settings.border_width) * focus; + let border_color = state.theme_settings.border_color.0; + let gradient = state.theme_settings.border_gradient; + + if let Some(border) = window_border_element( + state, + renderer, + id, + constrain, + pixels, + scale, + window_radius, + border_width, + border_color, + gradient, + focus as f32, + close_alpha * decoration_progress as f32, + output, + &programs, + ) { + elements.push(border.into()); + } + let shadow = window_shadow_element( + state, + renderer, + id, + constrain, + pixels, + scale, + window_radius, + shadow_offset_y, + shadow_blur, + shadow_opacity * f64::from(close_alpha) * decoration_progress, + shadow_color, + output, + &programs, + ); + if !scale_content + && let Some(snapshot) = state.resize_snapshots.get(&id) + && snapshot.context == renderer.context_id().erased() + && (snapshot.scale - scale).abs() < 0.001 + { + let size = snapshot.texture.size(); + let visible = Rectangle::new(pixels.loc, (size.w, size.h).into()).intersection(pixels); + if let Some(visible) = visible { + let clip = framebuffer_clip_rect( + pixels, + output.current_mode().unwrap().size, + output.current_transform().invert(), + ); + elements.push( + NativeTextureElement { + id: snapshot.id.clone(), + commit: snapshot.commit, + texture: snapshot.texture.clone(), + geometry: visible, + source: Rectangle::from_size(Size::from(( + f64::from(visible.size.w), + f64::from(visible.size.h), + ))), + alpha: crate::presentation::handoff_alpha(snapshot.elapsed) * close_alpha, + program: Some(programs.texture.clone()), + uniforms: vec![ + Uniform::new("clip_rect", clip).into_owned(), + Uniform::new("radius", scaled_effect_value(window_radius, constrain, scale)) + .into_owned(), + ], + } + .into(), + ); + } + } + elements.extend(rounded_window_elements( + renderer, + &window, + constrain, + pixels, + scale, + window_radius, + close_alpha, + state + .window_geometry + .get(&id) + .is_some_and(|geometry| geometry.presentation_changed), + output, + programs.texture.clone(), + behavior, + )); + let source = window.geometry().size; + if material_surface.is_none() + && !scale_content + && (source.w < constrain.size.w || source.h < constrain.size.h) + { + let mut color = state.theme_settings.surface_base_color.0; + color[3] = close_alpha; + if let Some(fill) = window_tint_element( + state, + renderer, + id, + constrain, + pixels, + scale, + window_radius, + color, + true, + output, + ) { + // Front-to-back: fill uncovered strips behind the native + // content instead of stretching it or exposing wallpaper. + elements.push(fill.into()); + } + } else { + state.window_resize_fills.remove(&id); + } + if let Some(surface) = material_surface + && let Some((background, _)) = material_element( + state, + renderer, + output, + surface, + constrain, + window_radius as f32, + 0, + constrain, + close_alpha, + ) + { + elements.push(background); + } + if let Some(shadow) = shadow { + elements.push(shadow.into()); + } + } else { + elements.extend(constrain_space_element::( + renderer, + &window, + constrain.loc, + close_alpha, + scale, + constrain, + ConstrainBehavior { + reference: ConstrainReference::Geometry, + behavior, + align: ConstrainAlign::TOP | ConstrainAlign::LEFT, + }, + )); + } + } + elements.extend(layer_elements( + state, + renderer, + output, + &[Layer::Bottom, Layer::Background], + )); + state.prepare_theme_wallpaper(renderer, output); + let progress = state.theme_progress(); + let wallpaper = state.wallpaper.element(renderer, output); + let has_wallpaper = wallpaper.is_some(); + if let Some(mut wallpaper) = wallpaper { + wallpaper.alpha = progress; + elements.push(wallpaper.into()); + } + if let Some(mut previous) = state.previous_theme_wallpaper(renderer, output) { + if !has_wallpaper { + previous.alpha = 1. - progress; + } + elements.push(previous.into()); + } + elements +} + +pub(crate) fn layer_surfaces(output: &Output) -> Vec { + layer_map_for_output(output).layers().cloned().collect() +} + +pub(super) fn layer_elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + requested_layers: &[Layer], +) -> Vec { + let scale = output.current_scale().fractional_scale(); + let Some(mode) = output.current_mode() else { + return Vec::new(); + }; + let output_crop = Rectangle::::from_size(mode.size); + let layers = { + let map = layer_map_for_output(output); + + requested_layers + .iter() + .flat_map(|requested| { + map.layers_on(*requested) + .rev() + .filter_map(|layer| map.layer_geometry(layer).map(|geometry| (geometry, layer.clone()))) + }) + .collect::>() + }; + + state.material_buffers.retain(|surface, _| surface.is_alive()); + + let mut elements = Vec::new(); + for (geometry, layer) in layers { + for (popup, offset) in PopupManager::popups_for_surface(layer.wl_surface()) { + let mut bounds = popup.geometry(); + let content_origin = geometry.loc + offset - bounds.loc; + bounds.loc = geometry.loc + offset; + append_material_surface( + state, + renderer, + output, + popup.wl_surface(), + bounds, + content_origin, + scale, + output_crop, + &mut elements, + ); + } + append_material_surface( + state, + renderer, + output, + layer.wl_surface(), + geometry, + geometry.loc, + scale, + output_crop, + &mut elements, + ); + } + + elements +} + +pub(super) fn cursor_elements( + state: &Ferese, + renderer: &mut GlesRenderer, + output_geometry: Rectangle, + scale: f64, +) -> Vec { + if state.input_capture.active() { + return Vec::new(); + } + let Some(pointer) = state.seat.get_pointer() else { + return Vec::new(); + }; + let output_crop = Rectangle::::from_size(output_geometry.size).to_physical_precise_round(scale); + let origin = Point::::default(); + let pointer_location = pointer.current_location() - output_geometry.loc.to_f64(); + match &state.cursor_status { + CursorImageStatus::Surface(surface) => { + let hotspot = with_states(surface, |states| { + states + .data_map + .get::() + .map(|attributes| attributes.lock().unwrap().hotspot) + .unwrap_or_default() + }); + let physical_location = (pointer_location - hotspot.to_f64()).to_physical_precise_round(scale); + + render_elements_from_surface_tree::>( + renderer, + surface, + physical_location, + scale, + 1.0, + RenderElementKind::Cursor, + ) + .into_iter() + .filter_map(|element| { + let element = RescaleRenderElement::from_element(element, origin, 1.0); + let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); + + CropRenderElement::from_element(element, scale, output_crop).map(Into::into) + }) + .collect() + } + CursorImageStatus::Named(icon) => { + let Some(cursor) = state.named_cursors.get(icon) else { + return Vec::new(); + }; + let hotspot = Point::::from((cursor.hotspot.x, cursor.hotspot.y)); + let physical_location = pointer_location.to_physical_precise_round(scale) - hotspot; + let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( + renderer, + physical_location.to_f64(), + &cursor.buffer, + None, + None, + None, + RenderElementKind::Cursor, + ) else { + return Vec::new(); + }; + + let element = RescaleRenderElement::from_element(element, origin, 1.0); + let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); + + CropRenderElement::from_element(element, scale, output_crop) + .map(|element| vec![element.into()]) + .unwrap_or_default() + } + CursorImageStatus::Hidden => Vec::new(), + } +} diff --git a/compositor/src/render/shaders/blur_shader.frag b/compositor/src/render/shaders/blur_shader.frag new file mode 100644 index 0000000..cd45a95 --- /dev/null +++ b/compositor/src/render/shaders/blur_shader.frag @@ -0,0 +1,42 @@ +#version 100 +//_DEFINES_ +#if defined(EXTERNAL) +#extension GL_OES_EGL_image_external : require +#endif +precision highp float; +#if defined(EXTERNAL) +uniform samplerExternalOES tex; +#else +uniform sampler2D tex; +#endif +uniform float alpha; +uniform vec4 visible_rect; +uniform float material_radius; +uniform vec2 texture_size; +uniform vec2 capture_origin; +uniform float blur_radius; +uniform float presentation_alpha; +uniform float background_opacity; +uniform vec4 tint; +varying vec2 v_coords; +void main() { + vec2 half_size = visible_rect.zw * 0.5; + vec2 d = abs(gl_FragCoord.xy - visible_rect.xy - half_size) - (half_size - vec2(material_radius)); + float sdf = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - material_radius; + float coverage = alpha * presentation_alpha * (1.0 - smoothstep(-0.5, 0.5, sdf)); + if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; } + vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size; + vec4 color = vec4(0.0); + float weights = 0.0; + // A spiral avoids aligning the sampling lattice with wallpaper patterns. + for (int i = 0; i < 256; i++) { + float radius = sqrt((float(i) + 0.5) / 256.0); + float angle = float(i) * 2.39996323; + vec2 offset = vec2(cos(angle), sin(angle)) * radius * blur_radius / texture_size; + float weight = exp(-4.5 * radius * radius); + color += texture2D(tex, coords + offset) * weight; + weights += weight; + } + vec3 background = color.rgb / weights; + gl_FragColor = vec4(mix(background, tint.rgb, tint.a * background_opacity) * coverage, coverage); +} diff --git a/compositor/src/render/shaders/material_shader.frag b/compositor/src/render/shaders/material_shader.frag new file mode 100644 index 0000000..27e401e --- /dev/null +++ b/compositor/src/render/shaders/material_shader.frag @@ -0,0 +1,31 @@ + +precision highp float; +uniform float alpha; +uniform vec4 tint; +uniform vec4 visible_rect; +uniform float material_radius; +uniform float paint_mode; +uniform vec4 shadow_rect; +uniform vec2 shadow_values; +varying vec2 v_coords; + +float rounded_distance(vec2 point, vec4 rect) { + vec2 half_size = rect.zw * 0.5; + float radius = min(material_radius, min(half_size.x, half_size.y)); + vec2 d = abs(point - rect.xy - half_size) - (half_size - vec2(radius)); + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius; +} +void main() { + float sdf = rounded_distance(gl_FragCoord.xy, visible_rect); + float coverage = 1.0 - smoothstep(-0.5, 0.5, sdf); + if (paint_mode > 0.5) { + float distance = max(rounded_distance(gl_FragCoord.xy, shadow_rect), 0.0); + float sigma = max(shadow_values.x * 0.5, 0.5); + float opacity = exp(-0.5 * distance * distance / (sigma * sigma)) + * shadow_values.y * (1.0 - coverage) * alpha; + gl_FragColor = vec4(0.0, 0.0, 0.0, opacity); + } else { + float opacity = tint.a * coverage * alpha; + gl_FragColor = vec4(tint.rgb * opacity, opacity); + } +} diff --git a/compositor/src/render/shaders/rounded_border_shader.frag b/compositor/src/render/shaders/rounded_border_shader.frag new file mode 100644 index 0000000..4728db1 --- /dev/null +++ b/compositor/src/render/shaders/rounded_border_shader.frag @@ -0,0 +1,55 @@ + +precision highp float; + +uniform float alpha; +uniform vec4 clip_rect; +uniform float radius; +uniform float border_width; +uniform vec4 border_color; +uniform vec4 border_color_to; +uniform vec4 gradient_line; +uniform vec4 focus_color; +uniform vec4 focus_color_to; +uniform vec4 focus_gradient_line; +uniform float focus_mix; +varying vec2 v_coords; + +#if defined(DEBUG_FLAGS) +uniform float tint; +#endif + +void main() { + vec2 point = gl_FragCoord.xy - clip_rect.xy; + vec2 half_size = clip_rect.zw * 0.5; + vec2 distance = abs(point - half_size) - (half_size - vec2(radius)); + float signed_distance = length(max(distance, 0.0)) + + min(max(distance.x, distance.y), 0.0) + - radius; + float outer_coverage = 1.0 - smoothstep(-0.5, 0.5, signed_distance); + + vec2 inner_half_size = max(half_size - vec2(border_width), vec2(0.0)); + float inner_radius = max(radius - border_width, 0.0); + vec2 inner_distance = abs(point - half_size) + - (inner_half_size - vec2(inner_radius)); + float inner_signed_distance = length(max(inner_distance, 0.0)) + + min(max(inner_distance.x, inner_distance.y), 0.0) + - inner_radius; + float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance); + float coverage = max(outer_coverage - inner_coverage, 0.0); + float progress = clamp(dot(gl_FragCoord.xy - gradient_line.xy, gradient_line.zw), 0.0, 1.0); + // Interpolate premultiplied endpoints: a transparent endpoint must not + // leak its RGB into the visible border or create a dark halo. + vec4 from = vec4(border_color.rgb * border_color.a, border_color.a); + vec4 to = vec4(border_color_to.rgb * border_color_to.a, border_color_to.a); + float focus_progress = clamp(dot(gl_FragCoord.xy - focus_gradient_line.xy, focus_gradient_line.zw), 0.0, 1.0); + vec4 focus_from = vec4(focus_color.rgb * focus_color.a, focus_color.a); + vec4 focus_to = vec4(focus_color_to.rgb * focus_color_to.a, focus_color_to.a); + vec4 color = mix(mix(from, to, progress), mix(focus_from, focus_to, focus_progress), focus_mix) * coverage * alpha; + +#if defined(DEBUG_FLAGS) + if (tint == 1.0) + color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8; +#endif + + gl_FragColor = color; +} diff --git a/compositor/src/render/shaders/rounded_texture_shader.frag b/compositor/src/render/shaders/rounded_texture_shader.frag new file mode 100644 index 0000000..f27c82a --- /dev/null +++ b/compositor/src/render/shaders/rounded_texture_shader.frag @@ -0,0 +1,49 @@ +#version 100 + +//_DEFINES_ + +#if defined(EXTERNAL) +#extension GL_OES_EGL_image_external : require +#endif + +precision highp float; +#if defined(EXTERNAL) +uniform samplerExternalOES tex; +#else +uniform sampler2D tex; +#endif + +uniform float alpha; +uniform vec4 clip_rect; +uniform float radius; +varying vec2 v_coords; + +#if defined(DEBUG_FLAGS) +uniform float tint; +#endif + +void main() { + vec4 color = texture2D(tex, v_coords); + +#if defined(NO_ALPHA) + color = vec4(color.rgb, 1.0) * alpha; +#else + color = color * alpha; +#endif + + vec2 point = gl_FragCoord.xy - clip_rect.xy; + vec2 half_size = clip_rect.zw * 0.5; + vec2 distance = abs(point - half_size) - (half_size - vec2(radius)); + float signed_distance = length(max(distance, 0.0)) + + min(max(distance.x, distance.y), 0.0) + - radius; + float coverage = 1.0 - smoothstep(-0.5, 0.5, signed_distance); + color *= coverage; + +#if defined(DEBUG_FLAGS) + if (tint == 1.0) + color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8; +#endif + + gl_FragColor = color; +} diff --git a/compositor/src/render/shaders/window_shadow_shader.frag b/compositor/src/render/shaders/window_shadow_shader.frag new file mode 100644 index 0000000..ac8b45a --- /dev/null +++ b/compositor/src/render/shaders/window_shadow_shader.frag @@ -0,0 +1,35 @@ + +precision mediump float; + +uniform float alpha; +uniform vec4 shadow_rect; +uniform float radius; +uniform float blur; +uniform float opacity; +uniform vec4 shadow_color; +varying vec2 v_coords; + +#if defined(DEBUG_FLAGS) +uniform float tint; +#endif + +void main() { + vec2 point = gl_FragCoord.xy - shadow_rect.xy; + vec2 half_size = shadow_rect.zw * 0.5; + vec2 distance = abs(point - half_size) - (half_size - vec2(radius)); + float signed_distance = length(max(distance, 0.0)) + + min(max(distance.x, distance.y), 0.0) + - radius; + float sigma = max(blur * 0.5, 0.5); + float normalized_distance = max(signed_distance, 0.0) / sigma; + float coverage = exp(-0.5 * normalized_distance * normalized_distance); + vec4 color = vec4(shadow_color.rgb * shadow_color.a, shadow_color.a) + * coverage * opacity * alpha; + +#if defined(DEBUG_FLAGS) + if (tint == 1.0) + color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8; +#endif + + gl_FragColor = color; +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 1d5cea3..100b870 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -1,3 +1,11 @@ +mod animation; +mod floating; +mod hit_testing; +mod layout; +mod navigation; +mod outputs; +mod windows; + use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use std::error::Error; @@ -77,16 +85,17 @@ use crate::ipc::IpcSocketGuard; use crate::overview::OverviewState; use crate::portal_session::PortalSession; use crate::portal_shortcuts::PortalShortcuts; +use crate::render::{ + BlurProgram, MaterialBuffers, MaterialProgram, OverviewScrim, ResizeSnapshot, RoundedClipPrograms, + WindowBorderBuffers, WindowShadowBuffers, +}; use crate::resize_transaction::ResizeTransaction; use crate::session_lock::{IdleSettings, Lock}; use crate::shell_control::ShellSnapshot; use crate::stacking::WindowStack; use crate::wallpaper::{WallpaperConfig, WallpaperState}; use crate::window_rules::{WindowRule, resolve as resolve_window_rules}; -use crate::winit::{ - BlurProgram, MaterialBuffers, MaterialProgram, NestedBackend, OverviewScrim, ResizeSnapshot, RoundedClipPrograms, - WindowBorderBuffers, WindowShadowBuffers, -}; +use crate::winit::NestedBackend; const CLOSE_ANIMATION_DURATION: Duration = Duration::from_millis(140); const WORKSPACE_SLIDE_DURATION: Duration = Duration::from_millis(220); @@ -762,163 +771,6 @@ impl Ferese { Ok(()) } - pub fn register_output(&mut self, output: &Output, identity: String) { - if self.session_lock.active { - self.session_lock.output_added(output); - self.lock_input_activity(); - } - let Some(geometry) = self.space.output_geometry(output) else { - tracing::error!(output = %output.name(), "cannot register an unmapped output"); - return; - }; - let output_id = *self.output_identity_ids.entry(identity).or_insert_with(|| { - let id = OutputId(self.next_output_id); - self.next_output_id = self.next_output_id.saturating_add(1); - id - }); - let fallback_workspace = (1..) - .find_map(|index| { - let workspace = self.workspaces.ensure_numeric(index).ok()?; - self.output_workspaces - .output_for_workspace(workspace) - .is_none() - .then_some(workspace) - }) - .expect("numeric workspaces are inexhaustible"); - let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h); - - let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace); - - match registration { - Ok(workspace) => { - self.output_ids.insert(output.clone(), output_id); - if self.output_workspaces.focused_output() == Some(output_id) { - self.activate_output_workspace(output_id, workspace); - } - self.send_shell_snapshots(); - } - Err(error) => { - tracing::error!(%error, output = %output.name(), "failed to register output"); - } - } - } - - pub fn unregister_output(&mut self, output: &Output) { - let Some(output_id) = self.output_ids.remove(output) else { - return; - }; - - self.workspace_slides.remove(&output_id); - self.overview_scrims.remove(&output_id); - self.pending_screencopies.retain(|capture| { - if capture.output == *output { - capture.fail(); - false - } else { - true - } - }); - self.space.unmap_output(output); - self.session_lock.surfaces.remove(output); - self.session_lock.backgrounds.remove(output); - self.session_lock.output_removed(output); - - match self.output_workspaces.disconnect(output_id) { - Ok(Some(target)) => { - if let Some(workspace) = self.output_workspaces.active_workspace(target) { - self.activate_output_workspace(target, workspace); - } - } - Ok(None) => { - self.focused_window = None; - self.restore_keyboard_focus(); - } - Err(error) => { - tracing::error!(%error, output = %output.name(), "failed to unregister output") - } - } - - self.relayout(); - } - - pub(crate) fn reposition_output_floats(&mut self, output: OutputId, old: Rect, new: Rect) { - let floats = self - .window_ids - .values() - .filter_map(|id| { - let workspace = self.workspaces.workspace_for_window(*id)?; - if self.output_workspaces.output_for_workspace(workspace) != Some(output) { - return None; - } - match self.workspaces.placement(*id)? { - WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))), - _ => None, - } - }) - .collect::>(); - for (id, rect) in floats { - let _ = self.workspaces.set_floating_rect(id, rect); - } - } - - fn activate_output_workspace(&mut self, output: OutputId, workspace: ferese_core::WorkspaceId) { - if let Err(error) = self.output_workspaces.focus_output(output) { - tracing::error!(%error, "failed to focus output"); - return; - } - - match self.workspaces.activate(workspace) { - Ok(focus) => self.focused_window = focus, - Err(error) => tracing::error!(%error, "failed to activate output workspace"), - } - } - - pub(crate) fn focused_output(&self) -> Option<&Output> { - let focused = self.output_workspaces.focused_output()?; - self.output_ids - .iter() - .find_map(|(output, id)| (*id == focused).then_some(output)) - } - - pub(crate) fn restore_output_focus(&mut self) { - if let Some(output) = self.output_workspaces.focused_output() - && let Some(workspace) = self.output_workspaces.active_workspace(output) - { - self.activate_output_workspace(output, workspace); - self.restore_keyboard_focus(); - } - } - - pub(crate) fn output_id(&self, output: &Output) -> Option { - self.output_ids.get(output).copied() - } - - pub(crate) fn focus_output_at(&mut self, position: Point) { - let output = self.space.outputs().find_map(|output| { - let geometry = self.space.output_geometry(output)?; - (position.x >= f64::from(geometry.loc.x) - && position.y >= f64::from(geometry.loc.y) - && position.x < f64::from(geometry.loc.x + geometry.size.w) - && position.y < f64::from(geometry.loc.y + geometry.size.h)) - .then(|| output.clone()) - }); - let Some(output) = output else { - return; - }; - let Some(output_id) = self.output_ids.get(&output).copied() else { - return; - }; - let Some(workspace) = self.output_workspaces.active_workspace(output_id) else { - return; - }; - if self.output_workspaces.focused_output() == Some(output_id) && self.workspaces.active_id() == workspace { - return; - } - - self.activate_output_workspace(output_id, workspace); - self.relayout(); - } - pub(crate) fn queue_dmabuf_import(&mut self, dmabuf: Dmabuf, notifier: ImportNotifier) { self.pending_dmabuf_imports.push((dmabuf, notifier)); } @@ -970,2651 +822,6 @@ impl Ferese { )?; Ok(socket_name) } - - pub fn surface_under(&self, position: Point) -> Option<(WlSurface, Point)> { - if self.session_lock.active { - return self.lock_surface_under(position); - } - if self.input_capture.active() { - return None; - } - self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) - .map(|(_, surface, origin)| (surface, origin)) - .or_else(|| self.window_surface_under(position)) - .or_else(|| { - self.layer_surface_under(position, &[Layer::Bottom, Layer::Background]) - .map(|(_, surface, origin)| (surface, origin)) - }) - } - - pub fn layer_under(&self, position: Point) -> Option<(LayerSurface, WlSurface, Point)> { - if self.session_lock.active { - return None; - } - if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) { - return Some(layer); - } - if self.window_surface_under(position).is_some() { - return None; - } - - self.layer_surface_under(position, &[Layer::Bottom, Layer::Background]) - } - - fn window_surface_under(&self, position: Point) -> Option<(WlSurface, Point)> { - if self.overview.is_active() { - return None; - } - - let workspace = self.workspace_under_pointer(position)?; - self.space.elements().rev().find_map(|window| { - let id = self.window_ids.get(window)?; - if self.workspaces.workspace_for_window(*id) != Some(workspace) { - return None; - } - let visual = self.presented_window_rect(*id)?; - let inside_visual = position.x >= visual.x - && position.y >= visual.y - && position.x < visual.x + visual.width - && position.y < visual.y + visual.height; - if !inside_visual { - return None; - } - - let (source_x, source_y) = self.inverse_presented_window_point(*id, position.x, position.y)?; - let source_point = Point::from((source_x, source_y)) + window.geometry().loc.to_f64(); - - let surface = window.surface_under(source_point, WindowSurfaceType::ALL); - let (surface, surface_location) = match surface { - Some(hit) => hit, - None if self.window_fills_visual_bounds(*id, workspace) => { - (window.toplevel()?.wl_surface().clone(), Point::from((0, 0))) - } - None => return None, - }; - let surface_point = source_point - surface_location.to_f64(); - Some((surface, position - surface_point)) - }) - } - - fn window_fills_visual_bounds(&self, id: WindowId, workspace: WorkspaceId) -> bool { - self.maximized_windows.contains(&id) - || self - .workspaces - .workspace(workspace) - .is_some_and(|workspace| workspace.fullscreen == Some(id)) - } - - fn layer_surface_under( - &self, - position: Point, - layers: &[Layer], - ) -> Option<(LayerSurface, WlSurface, Point)> { - let output = self.space.output_under(position).next()?; - let output_geometry = self.space.output_geometry(output)?; - let output_position = position - output_geometry.loc.to_f64(); - 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(); - let Some((surface, surface_location)) = layer.surface_under(layer_position, WindowSurfaceType::ALL) - else { - continue; - }; - let origin = output_geometry.loc + geometry.loc + surface_location; - - return Some((layer.clone(), surface, origin.to_f64())); - } - } - - None - } - - pub fn send_cursor_frame(&self, output: &Output) { - let CursorImageStatus::Surface(surface) = &self.cursor_status else { - return; - }; - let Some(pointer) = self.seat.get_pointer() else { - return; - }; - if self.space.output_under(pointer.current_location()).next() != Some(output) { - return; - } - - send_frames_surface_tree( - surface, - output, - self.start_time.elapsed(), - Some(Duration::ZERO), - |_, _| Some(output.clone()), - ); - } - - pub fn window_under_visual(&self, position: Point) -> Option { - let overview_active = self.overview.is_active(); - let workspace = self.workspace_under_pointer(position)?; - - let hit = |window: &Window| -> Option { - if !self.window_content_ready(window) { - return None; - } - - let id = self.window_ids.get(window)?; - if self.workspaces.workspace_for_window(*id) != Some(workspace) { - return None; - } - - let visual = self.presented_window_rect(*id)?; - let caption_height = if overview_active { 34.0 } else { 0.0 }; - let inside_visual = position.x >= visual.x - && position.y >= visual.y - && position.x < visual.x + visual.width - && position.y < visual.y + visual.height + caption_height; - if !inside_visual { - return None; - } - - if !overview_active { - let (source_x, source_y) = self.inverse_presented_window_point(*id, position.x, position.y)?; - let source_point = Point::from((source_x, source_y)) + window.geometry().loc.to_f64(); - if window.surface_under(source_point, WindowSurfaceType::ALL).is_none() - && !self.window_fills_visual_bounds(*id, workspace) - { - return None; - } - } - - Some(window.clone()) - }; - - if overview_active { - let mut candidates = self.window_ids.keys().cloned().collect::>(); - // Match Overview's front-to-back render order during overlapping motion. - candidates.sort_by_key(|window| std::cmp::Reverse(self.window_ids.get(window).map_or(0, |id| id.0))); - candidates.iter().find_map(hit) - } else { - self.space.elements().rev().find_map(hit) - } - } - - fn workspace_under_pointer(&self, position: Point) -> Option { - let output = self.space.output_under(position).next()?; - self.output_workspaces.active_workspace(self.output_id(output)?) - } - - pub(crate) fn window_belongs_to_output(&self, window: WindowId, output: &Output) -> bool { - let Some(output_id) = self.output_id(output) else { - return false; - }; - let workspace = self.workspaces.workspace_for_window(window); - workspace == self.output_workspaces.active_workspace(output_id) - || self - .workspace_slides - .get(&output_id) - .is_some_and(|slide| workspace.is_some_and(|workspace| slide.contains(workspace))) - } - - pub(crate) fn workspace_slide_offset(&self, window: WindowId) -> (f64, f64) { - let Some(workspace) = self.workspaces.workspace_for_window(window) else { - return (0.0, 0.0); - }; - let Some(output_id) = self - .workspace_slides - .iter() - .find_map(|(output, slide)| slide.contains(workspace).then_some(*output)) - .or_else(|| self.output_workspaces.output_for_workspace(workspace)) - else { - return (0.0, 0.0); - }; - let Some(slide) = self.workspace_slides.get(&output_id) else { - return (0.0, 0.0); - }; - let Some(size) = self - .output_ids - .iter() - .find(|(_, id)| **id == output_id) - .and_then(|(output, _)| self.space.output_geometry(output)) - .map(|geometry| geometry.size) - else { - return (0.0, 0.0); - }; - slide.offset(workspace, f64::from(size.w), f64::from(size.h)) - } - - pub(crate) fn cancel_workspace_slides(&mut self) -> bool { - let active = !self.workspace_slides.is_empty(); - self.workspace_slides.clear(); - active - } - - pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) { - if let Some(id) = self.window_ids.get(window) { - self.window_stack.raise(*id); - } - self.space.raise_element(window, activate); - self.sync_window_stacking(); - } - - pub(crate) fn sync_window_stacking(&mut self) { - let mut windows = self.space.elements().cloned().collect::>(); - windows.sort_by_key(|window| { - let id = self.window_ids.get(window).copied(); - let geometry = id.and_then(|id| self.window_geometry.get(&id)); - let floating = - id.is_some_and(|id| matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. }))); - let priority = crate::stacking::layer_priority( - floating, - geometry.is_some_and(|geometry| geometry.is_zooming()), - geometry.is_some_and(|geometry| geometry.is_fullscreen()), - floating - && id.is_some_and(|id| { - self.floating_above_fullscreen.get(&id).is_some_and(|parent| { - self.workspaces - .workspace_for_window(id) - .and_then(|workspace| self.workspaces.workspace(workspace)) - .is_some_and(|workspace| workspace.fullscreen == Some(*parent)) - }) - }), - ); - (priority, id.map_or(usize::MAX, |id| self.window_stack.rank(id))) - }); - if stacking_order_settled(self.space.elements(), &windows, |window| window.z_index()) { - return; - } - for window in windows { - self.space.raise_element(&window, false); - } - } - - pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> { - if !self.overview.is_presenting() { - return Some((1.0, 1.0)); - } - let id = self.window_ids.get(window)?; - let geometry = self.window_geometry.get(id)?; - let source = geometry.client.committed_size?; - let presented = self.presented_window_rect(*id)?; - - if source.width <= 0 || source.height <= 0 { - return None; - } - - Some(( - presented.width / f64::from(source.width), - presented.height / f64::from(source.height), - )) - } - - pub(crate) fn visual_rect_for_window(&self, window: &Window) -> Option> { - let id = self.window_ids.get(window)?; - let rect = self.presented_window_rect(*id)?; - let size = ClientSize::from_rect(rect); - Some(Rectangle::new( - (rect.x.round() as i32, rect.y.round() as i32).into(), - (size.width, size.height).into(), - )) - } - - pub(crate) fn window_content_ready(&self, window: &Window) -> bool { - window_has_buffer(window) - && self - .window_ids - .get(window) - .and_then(|id| self.window_geometry.get(id)) - .is_some_and(|geometry| geometry.client.committed_size.is_some()) - } - - pub(crate) fn restore_initial_floating_size(&mut self, window: &Window) { - let Some(toplevel) = window.toplevel() else { return }; - let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { - let attributes = states.data_map.get::().unwrap().lock().unwrap(); - ( - attributes.app_id.clone(), - attributes.title.clone(), - attributes.parent.is_some(), - ) - }); - let special_mode = toplevel.with_pending_state(|pending| { - pending.states.contains(xdg_toplevel::State::Fullscreen) - || pending.states.contains(xdg_toplevel::State::Maximized) - }); - let rule = resolve_window_rules(&self.window_rules, app_id.as_deref(), title.as_deref(), transient); - if transient - || special_mode - || rule.fullscreen == Some(true) - || !rule.floating.unwrap_or(rule.width.is_some() || rule.height.is_some()) - { - return; - } - let outputs = self.floating_outputs(); - let Some((remembered, work)) = app_id - .as_deref() - .and_then(|app| self.floating_memory.get(app)) - .and_then(|entry| { - let rect = entry.restore(&outputs)?; - let work = outputs.iter().find(|(name, _)| *name == entry.output)?.1; - Some((rect, work)) - }) - else { - return; - }; - // Metadata is available on the initial bufferless commit. Restore the - // size before the client creates its first buffer, without mapping or - // focusing the window. Use the same rule/constraint precedence as map. - let rect = self.place_floating( - window, - None, - work, - (remembered.width, remembered.height), - Some((rule.width, rule.height)), - ); - let size = ClientSize::from_rect(rect); - toplevel.with_pending_state(|pending| pending.size = Some((size.width, size.height).into())); - } - - pub(crate) fn add_rule_placed_window( - &mut self, - window: Window, - app_id: Option<&str>, - title: Option<&str>, - parent: Option, - ) { - let rule = resolve_window_rules(&self.window_rules, app_id, title, parent.is_some()); - let floating = rule - .floating - .unwrap_or(parent.is_some() || rule.width.is_some() || rule.height.is_some()); - if !floating { - self.add_tiled_window(window); - return; - } - if let Some(parent) = parent { - self.add_transient_window(window, parent); - return; - } - if let Some(pointer) = self.seat.get_pointer() { - self.focus_output_at(pointer.current_location()); - } - let remembered_work = app_id.and_then(|app| self.floating_memory.get(app)).and_then(|entry| { - let outputs = self.floating_outputs(); - entry.restore(&outputs)?; - outputs - .into_iter() - .find(|(name, _)| *name == entry.output) - .map(|(_, work)| work) - }); - if let Some(work) = remembered_work { - self.focus_output_at((work.x + work.width / 2.0, work.y + work.height / 2.0).into()); - } - let Some(bounds) = self.output_bounds() else { - self.add_tiled_window(window); - return; - }; - let id = WindowId(self.next_window_id); - self.next_window_id += 1; - let size_rect = client_size(&window) - .map(|size| natural_floating_rect(bounds, size)) - .unwrap_or_else(|| centered_floating_rect(bounds)); - let rect = self.place_floating(&window, None, bounds, (size_rect.width, size_rect.height), None); - let anchor = self - .focused_window - .and_then(|focused| self.presented_window_rect(focused)) - .unwrap_or(bounds); - self.floating_placement_anchors.insert(id, anchor); - let fullscreen = self.workspaces.active().fullscreen; - let focus = true; - if let Err(error) = self - .workspaces - .insert_floating_window(id, self.workspaces.active_id(), rect, focus) - { - tracing::error!(%error, ?id, "failed to insert rule-placed floating window"); - return; - } - if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() && remembered_work.is_none() - { - self.natural_floating_pending.insert(id); - } - self.window_ids.insert(window.clone(), id); - self.window_stack.insert(id); - if let Some(fullscreen) = fullscreen { - self.floating_above_fullscreen.insert(id, fullscreen); - } - if focus { - self.focused_window = Some(id); - } - self.space.map_element(window, (0, 0), focus); - // apply_initial_window_rules performs the first relayout/configure. - } - - pub fn add_tiled_window(&mut self, window: Window) { - let id = WindowId(self.next_window_id); - self.next_window_id += 1; - let focus_new_window = self.workspaces.active().fullscreen.is_none(); - - let axis = self - .output_bounds() - .and_then(|bounds| { - self.workspaces - .active() - .layout - .automatic_axis(self.focused_window, bounds) - .ok() - }) - .unwrap_or(Axis::Horizontal); - - if let Err(error) = self.workspaces.insert_window(id, axis, 0.5) { - tracing::error!(%error, ?id, "failed to insert window into layout"); - return; - } - - self.window_ids.insert(window.clone(), id); - self.window_stack.insert(id); - if focus_new_window { - self.focused_window = Some(id); - } - self.space.map_element(window, (0, 0), focus_new_window); - self.relayout(); - - if focus_new_window { - self.restore_keyboard_focus(); - } - } - - pub fn add_transient_window(&mut self, window: Window, parent: WindowId) { - let Some(workspace) = self.workspaces.workspace_for_window(parent) else { - self.add_tiled_window(window); - return; - }; - let Some(bounds) = self.floating_bounds_for_window(parent) else { - self.add_tiled_window(window); - return; - }; - let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds); - let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds); - let size_rect = client_size(&window) - .map(|s| natural_floating_rect(bounds, s)) - .unwrap_or_else(|| centered_transient_rect(visible_parent)); - let rect = self.place_floating(&window, None, bounds, (size_rect.width, size_rect.height), None); - let id = WindowId(self.next_window_id); - self.next_window_id += 1; - - let focus = workspace == self.workspaces.active_id() - && (self.focused_window == Some(parent) || self.workspaces.active().fullscreen == Some(parent)); - if let Err(error) = self.workspaces.insert_floating_window(id, workspace, rect, focus) { - tracing::error!(%error, ?id, ?parent, "failed to insert transient window"); - return; - } - - self.window_ids.insert(window.clone(), id); - self.window_stack.insert(id); - if client_size(&window).is_none() { - self.natural_floating_pending.insert(id); - } - if self - .workspaces - .workspace(workspace) - .is_some_and(|workspace| workspace.fullscreen == Some(parent)) - { - self.floating_above_fullscreen.insert(id, parent); - } - if focus { - self.focused_window = Some(id); - } - self.space.map_element(window, (0, 0), focus); - self.relayout(); - if focus { - self.restore_keyboard_focus(); - } - } - - pub(crate) fn apply_initial_window_rules( - &mut self, - window: &Window, - app_id: Option<&str>, - title: Option<&str>, - transient: bool, - ) { - let Some(id) = self.window_ids.get(window).copied() else { - return; - }; - if !self.window_rules_applied.insert(id) { - return; - } - - let rule = resolve_window_rules(&self.window_rules, app_id, title, transient); - self.apply_window_rule_result(window, rule); - } - - fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) { - let windows = self.window_ids.keys().cloned().collect::>(); - for window in windows { - let Some(toplevel) = window.toplevel() else { - continue; - }; - let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { - let attributes = states.data_map.get::().unwrap().lock().unwrap(); - ( - attributes.app_id.clone(), - attributes.title.clone(), - attributes.parent.is_some(), - ) - }); - let old = resolve_window_rules(old_rules, app_id.as_deref(), title.as_deref(), transient); - let new = resolve_window_rules(&self.window_rules, app_id.as_deref(), title.as_deref(), transient); - if let Some(new) = crate::window_rules::live_result(old, new, transient) { - self.apply_window_rule_result(&window, new); - } - } - } - - fn apply_window_rule_result(&mut self, window: &Window, rule: crate::window_rules::WindowRuleResult) { - let Some(id) = self.window_ids.get(window).copied() else { - return; - }; - let Some(bounds) = self.floating_bounds_for_window(id) else { - return; - }; - if rule == Default::default() { - return; - } - let axis = self - .workspaces - .active() - .layout - .automatic_axis(self.focused_window, bounds) - .unwrap_or(Axis::Horizontal); - - if let Some(workspace) = rule.workspace - && let Err(error) = self.workspaces.move_window_to_numeric(id, workspace, axis, 0.5) - { - tracing::warn!(%error, ?id, workspace, "failed to apply window workspace rule"); - } - let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds); - - let should_float = rule - .floating - .or((rule.width.is_some() || rule.height.is_some()).then_some(true)); - if let Some(should_float) = should_float { - let is_floating = matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. })); - let mut rect = centered_floating_rect(bounds); - if should_float && rule.width.is_none() && rule.height.is_none() { - if let Some(size) = client_size(window) { - rect = natural_floating_rect(bounds, size); - } else if Self::floating_metadata(window) - .0 - .as_deref() - .and_then(|app| self.floating_memory.get(app)) - .and_then(|entry| entry.restore(&self.floating_outputs())) - .is_none() - { - self.natural_floating_pending.insert(id); - } - } - rect.width = rule.width.unwrap_or(rect.width).min(bounds.width); - rect.height = rule.height.unwrap_or(rect.height).min(bounds.height); - if should_float { - rect = self.place_floating( - window, - Some(id), - bounds, - (rect.width, rect.height), - Some((rule.width, rule.height)), - ); - } - - let result = if should_float != is_floating { - self.workspaces.toggle_floating(id, rect, axis, 0.5).map(|_| ()) - } else if should_float && (rule.width.is_some() || rule.height.is_some()) { - self.workspaces.set_floating_rect(id, rect) - } else { - Ok(()) - }; - if let Err(error) = result { - tracing::warn!(%error, ?id, "failed to apply floating window rule"); - } - } - - if let Some(fullscreen) = rule.fullscreen - && let Err(error) = self.workspaces.set_fullscreen(id, fullscreen) - { - tracing::warn!(%error, ?id, fullscreen, "failed to apply fullscreen window rule"); - } - - if self.workspaces.workspace_for_window(id) != Some(self.workspaces.active_id()) - && self.focused_window == Some(id) - { - self.focused_window = self.workspaces.active().last_focused; - } - - self.relayout(); - self.restore_keyboard_focus(); - } - - pub fn make_window_transient(&mut self, window: &Window, parent: WindowId) { - let Some(id) = self.window_ids.get(window).copied() else { - return; - }; - if self.workspaces.workspace_for_window(id) != self.workspaces.workspace_for_window(parent) { - tracing::warn!(?id, ?parent, "ignored transient parent on another workspace"); - return; - } - let Some(bounds) = self.floating_bounds_for_window(parent) else { - return; - }; - let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds); - let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds); - let size_rect = client_size(window) - .map(|s| natural_floating_rect(bounds, s)) - .unwrap_or_else(|| centered_transient_rect(visible_parent)); - let rect = self.place_floating(window, Some(id), bounds, (size_rect.width, size_rect.height), None); - let result = match self.workspaces.placement(id) { - Some(WindowPlacement::Tiled) => self - .workspaces - .toggle_floating(id, rect, Axis::Horizontal, 0.5) - .map(|_| ()), - Some(WindowPlacement::Floating { .. }) => self.workspaces.set_floating_rect(id, rect), - None => return, - }; - - if let Err(error) = result { - tracing::warn!(%error, ?id, ?parent, "failed to apply transient placement"); - return; - } - - if self - .workspaces - .workspace_for_window(parent) - .and_then(|workspace| self.workspaces.workspace(workspace)) - .is_some_and(|workspace| workspace.fullscreen == Some(parent)) - { - self.floating_above_fullscreen.insert(id, parent); - } - if self.focused_window == Some(parent) { - if let Err(error) = self.workspaces.focus_window(id) { - tracing::warn!(%error, ?id, "failed to focus transient window"); - } else { - self.focused_window = Some(id); - self.window_stack.raise(id); - } - } - - self.relayout(); - self.restore_keyboard_focus(); - } - - pub fn remove_tiled_window(&mut self, window: &Window) { - // Uncommitted toplevels have not entered a workspace yet. - self.space.unmap_elem(window); - let Some(id) = self.window_ids.remove(window) else { - return; - }; - self.window_geometry.remove(&id); - self.resize_transactions.remove(&id); - self.resize_snapshots.remove(&id); - self.window_resize_fills.remove(&id); - self.maximized_windows.remove(&id); - self.maximized_column_widths.remove(&id); - self.window_stack.remove(id); - self.floating_above_fullscreen.remove(&id); - self.natural_floating_pending.remove(&id); - self.floating_window_memory.remove(&id); - self.floating_placement_anchors.remove(&id); - self.floating_resize_anchors.remove(&id); - self.window_borders.remove(&id); - self.window_dims.remove(&id); - self.window_dimming.remove(&id); - self.window_focus.remove(&id); - self.window_shadows.remove(&id); - self.closing_windows.remove(&id); - self.window_rules_applied.remove(&id); - self.scrolling_world_x.remove(&id); - self.viewport_coupled_widths.remove(&id); - self.pending_column_width_cycles.remove(&id); - if let Err(error) = self.workspaces.remove_window(id) { - tracing::error!(%error, ?id, "failed to remove window from layout"); - } - if self.focused_window == Some(id) { - self.focused_window = self.workspaces.active().last_focused; - } - - self.relayout(); - } - - fn visible_workspace_ids(&self) -> HashSet { - let mut visible = self - .output_workspaces - .connected_outputs() - .filter_map(|output| self.output_workspaces.active_workspace(output)) - .collect::>(); - visible.extend( - self.workspace_slides - .values() - .flat_map(|slide| slide.items.iter().map(|item| item.workspace)), - ); - visible - } - - fn prune_empty_workspaces(&mut self, visible: &HashSet) { - let mut protected = visible.clone(); - protected.extend(self.output_workspaces.history_workspaces()); - for workspace in self.workspaces.prune_empty(&protected) { - self.output_workspaces.forget_workspace(workspace); - self.viewport_animations.remove(&workspace); - } - } - - fn unmap_invisible_windows(&mut self, visible: &HashSet) -> bool { - let mut changed = false; - for (window, id) in &self.window_ids { - if visible.contains(id) { - continue; - } - let was_mapped = self.space.element_location(window).is_some(); - changed |= was_mapped; - if was_mapped && let Some(geometry) = self.window_geometry.get_mut(id) { - geometry.settle_presentation(); - } - self.space.unmap_elem(window); - } - changed - } - - pub fn relayout(&mut self) { - self.refresh_input_capture_zones(); - let visible_workspaces = self.visible_workspace_ids(); - self.prune_empty_workspaces(&visible_workspaces); - if self.session_lock.active { - self.configure_lock_surfaces(); - } - // Consume idle time before setting new targets; it must not become a - // large first animation step after a keypress on an idle desktop. - self.advance_animations(Instant::now()); - self.arrange_layers(); - let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); - let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles); - let outputs = self.space.outputs().cloned().collect::>(); - let constraints = self.window_constraints(); - let mut visible = HashSet::new(); - let mut placements = Vec::new(); - let mut visible_workspace_pairs = Vec::new(); - - for output in outputs { - let Some(output_id) = self.output_ids.get(&output).copied() else { - continue; - }; - let Some(active_workspace) = self.output_workspaces.active_workspace(output_id) else { - continue; - }; - visible_workspace_pairs.push((output.clone(), active_workspace)); - if let Some(slide) = self.workspace_slides.get(&output_id) { - for item in &slide.items { - if item.workspace != active_workspace { - visible_workspace_pairs.push((output.clone(), item.workspace)); - } - } - } - } - - for (output, workspace_id) in visible_workspace_pairs { - let Some(bounds) = self.output_bounds_for(&output) else { - continue; - }; - let fullscreen_bounds = self.full_output_bounds_for(&output).unwrap_or(bounds); - let scale = output.current_scale().fractional_scale(); - for layer in layer_map_for_output(&output).layers() { - crate::handlers::set_surface_tree_scale(layer.wl_surface(), scale); - } - let Some(workspace) = self.workspaces.workspace(workspace_id) else { - continue; - }; - let workspace_fullscreen = workspace.fullscreen; - let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_)); - let workspace_focus = workspace.last_focused; - let focused = self - .focused_window - .filter(|window| self.workspaces.workspace_for_window(*window) == Some(workspace_id)) - .or(workspace_focus); - let Some(workspace) = self.workspaces.workspace_mut(workspace_id) else { - continue; - }; - let layout = - match workspace - .layout - .geometry_with_constraints(bounds, self.gap_config, &constraints, focused) - { - Ok(layout) => layout, - Err(error) => { - tracing::error!(%error, ?workspace_id, "failed to compute tiled geometry"); - continue; - } - }; - let viewport_target = workspace.layout.viewport_x(); - let viewport_motion = viewport_target.map(|target| { - let viewport = self - .viewport_animations - .entry(workspace_id) - .or_insert_with(|| AnimatedValue::new(target)); - let target_changed = (viewport.target - target).abs() > 0.001; - viewport.retarget_preserving_motion(target); - if !self.animations_enabled { - viewport.snap(); - } - (viewport.current, target_changed) - }); - let viewport_current = viewport_motion.map(|(current, _)| current); - let viewport_target_changed = viewport_motion.is_some_and(|(_, changed)| changed); - - for warning in layout.warnings { - tracing::warn!( - window = ?warning.window, - kind = ?warning.kind, - requested = warning.requested, - assigned = warning.assigned, - "window size constraint could not be satisfied exactly" - ); - } - - for (window, id) in &self.window_ids { - if self.workspaces.workspace_for_window(*id) != Some(workspace_id) { - continue; - } - if let Some(toplevel) = window.toplevel() { - crate::handlers::set_surface_tree_scale(toplevel.wl_surface(), scale); - } - - 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 && !is_scrolling_layout { - maximized_rect(bounds, self.gap_config.outer) - } else { - match self.workspaces.placement(*id) { - Some(WindowPlacement::Tiled) => { - let Some(rect) = layout.geometry.get(id).copied() else { - continue; - }; - rect - } - Some(WindowPlacement::Floating { rect }) => { - let constrained = - constrained_floating_rect(rect, constraints.get(id).copied().unwrap_or_default()); - if constrained != rect { - let _ = self.workspaces.set_floating_rect(*id, constrained); - } - constrained - } - None => continue, - } - }; - let is_fullscreen = workspace_fullscreen == Some(*id); - let is_floating = matches!(self.workspaces.placement(*id), Some(WindowPlacement::Floating { .. })); - - visible.insert(*id); - let scrolling = if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) { - viewport_target - .zip(viewport_current) - .map(|(target, current)| (workspace_id, rect.x + target, current)) - } else { - None - }; - - placements.push(( - window.clone(), - *id, - rect, - is_fullscreen, - is_maximized, - is_floating, - scrolling, - pending_column_width_cycles.contains(id) && viewport_target_changed, - )); - } - } - - let mut layout_changed = self.unmap_invisible_windows(&visible); - - let now = self.start_time.elapsed(); - - let mut scrolling_world_x = HashMap::new(); - placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id)); - - for (window, id, rect, is_fullscreen, is_maximized, is_floating, scrolling, couple_width) in placements { - let was_mapped = self.space.element_location(&window).is_some(); - // A viewport-coupled width must never override fullscreen/floating geometry. - if scrolling.is_none() { - self.viewport_coupled_widths.remove(&id); - } - let (geometry, had_geometry) = match self.window_geometry.entry(id) { - Entry::Occupied(entry) => (entry.into_mut(), true), - Entry::Vacant(entry) => (entry.insert(WindowGeometry::new(rect, client_size(&window))), false), - }; - let mode = if is_fullscreen { - PresentationMode::Fullscreen - } else if is_maximized { - PresentationMode::Maximized - } else { - PresentationMode::Normal - }; - layout_changed |= !had_geometry || geometry.logical != rect; - let mut requested_size = geometry.set_presentation_mode(rect, mode, now); - if !was_mapped && (had_geometry || is_fullscreen) { - geometry.settle_presentation(); - } - if !self.animations_enabled { - geometry.advance(Duration::ZERO, self.spring_config, false); - requested_size = geometry.presentation_size_request(now).or(requested_size); - } - if geometry.is_zooming() { - self.viewport_coupled_widths.remove(&id); - } - if let Some((workspace, world_x, viewport_x)) = scrolling { - let restored_world_x = - restored_scrolling_world_x(had_geometry, geometry.visual.current.x, viewport_x, world_x); - let mut animated_world_x = previous_scrolling_world_x - .remove(&id) - .filter(|(previous_workspace, _)| *previous_workspace == workspace) - .map(|(_, world_x)| world_x) - .unwrap_or_else(|| AnimatedValue::new(restored_world_x)); - animated_world_x.set_target(world_x); - if !self.animations_enabled { - animated_world_x.snap(); - } - if !geometry.is_zooming() { - geometry.visual.current.x = animated_world_x.current - viewport_x; - geometry.visual.velocity.x = animated_world_x.velocity; - } - scrolling_world_x.insert(id, (workspace, animated_world_x)); - - let coupled = !geometry.is_zooming() - && (couple_width - || self - .viewport_coupled_widths - .get(&id) - .is_some_and(|(previous_workspace, _)| *previous_workspace == workspace)); - if coupled { - let width = self - .viewport_coupled_widths - .entry(id) - .or_insert_with(|| (workspace, AnimatedValue::new(geometry.visual.current.width))); - if width.0 != workspace { - *width = (workspace, AnimatedValue::new(geometry.visual.current.width)); - } - width.1.retarget_preserving_motion(rect.width); - if !self.animations_enabled { - width.1.snap(); - } - geometry.visual.current.width = width.1.current; - geometry.visual.velocity.width = width.1.velocity; - } else if pending_column_width_cycles.contains(&id) { - self.viewport_coupled_widths.remove(&id); - } - } - let visual = geometry.visual.current; - let natural_pending = - self.natural_floating_pending.contains(&id) && is_floating && !is_fullscreen && !is_maximized; - if natural_pending { - requested_size = None; - } - let location = (visual.x.round() as i32, visual.y.round() as i32); - - self.space.map_element(window.clone(), location, false); - if let Some(toplevel) = window.toplevel() { - let state_changed = toplevel.with_pending_state(|state| { - if natural_pending { - state.size = None; - } else if let Some(size) = requested_size { - state.size = Some((size.width, size.height).into()); - } - - let fullscreen_changed = if is_fullscreen { - state.states.set(xdg_toplevel::State::Fullscreen) - } else { - 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) - | state.states.set(xdg_toplevel::State::TiledRight) - | state.states.set(xdg_toplevel::State::TiledTop) - | state.states.set(xdg_toplevel::State::TiledBottom) - } else { - state.states.unset(xdg_toplevel::State::TiledLeft) - | state.states.unset(xdg_toplevel::State::TiledRight) - | state.states.unset(xdg_toplevel::State::TiledTop) - | state.states.unset(xdg_toplevel::State::TiledBottom) - }; - - let decoration_mode = if is_floating && !is_fullscreen && !is_maximized { - DecorationMode::ClientSide - } else { - DecorationMode::ServerSide - }; - let decoration_changed = state.decoration_mode != Some(decoration_mode); - state.decoration_mode = Some(decoration_mode); - - fullscreen_changed || maximized_changed || tiled_changed || decoration_changed - }); - - if (natural_pending || requested_size.is_some() || state_changed) - && let Some(serial) = toplevel.send_pending_configure() - && requested_size.is_some() - && had_geometry - && self.animations_enabled - { - self.resize_transactions.insert( - id, - crate::resize_transaction::ResizeTransaction::new(serial, now) - .with_source_geometry(window.geometry()), - ); - } - } - } - self.scrolling_world_x = scrolling_world_x; - if layout_changed { - self.backdrop_generation = self.backdrop_generation.wrapping_add(1); - } - self.sync_window_stacking(); - self.retarget_overview(); - self.send_shell_snapshots(); - - crate::backends::direct::render_all(self); - } - - pub fn advance_animations(&mut self, now: Instant) -> bool { - let delta = crate::presentation::frame_delta(&mut self.last_animation_tick, now); - self.advance_animations_by(delta) - } - - pub fn record_drm_presentation(&mut self, node: DrmNode, crtc: crtc::Handle, time: DrmEventTime, sequence: u32) { - if let Some(backend) = self.direct_backend.as_mut() { - backend.record_presentation(node, crtc, time, sequence); - } - } - - pub(crate) fn animation_fallback_deadline(&self, interval: std::time::Duration) -> Instant { - self.last_animation_tick + interval.saturating_mul(2) - } - - pub(crate) fn reset_animation_clock(&mut self) { - self.last_animation_tick = Instant::now(); - } - - fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool { - let delta = delta.mul_f64(self.animation_speed); - if self - .focus_swipe - .as_ref() - .is_some_and(|swipe| !self.focus_swipe_is_current(swipe)) - { - self.focus_swipe = None; - } - let visible_workspaces = if self.viewport_animations.is_empty() { - HashSet::new() - } else { - self.space - .outputs() - .filter_map(|output| self.output_ids.get(output)) - .filter_map(|output| self.output_workspaces.active_workspace(*output)) - .collect::>() - }; - let windows = self - .space - .elements() - .filter_map(|window| self.window_ids.get(window).copied().map(|id| (window.clone(), id))) - .collect::>(); - let mut active_animation = false; - let mut completed_slides = Vec::new(); - for (output, slide) in &mut self.workspace_slides { - if slide.held_progress.is_some() { - continue; - } - if slide.advance(delta) { - active_animation = true; - } else { - completed_slides.push(*output); - } - } - self.dismissing_popups.retain_mut(|(root, popup, motion)| { - if !smithay::utils::IsAlive::alive(popup.wl_surface()) { - return false; - } - let duration = if self.animations_enabled { 140.0 } else { 0.0 }; - let active = motion.advance_visual(0.0, delta, duration); - crate::effects::fade_dismissed_surface(popup.wl_surface(), motion.current); - active_animation |= active; - if !active { - let _ = PopupManager::dismiss_popup(root, popup); - } - active - }); - - let dim_settings = self.theme_settings.inactive_dim; - let duration = if self.animations_enabled { - dim_settings.duration_ms - } else { - 0.0 - }; - let mut dim_changed = false; - // Include hidden workspace windows: overview can present them too. - // Read selection once so these immutable fields can be borrowed alongside - // window_focus without allocating a temporary window-ID vector. - let selected_window = if self.overview.is_active() { - self.overview.selected() - } else { - self.focused_window - }; - for id in self.window_ids.values().copied() { - let selected = selected_window == Some(id); - let target = if selected { 1.0 } else { 0.0 }; - let focus = self - .window_focus - .entry(id) - .or_insert_with(|| crate::dimming::DimAnimation::new(target)); - let previous = focus.current; - active_animation |= focus.advance_visual(target, delta, duration); - dim_changed |= previous != focus.current; - } - for (_, id) in &windows { - let target = crate::dimming::target(dim_settings, self.focused_window, *id, self.overview.is_presenting()); - let dim = self - .window_dimming - .entry(*id) - .or_insert_with(|| crate::dimming::DimAnimation::new(target)); - let previous = dim.current; - active_animation |= dim.advance_visual(target, delta, duration); - dim_changed |= previous != dim.current; - } - - let mut ready_to_close = Vec::new(); - for (id, animation) in &mut self.closing_windows { - if animation.advance(delta, self.animations_enabled) { - ready_to_close.push(*id); - } else if !animation.close_sent { - active_animation = true; - } - } - for id in ready_to_close { - self.send_window_close(id); - } - - let now = self.start_time.elapsed(); - self.resize_transactions.retain(|id, transaction| { - if transaction.expired(now) { - tracing::warn!(?id, "resize presentation deadline reached"); - false - } else { - true - } - }); - let blocked_workspaces = self - .resize_transactions - .keys() - .filter_map(|id| self.workspaces.workspace_for_window(*id)) - .collect::>(); - let animations_enabled = self.animations_enabled; - let animation_speed = self.animation_speed; - self.resize_snapshots.retain(|id, snapshot| { - if !animations_enabled { - return false; - } - let waiting_for_client = self - .workspaces - .workspace_for_window(*id) - .is_some_and(|workspace| blocked_workspaces.contains(&workspace)); - // A shrinking client's destination buffer arrives before the - // animated bounds reach it. Keep the old native pixels covering - // that strip rather than fading them into the neutral resize fill. - let uncovered = self.window_geometry.get(id).is_some_and(|geometry| { - geometry.client.committed_size.is_some_and(|size| { - crate::presentation::resize_needs_old_frame( - geometry.visual.current, - geometry.logical, - size.width, - size.height, - ) - }) - }); - let blocked = waiting_for_client || uncovered; - let active = crate::presentation::advance_handoff( - &mut snapshot.elapsed, - &mut snapshot.last_tick, - now, - blocked, - animation_speed, - ); - if !blocked { - snapshot.commit.increment(); - } - if !active { - tracing::debug!(?id, bytes = snapshot.bytes(), "released resize handoff snapshot"); - } - active - }); - active_animation |= !self.resize_snapshots.is_empty(); - // Keep scheduling frames while waiting, so the deadline cannot stall. - active_animation |= !blocked_workspaces.is_empty(); - for (workspace, viewport) in &mut self.viewport_animations { - if !visible_workspaces.contains(workspace) { - continue; - } - if blocked_workspaces.contains(workspace) { - continue; - } - if let Some(swipe) = self.focus_swipe.as_ref().filter(|swipe| swipe.workspace == *workspace) { - viewport.current = swipe.position(); - viewport.velocity = 0.0; - } else if self.animations_enabled { - active_animation |= viewport.advance(delta, self.viewport_spring_config); - } else { - viewport.snap(); - } - } - - let mut settled_coupled_widths = Vec::new(); - for (window, id) in windows { - if self - .workspaces - .workspace_for_window(id) - .is_some_and(|workspace| blocked_workspaces.contains(&workspace)) - { - continue; - } - let Some(geometry) = self.window_geometry.get_mut(&id) else { - continue; - }; - let zooming = geometry.is_zooming(); - - let coupled_target = self.viewport_coupled_widths.get(&id).map(|(_, width)| width.target); - if coupled_target.is_some() { - geometry.visual.target.width = geometry.visual.current.width; - geometry.visual.velocity.width = 0.0; - } - active_animation |= geometry.advance(delta, self.spring_config, self.animations_enabled); - if let Some(target) = coupled_target { - geometry.visual.target.width = target; - } - if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id) - && let Some(viewport) = self.viewport_animations.get(workspace) - { - if zooming { - world_x.current = geometry.visual.current.x + viewport.current; - world_x.velocity = geometry.visual.velocity.x + viewport.velocity; - } else if self.animations_enabled { - active_animation |= world_x.advance(delta, self.spring_config); - } else { - world_x.snap(); - } - if !zooming { - geometry.visual.current.x = world_x.current - viewport.current; - geometry.visual.velocity.x = world_x.velocity - viewport.velocity; - } - } - if let Some((_, width)) = self.viewport_coupled_widths.get_mut(&id) { - let width_active = if self.animations_enabled { - width.advance(delta, self.viewport_spring_config) - } else { - width.snap(); - false - }; - active_animation |= width_active; - geometry.visual.current.width = width.current; - geometry.visual.velocity.width = width.velocity; - if !width_active { - settled_coupled_widths.push(id); - } - } - if let Some(size) = geometry.client.expire_wait(now) { - tracing::warn!( - ?id, - configured_width = size.width, - configured_height = size.height, - committed = ?geometry.client.committed_size, - "client did not commit the final configured size within 500 ms" - ); - } - if let Some(size) = geometry.presentation_size_request(now) - && !self.natural_floating_pending.contains(&id) - && let Some(toplevel) = window.toplevel() - { - toplevel.with_pending_state(|state| { - state.size = Some((size.width, size.height).into()); - }); - toplevel.send_pending_configure(); - } - let visual = geometry.visual.current; - self.space - .map_element(window, (visual.x.round() as i32, visual.y.round() as i32), false); - } - for id in settled_coupled_widths { - self.viewport_coupled_widths.remove(&id); - } - if !completed_slides.is_empty() { - for output in completed_slides { - self.workspace_slides.remove(&output); - } - let visible = self.visible_workspace_ids(); - let visible_windows = self - .window_ids - .values() - .copied() - .filter(|id| { - self.workspaces - .workspace_for_window(*id) - .is_none_or(|workspace| visible.contains(&workspace)) - }) - .collect::>(); - self.unmap_invisible_windows(&visible_windows); - self.prune_empty_workspaces(&visible); - self.send_shell_snapshots(); - active_animation = true; - } - active_animation |= self - .overview - .advance(delta, self.spring_config, self.animations_enabled); - self.sync_window_stacking(); - - if active_animation || dim_changed { - self.backdrop_generation = self.backdrop_generation.wrapping_add(1); - } - active_animation - } - - pub(crate) fn animations_enabled(&self) -> bool { - self.animations_enabled - } - - pub(crate) fn animation_duration(&self, duration: Duration) -> Duration { - scaled_animation_duration(duration, self.animations_enabled, self.animation_speed) - } - - pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) { - let Some((window, id)) = self - .window_ids - .iter() - .find(|(window, _)| { - window - .toplevel() - .is_some_and(|toplevel| toplevel.wl_surface() == surface) - }) - .map(|(window, id)| (window.clone(), *id)) - else { - return; - }; - let Some(transaction) = self.resize_transactions.get(&id) else { - return; - }; - let serial = with_states(surface, |states| { - states - .data_map - .get::() - .and_then(|data| data.lock().ok().and_then(|data| data.current_serial)) - }); - if !transaction.accepts(serial) { - return; - } - let Some(output) = self - .space - .outputs() - .find(|output| self.window_belongs_to_output(id, output)) - .cloned() - else { - return; - }; - let source_geometry = transaction.source_geometry().unwrap_or_else(|| window.geometry()); - let scale = output.current_scale().fractional_scale(); - let source_size = source_geometry.size.to_physical_precise_round(scale); - if self.resize_snapshots.get(&id).is_some_and(|snapshot| { - crate::presentation::snapshot_covers_source( - smithay::backend::renderer::Texture::size(&snapshot.texture), - source_size, - snapshot.scale, - scale, - ) - }) { - return; - } - let used: usize = self.resize_snapshots.values().map(|snapshot| snapshot.bytes()).sum(); - let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used); - let result = if let Some(backend) = &self.nested_backend { - // Commit dispatch does not run inside the Winit event callback; - // still never risk reentrant renderer access or a compositor panic. - match backend.try_borrow_mut() { - Ok(mut backend) => crate::winit::capture_resize_snapshot( - backend.renderer(), - &window, - source_geometry, - output.current_scale().fractional_scale(), - remaining, - ), - Err(_) => return, - } - } else if let Some(backend) = &mut self.direct_backend { - backend.capture_resize_snapshot(&window, source_geometry, &output, remaining) - } else { - return; - }; - match result { - Ok(Some(snapshot)) => { - tracing::debug!(?id, bytes = snapshot.bytes(), "captured native resize handoff"); - self.resize_snapshots.insert(id, snapshot); - } - Ok(None) => {} - Err(error) => { - tracing::debug!(%error, ?id, "resize snapshot unavailable; using native crop") - } - } - } - - pub fn record_client_commit(&mut self, window: &Window) { - let Some(&id) = self.window_ids.get(window) else { - return; - }; - // current_serial is promoted by Smithay on commit, unlike - // configure_serial, which only records an acknowledgement. - let committed_serial = window.toplevel().and_then(|toplevel| { - with_states(toplevel.wl_surface(), |states| { - states - .data_map - .get::() - .and_then(|data| data.lock().ok().and_then(|data| data.current_serial)) - }) - }); - if self - .resize_transactions - .get(&id) - .is_some_and(|transaction| transaction.accepts(committed_serial)) - { - self.resize_transactions.remove(&id); - } - let Some(size) = client_size(window) else { - return; - }; - let fullscreen = self - .workspaces - .workspace_for_window(id) - .and_then(|workspace| self.workspaces.workspace(workspace)) - .is_some_and(|workspace| workspace.fullscreen == Some(id)); - let normal_geometry = !fullscreen - && !self.maximized_windows.contains(&id) - && self - .window_geometry - .get(&id) - .is_none_or(|geometry| !geometry.is_zooming()); - let initial_floating = - self.natural_floating_pending.remove(&id) || self.floating_placement_anchors.contains_key(&id); - if initial_floating - && normal_geometry - && matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. })) - && let Some(bounds) = self.floating_bounds_for_window(id) - { - let rect = self.place_floating( - window, - Some(id), - bounds, - (f64::from(size.width), f64::from(size.height)), - Some((Some(f64::from(size.width)), Some(f64::from(size.height)))), - ); - self.floating_placement_anchors.remove(&id); - if self.workspaces.set_floating_rect(id, rect).is_ok() { - // This is the first mapped client buffer, not a user resize. - // Do not animate from the temporary placement box. - self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size))); - self.resize_transactions.remove(&id); - self.relayout(); - } - } - // A normal floating client may choose a different size (minimum sizes, - // terminal cell grids, or a dialog changing its contents). Its committed - // window geometry is authoritative once the latest configure is committed. - // Never let an old buffer undo a newer resize or a fullscreen transition. - self.floating_placement_anchors.remove(&id); - let settled_configure = window.toplevel().is_some_and(|toplevel| { - with_states(toplevel.wl_surface(), |states| { - states.data_map.get::().is_some_and(|data| { - let Ok(data) = data.lock() else { return false }; - floating_commit_is_current( - data.pending_configures().is_empty(), - data.current_serial, - data.configure_serial, - data.current.states.contains(xdg_toplevel::State::Resizing), - ) - }) - }) - }); - if settled_configure - && normal_geometry - && let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id) - && ClientSize::from_rect(rect) != size - { - let mut rect = Rect::new(rect.x, rect.y, f64::from(size.width), f64::from(size.height)); - if let Some((left, top, anchor)) = self.floating_resize_anchors.get(&id) { - rect = crate::floating::anchored_size(*anchor, (rect.width, rect.height), *left, *top); - } else if initial_floating && let Some(work) = self.floating_bounds_for_window(id) { - rect = crate::floating::clamp_to_work(rect, work); - } - if self.workspaces.set_floating_rect(id, rect).is_ok() { - self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size))); - self.resize_transactions.remove(&id); - self.resize_snapshots.remove(&id); - self.relayout(); - } - } - if settled_configure && self.floating_resize_anchors.remove(&id).is_some() { - self.remember_floating(window); - } - let Some(geometry) = self.window_geometry.get_mut(&id) else { - return; - }; - - let matches_target = geometry.client.commit(size); - tracing::debug!(?id, ?size, matches_target, "recorded client geometry commit"); - } - - pub fn focus_direction(&mut self, direction: Direction) { - self.focus_direction_with_slide(direction, false); - } - - fn focus_swipe_is_current(&self, swipe: &FocusSwipe) -> bool { - self.animations_enabled - && !self.session_lock.active - && !self.overview.is_presenting() - && self.workspaces.active_id() == swipe.workspace - && self.focused_window == Some(swipe.from) - && self.workspaces.workspace(swipe.workspace).is_some_and(|workspace| { - workspace.fullscreen.is_none() - && workspace.layout.contains(swipe.from) - && workspace.layout.contains(swipe.to) - }) - } - - pub(crate) fn preview_focus_swipe( - &mut self, - direction: Direction, - gesture_direction: SwipeDirection, - progress: f64, - ) { - if self.focus_swipe.is_none() { - if self.swipe.preview_started() || !self.animations_enabled || self.overview.is_presenting() { - return; - } - let Some(from) = self.focused_window else { return }; - let Some(bounds) = self.output_bounds() else { return }; - let workspace = self.workspaces.active(); - if workspace.fullscreen.is_some() || !matches!(workspace.layout, WorkspaceLayout::Scrolling(_)) { - return; - } - let Ok(Some(to)) = workspace.layout.directional_neighbor(from, direction, bounds) else { - return; - }; - let workspace_id = workspace.id; - let start = self - .viewport_animations - .get(&workspace_id) - .map(|viewport| viewport.current) - .or_else(|| workspace.layout.viewport_x()) - .unwrap_or(0.0); - let mut candidate = workspace.layout.clone(); - if let WorkspaceLayout::Scrolling(layout) = &mut candidate - && layout.slide_focus_from(from, to).is_err() - { - return; - } - if candidate - .geometry_with_constraints(bounds, self.gap_config, &self.window_constraints(), Some(to)) - .is_err() - { - return; - } - self.focus_swipe = Some(FocusSwipe { - workspace: workspace_id, - from, - to, - direction, - gesture_direction, - start, - destination: candidate.viewport_x().unwrap_or(start), - progress, - }); - self.swipe.mark_preview_started(); - } - if let Some(swipe) = &mut self.focus_swipe { - swipe.progress = progress; - } - self.backdrop_generation = self.backdrop_generation.wrapping_add(1); - self.cursor_redraw_pending = true; - } - - pub(crate) fn finish_focus_swipe(&mut self, direction: Option) -> bool { - let Some(swipe) = self.focus_swipe.take() else { - return false; - }; - let current = self.focus_swipe_is_current(&swipe); - // Release from the last input position even if no frame rendered that update. - if current && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) { - viewport.current = swipe.position(); - viewport.velocity = 0.0; - } - let neighbor = self.output_bounds().and_then(|bounds| { - self.workspaces - .active() - .layout - .directional_neighbor(swipe.from, swipe.direction, bounds) - .ok() - .flatten() - }); - self.last_animation_tick = Instant::now(); - if current && direction == Some(swipe.gesture_direction) && neighbor == Some(swipe.to) { - self.focus_direction_from_swipe(swipe.direction); - } else { - self.relayout(); - } - true - } - - pub(crate) fn focus_direction_from_swipe(&mut self, direction: Direction) { - self.focus_direction_with_slide(direction, true); - } - - fn focus_direction_with_slide(&mut self, direction: Direction, slide: bool) { - if self.input_capture.captures(1) { - return; - } - if self.focus_overview_direction(direction) { - return; - } - - let Some(current) = self.focused_window else { - return; - }; - let Some(bounds) = self.output_bounds() else { - return; - }; - let Ok(Some(next)) = self - .workspaces - .active() - .layout - .directional_neighbor(current, direction, bounds) - else { - return; - }; - - if let Err(error) = self.workspaces.focus_window(next) { - tracing::error!(%error, ?next, "failed to update workspace focus"); - return; - } - if slide - && let WorkspaceLayout::Scrolling(layout) = &mut self.workspaces.active_mut().layout - && let Err(error) = layout.slide_focus_from(current, next) - { - tracing::warn!(%error, ?current, ?next, "failed to slide swipe focus"); - } - let Some(window) = self - .window_ids - .iter() - .find_map(|(window, id)| (*id == next).then(|| window.clone())) - else { - return; - }; - let Some(surface) = window.toplevel().map(|toplevel| toplevel.wl_surface().clone()) else { - return; - }; - - self.focused_window = Some(next); - self.raise_window(&window, true); - self.seat.get_keyboard().expect("seat has a keyboard").set_focus( - self, - Some(surface), - smithay::utils::SERIAL_COUNTER.next_serial(), - ); - - for window in self.space.elements() { - if let Some(toplevel) = window.toplevel() { - toplevel.send_pending_configure(); - } - } - - self.relayout(); - } - - pub fn move_direction(&mut self, direction: Direction) { - let Some(current) = self.focused_window else { - return; - }; - let Some(bounds) = self.output_bounds() else { - return; - }; - - match self - .workspaces - .active_mut() - .layout - .move_window(current, direction, bounds) - { - Ok(true) => self.relayout(), - Ok(false) => {} - Err(error) => tracing::error!(%error, ?current, "failed to move tiled window"), - } - } - - pub fn resize_direction(&mut self, direction: Direction) { - const RESIZE_STEP: f64 = 0.05; - - let Some(current) = self.focused_window else { - return; - }; - - match self - .workspaces - .active_mut() - .layout - .resize_window(current, direction, RESIZE_STEP) - { - Ok(true) => self.relayout(), - Ok(false) => {} - Err(error) => tracing::error!(%error, ?current, "failed to resize tiled window"), - } - } - - pub fn toggle_layout_mode(&mut self) { - let Some(bounds) = self.output_bounds() else { - return; - }; - let mode = match self.workspaces.active().layout.mode() { - LayoutMode::Scrolling => LayoutMode::Tree, - LayoutMode::Tree => LayoutMode::Scrolling, - }; - - match self.workspaces.set_active_layout_mode(mode, bounds) { - Ok(true) => self.relayout(), - Ok(false) => {} - Err(error) => tracing::error!(%error, ?mode, "failed to change layout mode"), - } - } - - pub fn consume_focused_window(&mut self) { - let Some(window) = self.focused_window else { - return; - }; - let Some(bounds) = self.output_bounds() else { - return; - }; - if self.workspaces.active().layout.mode() != LayoutMode::Scrolling { - return; - } - let target = self - .workspaces - .active() - .layout - .directional_neighbor(window, Direction::Left, bounds) - .ok() - .flatten() - .or_else(|| { - self.workspaces - .active() - .layout - .directional_neighbor(window, Direction::Right, bounds) - .ok() - .flatten() - }); - let Some(target) = target else { - return; - }; - - if let Err(error) = self.workspaces.stack_window(window, target) { - tracing::error!(%error, ?window, ?target, "failed to consume window into column"); - return; - } - self.relayout(); - } - - pub fn expel_focused_window(&mut self) { - let Some(window) = self.focused_window else { - return; - }; - - match self.workspaces.extract_window(window) { - Ok(true) => self.relayout(), - Ok(false) => {} - Err(error) => tracing::error!(%error, ?window, "failed to expel window from column"), - } - } - - pub fn cycle_focused_column_width(&mut self) { - let Some(window) = self.focused_window else { - return; - }; - - match self.workspaces.cycle_column_width(window, &self.column_width_presets) { - Ok(true) => { - self.pending_column_width_cycles.insert(window); - self.relayout(); - } - Ok(false) => {} - Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"), - } - } - - pub fn center_focused_column(&mut self) { - let Some(window) = self.focused_window else { - return; - }; - let Some(bounds) = self.output_bounds() else { - return; - }; - let constraints = self.window_constraints(); - - match self - .workspaces - .center_window(window, bounds, self.gap_config, &constraints) - { - Ok(true) => self.relayout(), - Ok(false) => {} - Err(error) => tracing::error!(%error, ?window, "failed to center column"), - } - } - - pub fn toggle_focused_floating(&mut self) { - let Some(window) = self.focused_window else { - return; - }; - let Some(bounds) = self.output_bounds() else { - return; - }; - let mut floating_rect = match self.workspaces.placement(window) { - Some(WindowPlacement::Tiled) => self - .tiled_layout(bounds) - .ok() - .and_then(|layout| layout.geometry.get(&window).copied()) - .unwrap_or_else(|| centered_floating_rect(bounds)), - Some(WindowPlacement::Floating { rect }) => rect, - None => return, - }; - if let Some(handle) = self - .window_ids - .iter() - .find_map(|(handle, id)| (*id == window).then_some(handle.clone())) - { - if matches!( - self.workspaces.placement(window), - Some(WindowPlacement::Floating { .. }) - ) { - self.remember_floating(&handle); - } else { - floating_rect = self.place_floating( - &handle, - Some(window), - bounds, - (floating_rect.width, floating_rect.height), - None, - ); - } - } - let axis = self - .workspaces - .active() - .layout - .automatic_axis(Some(window), bounds) - .unwrap_or(Axis::Horizontal); - - if let Err(error) = self.workspaces.toggle_floating(window, floating_rect, axis, 0.5) { - tracing::error!(%error, ?window, "failed to toggle floating window"); - return; - } - - self.relayout(); - } - - pub fn toggle_focused_fullscreen(&mut self) { - let Some(window) = self.focused_window else { - return; - }; - - if let Err(error) = self.workspaces.toggle_fullscreen(window) { - tracing::error!(%error, ?window, "failed to toggle fullscreen window"); - return; - } - - 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); - } - if self.workspaces.placement(window) == Some(WindowPlacement::Tiled) { - let workspace_id = self.workspaces.workspace_for_window(window).unwrap(); - if let Some(workspace) = self.workspaces.workspace_mut(workspace_id) - && let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout - { - if enabled { - if let Some(column) = layout.columns().iter().find(|column| column.windows.contains(&window)) { - self.maximized_column_widths.entry(window).or_insert(column.width); - } - let _ = layout.set_column_width(window, ColumnWidth::Proportion(1.0)); - } else if let Some(width) = self.maximized_column_widths.remove(&window) { - let _ = layout.set_column_width(window, width); - } - } - } - 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; - }; - - if !self.animations_enabled { - self.send_window_close(focused); - return; - } - - self.closing_windows.entry(focused).or_default(); - crate::backends::direct::render_all(self); - } - - pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool { - if !self.window_ids.values().any(|window_id| *window_id == id) { - return false; - } - - if self.animations_enabled { - self.closing_windows.entry(id).or_default(); - crate::backends::direct::render_all(self); - } else { - self.send_window_close(id); - } - - true - } - - pub(crate) fn activate_managed_window(&mut self, id: WindowId) -> bool { - let Some(window) = self - .window_ids - .iter() - .find_map(|(window, window_id)| (*window_id == id).then(|| window.clone())) - else { - return false; - }; - if !activate_window_workspace(&mut self.workspaces, &mut self.output_workspaces, id) { - return false; - } - let workspace = self.workspaces.active_id(); - - if let Some(output) = self.output_workspaces.output_for_workspace(workspace) { - self.workspace_slides.remove(&output); - } - self.focused_window = Some(id); - self.raise_window(&window, true); - self.relayout(); - self.restore_keyboard_focus(); - true - } - - pub(crate) fn activate_managed_workspace(&mut self, workspace: WorkspaceId) -> bool { - self.activate_managed_workspace_internal(workspace, None) - } - - pub(crate) fn activate_managed_workspace_from_swipe( - &mut self, - workspace: WorkspaceId, - direction: SwipeDirection, - ) -> bool { - self.activate_managed_workspace_internal(workspace, Some(direction)) - } - - fn activate_managed_workspace_internal( - &mut self, - workspace: WorkspaceId, - slide_direction: Option, - ) -> bool { - if self.workspaces.workspace(workspace).is_none() { - return false; - } - - let output = self - .output_workspaces - .focused_output() - .or_else(|| self.output_workspaces.output_for_workspace(workspace)); - let Some(output) = output else { - return false; - }; - let previous = self.output_workspaces.active_workspace(output); - let owner = match self.output_workspaces.switch_workspace(output, workspace) { - Ok(ferese_core::WorkspaceSwitch::Activated(output)) - | Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output, - Err(_) => return false, - }; - - self.update_workspace_slide(output, owner, previous, workspace, slide_direction); - self.activate_output_workspace(owner, workspace); - self.relayout(); - self.restore_keyboard_focus(); - true - } - - pub(crate) fn closing_visual(&self, id: WindowId) -> (f64, f32) { - let Some(animation) = self.closing_windows.get(&id) else { - return (1.0, 1.0); - }; - let eased = smoothstep(animation.progress); - - (1.0 - eased * 0.02, (1.0 - eased) as f32) - } - - fn send_window_close(&self, id: WindowId) { - let Some(toplevel) = self - .window_ids - .iter() - .find_map(|(window, window_id)| (*window_id == id).then(|| window.toplevel()).flatten()) - else { - return; - }; - - toplevel.send_close(); - } - - pub fn set_window_fullscreen(&mut self, window: WindowId, enabled: bool) { - match self.workspaces.set_fullscreen(window, enabled) { - Ok(true) => self.relayout(), - Ok(false) => {} - Err(error) => { - tracing::error!(%error, ?window, enabled, "failed to set fullscreen window") - } - } - } - - pub fn is_floating_window(&self, window: &Window) -> bool { - self.window_ids - .get(window) - .and_then(|id| self.workspaces.placement(*id)) - .is_some_and(|placement| matches!(placement, WindowPlacement::Floating { .. })) - } - - pub fn set_floating_window_geometry( - &mut self, - window: &Window, - location: Point, - size: Size, - ) { - let Some(id) = self.window_ids.get(window).copied() else { - return; - }; - self.floating_resize_anchors.remove(&id); - let rect = Rect::new( - location.x as f64, - location.y as f64, - size.w.max(1) as f64, - size.h.max(1) as f64, - ); - - if let Err(error) = self.workspaces.set_floating_rect(id, rect) { - tracing::error!(%error, ?id, "failed to update floating window geometry"); - return; - } - - // Direct manipulation follows the pointer, including while the client is - // still drawing its next buffer. Rendering and hit testing share this rect. - self.resize_transactions.remove(&id); - self.resize_snapshots.remove(&id); - let geometry = self - .window_geometry - .entry(id) - .or_insert_with(|| WindowGeometry::new(rect, client_size(window))); - if let Some(size) = geometry.follow_pointer(rect, self.start_time.elapsed()) - && let Some(toplevel) = window.toplevel() - { - toplevel.with_pending_state(|state| { - state.size = Some((size.width, size.height).into()); - }); - toplevel.send_pending_configure(); - } - self.scrolling_world_x.remove(&id); - self.viewport_coupled_widths.remove(&id); - self.space.map_element(window.clone(), location, false); - self.sync_window_stacking(); - // Move/resize/cancel callbacks run with Smithay's pointer mutex held. - // Cursor rendering reads current_location(), which would lock it again. - // The event-loop epilogue coalesces this redraw after the grab returns. - self.cursor_redraw_pending = true; - } - - pub fn switch_workspace(&mut self, index: u32) { - self.switch_workspace_internal(index, None, false); - } - - pub(crate) fn switch_workspace_from_binding(&mut self, index: u32) { - self.switch_workspace_internal(index, None, self.workspace_auto_back_and_forth); - } - - pub(crate) fn workspace_back_and_forth(&mut self) { - let Some(output) = self.output_workspaces.focused_output() else { - return; - }; - if let Some(workspace) = self.output_workspaces.previous_workspace(output) { - self.activate_managed_workspace(workspace); - } - } - - fn switch_workspace_internal( - &mut self, - index: u32, - slide_direction: Option, - auto_back_and_forth: bool, - ) { - let workspace = match self.workspaces.ensure_numeric(index) { - Ok(workspace) => workspace, - Err(error) => { - tracing::error!(%error, index, "failed to switch workspace"); - return; - } - }; - let Some(output) = self.output_workspaces.focused_output() else { - return; - }; - let previous = self.output_workspaces.active_workspace(output); - let owner = match self - .output_workspaces - .select_workspace(output, workspace, auto_back_and_forth) - { - Ok(ferese_core::WorkspaceSwitch::Activated(output)) - | Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output, - Err(error) => { - tracing::error!(%error, index, "failed to assign workspace to output"); - return; - } - }; - let workspace = self - .output_workspaces - .active_workspace(owner) - .expect("workspace owner is connected"); - self.update_workspace_slide(output, owner, previous, workspace, slide_direction); - self.activate_output_workspace(owner, workspace); - - self.relayout(); - self.restore_keyboard_focus(); - } - - pub(crate) fn preview_workspace_swipe(&mut self, next: bool, direction: SwipeDirection, progress: f64) { - if !self.animations_enabled || self.overview.is_presenting() { - return; - } - if let Some((output, (from, to, _))) = self - .workspace_slides - .iter() - .find_map(|(output, slide)| slide.gesture.map(|gesture| (*output, gesture))) - { - if !workspace_swipe_is_current(&self.output_workspaces, output, from, to) { - self.finish_workspace_swipe(None); - return; - } - self.workspace_slides - .get_mut(&output) - .expect("gesture output exists") - .held_progress = Some(progress); - self.backdrop_generation = self.backdrop_generation.wrapping_add(1); - self.cursor_redraw_pending = true; - return; - } - if self.swipe.preview_started() { - return; - } - let Some(output) = self.output_workspaces.focused_output() else { - return; - }; - let Some(from) = self.output_workspaces.active_workspace(output) else { - return; - }; - let Some(to) = self.relative_workspace_target(next) else { - return; - }; - let previous = self.workspace_slides.remove(&output); - let mut slide = WorkspaceSlide::new(previous, from, to, direction); - slide.held_progress = Some(progress); - slide.gesture = Some((from, to, direction)); - self.workspace_slides.insert(output, slide); - self.swipe.mark_preview_started(); - self.backdrop_generation = self.backdrop_generation.wrapping_add(1); - self.relayout(); - } - - pub(crate) fn finish_workspace_swipe(&mut self, direction: Option) -> bool { - let Some((output, (from, to, expected))) = self - .workspace_slides - .iter() - .find_map(|(output, slide)| slide.gesture.map(|gesture| (*output, gesture))) - else { - return false; - }; - let committed = direction == Some(expected) - && workspace_swipe_is_current(&self.output_workspaces, output, from, to) - && self.activate_managed_workspace_from_swipe(to, expected); - if !committed { - if let Some(slide) = self.workspace_slides.get_mut(&output) { - slide.release(false); - } - self.last_animation_tick = Instant::now(); - self.relayout(); - } - true - } - - fn update_workspace_slide( - &mut self, - requested_output: OutputId, - owner: OutputId, - previous: Option, - workspace: WorkspaceId, - slide_direction: Option, - ) { - let mut previous_slide = self.workspace_slides.remove(&owner); - if owner == requested_output - && let Some(slide) = previous_slide.as_mut() - && slide - .gesture - .is_some_and(|(_, to, direction)| to == workspace && Some(direction) == slide_direction) - { - slide.release(true); - self.last_animation_tick = Instant::now(); - self.workspace_slides.insert(owner, previous_slide.unwrap()); - return; - } - if owner == requested_output - && let (Some(from), Some(direction)) = (previous, slide_direction) - && from != workspace - && self.animations_enabled - { - self.last_animation_tick = Instant::now(); - self.workspace_slides - .insert(owner, WorkspaceSlide::new(previous_slide, from, workspace, direction)); - } - } - - pub fn move_focused_to_workspace(&mut self, index: u32) { - let Some(window) = self.focused_window else { - return; - }; - let axis = self - .output_bounds() - .and_then(|bounds| { - let workspace = self.workspaces.ensure_numeric(index).ok()?; - let target = self.workspaces.workspace(workspace)?; - target.layout.automatic_axis(target.last_focused, bounds).ok() - }) - .unwrap_or(Axis::Horizontal); - - let destination = match self.workspaces.move_window_to_numeric(window, index, axis, 0.5) { - Ok(destination) => destination, - Err(error) => { - tracing::error!(%error, ?window, index, "failed to move window to workspace"); - return; - } - }; - if let Some(output) = self.output_workspaces.focused_output() - && let Err(error) = self.output_workspaces.assign_workspace(output, destination) - { - tracing::error!(%error, ?destination, "failed to assign destination workspace"); - } - - self.focused_window = self.workspaces.active().last_focused; - self.relayout(); - self.restore_keyboard_focus(); - } - - pub(crate) fn restore_keyboard_focus(&mut self) { - if self.input_capture.captures(1) { - return; - } - if self.session_lock.active { - self.focus_lock_surface(); - return; - } - let surface = self.focused_window.and_then(|focused| { - self.window_ids.iter().find_map(|(window, id)| { - (*id == focused) - .then(|| window.toplevel()) - .flatten() - .map(|toplevel| toplevel.wl_surface().clone()) - }) - }); - - self.seat.get_keyboard().expect("seat has a keyboard").set_focus( - self, - surface, - smithay::utils::SERIAL_COUNTER.next_serial(), - ); - } - - fn output_bounds(&self) -> Option { - let output = self.focused_output().or_else(|| self.space.outputs().next())?; - self.output_bounds_for(output) - } - - fn floating_outputs(&self) -> Vec<(String, Rect)> { - self.space - .outputs() - .filter_map(|output| self.output_bounds_for(output).map(|work| (output.name(), work))) - .collect() - } - - fn floating_metadata(window: &Window) -> (Option, Option) { - window - .toplevel() - .map(|top| { - let app = with_states(top.wl_surface(), |states| { - states - .data_map - .get::() - .and_then(|data| data.lock().ok().and_then(|data| data.app_id.clone())) - }); - (app, top.parent()) - }) - .unwrap_or_default() - } - - fn floating_obstacles(&self, exclude: Option, work: Rect) -> Vec<(u64, Rect)> { - self.space - .elements() - .filter_map(|window| { - let id = *self.window_ids.get(window)?; - if Some(id) == exclude { - return None; - } - let workspace = self.workspaces.workspace_for_window(id)?; - let owner = self.output_workspaces.output_for_workspace(workspace)?; - if self.output_workspaces.active_workspace(owner) != Some(workspace) { - return None; - } - if self - .workspaces - .workspace(workspace)? - .fullscreen - .is_some_and(|full| full != id) - && !matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. })) - { - return None; - } - let rect = self.presented_window_rect(id)?; - crate::floating::intersection(rect, work).map(|visible| (id.0, visible)) - }) - .collect() - } - - fn place_floating( - &mut self, - window: &Window, - id: Option, - work: Rect, - size: (f64, f64), - overrides: Option<(Option, Option)>, - ) -> Rect { - use crate::floating::{centered, clamp_to_work, intersection, min_overlap}; - let outputs = self.floating_outputs(); - let output = outputs - .iter() - .find(|(_, area)| *area == work) - .map(|(name, _)| name.as_str()) - .unwrap_or("unknown"); - let (app, parent) = Self::floating_metadata(window); - let parent_rect = parent - .as_ref() - .and_then(|surface| { - self.window_ids.iter().find_map(|(window, id)| { - window - .toplevel() - .is_some_and(|top| top.wl_surface() == surface) - .then_some(*id) - }) - }) - .and_then(|parent| self.presented_window_rect(parent)) - .and_then(|rect| intersection(rect, work)); - let remembered = id - .and_then(|id| self.floating_window_memory.get(&id)) - .or_else(|| { - if parent.is_none() { - app.as_deref().and_then(|app| self.floating_memory.get(app)) - } else { - None - } - }) - .filter(|entry| entry.output == output) - .and_then(|entry| entry.restore(&outputs)); - let (width, height) = overrides.unwrap_or_default(); - let constraints = window - .toplevel() - .map(|top| { - with_states(top.wl_surface(), |states| { - let mut cache = states.cached_state.get::(); - let state = cache.current(); - SizeConstraints { - min_width: state.min_size.w.max(1) as f64, - min_height: state.min_size.h.max(1) as f64, - max_width: (state.max_size.w > 0).then_some(state.max_size.w as f64), - max_height: (state.max_size.h > 0).then_some(state.max_size.h as f64), - } - }) - }) - .unwrap_or_default(); - let stored_size = if parent_rect.is_none() { - remembered.map(|r| (r.width, r.height)).unwrap_or(size) - } else { - size - }; - let sized = constrained_floating_rect( - Rect::new(0., 0., width.unwrap_or(stored_size.0), height.unwrap_or(stored_size.1)), - constraints, - ); - let dimensions = (sized.width, sized.height); - let rect = if let Some(parent) = parent_rect { - centered(dimensions, parent) - } else if let Some(mut rect) = remembered { - rect.width = dimensions.0; - rect.height = dimensions.1; - rect - } else { - let others = self - .floating_obstacles(id, work) - .into_iter() - .map(|(_, r)| r) - .collect::>(); - let anchor = id - .and_then(|id| self.floating_placement_anchors.get(&id).copied()) - .or_else(|| { - self.focused_window - .filter(|focused| Some(*focused) != id) - .and_then(|focused| self.presented_window_rect(focused)) - }) - .and_then(|rect| intersection(rect, work)) - .unwrap_or(work); - min_overlap( - dimensions, - work, - &others, - (anchor.x + anchor.width / 2., anchor.y + anchor.height / 2.), - ) - .unwrap_or_else(|| self.floating_cascade.place(output, work, dimensions)) - }; - clamp_to_work(rect, work) - } - - pub(crate) fn remember_floating(&mut self, window: &Window) { - let Some(id) = self.window_ids.get(window).copied() else { - return; - }; - let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id) else { - return; - }; - let (app, parent) = Self::floating_metadata(window); - if parent.is_some() { - return; - } - // A drag can finish on another output without changing workspace ownership. - let Some((output, work)) = self - .floating_outputs() - .into_iter() - .max_by(|(_, a), (_, b)| { - let area = |work| crate::floating::intersection(rect, work).map_or(0., |r| r.width * r.height); - area(*a).total_cmp(&area(*b)) - }) - .filter(|(_, work)| crate::floating::intersection(rect, *work).is_some()) - else { - return; - }; - let entry = crate::floating::Remembered::new(output, rect, work); - self.floating_window_memory.insert(id, entry.clone()); - if let Some(app) = app { - self.floating_memory.save(app, entry); - } - if self.nested_backend.is_none() - && let Some(path) = crate::floating::Memory::path() - && let Err(error) = self.floating_save_worker.queue(path, &self.floating_memory) - { - tracing::warn!(%error,"could not queue floating window placement save"); - } - } - - pub(crate) fn floating_snap_lines( - &self, - window: &Window, - raw: Rect, - ) -> (Vec, Vec) { - let id = self.window_ids.get(window).copied(); - let work = self - .floating_outputs() - .into_iter() - .max_by(|(_, a), (_, b)| { - let area = |work| crate::floating::intersection(raw, work).map_or(0., |r| r.width * r.height); - area(*a).total_cmp(&area(*b)) - }) - .filter(|(_, work)| crate::floating::intersection(raw, *work).is_some()) - .map(|(_, work)| work) - .or_else(|| id.and_then(|id| self.floating_bounds_for_window(id))); - let Some(work) = work else { - return Default::default(); - }; - crate::floating::snap_lines(raw, work, &self.floating_obstacles(id, work)) - } - - pub(crate) fn floating_snap_bypassed(&self) -> bool { - self.seat - .get_keyboard() - .is_some_and(|keyboard| keyboard.modifier_state().shift) - } - - fn floating_bounds_for_window(&self, window: WindowId) -> Option { - let output_id = self - .workspaces - .workspace_for_window(window) - .and_then(|workspace| self.output_workspaces.output_for_workspace(workspace)); - self.output_ids - .iter() - .find(|(_, id)| Some(**id) == output_id) - .and_then(|(output, _)| self.output_bounds_for(output)) - .or_else(|| self.output_bounds()) - } - - pub(crate) fn output_bounds_for(&self, output: &Output) -> Option { - let geometry = self.space.output_geometry(output)?; - let zone = layer_map_for_output(output).non_exclusive_zone(); - - Some(Rect::new( - (geometry.loc.x + zone.loc.x) as f64, - (geometry.loc.y + zone.loc.y) as f64, - zone.size.w.max(0) as f64, - zone.size.h.max(0) as f64, - )) - } - - fn full_output_bounds_for(&self, output: &Output) -> Option { - let geometry = self.space.output_geometry(output)?; - - Some(Rect::new( - geometry.loc.x as f64, - geometry.loc.y as f64, - geometry.size.w as f64, - geometry.size.h as f64, - )) - } - - fn arrange_layers(&self) { - let outputs = self.space.outputs().cloned().collect::>(); - - for output in outputs { - layer_map_for_output(&output).arrange(); - } - } - - fn tiled_layout(&mut self, bounds: Rect) -> Result { - let constraints = self.window_constraints(); - let focused = self.focused_window; - self.workspaces - .active_mut() - .layout - .geometry_with_constraints(bounds, self.gap_config, &constraints, focused) - } - - fn window_constraints(&self) -> HashMap { - self.window_ids - .iter() - .filter_map(|(window, id)| { - let toplevel = window.toplevel()?; - let (minimum, maximum) = with_states(toplevel.wl_surface(), |states| { - let mut cached = states.cached_state.get::(); - let state = cached.current(); - (state.min_size, state.max_size) - }); - - Some(( - *id, - SizeConstraints { - min_width: minimum.w.max(1) as f64, - min_height: minimum.h.max(1) as f64, - max_width: (maximum.w > 0).then_some(maximum.w as f64), - max_height: (maximum.h > 0).then_some(maximum.h as f64), - }, - )) - }) - .collect() - } } fn maximized_rect(bounds: Rect, gap: f64) -> Rect { diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs new file mode 100644 index 0000000..fe283e9 --- /dev/null +++ b/compositor/src/state/animation.rs @@ -0,0 +1,352 @@ +use super::*; + +impl Ferese { + pub fn advance_animations(&mut self, now: Instant) -> bool { + let delta = crate::presentation::frame_delta(&mut self.last_animation_tick, now); + self.advance_animations_by(delta) + } + + pub fn record_drm_presentation(&mut self, node: DrmNode, crtc: crtc::Handle, time: DrmEventTime, sequence: u32) { + if let Some(backend) = self.direct_backend.as_mut() { + backend.record_presentation(node, crtc, time, sequence); + } + } + + pub(crate) fn animation_fallback_deadline(&self, interval: std::time::Duration) -> Instant { + self.last_animation_tick + interval.saturating_mul(2) + } + + pub(crate) fn reset_animation_clock(&mut self) { + self.last_animation_tick = Instant::now(); + } + + pub(super) fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool { + let delta = delta.mul_f64(self.animation_speed); + if self + .focus_swipe + .as_ref() + .is_some_and(|swipe| !self.focus_swipe_is_current(swipe)) + { + self.focus_swipe = None; + } + let visible_workspaces = if self.viewport_animations.is_empty() { + HashSet::new() + } else { + self.space + .outputs() + .filter_map(|output| self.output_ids.get(output)) + .filter_map(|output| self.output_workspaces.active_workspace(*output)) + .collect::>() + }; + let windows = self + .space + .elements() + .filter_map(|window| self.window_ids.get(window).copied().map(|id| (window.clone(), id))) + .collect::>(); + let mut active_animation = false; + let mut completed_slides = Vec::new(); + for (output, slide) in &mut self.workspace_slides { + if slide.held_progress.is_some() { + continue; + } + if slide.advance(delta) { + active_animation = true; + } else { + completed_slides.push(*output); + } + } + self.dismissing_popups.retain_mut(|(root, popup, motion)| { + if !smithay::utils::IsAlive::alive(popup.wl_surface()) { + return false; + } + let duration = if self.animations_enabled { 140.0 } else { 0.0 }; + let active = motion.advance_visual(0.0, delta, duration); + crate::effects::fade_dismissed_surface(popup.wl_surface(), motion.current); + active_animation |= active; + if !active { + let _ = PopupManager::dismiss_popup(root, popup); + } + active + }); + + let dim_settings = self.theme_settings.inactive_dim; + let duration = if self.animations_enabled { + dim_settings.duration_ms + } else { + 0.0 + }; + let mut dim_changed = false; + // Include hidden workspace windows: overview can present them too. + // Read selection once so these immutable fields can be borrowed alongside + // window_focus without allocating a temporary window-ID vector. + let selected_window = if self.overview.is_active() { + self.overview.selected() + } else { + self.focused_window + }; + for id in self.window_ids.values().copied() { + let selected = selected_window == Some(id); + let target = if selected { 1.0 } else { 0.0 }; + let focus = self + .window_focus + .entry(id) + .or_insert_with(|| crate::dimming::DimAnimation::new(target)); + let previous = focus.current; + active_animation |= focus.advance_visual(target, delta, duration); + dim_changed |= previous != focus.current; + } + for (_, id) in &windows { + let target = crate::dimming::target(dim_settings, self.focused_window, *id, self.overview.is_presenting()); + let dim = self + .window_dimming + .entry(*id) + .or_insert_with(|| crate::dimming::DimAnimation::new(target)); + let previous = dim.current; + active_animation |= dim.advance_visual(target, delta, duration); + dim_changed |= previous != dim.current; + } + + let mut ready_to_close = Vec::new(); + for (id, animation) in &mut self.closing_windows { + if animation.advance(delta, self.animations_enabled) { + ready_to_close.push(*id); + } else if !animation.close_sent { + active_animation = true; + } + } + for id in ready_to_close { + self.send_window_close(id); + } + + let now = self.start_time.elapsed(); + self.resize_transactions.retain(|id, transaction| { + if transaction.expired(now) { + tracing::warn!(?id, "resize presentation deadline reached"); + false + } else { + true + } + }); + let blocked_workspaces = self + .resize_transactions + .keys() + .filter_map(|id| self.workspaces.workspace_for_window(*id)) + .collect::>(); + let animations_enabled = self.animations_enabled; + let animation_speed = self.animation_speed; + self.resize_snapshots.retain(|id, snapshot| { + if !animations_enabled { + return false; + } + let waiting_for_client = self + .workspaces + .workspace_for_window(*id) + .is_some_and(|workspace| blocked_workspaces.contains(&workspace)); + // A shrinking client's destination buffer arrives before the + // animated bounds reach it. Keep the old native pixels covering + // that strip rather than fading them into the neutral resize fill. + let uncovered = self.window_geometry.get(id).is_some_and(|geometry| { + geometry.client.committed_size.is_some_and(|size| { + crate::presentation::resize_needs_old_frame( + geometry.visual.current, + geometry.logical, + size.width, + size.height, + ) + }) + }); + let blocked = waiting_for_client || uncovered; + let active = crate::presentation::advance_handoff( + &mut snapshot.elapsed, + &mut snapshot.last_tick, + now, + blocked, + animation_speed, + ); + if !blocked { + snapshot.commit.increment(); + } + if !active { + tracing::debug!(?id, bytes = snapshot.bytes(), "released resize handoff snapshot"); + } + active + }); + active_animation |= !self.resize_snapshots.is_empty(); + // Keep scheduling frames while waiting, so the deadline cannot stall. + active_animation |= !blocked_workspaces.is_empty(); + for (workspace, viewport) in &mut self.viewport_animations { + if !visible_workspaces.contains(workspace) { + continue; + } + if blocked_workspaces.contains(workspace) { + continue; + } + if let Some(swipe) = self.focus_swipe.as_ref().filter(|swipe| swipe.workspace == *workspace) { + viewport.current = swipe.position(); + viewport.velocity = 0.0; + } else if self.animations_enabled { + active_animation |= viewport.advance(delta, self.viewport_spring_config); + } else { + viewport.snap(); + } + } + + let mut settled_coupled_widths = Vec::new(); + for (window, id) in windows { + if self + .workspaces + .workspace_for_window(id) + .is_some_and(|workspace| blocked_workspaces.contains(&workspace)) + { + continue; + } + let Some(geometry) = self.window_geometry.get_mut(&id) else { + continue; + }; + let zooming = geometry.is_zooming(); + + let coupled_target = self.viewport_coupled_widths.get(&id).map(|(_, width)| width.target); + if coupled_target.is_some() { + geometry.visual.target.width = geometry.visual.current.width; + geometry.visual.velocity.width = 0.0; + } + active_animation |= geometry.advance(delta, self.spring_config, self.animations_enabled); + if let Some(target) = coupled_target { + geometry.visual.target.width = target; + } + if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id) + && let Some(viewport) = self.viewport_animations.get(workspace) + { + if zooming { + world_x.current = geometry.visual.current.x + viewport.current; + world_x.velocity = geometry.visual.velocity.x + viewport.velocity; + } else if self.animations_enabled { + active_animation |= world_x.advance(delta, self.spring_config); + } else { + world_x.snap(); + } + if !zooming { + geometry.visual.current.x = world_x.current - viewport.current; + geometry.visual.velocity.x = world_x.velocity - viewport.velocity; + } + } + if let Some((_, width)) = self.viewport_coupled_widths.get_mut(&id) { + let width_active = if self.animations_enabled { + width.advance(delta, self.viewport_spring_config) + } else { + width.snap(); + false + }; + active_animation |= width_active; + geometry.visual.current.width = width.current; + geometry.visual.velocity.width = width.velocity; + if !width_active { + settled_coupled_widths.push(id); + } + } + if let Some(size) = geometry.client.expire_wait(now) { + tracing::warn!( + ?id, + configured_width = size.width, + configured_height = size.height, + committed = ?geometry.client.committed_size, + "client did not commit the final configured size within 500 ms" + ); + } + if let Some(size) = geometry.presentation_size_request(now) + && !self.natural_floating_pending.contains(&id) + && let Some(toplevel) = window.toplevel() + { + toplevel.with_pending_state(|state| { + state.size = Some((size.width, size.height).into()); + }); + toplevel.send_pending_configure(); + } + let visual = geometry.visual.current; + self.space + .map_element(window, (visual.x.round() as i32, visual.y.round() as i32), false); + } + for id in settled_coupled_widths { + self.viewport_coupled_widths.remove(&id); + } + if !completed_slides.is_empty() { + for output in completed_slides { + self.workspace_slides.remove(&output); + } + let visible = self.visible_workspace_ids(); + let visible_windows = self + .window_ids + .values() + .copied() + .filter(|id| { + self.workspaces + .workspace_for_window(*id) + .is_none_or(|workspace| visible.contains(&workspace)) + }) + .collect::>(); + self.unmap_invisible_windows(&visible_windows); + self.prune_empty_workspaces(&visible); + self.send_shell_snapshots(); + active_animation = true; + } + active_animation |= self + .overview + .advance(delta, self.spring_config, self.animations_enabled); + self.sync_window_stacking(); + + if active_animation || dim_changed { + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + } + active_animation + } + + pub(crate) fn animations_enabled(&self) -> bool { + self.animations_enabled + } + + pub(crate) fn animation_duration(&self, duration: Duration) -> Duration { + scaled_animation_duration(duration, self.animations_enabled, self.animation_speed) + } + + pub(crate) fn closing_visual(&self, id: WindowId) -> (f64, f32) { + let Some(animation) = self.closing_windows.get(&id) else { + return (1.0, 1.0); + }; + let eased = smoothstep(animation.progress); + + (1.0 - eased * 0.02, (1.0 - eased) as f32) + } + + pub(crate) fn workspace_slide_offset(&self, window: WindowId) -> (f64, f64) { + let Some(workspace) = self.workspaces.workspace_for_window(window) else { + return (0.0, 0.0); + }; + let Some(output_id) = self + .workspace_slides + .iter() + .find_map(|(output, slide)| slide.contains(workspace).then_some(*output)) + .or_else(|| self.output_workspaces.output_for_workspace(workspace)) + else { + return (0.0, 0.0); + }; + let Some(slide) = self.workspace_slides.get(&output_id) else { + return (0.0, 0.0); + }; + let Some(size) = self + .output_ids + .iter() + .find(|(_, id)| **id == output_id) + .and_then(|(output, _)| self.space.output_geometry(output)) + .map(|geometry| geometry.size) + else { + return (0.0, 0.0); + }; + slide.offset(workspace, f64::from(size.w), f64::from(size.h)) + } + + pub(crate) fn cancel_workspace_slides(&mut self) -> bool { + let active = !self.workspace_slides.is_empty(); + self.workspace_slides.clear(); + active + } +} diff --git a/compositor/src/state/floating.rs b/compositor/src/state/floating.rs new file mode 100644 index 0000000..8b0e379 --- /dev/null +++ b/compositor/src/state/floating.rs @@ -0,0 +1,434 @@ +use super::*; + +impl Ferese { + pub fn toggle_focused_floating(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + let mut floating_rect = match self.workspaces.placement(window) { + Some(WindowPlacement::Tiled) => self + .tiled_layout(bounds) + .ok() + .and_then(|layout| layout.geometry.get(&window).copied()) + .unwrap_or_else(|| centered_floating_rect(bounds)), + Some(WindowPlacement::Floating { rect }) => rect, + None => return, + }; + if let Some(handle) = self + .window_ids + .iter() + .find_map(|(handle, id)| (*id == window).then_some(handle.clone())) + { + if matches!( + self.workspaces.placement(window), + Some(WindowPlacement::Floating { .. }) + ) { + self.remember_floating(&handle); + } else { + floating_rect = self.place_floating( + &handle, + Some(window), + bounds, + (floating_rect.width, floating_rect.height), + None, + ); + } + } + let axis = self + .workspaces + .active() + .layout + .automatic_axis(Some(window), bounds) + .unwrap_or(Axis::Horizontal); + + if let Err(error) = self.workspaces.toggle_floating(window, floating_rect, axis, 0.5) { + tracing::error!(%error, ?window, "failed to toggle floating window"); + return; + } + + self.relayout(); + } + + pub fn toggle_focused_fullscreen(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + + if let Err(error) = self.workspaces.toggle_fullscreen(window) { + tracing::error!(%error, ?window, "failed to toggle fullscreen window"); + return; + } + + 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); + } + if self.workspaces.placement(window) == Some(WindowPlacement::Tiled) { + let workspace_id = self.workspaces.workspace_for_window(window).unwrap(); + if let Some(workspace) = self.workspaces.workspace_mut(workspace_id) + && let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout + { + if enabled { + if let Some(column) = layout.columns().iter().find(|column| column.windows.contains(&window)) { + self.maximized_column_widths.entry(window).or_insert(column.width); + } + let _ = layout.set_column_width(window, ColumnWidth::Proportion(1.0)); + } else if let Some(width) = self.maximized_column_widths.remove(&window) { + let _ = layout.set_column_width(window, width); + } + } + } + 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 set_window_fullscreen(&mut self, window: WindowId, enabled: bool) { + match self.workspaces.set_fullscreen(window, enabled) { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => { + tracing::error!(%error, ?window, enabled, "failed to set fullscreen window") + } + } + } + + pub fn is_floating_window(&self, window: &Window) -> bool { + self.window_ids + .get(window) + .and_then(|id| self.workspaces.placement(*id)) + .is_some_and(|placement| matches!(placement, WindowPlacement::Floating { .. })) + } + + pub fn set_floating_window_geometry( + &mut self, + window: &Window, + location: Point, + size: Size, + ) { + let Some(id) = self.window_ids.get(window).copied() else { + return; + }; + self.floating_resize_anchors.remove(&id); + let rect = Rect::new( + location.x as f64, + location.y as f64, + size.w.max(1) as f64, + size.h.max(1) as f64, + ); + + if let Err(error) = self.workspaces.set_floating_rect(id, rect) { + tracing::error!(%error, ?id, "failed to update floating window geometry"); + return; + } + + // Direct manipulation follows the pointer, including while the client is + // still drawing its next buffer. Rendering and hit testing share this rect. + self.resize_transactions.remove(&id); + self.resize_snapshots.remove(&id); + let geometry = self + .window_geometry + .entry(id) + .or_insert_with(|| WindowGeometry::new(rect, client_size(window))); + if let Some(size) = geometry.follow_pointer(rect, self.start_time.elapsed()) + && let Some(toplevel) = window.toplevel() + { + toplevel.with_pending_state(|state| { + state.size = Some((size.width, size.height).into()); + }); + toplevel.send_pending_configure(); + } + self.scrolling_world_x.remove(&id); + self.viewport_coupled_widths.remove(&id); + self.space.map_element(window.clone(), location, false); + self.sync_window_stacking(); + // Move/resize/cancel callbacks run with Smithay's pointer mutex held. + // Cursor rendering reads current_location(), which would lock it again. + // The event-loop epilogue coalesces this redraw after the grab returns. + self.cursor_redraw_pending = true; + } + + pub(super) fn output_bounds(&self) -> Option { + let output = self.focused_output().or_else(|| self.space.outputs().next())?; + self.output_bounds_for(output) + } + + pub(super) fn floating_outputs(&self) -> Vec<(String, Rect)> { + self.space + .outputs() + .filter_map(|output| self.output_bounds_for(output).map(|work| (output.name(), work))) + .collect() + } + + pub(super) fn floating_metadata(window: &Window) -> (Option, Option) { + window + .toplevel() + .map(|top| { + let app = with_states(top.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|data| data.lock().ok().and_then(|data| data.app_id.clone())) + }); + (app, top.parent()) + }) + .unwrap_or_default() + } + + pub(super) fn floating_obstacles(&self, exclude: Option, work: Rect) -> Vec<(u64, Rect)> { + self.space + .elements() + .filter_map(|window| { + let id = *self.window_ids.get(window)?; + if Some(id) == exclude { + return None; + } + let workspace = self.workspaces.workspace_for_window(id)?; + let owner = self.output_workspaces.output_for_workspace(workspace)?; + if self.output_workspaces.active_workspace(owner) != Some(workspace) { + return None; + } + if self + .workspaces + .workspace(workspace)? + .fullscreen + .is_some_and(|full| full != id) + && !matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. })) + { + return None; + } + let rect = self.presented_window_rect(id)?; + crate::floating::intersection(rect, work).map(|visible| (id.0, visible)) + }) + .collect() + } + + pub(super) fn place_floating( + &mut self, + window: &Window, + id: Option, + work: Rect, + size: (f64, f64), + overrides: Option<(Option, Option)>, + ) -> Rect { + use crate::floating::{centered, clamp_to_work, intersection, min_overlap}; + let outputs = self.floating_outputs(); + let output = outputs + .iter() + .find(|(_, area)| *area == work) + .map(|(name, _)| name.as_str()) + .unwrap_or("unknown"); + let (app, parent) = Self::floating_metadata(window); + let parent_rect = parent + .as_ref() + .and_then(|surface| { + self.window_ids.iter().find_map(|(window, id)| { + window + .toplevel() + .is_some_and(|top| top.wl_surface() == surface) + .then_some(*id) + }) + }) + .and_then(|parent| self.presented_window_rect(parent)) + .and_then(|rect| intersection(rect, work)); + let remembered = id + .and_then(|id| self.floating_window_memory.get(&id)) + .or_else(|| { + if parent.is_none() { + app.as_deref().and_then(|app| self.floating_memory.get(app)) + } else { + None + } + }) + .filter(|entry| entry.output == output) + .and_then(|entry| entry.restore(&outputs)); + let (width, height) = overrides.unwrap_or_default(); + let constraints = window + .toplevel() + .map(|top| { + with_states(top.wl_surface(), |states| { + let mut cache = states.cached_state.get::(); + let state = cache.current(); + SizeConstraints { + min_width: state.min_size.w.max(1) as f64, + min_height: state.min_size.h.max(1) as f64, + max_width: (state.max_size.w > 0).then_some(state.max_size.w as f64), + max_height: (state.max_size.h > 0).then_some(state.max_size.h as f64), + } + }) + }) + .unwrap_or_default(); + let stored_size = if parent_rect.is_none() { + remembered.map(|r| (r.width, r.height)).unwrap_or(size) + } else { + size + }; + let sized = constrained_floating_rect( + Rect::new(0., 0., width.unwrap_or(stored_size.0), height.unwrap_or(stored_size.1)), + constraints, + ); + let dimensions = (sized.width, sized.height); + let rect = if let Some(parent) = parent_rect { + centered(dimensions, parent) + } else if let Some(mut rect) = remembered { + rect.width = dimensions.0; + rect.height = dimensions.1; + rect + } else { + let others = self + .floating_obstacles(id, work) + .into_iter() + .map(|(_, r)| r) + .collect::>(); + let anchor = id + .and_then(|id| self.floating_placement_anchors.get(&id).copied()) + .or_else(|| { + self.focused_window + .filter(|focused| Some(*focused) != id) + .and_then(|focused| self.presented_window_rect(focused)) + }) + .and_then(|rect| intersection(rect, work)) + .unwrap_or(work); + min_overlap( + dimensions, + work, + &others, + (anchor.x + anchor.width / 2., anchor.y + anchor.height / 2.), + ) + .unwrap_or_else(|| self.floating_cascade.place(output, work, dimensions)) + }; + clamp_to_work(rect, work) + } + + pub(crate) fn remember_floating(&mut self, window: &Window) { + let Some(id) = self.window_ids.get(window).copied() else { + return; + }; + let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id) else { + return; + }; + let (app, parent) = Self::floating_metadata(window); + if parent.is_some() { + return; + } + // A drag can finish on another output without changing workspace ownership. + let Some((output, work)) = self + .floating_outputs() + .into_iter() + .max_by(|(_, a), (_, b)| { + let area = |work| crate::floating::intersection(rect, work).map_or(0., |r| r.width * r.height); + area(*a).total_cmp(&area(*b)) + }) + .filter(|(_, work)| crate::floating::intersection(rect, *work).is_some()) + else { + return; + }; + let entry = crate::floating::Remembered::new(output, rect, work); + self.floating_window_memory.insert(id, entry.clone()); + if let Some(app) = app { + self.floating_memory.save(app, entry); + } + if self.nested_backend.is_none() + && let Some(path) = crate::floating::Memory::path() + && let Err(error) = self.floating_save_worker.queue(path, &self.floating_memory) + { + tracing::warn!(%error,"could not queue floating window placement save"); + } + } + + pub(crate) fn floating_snap_lines( + &self, + window: &Window, + raw: Rect, + ) -> (Vec, Vec) { + let id = self.window_ids.get(window).copied(); + let work = self + .floating_outputs() + .into_iter() + .max_by(|(_, a), (_, b)| { + let area = |work| crate::floating::intersection(raw, work).map_or(0., |r| r.width * r.height); + area(*a).total_cmp(&area(*b)) + }) + .filter(|(_, work)| crate::floating::intersection(raw, *work).is_some()) + .map(|(_, work)| work) + .or_else(|| id.and_then(|id| self.floating_bounds_for_window(id))); + let Some(work) = work else { + return Default::default(); + }; + crate::floating::snap_lines(raw, work, &self.floating_obstacles(id, work)) + } + + pub(crate) fn floating_snap_bypassed(&self) -> bool { + self.seat + .get_keyboard() + .is_some_and(|keyboard| keyboard.modifier_state().shift) + } + + pub(super) fn floating_bounds_for_window(&self, window: WindowId) -> Option { + let output_id = self + .workspaces + .workspace_for_window(window) + .and_then(|workspace| self.output_workspaces.output_for_workspace(workspace)); + self.output_ids + .iter() + .find(|(_, id)| Some(**id) == output_id) + .and_then(|(output, _)| self.output_bounds_for(output)) + .or_else(|| self.output_bounds()) + } + + pub(crate) fn output_bounds_for(&self, output: &Output) -> Option { + let geometry = self.space.output_geometry(output)?; + let zone = layer_map_for_output(output).non_exclusive_zone(); + + Some(Rect::new( + (geometry.loc.x + zone.loc.x) as f64, + (geometry.loc.y + zone.loc.y) as f64, + zone.size.w.max(0) as f64, + zone.size.h.max(0) as f64, + )) + } + + pub(super) fn full_output_bounds_for(&self, output: &Output) -> Option { + let geometry = self.space.output_geometry(output)?; + + Some(Rect::new( + geometry.loc.x as f64, + geometry.loc.y as f64, + geometry.size.w as f64, + geometry.size.h as f64, + )) + } +} diff --git a/compositor/src/state/hit_testing.rs b/compositor/src/state/hit_testing.rs new file mode 100644 index 0000000..b19e116 --- /dev/null +++ b/compositor/src/state/hit_testing.rs @@ -0,0 +1,232 @@ +use super::*; + +impl Ferese { + pub fn surface_under(&self, position: Point) -> Option<(WlSurface, Point)> { + if self.session_lock.active { + return self.lock_surface_under(position); + } + if self.input_capture.active() { + return None; + } + self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) + .map(|(_, surface, origin)| (surface, origin)) + .or_else(|| self.window_surface_under(position)) + .or_else(|| { + self.layer_surface_under(position, &[Layer::Bottom, Layer::Background]) + .map(|(_, surface, origin)| (surface, origin)) + }) + } + + pub fn layer_under(&self, position: Point) -> Option<(LayerSurface, WlSurface, Point)> { + if self.session_lock.active { + return None; + } + if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) { + return Some(layer); + } + if self.window_surface_under(position).is_some() { + return None; + } + + self.layer_surface_under(position, &[Layer::Bottom, Layer::Background]) + } + + pub(super) fn window_surface_under( + &self, + position: Point, + ) -> Option<(WlSurface, Point)> { + if self.overview.is_active() { + return None; + } + + let workspace = self.workspace_under_pointer(position)?; + self.space.elements().rev().find_map(|window| { + let id = self.window_ids.get(window)?; + if self.workspaces.workspace_for_window(*id) != Some(workspace) { + return None; + } + let visual = self.presented_window_rect(*id)?; + let inside_visual = position.x >= visual.x + && position.y >= visual.y + && position.x < visual.x + visual.width + && position.y < visual.y + visual.height; + if !inside_visual { + return None; + } + + let (source_x, source_y) = self.inverse_presented_window_point(*id, position.x, position.y)?; + let source_point = Point::from((source_x, source_y)) + window.geometry().loc.to_f64(); + + let surface = window.surface_under(source_point, WindowSurfaceType::ALL); + let (surface, surface_location) = match surface { + Some(hit) => hit, + None if self.window_fills_visual_bounds(*id, workspace) => { + (window.toplevel()?.wl_surface().clone(), Point::from((0, 0))) + } + None => return None, + }; + let surface_point = source_point - surface_location.to_f64(); + Some((surface, position - surface_point)) + }) + } + + pub(super) fn window_fills_visual_bounds(&self, id: WindowId, workspace: WorkspaceId) -> bool { + self.maximized_windows.contains(&id) + || self + .workspaces + .workspace(workspace) + .is_some_and(|workspace| workspace.fullscreen == Some(id)) + } + + pub(super) fn layer_surface_under( + &self, + position: Point, + layers: &[Layer], + ) -> Option<(LayerSurface, WlSurface, Point)> { + let output = self.space.output_under(position).next()?; + let output_geometry = self.space.output_geometry(output)?; + let output_position = position - output_geometry.loc.to_f64(); + 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(); + let Some((surface, surface_location)) = layer.surface_under(layer_position, WindowSurfaceType::ALL) + else { + continue; + }; + let origin = output_geometry.loc + geometry.loc + surface_location; + + return Some((layer.clone(), surface, origin.to_f64())); + } + } + + None + } + + pub fn send_cursor_frame(&self, output: &Output) { + let CursorImageStatus::Surface(surface) = &self.cursor_status else { + return; + }; + let Some(pointer) = self.seat.get_pointer() else { + return; + }; + if self.space.output_under(pointer.current_location()).next() != Some(output) { + return; + } + + send_frames_surface_tree( + surface, + output, + self.start_time.elapsed(), + Some(Duration::ZERO), + |_, _| Some(output.clone()), + ); + } + + pub fn window_under_visual(&self, position: Point) -> Option { + let overview_active = self.overview.is_active(); + let workspace = self.workspace_under_pointer(position)?; + + let hit = |window: &Window| -> Option { + if !self.window_content_ready(window) { + return None; + } + + let id = self.window_ids.get(window)?; + if self.workspaces.workspace_for_window(*id) != Some(workspace) { + return None; + } + + let visual = self.presented_window_rect(*id)?; + let caption_height = if overview_active { 34.0 } else { 0.0 }; + let inside_visual = position.x >= visual.x + && position.y >= visual.y + && position.x < visual.x + visual.width + && position.y < visual.y + visual.height + caption_height; + if !inside_visual { + return None; + } + + if !overview_active { + let (source_x, source_y) = self.inverse_presented_window_point(*id, position.x, position.y)?; + let source_point = Point::from((source_x, source_y)) + window.geometry().loc.to_f64(); + if window.surface_under(source_point, WindowSurfaceType::ALL).is_none() + && !self.window_fills_visual_bounds(*id, workspace) + { + return None; + } + } + + Some(window.clone()) + }; + + if overview_active { + let mut candidates = self.window_ids.keys().cloned().collect::>(); + // Match Overview's front-to-back render order during overlapping motion. + candidates.sort_by_key(|window| std::cmp::Reverse(self.window_ids.get(window).map_or(0, |id| id.0))); + candidates.iter().find_map(hit) + } else { + self.space.elements().rev().find_map(hit) + } + } + + pub(super) fn workspace_under_pointer(&self, position: Point) -> Option { + let output = self.space.output_under(position).next()?; + self.output_workspaces.active_workspace(self.output_id(output)?) + } + + pub(crate) fn window_belongs_to_output(&self, window: WindowId, output: &Output) -> bool { + let Some(output_id) = self.output_id(output) else { + return false; + }; + let workspace = self.workspaces.workspace_for_window(window); + workspace == self.output_workspaces.active_workspace(output_id) + || self + .workspace_slides + .get(&output_id) + .is_some_and(|slide| workspace.is_some_and(|workspace| slide.contains(workspace))) + } + + pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> { + if !self.overview.is_presenting() { + return Some((1.0, 1.0)); + } + let id = self.window_ids.get(window)?; + let geometry = self.window_geometry.get(id)?; + let source = geometry.client.committed_size?; + let presented = self.presented_window_rect(*id)?; + + if source.width <= 0 || source.height <= 0 { + return None; + } + + Some(( + presented.width / f64::from(source.width), + presented.height / f64::from(source.height), + )) + } + + pub(crate) fn visual_rect_for_window(&self, window: &Window) -> Option> { + let id = self.window_ids.get(window)?; + let rect = self.presented_window_rect(*id)?; + let size = ClientSize::from_rect(rect); + Some(Rectangle::new( + (rect.x.round() as i32, rect.y.round() as i32).into(), + (size.width, size.height).into(), + )) + } + + pub(crate) fn window_content_ready(&self, window: &Window) -> bool { + window_has_buffer(window) + && self + .window_ids + .get(window) + .and_then(|id| self.window_geometry.get(id)) + .is_some_and(|geometry| geometry.client.committed_size.is_some()) + } +} diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs new file mode 100644 index 0000000..762a85c --- /dev/null +++ b/compositor/src/state/layout.rs @@ -0,0 +1,429 @@ +use super::*; + +impl Ferese { + pub(super) fn visible_workspace_ids(&self) -> HashSet { + let mut visible = self + .output_workspaces + .connected_outputs() + .filter_map(|output| self.output_workspaces.active_workspace(output)) + .collect::>(); + visible.extend( + self.workspace_slides + .values() + .flat_map(|slide| slide.items.iter().map(|item| item.workspace)), + ); + visible + } + + pub(super) fn prune_empty_workspaces(&mut self, visible: &HashSet) { + let mut protected = visible.clone(); + protected.extend(self.output_workspaces.history_workspaces()); + for workspace in self.workspaces.prune_empty(&protected) { + self.output_workspaces.forget_workspace(workspace); + self.viewport_animations.remove(&workspace); + } + } + + pub(super) fn unmap_invisible_windows(&mut self, visible: &HashSet) -> bool { + let mut changed = false; + for (window, id) in &self.window_ids { + if visible.contains(id) { + continue; + } + let was_mapped = self.space.element_location(window).is_some(); + changed |= was_mapped; + if was_mapped && let Some(geometry) = self.window_geometry.get_mut(id) { + geometry.settle_presentation(); + } + self.space.unmap_elem(window); + } + changed + } + + pub fn relayout(&mut self) { + self.refresh_input_capture_zones(); + let visible_workspaces = self.visible_workspace_ids(); + self.prune_empty_workspaces(&visible_workspaces); + if self.session_lock.active { + self.configure_lock_surfaces(); + } + // Consume idle time before setting new targets; it must not become a + // large first animation step after a keypress on an idle desktop. + self.advance_animations(Instant::now()); + self.arrange_layers(); + let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); + let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles); + let outputs = self.space.outputs().cloned().collect::>(); + let constraints = self.window_constraints(); + let mut visible = HashSet::new(); + let mut placements = Vec::new(); + let mut visible_workspace_pairs = Vec::new(); + + for output in outputs { + let Some(output_id) = self.output_ids.get(&output).copied() else { + continue; + }; + let Some(active_workspace) = self.output_workspaces.active_workspace(output_id) else { + continue; + }; + visible_workspace_pairs.push((output.clone(), active_workspace)); + if let Some(slide) = self.workspace_slides.get(&output_id) { + for item in &slide.items { + if item.workspace != active_workspace { + visible_workspace_pairs.push((output.clone(), item.workspace)); + } + } + } + } + + for (output, workspace_id) in visible_workspace_pairs { + let Some(bounds) = self.output_bounds_for(&output) else { + continue; + }; + let fullscreen_bounds = self.full_output_bounds_for(&output).unwrap_or(bounds); + let scale = output.current_scale().fractional_scale(); + for layer in layer_map_for_output(&output).layers() { + crate::handlers::set_surface_tree_scale(layer.wl_surface(), scale); + } + let Some(workspace) = self.workspaces.workspace(workspace_id) else { + continue; + }; + let workspace_fullscreen = workspace.fullscreen; + let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_)); + let workspace_focus = workspace.last_focused; + let focused = self + .focused_window + .filter(|window| self.workspaces.workspace_for_window(*window) == Some(workspace_id)) + .or(workspace_focus); + let Some(workspace) = self.workspaces.workspace_mut(workspace_id) else { + continue; + }; + let layout = + match workspace + .layout + .geometry_with_constraints(bounds, self.gap_config, &constraints, focused) + { + Ok(layout) => layout, + Err(error) => { + tracing::error!(%error, ?workspace_id, "failed to compute tiled geometry"); + continue; + } + }; + let viewport_target = workspace.layout.viewport_x(); + let viewport_motion = viewport_target.map(|target| { + let viewport = self + .viewport_animations + .entry(workspace_id) + .or_insert_with(|| AnimatedValue::new(target)); + let target_changed = (viewport.target - target).abs() > 0.001; + viewport.retarget_preserving_motion(target); + if !self.animations_enabled { + viewport.snap(); + } + (viewport.current, target_changed) + }); + let viewport_current = viewport_motion.map(|(current, _)| current); + let viewport_target_changed = viewport_motion.is_some_and(|(_, changed)| changed); + + for warning in layout.warnings { + tracing::warn!( + window = ?warning.window, + kind = ?warning.kind, + requested = warning.requested, + assigned = warning.assigned, + "window size constraint could not be satisfied exactly" + ); + } + + for (window, id) in &self.window_ids { + if self.workspaces.workspace_for_window(*id) != Some(workspace_id) { + continue; + } + if let Some(toplevel) = window.toplevel() { + crate::handlers::set_surface_tree_scale(toplevel.wl_surface(), scale); + } + + 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 && !is_scrolling_layout { + maximized_rect(bounds, self.gap_config.outer) + } else { + match self.workspaces.placement(*id) { + Some(WindowPlacement::Tiled) => { + let Some(rect) = layout.geometry.get(id).copied() else { + continue; + }; + rect + } + Some(WindowPlacement::Floating { rect }) => { + let constrained = + constrained_floating_rect(rect, constraints.get(id).copied().unwrap_or_default()); + if constrained != rect { + let _ = self.workspaces.set_floating_rect(*id, constrained); + } + constrained + } + None => continue, + } + }; + let is_fullscreen = workspace_fullscreen == Some(*id); + let is_floating = matches!(self.workspaces.placement(*id), Some(WindowPlacement::Floating { .. })); + + visible.insert(*id); + let scrolling = if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) { + viewport_target + .zip(viewport_current) + .map(|(target, current)| (workspace_id, rect.x + target, current)) + } else { + None + }; + + placements.push(( + window.clone(), + *id, + rect, + is_fullscreen, + is_maximized, + is_floating, + scrolling, + pending_column_width_cycles.contains(id) && viewport_target_changed, + )); + } + } + + let mut layout_changed = self.unmap_invisible_windows(&visible); + + let now = self.start_time.elapsed(); + + let mut scrolling_world_x = HashMap::new(); + placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id)); + + for (window, id, rect, is_fullscreen, is_maximized, is_floating, scrolling, couple_width) in placements { + let was_mapped = self.space.element_location(&window).is_some(); + // A viewport-coupled width must never override fullscreen/floating geometry. + if scrolling.is_none() { + self.viewport_coupled_widths.remove(&id); + } + let (geometry, had_geometry) = match self.window_geometry.entry(id) { + Entry::Occupied(entry) => (entry.into_mut(), true), + Entry::Vacant(entry) => (entry.insert(WindowGeometry::new(rect, client_size(&window))), false), + }; + let mode = if is_fullscreen { + PresentationMode::Fullscreen + } else if is_maximized { + PresentationMode::Maximized + } else { + PresentationMode::Normal + }; + layout_changed |= !had_geometry || geometry.logical != rect; + let mut requested_size = geometry.set_presentation_mode(rect, mode, now); + if !was_mapped && (had_geometry || is_fullscreen) { + geometry.settle_presentation(); + } + if !self.animations_enabled { + geometry.advance(Duration::ZERO, self.spring_config, false); + requested_size = geometry.presentation_size_request(now).or(requested_size); + } + if geometry.is_zooming() { + self.viewport_coupled_widths.remove(&id); + } + if let Some((workspace, world_x, viewport_x)) = scrolling { + let restored_world_x = + restored_scrolling_world_x(had_geometry, geometry.visual.current.x, viewport_x, world_x); + let mut animated_world_x = previous_scrolling_world_x + .remove(&id) + .filter(|(previous_workspace, _)| *previous_workspace == workspace) + .map(|(_, world_x)| world_x) + .unwrap_or_else(|| AnimatedValue::new(restored_world_x)); + animated_world_x.set_target(world_x); + if !self.animations_enabled { + animated_world_x.snap(); + } + if !geometry.is_zooming() { + geometry.visual.current.x = animated_world_x.current - viewport_x; + geometry.visual.velocity.x = animated_world_x.velocity; + } + scrolling_world_x.insert(id, (workspace, animated_world_x)); + + let coupled = !geometry.is_zooming() + && (couple_width + || self + .viewport_coupled_widths + .get(&id) + .is_some_and(|(previous_workspace, _)| *previous_workspace == workspace)); + if coupled { + let width = self + .viewport_coupled_widths + .entry(id) + .or_insert_with(|| (workspace, AnimatedValue::new(geometry.visual.current.width))); + if width.0 != workspace { + *width = (workspace, AnimatedValue::new(geometry.visual.current.width)); + } + width.1.retarget_preserving_motion(rect.width); + if !self.animations_enabled { + width.1.snap(); + } + geometry.visual.current.width = width.1.current; + geometry.visual.velocity.width = width.1.velocity; + } else if pending_column_width_cycles.contains(&id) { + self.viewport_coupled_widths.remove(&id); + } + } + let visual = geometry.visual.current; + let natural_pending = + self.natural_floating_pending.contains(&id) && is_floating && !is_fullscreen && !is_maximized; + if natural_pending { + requested_size = None; + } + let location = (visual.x.round() as i32, visual.y.round() as i32); + + self.space.map_element(window.clone(), location, false); + if let Some(toplevel) = window.toplevel() { + let state_changed = toplevel.with_pending_state(|state| { + if natural_pending { + state.size = None; + } else if let Some(size) = requested_size { + state.size = Some((size.width, size.height).into()); + } + + let fullscreen_changed = if is_fullscreen { + state.states.set(xdg_toplevel::State::Fullscreen) + } else { + 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) + | state.states.set(xdg_toplevel::State::TiledRight) + | state.states.set(xdg_toplevel::State::TiledTop) + | state.states.set(xdg_toplevel::State::TiledBottom) + } else { + state.states.unset(xdg_toplevel::State::TiledLeft) + | state.states.unset(xdg_toplevel::State::TiledRight) + | state.states.unset(xdg_toplevel::State::TiledTop) + | state.states.unset(xdg_toplevel::State::TiledBottom) + }; + + let decoration_mode = if is_floating && !is_fullscreen && !is_maximized { + DecorationMode::ClientSide + } else { + DecorationMode::ServerSide + }; + let decoration_changed = state.decoration_mode != Some(decoration_mode); + state.decoration_mode = Some(decoration_mode); + + fullscreen_changed || maximized_changed || tiled_changed || decoration_changed + }); + + if (natural_pending || requested_size.is_some() || state_changed) + && let Some(serial) = toplevel.send_pending_configure() + && requested_size.is_some() + && had_geometry + && self.animations_enabled + { + self.resize_transactions.insert( + id, + crate::resize_transaction::ResizeTransaction::new(serial, now) + .with_source_geometry(window.geometry()), + ); + } + } + } + self.scrolling_world_x = scrolling_world_x; + if layout_changed { + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + } + self.sync_window_stacking(); + self.retarget_overview(); + self.send_shell_snapshots(); + + crate::backends::direct::render_all(self); + } + + pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) { + if let Some(id) = self.window_ids.get(window) { + self.window_stack.raise(*id); + } + self.space.raise_element(window, activate); + self.sync_window_stacking(); + } + + pub(crate) fn sync_window_stacking(&mut self) { + let mut windows = self.space.elements().cloned().collect::>(); + windows.sort_by_key(|window| { + let id = self.window_ids.get(window).copied(); + let geometry = id.and_then(|id| self.window_geometry.get(&id)); + let floating = + id.is_some_and(|id| matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. }))); + let priority = crate::stacking::layer_priority( + floating, + geometry.is_some_and(|geometry| geometry.is_zooming()), + geometry.is_some_and(|geometry| geometry.is_fullscreen()), + floating + && id.is_some_and(|id| { + self.floating_above_fullscreen.get(&id).is_some_and(|parent| { + self.workspaces + .workspace_for_window(id) + .and_then(|workspace| self.workspaces.workspace(workspace)) + .is_some_and(|workspace| workspace.fullscreen == Some(*parent)) + }) + }), + ); + (priority, id.map_or(usize::MAX, |id| self.window_stack.rank(id))) + }); + if stacking_order_settled(self.space.elements(), &windows, |window| window.z_index()) { + return; + } + for window in windows { + self.space.raise_element(&window, false); + } + } + + pub(super) fn tiled_layout(&mut self, bounds: Rect) -> Result { + let constraints = self.window_constraints(); + let focused = self.focused_window; + self.workspaces + .active_mut() + .layout + .geometry_with_constraints(bounds, self.gap_config, &constraints, focused) + } + + pub(super) fn window_constraints(&self) -> HashMap { + self.window_ids + .iter() + .filter_map(|(window, id)| { + let toplevel = window.toplevel()?; + let (minimum, maximum) = with_states(toplevel.wl_surface(), |states| { + let mut cached = states.cached_state.get::(); + let state = cached.current(); + (state.min_size, state.max_size) + }); + + Some(( + *id, + SizeConstraints { + min_width: minimum.w.max(1) as f64, + min_height: minimum.h.max(1) as f64, + max_width: (maximum.w > 0).then_some(maximum.w as f64), + max_height: (maximum.h > 0).then_some(maximum.h as f64), + }, + )) + }) + .collect() + } + + pub(super) fn arrange_layers(&self) { + let outputs = self.space.outputs().cloned().collect::>(); + + for output in outputs { + layer_map_for_output(&output).arrange(); + } + } +} diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs new file mode 100644 index 0000000..a70efd6 --- /dev/null +++ b/compositor/src/state/navigation.rs @@ -0,0 +1,577 @@ +use super::*; + +impl Ferese { + pub fn focus_direction(&mut self, direction: Direction) { + self.focus_direction_with_slide(direction, false); + } + + pub(super) fn focus_swipe_is_current(&self, swipe: &FocusSwipe) -> bool { + self.animations_enabled + && !self.session_lock.active + && !self.overview.is_presenting() + && self.workspaces.active_id() == swipe.workspace + && self.focused_window == Some(swipe.from) + && self.workspaces.workspace(swipe.workspace).is_some_and(|workspace| { + workspace.fullscreen.is_none() + && workspace.layout.contains(swipe.from) + && workspace.layout.contains(swipe.to) + }) + } + + pub(crate) fn preview_focus_swipe( + &mut self, + direction: Direction, + gesture_direction: SwipeDirection, + progress: f64, + ) { + if self.focus_swipe.is_none() { + if self.swipe.preview_started() || !self.animations_enabled || self.overview.is_presenting() { + return; + } + let Some(from) = self.focused_window else { return }; + let Some(bounds) = self.output_bounds() else { return }; + let workspace = self.workspaces.active(); + if workspace.fullscreen.is_some() || !matches!(workspace.layout, WorkspaceLayout::Scrolling(_)) { + return; + } + let Ok(Some(to)) = workspace.layout.directional_neighbor(from, direction, bounds) else { + return; + }; + let workspace_id = workspace.id; + let start = self + .viewport_animations + .get(&workspace_id) + .map(|viewport| viewport.current) + .or_else(|| workspace.layout.viewport_x()) + .unwrap_or(0.0); + let mut candidate = workspace.layout.clone(); + if let WorkspaceLayout::Scrolling(layout) = &mut candidate + && layout.slide_focus_from(from, to).is_err() + { + return; + } + if candidate + .geometry_with_constraints(bounds, self.gap_config, &self.window_constraints(), Some(to)) + .is_err() + { + return; + } + self.focus_swipe = Some(FocusSwipe { + workspace: workspace_id, + from, + to, + direction, + gesture_direction, + start, + destination: candidate.viewport_x().unwrap_or(start), + progress, + }); + self.swipe.mark_preview_started(); + } + if let Some(swipe) = &mut self.focus_swipe { + swipe.progress = progress; + } + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + self.cursor_redraw_pending = true; + } + + pub(crate) fn finish_focus_swipe(&mut self, direction: Option) -> bool { + let Some(swipe) = self.focus_swipe.take() else { + return false; + }; + let current = self.focus_swipe_is_current(&swipe); + // Release from the last input position even if no frame rendered that update. + if current && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) { + viewport.current = swipe.position(); + viewport.velocity = 0.0; + } + let neighbor = self.output_bounds().and_then(|bounds| { + self.workspaces + .active() + .layout + .directional_neighbor(swipe.from, swipe.direction, bounds) + .ok() + .flatten() + }); + self.last_animation_tick = Instant::now(); + if current && direction == Some(swipe.gesture_direction) && neighbor == Some(swipe.to) { + self.focus_direction_from_swipe(swipe.direction); + } else { + self.relayout(); + } + true + } + + pub(crate) fn focus_direction_from_swipe(&mut self, direction: Direction) { + self.focus_direction_with_slide(direction, true); + } + + pub(super) fn focus_direction_with_slide(&mut self, direction: Direction, slide: bool) { + if self.input_capture.captures(1) { + return; + } + if self.focus_overview_direction(direction) { + return; + } + + let Some(current) = self.focused_window else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + let Ok(Some(next)) = self + .workspaces + .active() + .layout + .directional_neighbor(current, direction, bounds) + else { + return; + }; + + if let Err(error) = self.workspaces.focus_window(next) { + tracing::error!(%error, ?next, "failed to update workspace focus"); + return; + } + if slide + && let WorkspaceLayout::Scrolling(layout) = &mut self.workspaces.active_mut().layout + && let Err(error) = layout.slide_focus_from(current, next) + { + tracing::warn!(%error, ?current, ?next, "failed to slide swipe focus"); + } + let Some(window) = self + .window_ids + .iter() + .find_map(|(window, id)| (*id == next).then(|| window.clone())) + else { + return; + }; + let Some(surface) = window.toplevel().map(|toplevel| toplevel.wl_surface().clone()) else { + return; + }; + + self.focused_window = Some(next); + self.raise_window(&window, true); + self.seat.get_keyboard().expect("seat has a keyboard").set_focus( + self, + Some(surface), + smithay::utils::SERIAL_COUNTER.next_serial(), + ); + + for window in self.space.elements() { + if let Some(toplevel) = window.toplevel() { + toplevel.send_pending_configure(); + } + } + + self.relayout(); + } + + pub fn move_direction(&mut self, direction: Direction) { + let Some(current) = self.focused_window else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + + match self + .workspaces + .active_mut() + .layout + .move_window(current, direction, bounds) + { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => tracing::error!(%error, ?current, "failed to move tiled window"), + } + } + + pub fn resize_direction(&mut self, direction: Direction) { + const RESIZE_STEP: f64 = 0.05; + + let Some(current) = self.focused_window else { + return; + }; + + match self + .workspaces + .active_mut() + .layout + .resize_window(current, direction, RESIZE_STEP) + { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => tracing::error!(%error, ?current, "failed to resize tiled window"), + } + } + + pub fn toggle_layout_mode(&mut self) { + let Some(bounds) = self.output_bounds() else { + return; + }; + let mode = match self.workspaces.active().layout.mode() { + LayoutMode::Scrolling => LayoutMode::Tree, + LayoutMode::Tree => LayoutMode::Scrolling, + }; + + match self.workspaces.set_active_layout_mode(mode, bounds) { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => tracing::error!(%error, ?mode, "failed to change layout mode"), + } + } + + pub fn consume_focused_window(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + if self.workspaces.active().layout.mode() != LayoutMode::Scrolling { + return; + } + let target = self + .workspaces + .active() + .layout + .directional_neighbor(window, Direction::Left, bounds) + .ok() + .flatten() + .or_else(|| { + self.workspaces + .active() + .layout + .directional_neighbor(window, Direction::Right, bounds) + .ok() + .flatten() + }); + let Some(target) = target else { + return; + }; + + if let Err(error) = self.workspaces.stack_window(window, target) { + tracing::error!(%error, ?window, ?target, "failed to consume window into column"); + return; + } + self.relayout(); + } + + pub fn expel_focused_window(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + + match self.workspaces.extract_window(window) { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => tracing::error!(%error, ?window, "failed to expel window from column"), + } + } + + pub fn cycle_focused_column_width(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + + match self.workspaces.cycle_column_width(window, &self.column_width_presets) { + Ok(true) => { + self.pending_column_width_cycles.insert(window); + self.relayout(); + } + Ok(false) => {} + Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"), + } + } + + pub fn center_focused_column(&mut self) { + let Some(window) = self.focused_window else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + let constraints = self.window_constraints(); + + match self + .workspaces + .center_window(window, bounds, self.gap_config, &constraints) + { + Ok(true) => self.relayout(), + Ok(false) => {} + Err(error) => tracing::error!(%error, ?window, "failed to center column"), + } + } + + pub(crate) fn activate_managed_window(&mut self, id: WindowId) -> bool { + let Some(window) = self + .window_ids + .iter() + .find_map(|(window, window_id)| (*window_id == id).then(|| window.clone())) + else { + return false; + }; + if !activate_window_workspace(&mut self.workspaces, &mut self.output_workspaces, id) { + return false; + } + let workspace = self.workspaces.active_id(); + + if let Some(output) = self.output_workspaces.output_for_workspace(workspace) { + self.workspace_slides.remove(&output); + } + self.focused_window = Some(id); + self.raise_window(&window, true); + self.relayout(); + self.restore_keyboard_focus(); + true + } + + pub(crate) fn activate_managed_workspace(&mut self, workspace: WorkspaceId) -> bool { + self.activate_managed_workspace_internal(workspace, None) + } + + pub(crate) fn activate_managed_workspace_from_swipe( + &mut self, + workspace: WorkspaceId, + direction: SwipeDirection, + ) -> bool { + self.activate_managed_workspace_internal(workspace, Some(direction)) + } + + pub(super) fn activate_managed_workspace_internal( + &mut self, + workspace: WorkspaceId, + slide_direction: Option, + ) -> bool { + if self.workspaces.workspace(workspace).is_none() { + return false; + } + + let output = self + .output_workspaces + .focused_output() + .or_else(|| self.output_workspaces.output_for_workspace(workspace)); + let Some(output) = output else { + return false; + }; + let previous = self.output_workspaces.active_workspace(output); + let owner = match self.output_workspaces.switch_workspace(output, workspace) { + Ok(ferese_core::WorkspaceSwitch::Activated(output)) + | Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output, + Err(_) => return false, + }; + + self.update_workspace_slide(output, owner, previous, workspace, slide_direction); + self.activate_output_workspace(owner, workspace); + self.relayout(); + self.restore_keyboard_focus(); + true + } + + pub fn switch_workspace(&mut self, index: u32) { + self.switch_workspace_internal(index, None, false); + } + + pub(crate) fn switch_workspace_from_binding(&mut self, index: u32) { + self.switch_workspace_internal(index, None, self.workspace_auto_back_and_forth); + } + + pub(crate) fn workspace_back_and_forth(&mut self) { + let Some(output) = self.output_workspaces.focused_output() else { + return; + }; + if let Some(workspace) = self.output_workspaces.previous_workspace(output) { + self.activate_managed_workspace(workspace); + } + } + + pub(super) fn switch_workspace_internal( + &mut self, + index: u32, + slide_direction: Option, + auto_back_and_forth: bool, + ) { + let workspace = match self.workspaces.ensure_numeric(index) { + Ok(workspace) => workspace, + Err(error) => { + tracing::error!(%error, index, "failed to switch workspace"); + return; + } + }; + let Some(output) = self.output_workspaces.focused_output() else { + return; + }; + let previous = self.output_workspaces.active_workspace(output); + let owner = match self + .output_workspaces + .select_workspace(output, workspace, auto_back_and_forth) + { + Ok(ferese_core::WorkspaceSwitch::Activated(output)) + | Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output, + Err(error) => { + tracing::error!(%error, index, "failed to assign workspace to output"); + return; + } + }; + let workspace = self + .output_workspaces + .active_workspace(owner) + .expect("workspace owner is connected"); + self.update_workspace_slide(output, owner, previous, workspace, slide_direction); + self.activate_output_workspace(owner, workspace); + + self.relayout(); + self.restore_keyboard_focus(); + } + + pub(crate) fn preview_workspace_swipe(&mut self, next: bool, direction: SwipeDirection, progress: f64) { + if !self.animations_enabled || self.overview.is_presenting() { + return; + } + if let Some((output, (from, to, _))) = self + .workspace_slides + .iter() + .find_map(|(output, slide)| slide.gesture.map(|gesture| (*output, gesture))) + { + if !workspace_swipe_is_current(&self.output_workspaces, output, from, to) { + self.finish_workspace_swipe(None); + return; + } + self.workspace_slides + .get_mut(&output) + .expect("gesture output exists") + .held_progress = Some(progress); + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + self.cursor_redraw_pending = true; + return; + } + if self.swipe.preview_started() { + return; + } + let Some(output) = self.output_workspaces.focused_output() else { + return; + }; + let Some(from) = self.output_workspaces.active_workspace(output) else { + return; + }; + let Some(to) = self.relative_workspace_target(next) else { + return; + }; + let previous = self.workspace_slides.remove(&output); + let mut slide = WorkspaceSlide::new(previous, from, to, direction); + slide.held_progress = Some(progress); + slide.gesture = Some((from, to, direction)); + self.workspace_slides.insert(output, slide); + self.swipe.mark_preview_started(); + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + self.relayout(); + } + + pub(crate) fn finish_workspace_swipe(&mut self, direction: Option) -> bool { + let Some((output, (from, to, expected))) = self + .workspace_slides + .iter() + .find_map(|(output, slide)| slide.gesture.map(|gesture| (*output, gesture))) + else { + return false; + }; + let committed = direction == Some(expected) + && workspace_swipe_is_current(&self.output_workspaces, output, from, to) + && self.activate_managed_workspace_from_swipe(to, expected); + if !committed { + if let Some(slide) = self.workspace_slides.get_mut(&output) { + slide.release(false); + } + self.last_animation_tick = Instant::now(); + self.relayout(); + } + true + } + + pub(super) fn update_workspace_slide( + &mut self, + requested_output: OutputId, + owner: OutputId, + previous: Option, + workspace: WorkspaceId, + slide_direction: Option, + ) { + let mut previous_slide = self.workspace_slides.remove(&owner); + if owner == requested_output + && let Some(slide) = previous_slide.as_mut() + && slide + .gesture + .is_some_and(|(_, to, direction)| to == workspace && Some(direction) == slide_direction) + { + slide.release(true); + self.last_animation_tick = Instant::now(); + self.workspace_slides.insert(owner, previous_slide.unwrap()); + return; + } + if owner == requested_output + && let (Some(from), Some(direction)) = (previous, slide_direction) + && from != workspace + && self.animations_enabled + { + self.last_animation_tick = Instant::now(); + self.workspace_slides + .insert(owner, WorkspaceSlide::new(previous_slide, from, workspace, direction)); + } + } + + pub fn move_focused_to_workspace(&mut self, index: u32) { + let Some(window) = self.focused_window else { + return; + }; + let axis = self + .output_bounds() + .and_then(|bounds| { + let workspace = self.workspaces.ensure_numeric(index).ok()?; + let target = self.workspaces.workspace(workspace)?; + target.layout.automatic_axis(target.last_focused, bounds).ok() + }) + .unwrap_or(Axis::Horizontal); + + let destination = match self.workspaces.move_window_to_numeric(window, index, axis, 0.5) { + Ok(destination) => destination, + Err(error) => { + tracing::error!(%error, ?window, index, "failed to move window to workspace"); + return; + } + }; + if let Some(output) = self.output_workspaces.focused_output() + && let Err(error) = self.output_workspaces.assign_workspace(output, destination) + { + tracing::error!(%error, ?destination, "failed to assign destination workspace"); + } + + self.focused_window = self.workspaces.active().last_focused; + self.relayout(); + self.restore_keyboard_focus(); + } + + pub(crate) fn restore_keyboard_focus(&mut self) { + if self.input_capture.captures(1) { + return; + } + if self.session_lock.active { + self.focus_lock_surface(); + return; + } + let surface = self.focused_window.and_then(|focused| { + self.window_ids.iter().find_map(|(window, id)| { + (*id == focused) + .then(|| window.toplevel()) + .flatten() + .map(|toplevel| toplevel.wl_surface().clone()) + }) + }); + + self.seat.get_keyboard().expect("seat has a keyboard").set_focus( + self, + surface, + smithay::utils::SERIAL_COUNTER.next_serial(), + ); + } +} diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs new file mode 100644 index 0000000..2174aad --- /dev/null +++ b/compositor/src/state/outputs.rs @@ -0,0 +1,160 @@ +use super::*; + +impl Ferese { + pub fn register_output(&mut self, output: &Output, identity: String) { + if self.session_lock.active { + self.session_lock.output_added(output); + self.lock_input_activity(); + } + let Some(geometry) = self.space.output_geometry(output) else { + tracing::error!(output = %output.name(), "cannot register an unmapped output"); + return; + }; + let output_id = *self.output_identity_ids.entry(identity).or_insert_with(|| { + let id = OutputId(self.next_output_id); + self.next_output_id = self.next_output_id.saturating_add(1); + id + }); + let fallback_workspace = (1..) + .find_map(|index| { + let workspace = self.workspaces.ensure_numeric(index).ok()?; + self.output_workspaces + .output_for_workspace(workspace) + .is_none() + .then_some(workspace) + }) + .expect("numeric workspaces are inexhaustible"); + let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h); + + let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace); + + match registration { + Ok(workspace) => { + self.output_ids.insert(output.clone(), output_id); + if self.output_workspaces.focused_output() == Some(output_id) { + self.activate_output_workspace(output_id, workspace); + } + self.send_shell_snapshots(); + } + Err(error) => { + tracing::error!(%error, output = %output.name(), "failed to register output"); + } + } + } + + pub fn unregister_output(&mut self, output: &Output) { + let Some(output_id) = self.output_ids.remove(output) else { + return; + }; + + self.workspace_slides.remove(&output_id); + self.overview_scrims.remove(&output_id); + self.pending_screencopies.retain(|capture| { + if capture.output == *output { + capture.fail(); + false + } else { + true + } + }); + self.space.unmap_output(output); + self.session_lock.surfaces.remove(output); + self.session_lock.backgrounds.remove(output); + self.session_lock.output_removed(output); + + match self.output_workspaces.disconnect(output_id) { + Ok(Some(target)) => { + if let Some(workspace) = self.output_workspaces.active_workspace(target) { + self.activate_output_workspace(target, workspace); + } + } + Ok(None) => { + self.focused_window = None; + self.restore_keyboard_focus(); + } + Err(error) => { + tracing::error!(%error, output = %output.name(), "failed to unregister output") + } + } + + self.relayout(); + } + + pub(crate) fn reposition_output_floats(&mut self, output: OutputId, old: Rect, new: Rect) { + let floats = self + .window_ids + .values() + .filter_map(|id| { + let workspace = self.workspaces.workspace_for_window(*id)?; + if self.output_workspaces.output_for_workspace(workspace) != Some(output) { + return None; + } + match self.workspaces.placement(*id)? { + WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))), + _ => None, + } + }) + .collect::>(); + for (id, rect) in floats { + let _ = self.workspaces.set_floating_rect(id, rect); + } + } + + pub(super) fn activate_output_workspace(&mut self, output: OutputId, workspace: ferese_core::WorkspaceId) { + if let Err(error) = self.output_workspaces.focus_output(output) { + tracing::error!(%error, "failed to focus output"); + return; + } + + match self.workspaces.activate(workspace) { + Ok(focus) => self.focused_window = focus, + Err(error) => tracing::error!(%error, "failed to activate output workspace"), + } + } + + pub(crate) fn focused_output(&self) -> Option<&Output> { + let focused = self.output_workspaces.focused_output()?; + self.output_ids + .iter() + .find_map(|(output, id)| (*id == focused).then_some(output)) + } + + pub(crate) fn restore_output_focus(&mut self) { + if let Some(output) = self.output_workspaces.focused_output() + && let Some(workspace) = self.output_workspaces.active_workspace(output) + { + self.activate_output_workspace(output, workspace); + self.restore_keyboard_focus(); + } + } + + pub(crate) fn output_id(&self, output: &Output) -> Option { + self.output_ids.get(output).copied() + } + + pub(crate) fn focus_output_at(&mut self, position: Point) { + let output = self.space.outputs().find_map(|output| { + let geometry = self.space.output_geometry(output)?; + (position.x >= f64::from(geometry.loc.x) + && position.y >= f64::from(geometry.loc.y) + && position.x < f64::from(geometry.loc.x + geometry.size.w) + && position.y < f64::from(geometry.loc.y + geometry.size.h)) + .then(|| output.clone()) + }); + let Some(output) = output else { + return; + }; + let Some(output_id) = self.output_ids.get(&output).copied() else { + return; + }; + let Some(workspace) = self.output_workspaces.active_workspace(output_id) else { + return; + }; + if self.output_workspaces.focused_output() == Some(output_id) && self.workspaces.active_id() == workspace { + return; + } + + self.activate_output_workspace(output_id, workspace); + self.relayout(); + } +} diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs new file mode 100644 index 0000000..c50b8ad --- /dev/null +++ b/compositor/src/state/windows.rs @@ -0,0 +1,642 @@ +use super::*; + +impl Ferese { + pub(crate) fn restore_initial_floating_size(&mut self, window: &Window) { + let Some(toplevel) = window.toplevel() else { return }; + let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { + let attributes = states.data_map.get::().unwrap().lock().unwrap(); + ( + attributes.app_id.clone(), + attributes.title.clone(), + attributes.parent.is_some(), + ) + }); + let special_mode = toplevel.with_pending_state(|pending| { + pending.states.contains(xdg_toplevel::State::Fullscreen) + || pending.states.contains(xdg_toplevel::State::Maximized) + }); + let rule = resolve_window_rules(&self.window_rules, app_id.as_deref(), title.as_deref(), transient); + if transient + || special_mode + || rule.fullscreen == Some(true) + || !rule.floating.unwrap_or(rule.width.is_some() || rule.height.is_some()) + { + return; + } + let outputs = self.floating_outputs(); + let Some((remembered, work)) = app_id + .as_deref() + .and_then(|app| self.floating_memory.get(app)) + .and_then(|entry| { + let rect = entry.restore(&outputs)?; + let work = outputs.iter().find(|(name, _)| *name == entry.output)?.1; + Some((rect, work)) + }) + else { + return; + }; + // Metadata is available on the initial bufferless commit. Restore the + // size before the client creates its first buffer, without mapping or + // focusing the window. Use the same rule/constraint precedence as map. + let rect = self.place_floating( + window, + None, + work, + (remembered.width, remembered.height), + Some((rule.width, rule.height)), + ); + let size = ClientSize::from_rect(rect); + toplevel.with_pending_state(|pending| pending.size = Some((size.width, size.height).into())); + } + + pub(crate) fn add_rule_placed_window( + &mut self, + window: Window, + app_id: Option<&str>, + title: Option<&str>, + parent: Option, + ) { + let rule = resolve_window_rules(&self.window_rules, app_id, title, parent.is_some()); + let floating = rule + .floating + .unwrap_or(parent.is_some() || rule.width.is_some() || rule.height.is_some()); + if !floating { + self.add_tiled_window(window); + return; + } + if let Some(parent) = parent { + self.add_transient_window(window, parent); + return; + } + if let Some(pointer) = self.seat.get_pointer() { + self.focus_output_at(pointer.current_location()); + } + let remembered_work = app_id.and_then(|app| self.floating_memory.get(app)).and_then(|entry| { + let outputs = self.floating_outputs(); + entry.restore(&outputs)?; + outputs + .into_iter() + .find(|(name, _)| *name == entry.output) + .map(|(_, work)| work) + }); + if let Some(work) = remembered_work { + self.focus_output_at((work.x + work.width / 2.0, work.y + work.height / 2.0).into()); + } + let Some(bounds) = self.output_bounds() else { + self.add_tiled_window(window); + return; + }; + let id = WindowId(self.next_window_id); + self.next_window_id += 1; + let size_rect = client_size(&window) + .map(|size| natural_floating_rect(bounds, size)) + .unwrap_or_else(|| centered_floating_rect(bounds)); + let rect = self.place_floating(&window, None, bounds, (size_rect.width, size_rect.height), None); + let anchor = self + .focused_window + .and_then(|focused| self.presented_window_rect(focused)) + .unwrap_or(bounds); + self.floating_placement_anchors.insert(id, anchor); + let fullscreen = self.workspaces.active().fullscreen; + let focus = true; + if let Err(error) = self + .workspaces + .insert_floating_window(id, self.workspaces.active_id(), rect, focus) + { + tracing::error!(%error, ?id, "failed to insert rule-placed floating window"); + return; + } + if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() && remembered_work.is_none() + { + self.natural_floating_pending.insert(id); + } + self.window_ids.insert(window.clone(), id); + self.window_stack.insert(id); + if let Some(fullscreen) = fullscreen { + self.floating_above_fullscreen.insert(id, fullscreen); + } + if focus { + self.focused_window = Some(id); + } + self.space.map_element(window, (0, 0), focus); + // apply_initial_window_rules performs the first relayout/configure. + } + + pub fn add_tiled_window(&mut self, window: Window) { + let id = WindowId(self.next_window_id); + self.next_window_id += 1; + let focus_new_window = self.workspaces.active().fullscreen.is_none(); + + let axis = self + .output_bounds() + .and_then(|bounds| { + self.workspaces + .active() + .layout + .automatic_axis(self.focused_window, bounds) + .ok() + }) + .unwrap_or(Axis::Horizontal); + + if let Err(error) = self.workspaces.insert_window(id, axis, 0.5) { + tracing::error!(%error, ?id, "failed to insert window into layout"); + return; + } + + self.window_ids.insert(window.clone(), id); + self.window_stack.insert(id); + if focus_new_window { + self.focused_window = Some(id); + } + self.space.map_element(window, (0, 0), focus_new_window); + self.relayout(); + + if focus_new_window { + self.restore_keyboard_focus(); + } + } + + pub fn add_transient_window(&mut self, window: Window, parent: WindowId) { + let Some(workspace) = self.workspaces.workspace_for_window(parent) else { + self.add_tiled_window(window); + return; + }; + let Some(bounds) = self.floating_bounds_for_window(parent) else { + self.add_tiled_window(window); + return; + }; + let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds); + let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds); + let size_rect = client_size(&window) + .map(|s| natural_floating_rect(bounds, s)) + .unwrap_or_else(|| centered_transient_rect(visible_parent)); + let rect = self.place_floating(&window, None, bounds, (size_rect.width, size_rect.height), None); + let id = WindowId(self.next_window_id); + self.next_window_id += 1; + + let focus = workspace == self.workspaces.active_id() + && (self.focused_window == Some(parent) || self.workspaces.active().fullscreen == Some(parent)); + if let Err(error) = self.workspaces.insert_floating_window(id, workspace, rect, focus) { + tracing::error!(%error, ?id, ?parent, "failed to insert transient window"); + return; + } + + self.window_ids.insert(window.clone(), id); + self.window_stack.insert(id); + if client_size(&window).is_none() { + self.natural_floating_pending.insert(id); + } + if self + .workspaces + .workspace(workspace) + .is_some_and(|workspace| workspace.fullscreen == Some(parent)) + { + self.floating_above_fullscreen.insert(id, parent); + } + if focus { + self.focused_window = Some(id); + } + self.space.map_element(window, (0, 0), focus); + self.relayout(); + if focus { + self.restore_keyboard_focus(); + } + } + + pub(crate) fn apply_initial_window_rules( + &mut self, + window: &Window, + app_id: Option<&str>, + title: Option<&str>, + transient: bool, + ) { + let Some(id) = self.window_ids.get(window).copied() else { + return; + }; + if !self.window_rules_applied.insert(id) { + return; + } + + let rule = resolve_window_rules(&self.window_rules, app_id, title, transient); + self.apply_window_rule_result(window, rule); + } + + pub(super) fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) { + let windows = self.window_ids.keys().cloned().collect::>(); + for window in windows { + let Some(toplevel) = window.toplevel() else { + continue; + }; + let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { + let attributes = states.data_map.get::().unwrap().lock().unwrap(); + ( + attributes.app_id.clone(), + attributes.title.clone(), + attributes.parent.is_some(), + ) + }); + let old = resolve_window_rules(old_rules, app_id.as_deref(), title.as_deref(), transient); + let new = resolve_window_rules(&self.window_rules, app_id.as_deref(), title.as_deref(), transient); + if let Some(new) = crate::window_rules::live_result(old, new, transient) { + self.apply_window_rule_result(&window, new); + } + } + } + + pub(super) fn apply_window_rule_result(&mut self, window: &Window, rule: crate::window_rules::WindowRuleResult) { + let Some(id) = self.window_ids.get(window).copied() else { + return; + }; + let Some(bounds) = self.floating_bounds_for_window(id) else { + return; + }; + if rule == Default::default() { + return; + } + let axis = self + .workspaces + .active() + .layout + .automatic_axis(self.focused_window, bounds) + .unwrap_or(Axis::Horizontal); + + if let Some(workspace) = rule.workspace + && let Err(error) = self.workspaces.move_window_to_numeric(id, workspace, axis, 0.5) + { + tracing::warn!(%error, ?id, workspace, "failed to apply window workspace rule"); + } + let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds); + + let should_float = rule + .floating + .or((rule.width.is_some() || rule.height.is_some()).then_some(true)); + if let Some(should_float) = should_float { + let is_floating = matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. })); + let mut rect = centered_floating_rect(bounds); + if should_float && rule.width.is_none() && rule.height.is_none() { + if let Some(size) = client_size(window) { + rect = natural_floating_rect(bounds, size); + } else if Self::floating_metadata(window) + .0 + .as_deref() + .and_then(|app| self.floating_memory.get(app)) + .and_then(|entry| entry.restore(&self.floating_outputs())) + .is_none() + { + self.natural_floating_pending.insert(id); + } + } + rect.width = rule.width.unwrap_or(rect.width).min(bounds.width); + rect.height = rule.height.unwrap_or(rect.height).min(bounds.height); + if should_float { + rect = self.place_floating( + window, + Some(id), + bounds, + (rect.width, rect.height), + Some((rule.width, rule.height)), + ); + } + + let result = if should_float != is_floating { + self.workspaces.toggle_floating(id, rect, axis, 0.5).map(|_| ()) + } else if should_float && (rule.width.is_some() || rule.height.is_some()) { + self.workspaces.set_floating_rect(id, rect) + } else { + Ok(()) + }; + if let Err(error) = result { + tracing::warn!(%error, ?id, "failed to apply floating window rule"); + } + } + + if let Some(fullscreen) = rule.fullscreen + && let Err(error) = self.workspaces.set_fullscreen(id, fullscreen) + { + tracing::warn!(%error, ?id, fullscreen, "failed to apply fullscreen window rule"); + } + + if self.workspaces.workspace_for_window(id) != Some(self.workspaces.active_id()) + && self.focused_window == Some(id) + { + self.focused_window = self.workspaces.active().last_focused; + } + + self.relayout(); + self.restore_keyboard_focus(); + } + + pub fn make_window_transient(&mut self, window: &Window, parent: WindowId) { + let Some(id) = self.window_ids.get(window).copied() else { + return; + }; + if self.workspaces.workspace_for_window(id) != self.workspaces.workspace_for_window(parent) { + tracing::warn!(?id, ?parent, "ignored transient parent on another workspace"); + return; + } + let Some(bounds) = self.floating_bounds_for_window(parent) else { + return; + }; + let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds); + let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds); + let size_rect = client_size(window) + .map(|s| natural_floating_rect(bounds, s)) + .unwrap_or_else(|| centered_transient_rect(visible_parent)); + let rect = self.place_floating(window, Some(id), bounds, (size_rect.width, size_rect.height), None); + let result = match self.workspaces.placement(id) { + Some(WindowPlacement::Tiled) => self + .workspaces + .toggle_floating(id, rect, Axis::Horizontal, 0.5) + .map(|_| ()), + Some(WindowPlacement::Floating { .. }) => self.workspaces.set_floating_rect(id, rect), + None => return, + }; + + if let Err(error) = result { + tracing::warn!(%error, ?id, ?parent, "failed to apply transient placement"); + return; + } + + if self + .workspaces + .workspace_for_window(parent) + .and_then(|workspace| self.workspaces.workspace(workspace)) + .is_some_and(|workspace| workspace.fullscreen == Some(parent)) + { + self.floating_above_fullscreen.insert(id, parent); + } + if self.focused_window == Some(parent) { + if let Err(error) = self.workspaces.focus_window(id) { + tracing::warn!(%error, ?id, "failed to focus transient window"); + } else { + self.focused_window = Some(id); + self.window_stack.raise(id); + } + } + + self.relayout(); + self.restore_keyboard_focus(); + } + + pub fn remove_tiled_window(&mut self, window: &Window) { + // Uncommitted toplevels have not entered a workspace yet. + self.space.unmap_elem(window); + let Some(id) = self.window_ids.remove(window) else { + return; + }; + self.window_geometry.remove(&id); + self.resize_transactions.remove(&id); + self.resize_snapshots.remove(&id); + self.window_resize_fills.remove(&id); + self.maximized_windows.remove(&id); + self.maximized_column_widths.remove(&id); + self.window_stack.remove(id); + self.floating_above_fullscreen.remove(&id); + self.natural_floating_pending.remove(&id); + self.floating_window_memory.remove(&id); + self.floating_placement_anchors.remove(&id); + self.floating_resize_anchors.remove(&id); + self.window_borders.remove(&id); + self.window_dims.remove(&id); + self.window_dimming.remove(&id); + self.window_focus.remove(&id); + self.window_shadows.remove(&id); + self.closing_windows.remove(&id); + self.window_rules_applied.remove(&id); + self.scrolling_world_x.remove(&id); + self.viewport_coupled_widths.remove(&id); + self.pending_column_width_cycles.remove(&id); + if let Err(error) = self.workspaces.remove_window(id) { + tracing::error!(%error, ?id, "failed to remove window from layout"); + } + if self.focused_window == Some(id) { + self.focused_window = self.workspaces.active().last_focused; + } + + self.relayout(); + } + + pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) { + let Some((window, id)) = self + .window_ids + .iter() + .find(|(window, _)| { + window + .toplevel() + .is_some_and(|toplevel| toplevel.wl_surface() == surface) + }) + .map(|(window, id)| (window.clone(), *id)) + else { + return; + }; + let Some(transaction) = self.resize_transactions.get(&id) else { + return; + }; + let serial = with_states(surface, |states| { + states + .data_map + .get::() + .and_then(|data| data.lock().ok().and_then(|data| data.current_serial)) + }); + if !transaction.accepts(serial) { + return; + } + let Some(output) = self + .space + .outputs() + .find(|output| self.window_belongs_to_output(id, output)) + .cloned() + else { + return; + }; + let source_geometry = transaction.source_geometry().unwrap_or_else(|| window.geometry()); + let scale = output.current_scale().fractional_scale(); + let source_size = source_geometry.size.to_physical_precise_round(scale); + if self.resize_snapshots.get(&id).is_some_and(|snapshot| { + crate::presentation::snapshot_covers_source( + smithay::backend::renderer::Texture::size(&snapshot.texture), + source_size, + snapshot.scale, + scale, + ) + }) { + return; + } + let used: usize = self.resize_snapshots.values().map(|snapshot| snapshot.bytes()).sum(); + let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used); + let result = if let Some(backend) = &self.nested_backend { + // Commit dispatch does not run inside the Winit event callback; + // still never risk reentrant renderer access or a compositor panic. + match backend.try_borrow_mut() { + Ok(mut backend) => crate::render::capture_resize_snapshot( + backend.renderer(), + &window, + source_geometry, + output.current_scale().fractional_scale(), + remaining, + ), + Err(_) => return, + } + } else if let Some(backend) = &mut self.direct_backend { + backend.capture_resize_snapshot(&window, source_geometry, &output, remaining) + } else { + return; + }; + match result { + Ok(Some(snapshot)) => { + tracing::debug!(?id, bytes = snapshot.bytes(), "captured native resize handoff"); + self.resize_snapshots.insert(id, snapshot); + } + Ok(None) => {} + Err(error) => { + tracing::debug!(%error, ?id, "resize snapshot unavailable; using native crop") + } + } + } + + pub fn record_client_commit(&mut self, window: &Window) { + let Some(&id) = self.window_ids.get(window) else { + return; + }; + // current_serial is promoted by Smithay on commit, unlike + // configure_serial, which only records an acknowledgement. + let committed_serial = window.toplevel().and_then(|toplevel| { + with_states(toplevel.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|data| data.lock().ok().and_then(|data| data.current_serial)) + }) + }); + if self + .resize_transactions + .get(&id) + .is_some_and(|transaction| transaction.accepts(committed_serial)) + { + self.resize_transactions.remove(&id); + } + let Some(size) = client_size(window) else { + return; + }; + let fullscreen = self + .workspaces + .workspace_for_window(id) + .and_then(|workspace| self.workspaces.workspace(workspace)) + .is_some_and(|workspace| workspace.fullscreen == Some(id)); + let normal_geometry = !fullscreen + && !self.maximized_windows.contains(&id) + && self + .window_geometry + .get(&id) + .is_none_or(|geometry| !geometry.is_zooming()); + let initial_floating = + self.natural_floating_pending.remove(&id) || self.floating_placement_anchors.contains_key(&id); + if initial_floating + && normal_geometry + && matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. })) + && let Some(bounds) = self.floating_bounds_for_window(id) + { + let rect = self.place_floating( + window, + Some(id), + bounds, + (f64::from(size.width), f64::from(size.height)), + Some((Some(f64::from(size.width)), Some(f64::from(size.height)))), + ); + self.floating_placement_anchors.remove(&id); + if self.workspaces.set_floating_rect(id, rect).is_ok() { + // This is the first mapped client buffer, not a user resize. + // Do not animate from the temporary placement box. + self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size))); + self.resize_transactions.remove(&id); + self.relayout(); + } + } + // A normal floating client may choose a different size (minimum sizes, + // terminal cell grids, or a dialog changing its contents). Its committed + // window geometry is authoritative once the latest configure is committed. + // Never let an old buffer undo a newer resize or a fullscreen transition. + self.floating_placement_anchors.remove(&id); + let settled_configure = window.toplevel().is_some_and(|toplevel| { + with_states(toplevel.wl_surface(), |states| { + states.data_map.get::().is_some_and(|data| { + let Ok(data) = data.lock() else { return false }; + floating_commit_is_current( + data.pending_configures().is_empty(), + data.current_serial, + data.configure_serial, + data.current.states.contains(xdg_toplevel::State::Resizing), + ) + }) + }) + }); + if settled_configure + && normal_geometry + && let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id) + && ClientSize::from_rect(rect) != size + { + let mut rect = Rect::new(rect.x, rect.y, f64::from(size.width), f64::from(size.height)); + if let Some((left, top, anchor)) = self.floating_resize_anchors.get(&id) { + rect = crate::floating::anchored_size(*anchor, (rect.width, rect.height), *left, *top); + } else if initial_floating && let Some(work) = self.floating_bounds_for_window(id) { + rect = crate::floating::clamp_to_work(rect, work); + } + if self.workspaces.set_floating_rect(id, rect).is_ok() { + self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size))); + self.resize_transactions.remove(&id); + self.resize_snapshots.remove(&id); + self.relayout(); + } + } + if settled_configure && self.floating_resize_anchors.remove(&id).is_some() { + self.remember_floating(window); + } + let Some(geometry) = self.window_geometry.get_mut(&id) else { + return; + }; + + let matches_target = geometry.client.commit(size); + tracing::debug!(?id, ?size, matches_target, "recorded client geometry commit"); + } + + pub fn close_focused_window(&mut self) { + let Some(focused) = self.focused_window else { + return; + }; + + if !self.animations_enabled { + self.send_window_close(focused); + return; + } + + self.closing_windows.entry(focused).or_default(); + crate::backends::direct::render_all(self); + } + + pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool { + if !self.window_ids.values().any(|window_id| *window_id == id) { + return false; + } + + if self.animations_enabled { + self.closing_windows.entry(id).or_default(); + crate::backends::direct::render_all(self); + } else { + self.send_window_close(id); + } + + true + } + + pub(super) fn send_window_close(&self, id: WindowId) { + let Some(toplevel) = self + .window_ids + .iter() + .find_map(|(window, window_id)| (*window_id == id).then(|| window.toplevel()).flatten()) + else { + return; + }; + + toplevel.send_close(); + } +} diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index f8fdab0..97e80cb 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -1,823 +1,34 @@ use std::cell::{Cell, RefCell}; -use std::collections::HashMap; use std::error::Error; use std::rc::Rc; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; -use smithay::backend::allocator::Fourcc; use smithay::backend::renderer::damage::OutputDamageTracker; -use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; -use smithay::backend::renderer::element::solid::SolidColorRenderElement; -use smithay::backend::renderer::element::surface::{ - WaylandSurfaceRenderElement, WaylandSurfaceTexture, render_elements_from_surface_tree, -}; -use smithay::backend::renderer::element::utils::{ - ConstrainAlign, ConstrainScaleBehavior, CropRenderElement, Relocate, RelocateRenderElement, RescaleRenderElement, - constrain_render_elements, -}; -use smithay::backend::renderer::element::{ - Element, Id, Kind as RenderElementKind, RenderElement, RenderElementStates, UnderlyingStorage, render_elements, -}; -use smithay::backend::renderer::gles::element::PixelShaderElement; -use smithay::backend::renderer::gles::{ - GlesError, GlesFrame, GlesPixelProgram, GlesRenderer, GlesTexProgram, GlesTexture, Uniform, UniformName, - UniformType, -}; -use smithay::backend::renderer::utils::{CommitCounter, DamageSet, OpaqueRegions, draw_render_elements}; -use smithay::backend::renderer::{ - Bind, Color32F, ErasedContextId, ExportMem, Frame, ImportDma, Offscreen, Renderer, Texture, -}; -use smithay::backend::winit; -use smithay::backend::winit::WinitEvent; -use smithay::desktop::space::{ConstrainBehavior, ConstrainReference, constrain_space_element}; -use smithay::desktop::{LayerSurface, PopupManager, layer_map_for_output}; -use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData}; +use smithay::backend::renderer::element::RenderElementStates; +use smithay::backend::renderer::gles::GlesRenderer; +use smithay::backend::renderer::{Frame, ImportDma, Renderer}; +use smithay::backend::winit::{self, WinitEvent}; +use smithay::desktop::layer_map_for_output; use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel}; use smithay::reexports::calloop::EventLoop; use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; use smithay::reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind; -use smithay::reexports::wayland_server::Resource; -use smithay::utils::{ - Buffer, Clock, Logical, Monotonic, Physical, Point, Rectangle, Scale as RenderScale, Size, Transform, -}; -use smithay::wayland::compositor::with_states; +use smithay::utils::{Clock, Monotonic, Physical, Rectangle, Transform}; use smithay::wayland::presentation::Refresh; -use smithay::wayland::shell::wlr_layer::Layer; use crate::Ferese; use crate::metrics::{FrameEffectMetrics, RenderMetrics}; -use crate::presentation::{NativeTextureElement, PhysicalShaderElement, physical_rect}; - -type SurfaceRenderElement = - CropRenderElement>>>; - -type WindowRenderElement = CropRenderElement>>; - -type MemoryRenderElement = - CropRenderElement>>>; - -type PhysicalDamage = Vec>; -type DamageRenderResult = Result<(Option, FrameEffectMetrics, RenderElementStates), Box>; - -render_elements! { - pub(crate) AnimatedWindowRenderElement<=GlesRenderer>; - Window=WindowRenderElement, - Surface=SurfaceRenderElement, - Memory=MemoryRenderElement, - Solid=SolidColorRenderElement, - LockSurface=WaylandSurfaceRenderElement, - Border=PixelShaderElement, - Blur=BlurRenderElement, - Effect=PhysicalShaderElement, - Native=NativeTextureElement, -} +use crate::render::{animated_window_elements, frame_effect_metrics, layer_surfaces, output_elements, redraw_output}; pub(crate) type NestedBackend = Rc>>; - -#[derive(Debug)] -pub(crate) struct ResizeSnapshot { - pub texture: GlesTexture, - pub context: ErasedContextId, - pub id: Id, - pub commit: CommitCounter, - pub elapsed: Duration, - pub last_tick: Option, - pub scale: f64, -} - -impl ResizeSnapshot { - pub(crate) fn bytes(&self) -> usize { - self.texture.size().w as usize * self.texture.size().h as usize * 4 - } -} - -pub(crate) fn capture_resize_snapshot( - renderer: &mut GlesRenderer, - window: &smithay::desktop::Window, - geometry: Rectangle, - scale: f64, - remaining: usize, -) -> Result, GlesError> { - let Some(toplevel) = window.toplevel() else { - return Ok(None); - }; - let size: Size = geometry.size.to_physical_precise_round(scale); - let bytes = (size.w.max(0) as usize) - .saturating_mul(size.h.max(0) as usize) - .saturating_mul(4); - if bytes == 0 || bytes > remaining { - return Ok(None); - } - // Called before on_commit_buffer_handler: renderer surface state still - // owns the previous buffer. Copy only during an actual resize handoff. - let content = render_elements_from_surface_tree::>( - renderer, - toplevel.wl_surface(), - Point::::from((-geometry.loc.x, -geometry.loc.y)).to_physical_precise_round(scale), - scale, - 1.0, - RenderElementKind::Unspecified, - ); - if content.is_empty() { - return Ok(None); - } - let mut texture = Offscreen::::create_buffer(renderer, Fourcc::Abgr8888, (size.w, size.h).into())?; - { - let mut target = renderer.bind(&mut texture)?; - let mut frame = renderer.render(&mut target, size, Transform::Normal)?; - let damage = Rectangle::from_size(size); - frame.clear(Color32F::new(0.0, 0.0, 0.0, 0.0), &[damage])?; - draw_render_elements(&mut frame, scale, &content, &[damage])?; - let _ = frame.finish()?; - } - Ok(Some(ResizeSnapshot { - texture, - context: renderer.context_id().erased(), - id: Id::new(), - commit: CommitCounter::default(), - elapsed: Duration::ZERO, - last_tick: None, - scale, - })) -} - -pub(crate) fn capture_window_buffer( - renderer: &mut GlesRenderer, - window: &smithay::desktop::Window, - geometry: Rectangle, - scale: f64, -) -> Result { - capture_window_frame(renderer, window, geometry, scale, None) -} - -pub(crate) fn capture_window_frame( - renderer: &mut GlesRenderer, - window: &smithay::desktop::Window, - geometry: Rectangle, - scale: f64, - cursor: Option<(&Ferese, Rectangle)>, -) -> Result { - let mut snapshot = capture_resize_snapshot(renderer, window, geometry, scale, 128 * 1024 * 1024) - .map_err(|error| error.to_string())? - .ok_or("Window has no capturable content")?; - let size = snapshot.texture.size(); - if let Some((state, cursor_geometry)) = cursor { - let elements = cursor_elements(state, renderer, cursor_geometry, scale); - let mut target = renderer.bind(&mut snapshot.texture).map_err(|e| e.to_string())?; - let mut frame = renderer - .render(&mut target, (size.w, size.h).into(), Transform::Normal) - .map_err(|e| e.to_string())?; - draw_render_elements( - &mut frame, - scale, - &elements, - &[Rectangle::from_size((size.w, size.h).into())], - ) - .map_err(|e| e.to_string())?; - let _ = frame.finish().map_err(|e| e.to_string())?; - } - let mapping = renderer - .copy_texture(&snapshot.texture, Rectangle::from_size(size), Fourcc::Abgr8888) - .map_err(|error| error.to_string())?; - if mapping.format() != Some(Fourcc::Abgr8888) { - return Err("Unsupported window readback format".into()); - } - let bytes = (size.w as usize) - .checked_mul(size.h as usize) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or("Window is too large")?; - let source = renderer.map_texture(&mapping).map_err(|error| error.to_string())?; - if source.len() < bytes { - return Err("Incomplete window readback".into()); - } - let mut pixels = source[..bytes].to_vec(); - for pixel in pixels.as_chunks_mut::<4>().0 { - pixel.swap(0, 2); - let alpha = pixel[3] as u16; - for channel in &mut pixel[..3] { - *channel = if alpha == 0 { - 0 - } else { - ((*channel as u16 * 255 + alpha / 2) / alpha).min(255) as u8 - }; - } - } - Ok(crate::handlers::screenshot::CaptureBuffer { - width: size.w, - height: size.h, - stride: size.w as usize * 4, - pixels, - }) -} - -render_elements! { - WindowContentRenderElement<=GlesRenderer>; - Rounded=RoundedSurfaceRenderElement, - Popup=WaylandSurfaceRenderElement, -} - -const ROUNDED_TEXTURE_SHADER: &str = r#"#version 100 - -//_DEFINES_ - -#if defined(EXTERNAL) -#extension GL_OES_EGL_image_external : require -#endif - -precision highp float; -#if defined(EXTERNAL) -uniform samplerExternalOES tex; -#else -uniform sampler2D tex; -#endif - -uniform float alpha; -uniform vec4 clip_rect; -uniform float radius; -varying vec2 v_coords; - -#if defined(DEBUG_FLAGS) -uniform float tint; -#endif - -void main() { - vec4 color = texture2D(tex, v_coords); - -#if defined(NO_ALPHA) - color = vec4(color.rgb, 1.0) * alpha; -#else - color = color * alpha; -#endif - - vec2 point = gl_FragCoord.xy - clip_rect.xy; - vec2 half_size = clip_rect.zw * 0.5; - vec2 distance = abs(point - half_size) - (half_size - vec2(radius)); - float signed_distance = length(max(distance, 0.0)) - + min(max(distance.x, distance.y), 0.0) - - radius; - float coverage = 1.0 - smoothstep(-0.5, 0.5, signed_distance); - color *= coverage; - -#if defined(DEBUG_FLAGS) - if (tint == 1.0) - color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8; -#endif - - gl_FragColor = color; -} -"#; - -const ROUNDED_BORDER_SHADER: &str = r#" -precision highp float; - -uniform float alpha; -uniform vec4 clip_rect; -uniform float radius; -uniform float border_width; -uniform vec4 border_color; -uniform vec4 border_color_to; -uniform vec4 gradient_line; -uniform vec4 focus_color; -uniform vec4 focus_color_to; -uniform vec4 focus_gradient_line; -uniform float focus_mix; -varying vec2 v_coords; - -#if defined(DEBUG_FLAGS) -uniform float tint; -#endif - -void main() { - vec2 point = gl_FragCoord.xy - clip_rect.xy; - vec2 half_size = clip_rect.zw * 0.5; - vec2 distance = abs(point - half_size) - (half_size - vec2(radius)); - float signed_distance = length(max(distance, 0.0)) - + min(max(distance.x, distance.y), 0.0) - - radius; - float outer_coverage = 1.0 - smoothstep(-0.5, 0.5, signed_distance); - - vec2 inner_half_size = max(half_size - vec2(border_width), vec2(0.0)); - float inner_radius = max(radius - border_width, 0.0); - vec2 inner_distance = abs(point - half_size) - - (inner_half_size - vec2(inner_radius)); - float inner_signed_distance = length(max(inner_distance, 0.0)) - + min(max(inner_distance.x, inner_distance.y), 0.0) - - inner_radius; - float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance); - float coverage = max(outer_coverage - inner_coverage, 0.0); - float progress = clamp(dot(gl_FragCoord.xy - gradient_line.xy, gradient_line.zw), 0.0, 1.0); - // Interpolate premultiplied endpoints: a transparent endpoint must not - // leak its RGB into the visible border or create a dark halo. - vec4 from = vec4(border_color.rgb * border_color.a, border_color.a); - vec4 to = vec4(border_color_to.rgb * border_color_to.a, border_color_to.a); - float focus_progress = clamp(dot(gl_FragCoord.xy - focus_gradient_line.xy, focus_gradient_line.zw), 0.0, 1.0); - vec4 focus_from = vec4(focus_color.rgb * focus_color.a, focus_color.a); - vec4 focus_to = vec4(focus_color_to.rgb * focus_color_to.a, focus_color_to.a); - vec4 color = mix(mix(from, to, progress), mix(focus_from, focus_to, focus_progress), focus_mix) * coverage * alpha; - -#if defined(DEBUG_FLAGS) - if (tint == 1.0) - color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8; -#endif - - gl_FragColor = color; -} -"#; - -const WINDOW_SHADOW_SHADER: &str = r#" -precision mediump float; - -uniform float alpha; -uniform vec4 shadow_rect; -uniform float radius; -uniform float blur; -uniform float opacity; -uniform vec4 shadow_color; -varying vec2 v_coords; - -#if defined(DEBUG_FLAGS) -uniform float tint; -#endif - -void main() { - vec2 point = gl_FragCoord.xy - shadow_rect.xy; - vec2 half_size = shadow_rect.zw * 0.5; - vec2 distance = abs(point - half_size) - (half_size - vec2(radius)); - float signed_distance = length(max(distance, 0.0)) - + min(max(distance.x, distance.y), 0.0) - - radius; - float sigma = max(blur * 0.5, 0.5); - float normalized_distance = max(signed_distance, 0.0) / sigma; - float coverage = exp(-0.5 * normalized_distance * normalized_distance); - vec4 color = vec4(shadow_color.rgb * shadow_color.a, shadow_color.a) - * coverage * opacity * alpha; - -#if defined(DEBUG_FLAGS) - if (tint == 1.0) - color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8; -#endif - - gl_FragColor = color; -} -"#; - -// The material program paints either the fallback tint, the edges above client -// content, or the shadow outside the surface. All coordinates are physical px. -const MATERIAL_SHADER: &str = r#" -precision highp float; -uniform float alpha; -uniform vec4 tint; -uniform vec4 visible_rect; -uniform float material_radius; -uniform float paint_mode; -uniform vec4 shadow_rect; -uniform vec2 shadow_values; -varying vec2 v_coords; - -float rounded_distance(vec2 point, vec4 rect) { - vec2 half_size = rect.zw * 0.5; - float radius = min(material_radius, min(half_size.x, half_size.y)); - vec2 d = abs(point - rect.xy - half_size) - (half_size - vec2(radius)); - return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius; -} -void main() { - float sdf = rounded_distance(gl_FragCoord.xy, visible_rect); - float coverage = 1.0 - smoothstep(-0.5, 0.5, sdf); - if (paint_mode > 0.5) { - float distance = max(rounded_distance(gl_FragCoord.xy, shadow_rect), 0.0); - float sigma = max(shadow_values.x * 0.5, 0.5); - float opacity = exp(-0.5 * distance * distance / (sigma * sigma)) - * shadow_values.y * (1.0 - coverage) * alpha; - gl_FragColor = vec4(0.0, 0.0, 0.0, opacity); - } else { - float opacity = tint.a * coverage * alpha; - gl_FragColor = vec4(tint.rgb * opacity, opacity); - } -} -"#; - -const BLUR_SHADER: &str = r#"#version 100 -//_DEFINES_ -#if defined(EXTERNAL) -#extension GL_OES_EGL_image_external : require -#endif -precision highp float; -#if defined(EXTERNAL) -uniform samplerExternalOES tex; -#else -uniform sampler2D tex; -#endif -uniform float alpha; -uniform vec4 visible_rect; -uniform float material_radius; -uniform vec2 texture_size; -uniform vec2 capture_origin; -uniform float blur_radius; -uniform float presentation_alpha; -uniform float background_opacity; -uniform vec4 tint; -varying vec2 v_coords; -void main() { - vec2 half_size = visible_rect.zw * 0.5; - vec2 d = abs(gl_FragCoord.xy - visible_rect.xy - half_size) - (half_size - vec2(material_radius)); - float sdf = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - material_radius; - float coverage = alpha * presentation_alpha * (1.0 - smoothstep(-0.5, 0.5, sdf)); - if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; } - vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size; - vec4 color = vec4(0.0); - float weights = 0.0; - // A spiral avoids aligning the sampling lattice with wallpaper patterns. - for (int i = 0; i < 256; i++) { - float radius = sqrt((float(i) + 0.5) / 256.0); - float angle = float(i) * 2.39996323; - vec2 offset = vec2(cos(angle), sin(angle)) * radius * blur_radius / texture_size; - float weight = exp(-4.5 * radius * radius); - color += texture2D(tex, coords + offset) * weight; - weights += weight; - } - vec3 background = color.rgb / weights; - gl_FragColor = vec4(mix(background, tint.rgb, tint.a * background_opacity) * coverage, coverage); -} -"#; - -#[derive(Clone, Debug)] -pub(crate) struct BlurProgram(GlesTexProgram); - -#[derive(Clone, Debug)] -struct BlurCapture { - texture: GlesTexture, - dirty: Arc, - geometry: Rectangle, -} - -#[derive(Clone, Debug)] -struct BlurRenderElement { - capture_dirty: Arc, - texture: GlesTexture, - program: GlesTexProgram, - id: Id, - commit: CommitCounter, - geometry: Rectangle, - capture_rect: [i32; 4], - uniforms: Vec>, -} - -impl BlurRenderElement { - fn new( - texture: GlesTexture, - program: GlesTexProgram, - parameters: &MaterialParameters, - capture_dirty: Arc, - ) -> Self { - Self { - capture_dirty, - texture, - program, - id: Id::new(), - commit: CommitCounter::default(), - geometry: parameters.sample_geometry, - capture_rect: framebuffer_capture_rect(parameters.sample_framebuffer), - uniforms: blur_uniforms(parameters), - } - } - - fn update(&mut self, parameters: &MaterialParameters) { - self.capture_dirty.store(true, Ordering::Relaxed); - self.geometry = parameters.sample_geometry; - self.capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer); - self.uniforms = blur_uniforms(parameters); - self.commit.increment(); - } -} - -impl Element for BlurRenderElement { - fn id(&self) -> &Id { - &self.id - } - - fn current_commit(&self) -> CommitCounter { - self.commit - } - - fn src(&self) -> Rectangle { - Rectangle::from_size(self.texture.size().to_f64()) - } - - fn geometry(&self, scale: RenderScale) -> Rectangle { - self.geometry.to_physical_precise_round(scale) - } - - fn damage_since(&self, scale: RenderScale, commit: Option) -> DamageSet { - blur_damage(self.geometry(scale).size, self.commit, commit) - } - - fn opaque_regions(&self, _scale: RenderScale) -> OpaqueRegions { - // Backdrop dependencies must remain visible to damage tracking even - // though the shader produces opaque pixels inside the visible region. - OpaqueRegions::default() - } - - fn alpha(&self) -> f32 { - 1.0 - } - - fn kind(&self) -> RenderElementKind { - RenderElementKind::Unspecified - } -} - -fn blur_damage( - size: Size, - current: CommitCounter, - previous: Option, -) -> DamageSet { - if previous == Some(current) { - DamageSet::default() - } else { - // A new scene needs its entire sampling halo repainted before capture. - DamageSet::from_slice(&[Rectangle::from_size((size.w, size.h).into())]) - } -} - -impl RenderElement for BlurRenderElement { - fn draw( - &self, - frame: &mut GlesFrame<'_, '_>, - src: Rectangle, - dst: Rectangle, - damage: &[Rectangle], - opaque_regions: &[Rectangle], - ) -> Result<(), GlesError> { - let texture = self.texture.tex_id(); - let [x, y, width, height] = self.capture_rect; - // Buffer-age restoration and unrelated partial damage must reuse the - // clean captured scene. Recapturing a partly repainted framebuffer - // would feed previous blurred pixels back into the blur. - if self.capture_dirty.load(Ordering::Relaxed) { - let error = frame.with_context(|gl| unsafe { - gl.GetError(); - let mut framebuffer = 0; - let mut previous_read_buffer = 0; - gl.GetIntegerv( - smithay::backend::renderer::gles::ffi::READ_FRAMEBUFFER_BINDING, - &mut framebuffer, - ); - gl.GetIntegerv( - smithay::backend::renderer::gles::ffi::READ_BUFFER, - &mut previous_read_buffer, - ); - gl.ReadBuffer(if framebuffer == 0 { - smithay::backend::renderer::gles::ffi::BACK - } else { - smithay::backend::renderer::gles::ffi::COLOR_ATTACHMENT0 - }); - gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, texture); - gl.TexParameteri( - smithay::backend::renderer::gles::ffi::TEXTURE_2D, - smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_S, - smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32, - ); - gl.TexParameteri( - smithay::backend::renderer::gles::ffi::TEXTURE_2D, - smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_T, - smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32, - ); - gl.CopyTexSubImage2D( - smithay::backend::renderer::gles::ffi::TEXTURE_2D, - 0, - 0, - 0, - x, - y, - width, - height, - ); - gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, 0); - let error = gl.GetError(); - gl.ReadBuffer(previous_read_buffer as u32); - error - })?; - if error != smithay::backend::renderer::gles::ffi::NO_ERROR { - tracing::warn!(error, "backdrop framebuffer capture failed"); - } - self.capture_dirty.store(false, Ordering::Relaxed); - } - - frame.render_texture_from_to( - &self.texture, - src, - dst, - damage, - opaque_regions, - Transform::Normal, - 1.0, - Some(&self.program), - &self.uniforms, - ) - } - - fn underlying_storage(&self, _renderer: &mut GlesRenderer) -> Option> { - None - } -} - -#[derive(Clone, Debug)] -pub(crate) struct RoundedClipPrograms { - texture: GlesTexProgram, - border: GlesPixelProgram, - shadow: GlesPixelProgram, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub(crate) enum OverviewChromePart { - Card, - Outline, - Strip, - Caption, -} - -#[derive(Debug)] -pub(crate) struct OverviewScrim { - chrome: HashMap<(u64, OverviewChromePart), HashMap>, -} - -#[derive(Clone, Debug)] -pub(crate) struct MaterialProgram(GlesPixelProgram); - -#[derive(Clone, Debug, PartialEq)] -struct MaterialParameters { - presentation_alpha: f32, - background_opacity: f32, - blur: f32, - scene_generation: u64, - sample_geometry: Rectangle, - sample_physical: Rectangle, - sample_framebuffer: [f32; 4], - radius: f32, - shadow_rect: [f32; 4], - shadow_values: [f32; 2], - shadow_bounds: Rectangle, - geometry: Rectangle, - tint: [f32; 4], - generation: u64, - opaque: bool, - visible_framebuffer: [f32; 4], -} - -#[derive(Clone, Debug)] -enum MaterialElement { - Fill(PixelShaderElement), - Blur(BlurRenderElement), -} - -#[derive(Debug)] -struct CachedMaterial { - element: MaterialElement, - parameters: MaterialParameters, - shadow: PixelShaderElement, -} - -#[derive(Debug, Default)] -pub(crate) struct MaterialBuffers { - contexts: HashMap<(ErasedContextId, usize), CachedMaterial>, - captures: HashMap, -} - -#[derive(Clone, Debug, PartialEq)] -struct BorderParameters { - geometry: Rectangle, - clip_rect: [f32; 4], - radius: f32, - width: f32, - color: [f32; 4], - color_to: [f32; 4], - gradient_line: [f32; 4], - focus_color: [f32; 4], - focus_color_to: [f32; 4], - focus_gradient_line: [f32; 4], - focus_mix: f32, -} - -#[derive(Debug)] -struct CachedBorder { - element: PixelShaderElement, - parameters: BorderParameters, -} - -#[derive(Debug, Default)] -pub(crate) struct WindowBorderBuffers { - contexts: HashMap, -} - -#[derive(Clone, Debug, PartialEq)] -struct ShadowParameters { - blur: f32, - bounds: Rectangle, - shadow_rect: [f32; 4], - radius: f32, - opacity: f32, - color: [f32; 4], -} - -#[derive(Debug)] -struct CachedShadow { - element: PixelShaderElement, - parameters: ShadowParameters, -} - -#[derive(Debug, Default)] -pub(crate) struct WindowShadowBuffers { - contexts: HashMap, -} - -#[derive(Debug)] -struct RoundedSurfaceRenderElement { - inner: WaylandSurfaceRenderElement, - program: GlesTexProgram, - clip_rect: [f32; 4], - radius: f32, - clip_changed: bool, -} - -impl Element for RoundedSurfaceRenderElement { - fn id(&self) -> &Id { - self.inner.id() - } - - fn current_commit(&self) -> CommitCounter { - self.inner.current_commit() - } - - fn geometry(&self, scale: RenderScale) -> Rectangle { - self.inner.geometry(scale) - } - - fn transform(&self) -> Transform { - self.inner.transform() - } - - fn src(&self) -> Rectangle { - self.inner.src() - } - - fn damage_since(&self, scale: RenderScale, commit: Option) -> DamageSet { - if self.clip_changed { - return DamageSet::from_slice(&[Rectangle::from_size(self.inner.geometry(scale).size)]); - } - self.inner.damage_since(scale, commit) - } - - fn opaque_regions(&self, scale: RenderScale) -> OpaqueRegions { - if self.radius == 0.0 { - self.inner.opaque_regions(scale) - } else { - OpaqueRegions::default() - } - } - - fn alpha(&self) -> f32 { - self.inner.alpha() - } - - fn kind(&self) -> RenderElementKind { - self.inner.kind() - } -} - -impl RenderElement for RoundedSurfaceRenderElement { - fn draw( - &self, - frame: &mut GlesFrame<'_, '_>, - src: Rectangle, - dst: Rectangle, - damage: &[Rectangle], - opaque_regions: &[Rectangle], - ) -> Result<(), GlesError> { - match self.inner.texture() { - WaylandSurfaceTexture::Texture(texture) => frame.render_texture_from_to( - texture, - src, - dst, - damage, - opaque_regions, - self.transform(), - self.alpha(), - Some(&self.program), - &[ - Uniform::new("clip_rect", self.clip_rect), - Uniform::new("radius", self.radius), - ], - ), - WaylandSurfaceTexture::SolidColor(_) => self.inner.draw(frame, src, dst, damage, opaque_regions), - } - } - - fn underlying_storage(&self, renderer: &mut GlesRenderer) -> Option> { - self.inner.underlying_storage(renderer) - } -} +type DamageRenderResult = Result< + ( + Option>>, + FrameEffectMetrics, + RenderElementStates, + ), + Box, +>; pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<(), Box> { let (mut backend, event_source) = winit::init::()?; @@ -1036,1952 +247,13 @@ fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output, refresh: Dur state.send_cursor_frame(output); } -pub(crate) fn animated_window_elements( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, -) -> Vec { - output_elements(state, renderer, output, true) -} - -pub(crate) fn cursorless_window_elements( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, -) -> Vec { - output_elements(state, renderer, output, false) -} - -pub(crate) fn frame_effect_metrics(_elements: &[AnimatedWindowRenderElement], _scale: f64) -> FrameEffectMetrics { - FrameEffectMetrics -} - -fn output_elements( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, - include_cursor: bool, -) -> Vec { - if state.session_lock.active { - state.configure_lock_surfaces(); - let Some(geometry) = state.space.output_geometry(output) else { - return Vec::new(); - }; - let scale = output.current_scale().fractional_scale(); - let background = state.session_lock.backgrounds.entry(output.clone()).or_insert_with(|| { - smithay::backend::renderer::element::solid::SolidColorBuffer::new(geometry.size, [0.0, 0.0, 0.0, 1.0]) - }); - background.resize(geometry.size); - let mut elements = Vec::new(); - let opacity = state.session_lock.idle_opacity; - if opacity > 0.0 { - let overlay = state - .session_lock - .idle_overlays - .entry(output.clone()) - .or_insert_with(|| { - smithay::backend::renderer::element::solid::SolidColorBuffer::new( - geometry.size, - [0.0, 0.0, 0.0, 1.0], - ) - }); - overlay.resize(geometry.size); - elements.push( - SolidColorRenderElement::from_buffer(overlay, (0, 0), scale, opacity, RenderElementKind::Unspecified) - .into(), - ); - } - if let Some(surface) = state - .session_lock - .surfaces - .get(output) - .filter(|surface| surface.alive()) - { - elements.extend( - render_elements_from_surface_tree::>( - renderer, - surface.wl_surface(), - (0, 0), - scale, - 1.0, - RenderElementKind::Unspecified, - ) - .into_iter() - .map(AnimatedWindowRenderElement::from), - ); - } - elements.push( - SolidColorRenderElement::from_buffer(background, (0, 0), scale, 1.0, RenderElementKind::Unspecified).into(), - ); - return elements; - } - let Some(output_geometry) = state.space.output_geometry(output) else { - return Vec::new(); - }; - let scale = output.current_scale().fractional_scale(); - let rounded_clip_program = rounded_clip_program(state, renderer); - - let mut elements = if include_cursor { - cursor_elements(state, renderer, output_geometry, scale) - } else { - Vec::new() - }; - 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)); - if state.overview.is_presenting() { - elements.extend(overview_strip_elements(state, renderer, output, output_geometry, scale)); - } - let mut candidates = if state.overview.is_active() { - state.window_ids.keys().cloned().collect::>() - } else { - state.space.elements().rev().cloned().collect::>() - }; - if state.overview.is_active() { - candidates.sort_by_key(|window| std::cmp::Reverse(state.window_ids.get(window).map_or(0, |id| id.0))); - } - let windows = candidates - .iter() - .filter_map(|window| { - // Configure immediately, but present the frame/shadow only after - // the first buffer commit has settled the actual client geometry. - if !state.window_content_ready(window) { - return None; - } - let id = *state.window_ids.get(window)?; - // Scrolling columns may sit outside their monitor's rectangle. - // They must not reappear on a neighboring output just because - // their global animated coordinates overlap it. - if !state.window_belongs_to_output(id, output) { - return None; - } - let visual = state.presented_window_rect(id)?; - let (close_scale, close_alpha) = state.closing_visual(id); - let visual = scaled_visual_rect(visual, close_scale); - let decoration_progress = state - .window_geometry - .get(&id) - .map_or(1.0, |geometry| geometry.decorations.clamp(0.0, 1.0)); - - Some((window.clone(), id, visual, decoration_progress, close_alpha)) - }) - .collect::>(); - - for (window, id, visual, decoration_progress, close_alpha) in windows { - let constrain = rounded_visual_rect(visual, output_geometry.loc); - let pixels = physical_rect(visual, output_geometry.loc, scale); - let material_surface = window - .toplevel() - .map(|toplevel| toplevel.wl_surface()) - .filter(|surface| { - crate::effects::surface_role(surface) - .is_some_and(|(role, _)| role == crate::effects::SemanticRole::Modal) - }); - let window_radius = if material_surface.is_some() { - state.theme_settings.material_radius - } else { - state.theme_settings.window_radius - } * decoration_progress; - // Only overview/close intentionally scale the complete application. - let scale_content = state.overview.is_presenting() || state.closing_visual(id).0 != 1.0; - let behavior = resize_content_behavior(scale_content); - - let dim = state.window_dimming.get(&id).map_or(0.0, |dim| dim.current); - if let Some(overlay) = window_tint_element( - state, - renderer, - id, - constrain, - pixels, - scale, - window_radius, - [0.0, 0.0, 0.0, dim as f32 * close_alpha], - false, - output, - ) { - // Front-to-back: dim the application and its border, not its shadow - // or other windows/layer surfaces. The overlay is input-transparent. - elements.push(overlay.into()); - } - - if let Some(programs) = rounded_clip_program.clone() { - let shadow_offset_y = state.theme_settings.shadow_offset_y; - let shadow_blur = state.theme_settings.shadow_blur; - let shadow_opacity = state.theme_settings.shadow_opacity; - let shadow_color = state.theme_settings.shadow_color.0; - let focused = if state.overview.is_active() { - state.overview_selected(id) - } else { - state.focused_window == Some(id) - }; - let focus = state - .window_focus - .get(&id) - .map_or(if focused { 1.0 } else { 0.0 }, |v| v.current); - let border_width = state.theme_settings.border_width - + (state.theme_settings.focus_ring_width - state.theme_settings.border_width) * focus; - let border_color = state.theme_settings.border_color.0; - let gradient = state.theme_settings.border_gradient; - - if let Some(border) = window_border_element( - state, - renderer, - id, - constrain, - pixels, - scale, - window_radius, - border_width, - border_color, - gradient, - focus as f32, - close_alpha * decoration_progress as f32, - output, - &programs, - ) { - elements.push(border.into()); - } - let shadow = window_shadow_element( - state, - renderer, - id, - constrain, - pixels, - scale, - window_radius, - shadow_offset_y, - shadow_blur, - shadow_opacity * f64::from(close_alpha) * decoration_progress, - shadow_color, - output, - &programs, - ); - if !scale_content - && let Some(snapshot) = state.resize_snapshots.get(&id) - && snapshot.context == renderer.context_id().erased() - && (snapshot.scale - scale).abs() < 0.001 - { - let size = snapshot.texture.size(); - let visible = Rectangle::new(pixels.loc, (size.w, size.h).into()).intersection(pixels); - if let Some(visible) = visible { - let clip = framebuffer_clip_rect( - pixels, - output.current_mode().unwrap().size, - output.current_transform().invert(), - ); - elements.push( - NativeTextureElement { - id: snapshot.id.clone(), - commit: snapshot.commit, - texture: snapshot.texture.clone(), - geometry: visible, - source: Rectangle::from_size(Size::from(( - f64::from(visible.size.w), - f64::from(visible.size.h), - ))), - alpha: crate::presentation::handoff_alpha(snapshot.elapsed) * close_alpha, - program: Some(programs.texture.clone()), - uniforms: vec![ - Uniform::new("clip_rect", clip).into_owned(), - Uniform::new("radius", scaled_effect_value(window_radius, constrain, scale)) - .into_owned(), - ], - } - .into(), - ); - } - } - elements.extend(rounded_window_elements( - renderer, - &window, - constrain, - pixels, - scale, - window_radius, - close_alpha, - state - .window_geometry - .get(&id) - .is_some_and(|geometry| geometry.presentation_changed), - output, - programs.texture.clone(), - behavior, - )); - let source = window.geometry().size; - if material_surface.is_none() - && !scale_content - && (source.w < constrain.size.w || source.h < constrain.size.h) - { - let mut color = state.theme_settings.surface_base_color.0; - color[3] = close_alpha; - if let Some(fill) = window_tint_element( - state, - renderer, - id, - constrain, - pixels, - scale, - window_radius, - color, - true, - output, - ) { - // Front-to-back: fill uncovered strips behind the native - // content instead of stretching it or exposing wallpaper. - elements.push(fill.into()); - } - } else { - state.window_resize_fills.remove(&id); - } - if let Some(surface) = material_surface - && let Some((background, _)) = material_element( - state, - renderer, - output, - surface, - constrain, - window_radius as f32, - 0, - constrain, - close_alpha, - ) - { - elements.push(background); - } - if let Some(shadow) = shadow { - elements.push(shadow.into()); - } - } else { - elements.extend(constrain_space_element::( - renderer, - &window, - constrain.loc, - close_alpha, - scale, - constrain, - ConstrainBehavior { - reference: ConstrainReference::Geometry, - behavior, - align: ConstrainAlign::TOP | ConstrainAlign::LEFT, - }, - )); - } - } - elements.extend(layer_elements( - state, - renderer, - output, - &[Layer::Bottom, Layer::Background], - )); - state.prepare_theme_wallpaper(renderer, output); - let progress = state.theme_progress(); - let wallpaper = state.wallpaper.element(renderer, output); - let has_wallpaper = wallpaper.is_some(); - if let Some(mut wallpaper) = wallpaper { - wallpaper.alpha = progress; - elements.push(wallpaper.into()); - } - if let Some(mut previous) = state.previous_theme_wallpaper(renderer, output) { - if !has_wallpaper { - previous.alpha = 1. - progress; - } - elements.push(previous.into()); - } - elements -} - -#[allow(clippy::too_many_arguments)] -fn overview_chrome_element( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, - output_geometry: Rectangle, - scale: f64, - key: (u64, OverviewChromePart), - rect: ferese_layout::Rect, - color: [f32; 4], - width: f64, - radius: f64, -) -> Option { - let output_id = state.output_id(output)?; - let geometry = rounded_visual_rect(rect, output_geometry.loc); - let physical = physical_rect(rect, output_geometry.loc, scale); - let mode = output.current_mode()?; - let parameters = BorderParameters { - geometry, - clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), - radius: (radius * scale) as f32, - width: (width * scale) as f32, - color, - color_to: color, - gradient_line: [0.0; 4], - focus_color: color, - focus_color_to: color, - focus_gradient_line: [0.0; 4], - focus_mix: 0.0, - }; - let program = if key.1 == OverviewChromePart::Outline { - rounded_clip_program(state, renderer)?.border - } else { - material_program(state, renderer)?.0 - }; - let uniforms = |p: &BorderParameters| { - if key.1 == OverviewChromePart::Outline { - border_uniforms(p) - } else { - vec![ - Uniform::new("visible_rect", p.clip_rect).into_owned(), - Uniform::new("material_radius", p.radius).into_owned(), - Uniform::new("tint", p.color).into_owned(), - Uniform::new("paint_mode", 0.0_f32).into_owned(), - Uniform::new("shadow_rect", p.clip_rect).into_owned(), - Uniform::new("shadow_values", [0.0_f32; 2]).into_owned(), - ] - } - }; - let context = renderer.context_id().erased(); - let chrome = &mut state - .overview_scrims - .entry(output_id) - .or_insert_with(|| OverviewScrim { chrome: HashMap::new() }) - .chrome; - let cached = chrome - .entry(key) - .or_default() - .entry(context) - .or_insert_with(|| CachedBorder { - element: PixelShaderElement::new( - program, - geometry, - None, - 1.0, - uniforms(¶meters), - RenderElementKind::Unspecified, - ), - parameters: parameters.clone(), - }); - if cached.parameters != parameters { - if cached.parameters.geometry != geometry { - cached.element.resize(geometry, None); - } - cached.element.update_uniforms(uniforms(¶meters)); - cached.parameters = parameters; - } - Some(PhysicalShaderElement { - inner: cached.element.clone(), - geometry: physical, - }) -} - -fn overview_strip_elements( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, - output_geometry: Rectangle, - scale: f64, -) -> Vec { - let alpha = state.overview.opacity(); - if alpha <= 0.001 { - return Vec::new(); - } - let Some(bounds) = state.output_bounds_for(output) else { - return Vec::new(); - }; - let cards = state.overview_workspace_cards(output); - let strip = crate::overview::workspace_strip(bounds); - let panel = match (cards.first(), cards.last()) { - (Some(first), Some(last)) => ferese_layout::Rect::new( - first.rect.x - 12.0, - strip.y, - last.rect.x + last.rect.width - first.rect.x + 24.0, - strip.height, - ), - _ => strip, - }; - let radius = state.theme_settings.material_radius; - let material_opacity = crate::effects::resolve_material( - crate::effects::SemanticRole::Panel, - state.theme_settings.material_style, - state.theme_settings.shell_opacity as f32, - ) - .opacity; - let mut elements = Vec::new(); - let programs = rounded_clip_program(state, renderer); - // Resolve IDs once per strip, rather than scanning all managed windows - // separately for every miniature. Own the handles so rendering can mutate state. - let windows_by_id: std::collections::HashMap<_, _> = state - .window_ids - .iter() - .map(|(window, id)| (*id, window.clone())) - .collect(); - if let Some(output_id) = state.output_id(output) - && let Some(cache) = state.overview_scrims.get_mut(&output_id) - { - cache.chrome.retain(|(id, part), _| match part { - OverviewChromePart::Caption => windows_by_id.keys().any(|window| window.0 == *id), - OverviewChromePart::Card | OverviewChromePart::Outline => cards.iter().any(|card| card.workspace.0 == *id), - OverviewChromePart::Strip => true, - }); - } - // Cached title textures are independent of window content and stay readable - // when thumbnails are small. Their pills use the shell's material and radius. - for (id, window) in &windows_by_id { - if !state.window_belongs_to_output(*id, output) { - continue; - } - let Some(rect) = state.presented_window_rect(*id) else { - continue; - }; - let Some((buffer, size)) = state.overview_window_label(window, scale, rect.width) else { - continue; - }; - let width = (f64::from(size.w) / scale + 16.0).min(rect.width); - let caption = ferese_layout::Rect::new( - rect.x + (rect.width - width) * 0.5, - rect.y + rect.height + 8.0, - width, - 26.0, - ); - let location = Point::::from(( - ((caption.x - f64::from(output_geometry.loc.x) + caption.width * 0.5) * scale).round() as i32 - size.w / 2, - ((caption.y - f64::from(output_geometry.loc.y) + 4.0) * scale).round() as i32, - )); - if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( - renderer, - location.to_f64(), - &buffer, - Some(alpha), - Some(Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into())), - Some( - ( - (f64::from(size.w) / scale).round() as i32, - (f64::from(size.h) / scale).round() as i32, - ) - .into(), - ), - RenderElementKind::Unspecified, - ) { - let element = RescaleRenderElement::from_element(element, location, 1.0); - let element = RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative); - if let Some(element) = - CropRenderElement::from_element(element, scale, physical_rect(caption, output_geometry.loc, scale)) - { - elements.push(element.into()); - } - } - let mut fill = state.theme_settings.surface_base_color.0; - fill[3] = alpha * material_opacity; - if let Some(background) = overview_chrome_element( - state, - renderer, - output, - output_geometry, - scale, - (id.0, OverviewChromePart::Caption), - caption, - fill, - 0.0, - radius.min(13.0), - ) { - elements.push(background.into()); - } - } - for card in cards { - // Front-to-back: outline and live miniatures above each card, all above - // the strip. Nothing is painted behind the main overview window grid. - if card.selected { - let mut color = state.theme_settings.accent_color.0; - color[3] *= alpha; - if let Some(border) = overview_chrome_element( - state, - renderer, - output, - output_geometry, - scale, - (card.workspace.0, OverviewChromePart::Outline), - card.rect, - color, - 1.0, - radius, - ) { - elements.push(border.into()); - } - } - if let Some(programs) = &programs { - for (id, rect) in &card.windows { - if let Some(window) = windows_by_id.get(id) { - elements.extend(rounded_window_elements( - renderer, - window, - rounded_visual_rect(*rect, output_geometry.loc), - physical_rect(*rect, output_geometry.loc, scale), - scale, - 4.0, - alpha, - true, - output, - programs.texture.clone(), - ConstrainScaleBehavior::Stretch, - )); - } - } - } - if let Some((buffer, size)) = state.overview_workspace_label(card.workspace, scale) { - let location = Point::::from(( - ((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale).round() as i32 - - size.w / 2, - ((card.rect.y - f64::from(output_geometry.loc.y) + card.rect.height - 20.0) * scale).round() as i32, - )); - if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( - renderer, - location.to_f64(), - &buffer, - Some(alpha), - Some(Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into())), - Some( - ( - (f64::from(size.w) / scale).round() as i32, - (f64::from(size.h) / scale).round() as i32, - ) - .into(), - ), - RenderElementKind::Unspecified, - ) { - let element = RescaleRenderElement::from_element(element, location, 1.0); - let element = RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative); - if let Some(element) = CropRenderElement::from_element( - element, - scale, - physical_rect(card.rect, output_geometry.loc, scale), - ) { - elements.push(element.into()); - } - } - } - let mut card_color = state.theme_settings.surface_base_color.0; - for component in &mut card_color[..3] { - *component = (*component + 0.035).min(1.0); - } - card_color[3] = alpha * material_opacity; - if let Some(card_fill) = overview_chrome_element( - state, - renderer, - output, - output_geometry, - scale, - (card.workspace.0, OverviewChromePart::Card), - card.rect, - card_color, - 0.0, - radius, - ) { - elements.push(card_fill.into()); - } - } - let mut color = state.theme_settings.surface_base_color.0; - color[3] = alpha - * crate::effects::resolve_material( - crate::effects::SemanticRole::Panel, - state.theme_settings.material_style, - state.theme_settings.shell_opacity as f32, - ) - .opacity; - if let Some(strip) = overview_chrome_element( - state, - renderer, - output, - output_geometry, - scale, - (0, OverviewChromePart::Strip), - panel, - color, - 0.0, - radius, - ) { - elements.push(strip.into()); - } - elements -} - -fn rounded_clip_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { - let context = renderer.context_id().erased(); - if let Some(program) = state.rounded_clip_programs.get(&context) { - return Some(program.clone()); - } - - let texture_uniforms = [ - UniformName::new("clip_rect", UniformType::_4f), - UniformName::new("radius", UniformType::_1f), - ]; - let border_uniforms = [ - UniformName::new("clip_rect", UniformType::_4f), - UniformName::new("radius", UniformType::_1f), - UniformName::new("border_width", UniformType::_1f), - UniformName::new("border_color", UniformType::_4f), - UniformName::new("border_color_to", UniformType::_4f), - UniformName::new("gradient_line", UniformType::_4f), - UniformName::new("focus_color", UniformType::_4f), - UniformName::new("focus_color_to", UniformType::_4f), - UniformName::new("focus_gradient_line", UniformType::_4f), - UniformName::new("focus_mix", UniformType::_1f), - ]; - let shadow_uniforms = [ - UniformName::new("shadow_rect", UniformType::_4f), - UniformName::new("radius", UniformType::_1f), - UniformName::new("blur", UniformType::_1f), - UniformName::new("opacity", UniformType::_1f), - UniformName::new("shadow_color", UniformType::_4f), - ]; - let texture = renderer.compile_custom_texture_shader(ROUNDED_TEXTURE_SHADER, &texture_uniforms); - let border = renderer.compile_custom_pixel_shader(ROUNDED_BORDER_SHADER, &border_uniforms); - let shadow = renderer.compile_custom_pixel_shader(WINDOW_SHADOW_SHADER, &shadow_uniforms); - let compiled = texture.and_then(|texture| border.and_then(|border| shadow.map(|shadow| (texture, border, shadow)))); - match compiled { - Ok((texture, border, shadow)) => { - let programs = RoundedClipPrograms { - texture, - border, - shadow, - }; - state.rounded_clip_programs.insert(context, programs.clone()); - Some(programs) - } - Err(error) => { - tracing::error!(%error, "failed to compile rounded-window shader"); - None - } - } -} - -#[allow(clippy::too_many_arguments)] -fn window_border_element( - state: &mut Ferese, - renderer: &GlesRenderer, - id: ferese_layout::WindowId, - geometry: Rectangle, - physical: Rectangle, - scale: f64, - requested_radius: f64, - requested_width: f64, - color: [f32; 4], - gradient: Option, - focus_mix: f32, - opacity: f32, - output: &Output, - programs: &RoundedClipPrograms, -) -> Option { - let mode = output.current_mode()?; - let width = scaled_effect_value(requested_width, geometry, scale); - if width == 0.0 { - return None; - } - - let (from, to, gradient_line) = match gradient { - Some(gradient) => ( - gradient.from.0, - gradient.to.0, - border_gradient_line(physical, mode.size, output.current_transform().invert(), gradient.angle), - ), - None => (color, color, [0.0; 4]), - }; - let (focus_from, focus_to, focus_gradient_line) = match state.theme_settings.focus_ring_gradient { - Some(g) => ( - g.from.0, - g.to.0, - border_gradient_line(physical, mode.size, output.current_transform().invert(), g.angle), - ), - None => ( - state.theme_settings.accent_color.0, - state.theme_settings.accent_color.0, - [0.0; 4], - ), - }; - let parameters = BorderParameters { - geometry, - clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), - radius: scaled_effect_value(requested_radius, geometry, scale), - width, - color: color_with_alpha(from, opacity), - color_to: color_with_alpha(to, opacity), - gradient_line, - focus_color: color_with_alpha(focus_from, opacity), - focus_color_to: color_with_alpha(focus_to, opacity), - focus_gradient_line, - focus_mix, - }; - let context = renderer.context_id().erased(); - let buffers = state.window_borders.entry(id).or_default(); - if !buffers.contexts.contains_key(&context) { - let element = PixelShaderElement::new( - programs.border.clone(), - geometry, - None, - 1.0, - border_uniforms(¶meters), - RenderElementKind::Unspecified, - ); - buffers.contexts.insert( - context.clone(), - CachedBorder { - element, - parameters: parameters.clone(), - }, - ); - } - - let cached = buffers.contexts.get_mut(&context)?; - if cached.parameters != parameters { - if cached.parameters.geometry != parameters.geometry { - cached.element.resize(parameters.geometry, None); - } - cached.element.update_uniforms(border_uniforms(¶meters)); - cached.parameters = parameters; - } - - Some(PhysicalShaderElement { - inner: cached.element.clone(), - geometry: physical, - }) -} - -fn border_uniforms(parameters: &BorderParameters) -> Vec> { - vec![ - Uniform::new("clip_rect", parameters.clip_rect).into_owned(), - Uniform::new("radius", parameters.radius).into_owned(), - Uniform::new("border_width", parameters.width).into_owned(), - Uniform::new("border_color", parameters.color).into_owned(), - Uniform::new("border_color_to", parameters.color_to).into_owned(), - Uniform::new("gradient_line", parameters.gradient_line).into_owned(), - Uniform::new("focus_color", parameters.focus_color).into_owned(), - Uniform::new("focus_color_to", parameters.focus_color_to).into_owned(), - Uniform::new("focus_gradient_line", parameters.focus_gradient_line).into_owned(), - Uniform::new("focus_mix", parameters.focus_mix).into_owned(), - ] -} - -#[allow(clippy::too_many_arguments)] -fn window_tint_element( - state: &mut Ferese, - renderer: &mut GlesRenderer, - id: ferese_layout::WindowId, - geometry: Rectangle, - physical: Rectangle, - scale: f64, - radius: f64, - color: [f32; 4], - resize_fill: bool, - output: &Output, -) -> Option { - if color[3] <= 0.0 { - if resize_fill { - state.window_resize_fills.remove(&id); - } else { - state.window_dims.remove(&id); - } - return None; - } - let mode = output.current_mode()?; - let program = material_program(state, renderer)?; - let parameters = BorderParameters { - geometry, - clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), - radius: scaled_effect_value(radius, geometry, scale), - width: 0.0, - color, - color_to: color, - gradient_line: [0.0; 4], - focus_color: color, - focus_color_to: color, - focus_gradient_line: [0.0; 4], - focus_mix: 0.0, - }; - let uniforms = |p: &BorderParameters| { - vec![ - Uniform::new("visible_rect", p.clip_rect).into_owned(), - Uniform::new("material_radius", p.radius).into_owned(), - Uniform::new("tint", p.color).into_owned(), - Uniform::new("paint_mode", 0.0_f32).into_owned(), - Uniform::new("shadow_rect", p.clip_rect).into_owned(), - Uniform::new("shadow_values", [0.0_f32; 2]).into_owned(), - ] - }; - let context = renderer.context_id().erased(); - let buffers = if resize_fill { - state.window_resize_fills.entry(id).or_default() - } else { - state.window_dims.entry(id).or_default() - }; - let cached = buffers.contexts.entry(context).or_insert_with(|| CachedBorder { - element: PixelShaderElement::new( - program.0, - geometry, - None, - 1.0, - uniforms(¶meters), - RenderElementKind::Unspecified, - ), - parameters: parameters.clone(), - }); - if cached.parameters != parameters { - if cached.parameters.geometry != geometry { - cached.element.resize(geometry, None); - } - // Updating uniforms advances the element's commit so unchanged client - // buffers still repaint while focus dimming animates. - cached.element.update_uniforms(uniforms(¶meters)); - cached.parameters = parameters; - } - Some(PhysicalShaderElement { - inner: cached.element.clone(), - geometry: physical, - }) -} - -#[allow(clippy::too_many_arguments)] -fn window_shadow_element( - state: &mut Ferese, - renderer: &GlesRenderer, - id: ferese_layout::WindowId, - geometry: Rectangle, - physical: Rectangle, - scale: f64, - requested_radius: f64, - offset_y: f64, - blur: f64, - opacity: f64, - color: [f32; 4], - output: &Output, - programs: &RoundedClipPrograms, -) -> Option { - let mode = output.current_mode()?; - if opacity == 0.0 || color[3] == 0.0 { - return None; - } - - let shadow_geometry = Rectangle::new( - (physical.loc.x, physical.loc.y + (offset_y * scale).round() as i32).into(), - physical.size, - ); - let bounds = shadow_bounds(geometry, offset_y, blur); - let parameters = ShadowParameters { - blur: (blur * scale) as f32, - bounds, - shadow_rect: framebuffer_clip_rect(shadow_geometry, mode.size, output.current_transform().invert()), - radius: scaled_effect_value(requested_radius, geometry, scale), - opacity: opacity as f32, - color, - }; - let context = renderer.context_id().erased(); - let buffers = state.window_shadows.entry(id).or_default(); - if !buffers.contexts.contains_key(&context) { - let element = PixelShaderElement::new( - programs.shadow.clone(), - bounds, - None, - 1.0, - shadow_uniforms(¶meters), - RenderElementKind::Unspecified, - ); - buffers.contexts.insert( - context.clone(), - CachedShadow { - element, - parameters: parameters.clone(), - }, - ); - } - - let cached = buffers.contexts.get_mut(&context)?; - if cached.parameters != parameters { - if cached.parameters.bounds != parameters.bounds { - cached.element.resize(parameters.bounds, None); - } - cached.element.update_uniforms(shadow_uniforms(¶meters)); - cached.parameters = parameters; - } - - let extent = (blur * scale * 2.0).ceil() as i32; - Some(PhysicalShaderElement { - inner: cached.element.clone(), - geometry: Rectangle::new( - (shadow_geometry.loc.x - extent, shadow_geometry.loc.y - extent).into(), - (shadow_geometry.size.w + 2 * extent, shadow_geometry.size.h + 2 * extent).into(), - ), - }) -} - -fn shadow_uniforms(parameters: &ShadowParameters) -> Vec> { - vec![ - Uniform::new("shadow_rect", parameters.shadow_rect).into_owned(), - Uniform::new("radius", parameters.radius).into_owned(), - Uniform::new("blur", parameters.blur).into_owned(), - Uniform::new("opacity", parameters.opacity).into_owned(), - Uniform::new("shadow_color", parameters.color).into_owned(), - ] -} - -fn shadow_bounds(geometry: Rectangle, offset_y: f64, blur: f64) -> Rectangle { - let extent = (blur * 2.0).ceil() as i32; - let offset_y = offset_y.round() as i32; - - Rectangle::new( - (geometry.loc.x - extent, geometry.loc.y + offset_y - extent).into(), - (geometry.size.w + extent * 2, geometry.size.h + extent * 2).into(), - ) -} - -fn scaled_effect_value(requested: f64, geometry: Rectangle, scale: f64) -> f32 { - requested - .min(f64::from(geometry.size.w.min(geometry.size.h)) / 2.0) - .max(0.0) as f32 - * scale as f32 -} - -#[allow(clippy::too_many_arguments)] -fn rounded_window_elements( - renderer: &mut GlesRenderer, - window: &smithay::desktop::Window, - constrain: Rectangle, - physical_constrain: Rectangle, - scale: f64, - requested_radius: f64, - alpha: f32, - clip_changed: bool, - output: &Output, - program: GlesTexProgram, - behavior: ConstrainScaleBehavior, -) -> Vec { - let Some(toplevel) = window.toplevel() else { - return Vec::new(); - }; - let Some(mode) = output.current_mode() else { - return Vec::new(); - }; - - let geometry = window.geometry(); - let reference = geometry.to_physical_precise_round(scale); - let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale); - let clip = framebuffer_clip_rect(physical_constrain, mode.size, output.current_transform().invert()); - let radius = scaled_effect_value(requested_radius, constrain, scale); - let surface = toplevel.wl_surface(); - - let mut content = PopupManager::popups_for_surface(surface) - .flat_map(|(popup, popup_offset)| { - let offset = (geometry.loc + popup_offset - popup.geometry().loc).to_physical_precise_round(scale); - - render_elements_from_surface_tree::>( - renderer, - popup.wl_surface(), - location + offset, - scale, - alpha, - RenderElementKind::Unspecified, - ) - .into_iter() - .map(WindowContentRenderElement::from) - }) - .collect::>(); - content.extend( - render_elements_from_surface_tree::>( - renderer, - surface, - location, - scale, - alpha, - RenderElementKind::Unspecified, - ) - .into_iter() - .map(|inner| { - RoundedSurfaceRenderElement { - inner, - program: program.clone(), - clip_rect: clip, - radius, - clip_changed, - } - .into() - }), - ); - - constrain_render_elements( - content, - location, - physical_constrain, - reference, - behavior, - ConstrainAlign::TOP | ConstrainAlign::LEFT, - scale, - ) - .map(Into::into) - .collect() -} - -fn resize_content_behavior(intentional_scale: bool) -> ConstrainScaleBehavior { - if intentional_scale { - ConstrainScaleBehavior::Stretch - } else { - ConstrainScaleBehavior::CutOff - } -} - -fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect { - let scale = scale.clamp(0.0, 1.0); - let width = rect.width * scale; - let height = rect.height * scale; - - ferese_layout::Rect::new( - rect.x + (rect.width - width) / 2.0, - rect.y + (rect.height - height) / 2.0, - width, - height, - ) -} - -fn color_with_alpha(mut color: [f32; 4], alpha: f32) -> [f32; 4] { - color[3] *= alpha; - color -} - -fn rounded_visual_rect(rect: ferese_layout::Rect, output_location: Point) -> Rectangle { - let left = (rect.x - f64::from(output_location.x)).round() as i32; - let top = (rect.y - f64::from(output_location.y)).round() as i32; - let right = (rect.x + rect.width - f64::from(output_location.x)).round() as i32; - let bottom = (rect.y + rect.height - f64::from(output_location.y)).round() as i32; - - Rectangle::new( - (left, top).into(), - ((right - left).max(1), (bottom - top).max(1)).into(), - ) -} - -fn framebuffer_clip_rect( - geometry: Rectangle, - output_size: smithay::utils::Size, - transform: Transform, -) -> [f32; 4] { - // Match GlesFrame's projection: Smithay already accounts for GL's Y axis. - // An extra bottom-left conversion here mirrors shader clips independently - // of the surface geometry (notably with the nested Flipped180 output). - let element_size = transform.transform_size(output_size); - let transformed = transform.transform_rect_in(geometry, &element_size); - - [ - transformed.loc.x as f32, - transformed.loc.y as f32, - transformed.size.w as f32, - transformed.size.h as f32, - ] -} - -fn border_gradient_line( - geometry: Rectangle, - output_size: Size, - transform: Transform, - angle: f64, -) -> [f32; 4] { - let angle = angle.to_radians(); - let direction = (angle.cos(), angle.sin()); - let width = f64::from(geometry.size.w); - let height = f64::from(geometry.size.h); - let extent = (width * direction.0.abs() + height * direction.1.abs()) * 0.5; - let center = geometry.loc.to_f64() + Point::from((width * 0.5, height * 0.5)); - let offset: Point = (direction.0 * extent, direction.1 * extent).into(); - // Apply the same output-to-framebuffer transform as the rounded clip. - // This keeps angles in window coordinates on rotated/flipped monitors. - let area = transform.transform_size(output_size).to_f64(); - let start = transform.transform_point_in(center - offset, &area); - let end = transform.transform_point_in(center + offset, &area); - let delta = end - start; - let length_squared = (delta.x * delta.x + delta.y * delta.y).max(0.000001); - [ - start.x as f32, - start.y as f32, - (delta.x / length_squared) as f32, - (delta.y / length_squared) as f32, - ] -} - -pub(crate) fn redraw_output( - renderer: &mut R, - framebuffer: &mut R::Framebuffer<'_>, - output: &Output, - elements: &[E], -) -> Result<(), R::Error> -where - R: Renderer, - R::TextureId: 'static, - E: RenderElement, -{ - let mode = output.current_mode().expect("output has a mode"); - let transform = output.current_transform().invert(); - let damage = Rectangle::from_size(transform.transform_size(mode.size)); - let mut frame = renderer.render(framebuffer, mode.size, transform)?; - - frame.clear(Color32F::new(0.035, 0.04, 0.055, 1.0), &[damage])?; - draw_render_elements( - &mut frame, - output.current_scale().fractional_scale(), - elements, - &[damage], - )?; - let _ = frame.finish()?; - - Ok(()) -} - -fn layer_surfaces(output: &Output) -> Vec { - layer_map_for_output(output).layers().cloned().collect() -} - -fn layer_elements( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, - requested_layers: &[Layer], -) -> Vec { - let scale = output.current_scale().fractional_scale(); - let Some(mode) = output.current_mode() else { - return Vec::new(); - }; - let output_crop = Rectangle::::from_size(mode.size); - let layers = { - let map = layer_map_for_output(output); - - requested_layers - .iter() - .flat_map(|requested| { - map.layers_on(*requested) - .rev() - .filter_map(|layer| map.layer_geometry(layer).map(|geometry| (geometry, layer.clone()))) - }) - .collect::>() - }; - - state.material_buffers.retain(|surface, _| surface.is_alive()); - - let mut elements = Vec::new(); - for (geometry, layer) in layers { - for (popup, offset) in PopupManager::popups_for_surface(layer.wl_surface()) { - let mut bounds = popup.geometry(); - let content_origin = geometry.loc + offset - bounds.loc; - bounds.loc = geometry.loc + offset; - append_material_surface( - state, - renderer, - output, - popup.wl_surface(), - bounds, - content_origin, - scale, - output_crop, - &mut elements, - ); - } - append_material_surface( - state, - renderer, - output, - layer.wl_surface(), - geometry, - geometry.loc, - scale, - output_crop, - &mut elements, - ); - } - - elements -} - -#[allow(clippy::too_many_arguments)] -fn append_material_surface( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, - surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, - geometry: Rectangle, - content_origin: Point, - scale: f64, - output_crop: Rectangle, - elements: &mut Vec, -) { - let radius = - if crate::effects::surface_role(surface).is_some_and(|(role, _)| role == crate::effects::SemanticRole::Panel) { - state.theme_settings.panel_radius - } else { - state.theme_settings.material_radius - }; - let regions = crate::effects::surface_regions(surface); - let targets: Vec<_> = match ®ions { - None => vec![(geometry, radius as f32)], - Some(regions) => regions - .iter() - .filter_map(|r| { - Rectangle::new( - (content_origin.x + r[0], content_origin.y + r[1]).into(), - (r[2], r[3]).into(), - ) - .intersection(geometry) - .map(|rect| (rect, r[4] as f32)) - }) - .collect(), - }; - let materials: Vec<_> = targets - .iter() - .enumerate() - .filter_map(|(index, (rect, radius))| { - material_element(state, renderer, output, surface, *rect, *radius, index, geometry, 1.0) - }) - .collect(); - if let Some(buffers) = state.material_buffers.get_mut(surface) { - buffers.contexts.retain(|(_, index), _| *index < targets.len()); - } - let material = (!materials.is_empty() && regions.is_none()).then_some(()); - let clip = material.as_ref().and_then(|_| { - let mode = output.current_mode()?; - let program = rounded_clip_program(state, renderer)?; - Some(( - program.texture, - framebuffer_clip_rect( - geometry.to_physical_precise_round(scale), - mode.size, - output.current_transform().invert(), - ), - scaled_effect_value(radius, geometry, scale), - )) - }); - // Smithay element lists are front to back: content, material, shadow. - let content = render_elements_from_surface_tree::>( - renderer, - surface, - content_origin.to_physical_precise_round(scale), - scale, - crate::effects::surface_opacity(surface), - RenderElementKind::Unspecified, - ); - elements.extend(content.into_iter().filter_map(|element| { - let origin = Point::::default(); - let element = if let Some((program, clip_rect, radius)) = &clip { - WindowContentRenderElement::Rounded(RoundedSurfaceRenderElement { - inner: element, - program: program.clone(), - clip_rect: *clip_rect, - radius: *radius, - clip_changed: false, - }) - } else { - WindowContentRenderElement::Popup(element) - }; - let element = RescaleRenderElement::from_element(element, origin, 1.0); - let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); - CropRenderElement::from_element(element, scale, output_crop).map(Into::into) - })); - for (background, shadow) in materials { - elements.push(background); - elements.push(shadow); - } -} - -fn material_element( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, - surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, - geometry: Rectangle, - radius: f32, - index: usize, - capture_geometry: Rectangle, - alpha: f32, -) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { - let (role, generation) = crate::effects::surface_role(surface)?; - let material = crate::effects::resolve_material( - role, - state.theme_settings.material_style, - state.theme_settings.shell_opacity as f32, - ); - let presentation_alpha = crate::effects::surface_opacity(surface) * alpha; - let mode = output.current_mode()?; - let scale = output.current_scale().fractional_scale(); - let transform = output.current_transform().invert(); - let background = if role == crate::effects::SemanticRole::Panel { - state.theme_settings.bar_background_color - } else { - state.theme_settings.surface_base_color - }; - let [red, green, blue, _] = background.0; - let radius = radius.min(geometry.size.w.min(geometry.size.h) as f32 * 0.5); - let [offset_y, shadow_blur, shadow_opacity] = material.shadow; - let edge_bar = role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0; - let shadow_opacity = if edge_bar { - 0.0 - } else { - shadow_opacity * (state.theme_settings.shadow_opacity / 0.2) * f64::from(presentation_alpha) - }; - let shadow_geometry = Rectangle::new( - (geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), - geometry.size, - ); - let background_opacity = material.opacity; - let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur); - let output_size = output - .current_transform() - .transform_size(mode.size) - .to_f64() - .to_logical(scale) - .to_i32_ceil(); - let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size); - let sample_physical = sample_geometry.to_physical_precise_round(scale); - let mut parameters = MaterialParameters { - presentation_alpha, - background_opacity, - blur: (blur * scale) as f32, - scene_generation: if blur > 0.0 { state.backdrop_generation } else { 0 }, - sample_geometry, - sample_physical, - sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform), - radius: radius * scale as f32, - shadow_rect: framebuffer_clip_rect(shadow_geometry.to_physical_precise_round(scale), mode.size, transform), - shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32], - shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur), - geometry, - tint: [red, green, blue, material.opacity * presentation_alpha], - generation, - opaque: material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0, - visible_framebuffer: framebuffer_clip_rect(geometry.to_physical_precise_round(scale), mode.size, transform), - }; - let context = renderer.context_id().erased(); - let program = material_program(state, renderer)?; - let blur_program = (blur > 0.0).then(|| blur_program(state, renderer)).flatten(); - let capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer); - let capture_size = Size::from((capture_rect[2], capture_rect[3])); - let buffers = state.material_buffers.entry(surface.clone()).or_default(); - let capture = if let Some(blur_program) = blur_program { - if buffers - .captures - .get(&context) - .is_none_or(|c| c.geometry != sample_geometry || c.texture.size() != capture_size) - { - let texture = Offscreen::::create_buffer(renderer, Fourcc::Abgr8888, capture_size) - .map_err(|error| { - tracing::warn!(%error, ?sample_physical, "backdrop capture allocation failed"); - error - }) - .ok(); - if let Some(texture) = texture { - buffers.captures.insert( - context.clone(), - BlurCapture { - texture, - dirty: Arc::new(AtomicBool::new(true)), - geometry: sample_geometry, - }, - ); - } else { - buffers.captures.remove(&context); - } - } - buffers - .captures - .get(&context) - .cloned() - .map(|capture| (capture, blur_program)) - } else { - buffers.captures.remove(&context); - None - }; - if capture.is_some() { - parameters.tint[3] = state.theme_settings.material_tint_strength as f32; - } - let make_element = || { - if let Some((capture, program)) = &capture { - MaterialElement::Blur(BlurRenderElement::new( - capture.texture.clone(), - program.0.clone(), - ¶meters, - capture.dirty.clone(), - )) - } else { - MaterialElement::Fill(PixelShaderElement::new( - program.0.clone(), - geometry, - parameters.opaque.then(|| vec![Rectangle::from_size(geometry.size)]), - 1.0, - material_uniforms(¶meters), - RenderElementKind::Unspecified, - )) - } - }; - let cached = buffers - .contexts - .entry((context, index)) - .or_insert_with(|| CachedMaterial { - element: make_element(), - parameters: parameters.clone(), - shadow: PixelShaderElement::new( - program.0.clone(), - parameters.shadow_bounds, - None, - 1.0, - decoration_uniforms(¶meters, 2.0), - RenderElementKind::Unspecified, - ), - }); - let replace = match &cached.element { - MaterialElement::Fill(_) => capture.is_some(), - MaterialElement::Blur(element) => capture - .as_ref() - .is_none_or(|(capture, _)| capture.texture.tex_id() != element.texture.tex_id()), - }; - if replace { - cached.element = make_element(); - } - if cached.parameters != parameters { - cached.shadow.resize(parameters.shadow_bounds, None); - cached.shadow.update_uniforms(decoration_uniforms(¶meters, 2.0)); - match &mut cached.element { - MaterialElement::Fill(element) => { - element.resize( - geometry, - parameters.opaque.then(|| vec![Rectangle::from_size(geometry.size)]), - ); - element.update_uniforms(material_uniforms(¶meters)); - } - MaterialElement::Blur(element) => element.update(¶meters), - } - cached.parameters = parameters; - } - let background = match &cached.element { - MaterialElement::Fill(element) => element.clone().into(), - MaterialElement::Blur(element) => element.clone().into(), - }; - Some((background, cached.shadow.clone().into())) -} - -fn material_blur_radius(style: crate::config::MaterialStyle, opacity: f32, radius: f64) -> f64 { - if style == crate::config::MaterialStyle::Translucent && opacity > 0.0 { - radius - } else { - 0.0 - } -} - -fn blur_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { - let context = renderer.context_id().erased(); - if let Some(program) = state.blur_programs.get(&context) { - return Some(program.clone()); - } - match renderer.compile_custom_texture_shader(BLUR_SHADER, &blur_uniform_names()) { - Ok(program) => { - let program = BlurProgram(program); - state.blur_programs.insert(context, program.clone()); - Some(program) - } - Err(error) => { - tracing::warn!(%error,"backdrop blur unavailable; using plain translucent fill"); - None - } - } -} - -fn blur_uniform_names() -> [UniformName<'static>; 8] { - [ - UniformName::new("visible_rect", UniformType::_4f), - UniformName::new("material_radius", UniformType::_1f), - UniformName::new("texture_size", UniformType::_2f), - UniformName::new("capture_origin", UniformType::_2f), - UniformName::new("blur_radius", UniformType::_1f), - UniformName::new("presentation_alpha", UniformType::_1f), - UniformName::new("background_opacity", UniformType::_1f), - UniformName::new("tint", UniformType::_4f), - ] -} - -fn blur_uniforms(p: &MaterialParameters) -> Vec> { - vec![ - Uniform::new("visible_rect", p.visible_framebuffer).into_owned(), - Uniform::new("material_radius", p.radius).into_owned(), - Uniform::new("texture_size", [p.sample_framebuffer[2], p.sample_framebuffer[3]]).into_owned(), - Uniform::new("capture_origin", [p.sample_framebuffer[0], p.sample_framebuffer[1]]).into_owned(), - Uniform::new("blur_radius", p.blur).into_owned(), - Uniform::new("presentation_alpha", p.presentation_alpha).into_owned(), - Uniform::new("background_opacity", p.background_opacity).into_owned(), - Uniform::new("tint", p.tint).into_owned(), - ] -} - -fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] { - rect.map(|v| v.round() as i32) -} - -fn expanded_blur_region( - visible: Rectangle, - radius: i32, - output_size: Size, -) -> Rectangle { - let left = (visible.loc.x - radius).max(0); - let top = (visible.loc.y - radius).max(0); - let right = (visible.loc.x + visible.size.w + radius).min(output_size.w); - let bottom = (visible.loc.y + visible.size.h + radius).min(output_size.h); - Rectangle::new( - (left, top).into(), - ((right - left).max(1), (bottom - top).max(1)).into(), - ) -} - -fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { - let context = renderer.context_id().erased(); - if let Some(program) = state.material_programs.get(&context) { - return Some(program.clone()); - } - - let uniforms = [ - UniformName::new("visible_rect", UniformType::_4f), - UniformName::new("material_radius", UniformType::_1f), - UniformName::new("tint", UniformType::_4f), - UniformName::new("paint_mode", UniformType::_1f), - UniformName::new("shadow_rect", UniformType::_4f), - UniformName::new("shadow_values", UniformType::_2f), - ]; - match renderer.compile_custom_pixel_shader(MATERIAL_SHADER, &uniforms) { - Ok(program) => { - let program = MaterialProgram(program); - state.material_programs.insert(context, program.clone()); - Some(program) - } - Err(error) => { - tracing::error!(%error, "failed to compile semantic material shader"); - None - } - } -} - -fn material_uniforms(parameters: &MaterialParameters) -> Vec> { - decoration_uniforms(parameters, 0.0) -} - -fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f32) -> Vec> { - vec![ - Uniform::new("paint_mode", paint_mode).into_owned(), - Uniform::new("shadow_rect", parameters.shadow_rect).into_owned(), - Uniform::new("shadow_values", parameters.shadow_values).into_owned(), - Uniform::new("visible_rect", parameters.visible_framebuffer).into_owned(), - Uniform::new("material_radius", parameters.radius).into_owned(), - Uniform::new("tint", parameters.tint).into_owned(), - ] -} - -fn cursor_elements( - state: &Ferese, - renderer: &mut GlesRenderer, - output_geometry: Rectangle, - scale: f64, -) -> Vec { - if state.input_capture.active() { - return Vec::new(); - } - let Some(pointer) = state.seat.get_pointer() else { - return Vec::new(); - }; - let output_crop = Rectangle::::from_size(output_geometry.size).to_physical_precise_round(scale); - let origin = Point::::default(); - let pointer_location = pointer.current_location() - output_geometry.loc.to_f64(); - match &state.cursor_status { - CursorImageStatus::Surface(surface) => { - let hotspot = with_states(surface, |states| { - states - .data_map - .get::() - .map(|attributes| attributes.lock().unwrap().hotspot) - .unwrap_or_default() - }); - let physical_location = (pointer_location - hotspot.to_f64()).to_physical_precise_round(scale); - - render_elements_from_surface_tree::>( - renderer, - surface, - physical_location, - scale, - 1.0, - RenderElementKind::Cursor, - ) - .into_iter() - .filter_map(|element| { - let element = RescaleRenderElement::from_element(element, origin, 1.0); - let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); - - CropRenderElement::from_element(element, scale, output_crop).map(Into::into) - }) - .collect() - } - CursorImageStatus::Named(icon) => { - let Some(cursor) = state.named_cursors.get(icon) else { - return Vec::new(); - }; - let hotspot = Point::::from((cursor.hotspot.x, cursor.hotspot.y)); - let physical_location = pointer_location.to_physical_precise_round(scale) - hotspot; - let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( - renderer, - physical_location.to_f64(), - &cursor.buffer, - None, - None, - None, - RenderElementKind::Cursor, - ) else { - return Vec::new(); - }; - - let element = RescaleRenderElement::from_element(element, origin, 1.0); - let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); - - CropRenderElement::from_element(element, scale, output_crop) - .map(|element| vec![element.into()]) - .unwrap_or_default() - } - CursorImageStatus::Hidden => Vec::new(), - } -} - fn normalized_scale(scale: f64) -> f64 { if scale.is_finite() && scale > 0.0 { scale } else { 1.0 } } #[cfg(test)] mod tests { - use smithay::backend::renderer::damage::OutputDamageTracker; - use smithay::backend::renderer::element::{Element, Id}; - use smithay::backend::renderer::utils::CommitCounter; - use smithay::utils::{Buffer, Logical, Physical, Rectangle, Scale, Transform}; - - use super::{ - border_gradient_line, color_with_alpha, framebuffer_clip_rect, normalized_scale, resize_content_behavior, - rounded_visual_rect, scaled_visual_rect, shadow_bounds, - }; - - #[test] - #[ignore = "requires an EGL rendering device"] - fn translucent_blur_does_not_leak_the_sharp_backdrop() { - use smithay::backend::allocator::Fourcc; - use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay}; - use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture, Uniform}; - use smithay::backend::renderer::{Bind, Color32F, ExportMem, Frame, ImportMem, Offscreen, Renderer}; - - let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device"); - let display = unsafe { EGLDisplay::new(device).unwrap() }; - let context = EGLContext::new(&display).unwrap(); - let mut renderer = unsafe { GlesRenderer::new(context).unwrap() }; - let program = renderer - .compile_custom_texture_shader(super::BLUR_SHADER, &super::blur_uniform_names()) - .unwrap(); - let size = (32, 32).into(); - let mut pixels = vec![128u8; 32 * 32 * 4]; - for pixel in pixels.chunks_exact_mut(4) { - pixel[3] = 255; - } - let blurred = renderer.import_memory(&pixels, Fourcc::Abgr8888, size, false).unwrap(); - let mut target_texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, size).unwrap(); - let damage = Rectangle::::from_size((32, 32).into()); - - for (opacity, strength, tint) in [ - (0.2f32, 0.5f32, 0.1f32), - (0.71, 0.5, 0.1), - (1.0, 0.5, 0.1), - (0.94, 1.0, 245.0 / 255.0), - ] { - let uniforms = vec![ - Uniform::new("visible_rect", [0.0f32, 0.0, 32.0, 32.0]), - Uniform::new("material_radius", 0.0f32), - Uniform::new("texture_size", [32.0f32, 32.0]), - Uniform::new("capture_origin", [0.0f32, 0.0]), - Uniform::new("blur_radius", 12.0f32), - Uniform::new("presentation_alpha", 1.0f32), - Uniform::new("background_opacity", opacity), - Uniform::new("tint", [tint, tint, tint, strength]), - ]; - let mut target = renderer.bind(&mut target_texture).unwrap(); - { - let mut frame = renderer - .render(&mut target, (32, 32).into(), Transform::Normal) - .unwrap(); - frame.clear(Color32F::new(0.0, 0.0, 0.0, 1.0), &[damage]).unwrap(); - frame - .draw_solid( - Rectangle::new((0, 0).into(), (16, 32).into()), - &[damage], - Color32F::new(1.0, 1.0, 1.0, 1.0), - ) - .unwrap(); - frame - .render_texture_from_to( - &blurred, - Rectangle::from_size(size.to_f64()), - damage, - &[damage], - &[], - Transform::Normal, - 1.0, - Some(&program), - &uniforms, - ) - .unwrap(); - let _ = frame.finish().unwrap(); - } - let mapping = renderer - .copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888) - .unwrap(); - let rendered = renderer.map_texture(&mapping).unwrap(); - let left = rendered[(16 * 32 + 8) * 4]; - let right = rendered[(16 * 32 + 24) * 4]; - assert!( - left.abs_diff(right) <= 2, - "opacity {opacity} leaked the original backdrop: {left} versus {right}" - ); - let expected = (128.0 * (1.0 - strength * opacity) + tint * 255.0 * strength * opacity).round() as u8; - assert!( - left.abs_diff(expected) <= 2, - "opacity {opacity} failed to control tint: {left} versus {expected}" - ); - } - } - - #[test] - fn gradient_direction_survives_every_output_transform() { - let mode = (2560, 1600).into(); - for transform in [ - Transform::Normal, - Transform::_90, - Transform::_180, - Transform::_270, - Transform::Flipped, - Transform::Flipped90, - Transform::Flipped180, - Transform::Flipped270, - ] { - let area = transform.transform_size(mode).to_f64(); - let rect: Rectangle = Rectangle::new((110, 60).into(), (800, 600).into()); - for (angle, first, last) in [ - (0.0, (110.0, 360.0), (910.0, 360.0)), - (90.0, (510.0, 60.0), (510.0, 660.0)), - (45.0, (110.0, 60.0), (910.0, 660.0)), - (135.0, (910.0, 60.0), (110.0, 660.0)), - ] { - let line = border_gradient_line(rect, mode, transform, angle); - let progress = |point: smithay::utils::Point| { - let point = transform.transform_point_in(point, &area); - (point.x - f64::from(line[0])) * f64::from(line[2]) - + (point.y - f64::from(line[1])) * f64::from(line[3]) - }; - assert!(progress(first.into()).abs() < 0.00001, "{transform:?}, {angle}"); - assert!((progress(last.into()) - 1.0).abs() < 0.00001, "{transform:?}, {angle}"); - } - } - } - - #[test] - fn gradient_coordinates_follow_fractional_resize_and_translation() { - for scale in [1.0, 1.25, 1.5, 1.8, 2.0] { - for width in [500.0, 733.25, 1000.0] { - let rect = crate::presentation::physical_rect( - ferese_layout::Rect::new(40.25, 60.75, width, 600.5), - (0, 0).into(), - scale, - ); - let line = border_gradient_line(rect, (3840, 2160).into(), Transform::Normal, 0.0); - assert!((f64::from(line[0]) - f64::from(rect.loc.x)).abs() < 0.001); - assert!((f64::from(line[2]) * f64::from(rect.size.w) - 1.0).abs() < 0.00001); - assert!(line.iter().all(|component| component.is_finite())); - } - } - } - - #[derive(Debug)] - struct DamageElement { - id: Id, - geometry: Rectangle, - commit: CommitCounter, - } - - impl DamageElement { - fn new(geometry: Rectangle) -> Self { - Self { - id: Id::new(), - geometry, - commit: CommitCounter::default(), - } - } - } - - impl Element for DamageElement { - fn id(&self) -> &Id { - &self.id - } - - fn current_commit(&self) -> CommitCounter { - self.commit - } - - fn src(&self) -> Rectangle { - Rectangle::from_size(self.geometry.size.to_f64().to_buffer(1.0, Transform::Normal)) - } - - fn geometry(&self, scale: Scale) -> Rectangle { - self.geometry.to_physical_precise_round(scale) - } - - fn damage_since( - &self, - scale: Scale, - commit: Option, - ) -> smithay::backend::renderer::utils::DamageSet { - if commit == Some(self.current_commit()) { - smithay::backend::renderer::utils::DamageSet::default() - } else { - smithay::backend::renderer::utils::DamageSet::from_slice(&[Rectangle::from_size( - self.geometry(scale).size, - )]) - } - } - } - - #[test] - fn resize_keeps_source_pixels_native_when_growing_and_shrinking() { - use smithay::backend::renderer::element::utils::{ConstrainAlign, constrain_render_elements}; - for scale in [1.0, 1.5, 1.8, 2.0] { - for width in [300, 600, 1200] { - let source = Rectangle::::from_size((600, 800).into()); - let destination = Rectangle::::from_size((width, 800).into()); - let element = constrain_render_elements( - [DamageElement::new(source)], - (0, 0), - destination.to_physical_precise_round(scale), - source.to_physical_precise_round(scale), - resize_content_behavior(false), - ConstrainAlign::TOP | ConstrainAlign::LEFT, - scale, - ) - .next() - .unwrap(); - // Comparing destination pixels to sampled source pixels catches - // stretching, unlike tests of configure timing alone. - let physical = element.geometry(scale.into()); - let sampled = element.src(); - assert!((f64::from(physical.size.w) / sampled.size.w - scale).abs() < 0.01); - assert!((f64::from(physical.size.h) / sampled.size.h - scale).abs() < 0.01); - } - } - } - - #[test] - fn overview_retains_intentional_content_scaling() { - assert!(matches!( - resize_content_behavior(true), - smithay::backend::renderer::element::utils::ConstrainScaleBehavior::Stretch - )); - } - + use super::normalized_scale; #[test] fn accepts_positive_finite_scale() { assert_eq!(normalized_scale(1.25), 1.25); @@ -2993,161 +265,4 @@ mod tests { assert_eq!(normalized_scale(f64::NAN), 1.0); assert_eq!(normalized_scale(f64::INFINITY), 1.0); } - - #[test] - fn clip_rect_matches_smithay_gles_projection() { - let geometry = Rectangle::::new((10, 5).into(), (30, 40).into()); - - assert_eq!( - framebuffer_clip_rect(geometry, (100, 80).into(), Transform::Normal), - [10.0, 5.0, 30.0, 40.0] - ); - } - - #[test] - fn clip_rect_follows_output_transform() { - let geometry = Rectangle::::new((10, 5).into(), (30, 40).into()); - - assert_eq!( - framebuffer_clip_rect(geometry, (100, 80).into(), Transform::Flipped180), - [10.0, 35.0, 30.0, 40.0] - ); - assert_eq!( - framebuffer_clip_rect(geometry, (100, 80).into(), Transform::_180), - [60.0, 35.0, 30.0, 40.0] - ); - } - - #[test] - fn panel_clearance_clip_matches_gles_projection_at_fractional_scales() { - for scale in [1.0, 1.25, 1.75, 2.0] { - for transform in [ - Transform::Normal, - Transform::_90, - Transform::_180, - Transform::_270, - Transform::Flipped, - Transform::Flipped90, - Transform::Flipped180, - Transform::Flipped270, - ] { - let output_size = - smithay::utils::Size::::from(((1280.0 * scale) as i32, (800.0 * scale) as i32)); - let element_size = transform.transform_size(output_size); - let logical_height = f64::from(element_size.h) / scale; - // Bar and exclusion end at 40; the 10px outer gap ends at 50. - let geometry = - Rectangle::::new((10.0, 50.0).into(), (400.0, logical_height - 60.0).into()) - .to_physical_precise_round(scale); - let clip = framebuffer_clip_rect(geometry, output_size, transform); - // Independently reproduce GlesFrame's orthographic projection, - // transform matrix, GL flip, and viewport mapping for each corner. - let matrix = transform.matrix(); - let mut xs = Vec::new(); - let mut ys = Vec::new(); - for x in [geometry.loc.x, geometry.loc.x + geometry.size.w] { - for y in [geometry.loc.y, geometry.loc.y + geometry.size.h] { - let nx = 2.0 * x as f32 / element_size.w as f32 - 1.0; - let ny = 1.0 - 2.0 * y as f32 / element_size.h as f32; - let tx = matrix[0][0] * nx + matrix[1][0] * ny; - let ty = matrix[0][1] * nx + matrix[1][1] * ny; - xs.push((tx + 1.0) * output_size.w as f32 / 2.0); - ys.push((1.0 - ty) * output_size.h as f32 / 2.0); - } - } - let min_x = xs.iter().copied().fold(f32::INFINITY, f32::min); - let max_x = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max); - let min_y = ys.iter().copied().fold(f32::INFINITY, f32::min); - let max_y = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max); - for (actual, expected) in clip.into_iter().zip([min_x, min_y, max_x - min_x, max_y - min_y]) { - assert!( - (actual - expected).abs() < 0.001, - "{transform:?}, scale={scale}: {actual} != {expected}" - ); - } - } - } - } - - #[test] - fn shadow_bounds_include_blur_and_offset() { - let geometry = Rectangle::::new((100, 80).into(), (400, 300).into()); - - assert_eq!( - shadow_bounds(geometry, 4.0, 18.0), - Rectangle::new((64, 48).into(), (472, 372).into()) - ); - assert_eq!( - shadow_bounds(geometry, -6.0, 10.0), - Rectangle::new((80, 54).into(), (440, 340).into()) - ); - } - - #[test] - fn damage_tracks_old_and_new_expanded_shadow_bounds() { - let old_window = Rectangle::::new((100, 80).into(), (400, 300).into()); - let new_window = Rectangle::::new((300, 180).into(), (400, 300).into()); - let mut shadow = DamageElement::new(shadow_bounds(old_window, 4.0, 18.0)); - let mut tracker = OutputDamageTracker::new((1_000, 800), Scale::from(1.0), Transform::Normal); - - tracker.damage_output(0, &[&shadow]).unwrap(); - shadow.geometry = shadow_bounds(new_window, 4.0, 18.0); - let damage = tracker.damage_output(1, &[&shadow]).unwrap().0.cloned().unwrap(); - - assert!(damage.iter().any(|rect| rect.contains((64, 48)))); - assert!(damage.iter().any(|rect| rect.contains((735, 519)))); - - let removal_damage = tracker - .damage_output::<&DamageElement>(1, &[]) - .unwrap() - .0 - .cloned() - .unwrap(); - assert!(removal_damage.iter().any(|rect| rect.contains((300, 180)))); - } - - #[test] - fn translucent_materials_blur_at_full_opacity() { - use crate::config::MaterialStyle; - assert_eq!(super::material_blur_radius(MaterialStyle::Translucent, 0.0, 18.0), 0.0); - assert_eq!(super::material_blur_radius(MaterialStyle::Translucent, 1.0, 18.0), 18.0); - assert_eq!( - super::material_blur_radius(MaterialStyle::Translucent, 0.79, 18.0), - 18.0 - ); - assert_eq!(super::material_blur_radius(MaterialStyle::Solid, 0.79, 18.0), 0.0); - } - - #[test] - fn close_transform_scales_about_the_window_center() { - let rect = ferese_layout::Rect::new(100.0, 50.0, 400.0, 300.0); - - assert_eq!( - scaled_visual_rect(rect, 0.98), - ferese_layout::Rect::new(104.0, 53.0, 392.0, 294.0) - ); - assert_eq!(scaled_visual_rect(rect, 1.0), rect); - } - - #[test] - fn close_opacity_only_changes_the_alpha_channel() { - assert_eq!(color_with_alpha([0.2, 0.4, 0.6, 0.8], 0.5), [0.2, 0.4, 0.6, 0.4]); - } - - #[test] - fn visual_rect_rounds_shared_edges_instead_of_size() { - let rect = ferese_layout::Rect::new(100.5, 50.25, 399.5, 299.75); - let rounded = rounded_visual_rect(rect, (0, 0).into()); - - assert_eq!(rounded, Rectangle::new((101, 50).into(), (399, 300).into())); - assert_eq!(rounded.loc.x + rounded.size.w, 500); - assert_eq!(rounded.loc.y + rounded.size.h, 350); - - for tenth in 0..=10 { - let left = 100.0 + f64::from(tenth) / 10.0; - let frame = rounded_visual_rect(ferese_layout::Rect::new(left, 50.0, 500.0 - left, 300.0), (0, 0).into()); - - assert_eq!(frame.loc.x + frame.size.w, 500); - } - } } diff --git a/shell/ferese-settings/src/form_controls.rs b/shell/ferese-settings/src/form_controls.rs new file mode 100644 index 0000000..e06f85d --- /dev/null +++ b/shell/ferese-settings/src/form_controls.rs @@ -0,0 +1,247 @@ +use cosmic::widget::column; + +use super::{ + Alignment, App, Element, Field, Kind, Length, Message, button, container, displays, fonts, row, set, slider, + text_input, visuals, widget, +}; + +impl App { + pub(super) fn label<'a>( + &self, + text: impl Into> + 'a, + size: f32, + ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { + ferese_theme::text(text, self.font).size(size) + } + + pub(super) fn note(&self, text: &str) -> Element<'static, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + self.label(text.to_owned(), 12.) + .class(cosmic::theme::Text::Color(palette.muted)) + .into() + } + + pub(super) fn field(&self, mut field: Field) -> Element<'static, Message> { + if field.path == "theme.material.opacity" { + let appearance = self.resolved.theme.appearance; + let name = match appearance { + ferese_config::theme::Appearance::Light => "light", + ferese_config::theme::Appearance::Dark => "dark", + }; + field.path = format!("theme.{name}.material.opacity"); + field.description = format!("Opacity for the current {name} appearance. Text and icons stay opaque."); + if let Kind::Range { default, .. } = &mut field.kind { + *default = self.resolved.theme.tokens.material.opacity; + } + } + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.)); + if !field.description.is_empty() { + labels = labels.push( + self.label(field.description.clone(), 11.) + .class(cosmic::theme::Text::Color(palette.muted)), + ); + } + let labels = labels.width(Length::Fill); + let path = field.path.clone(); + let control: Element<'static, Message> = match field.kind.clone() { + Kind::Font => { + let value = self + .inputs + .get(&path) + .cloned() + .unwrap_or_else(|| self.draft.string(&path, "")); + let families = fonts::families(); + let selected = if value.is_empty() { + Some(0) + } else { + families.iter().position(|family| family == &value) + }; + let selection_path = path.clone(); + let commit = field.clone(); + column([]) + .spacing(6) + .push( + cosmic::iced::widget::pick_list( + families, + selected.map(|index| families[index].clone()), + move |family: String| { + let value = if family == "Default font" { + String::new() + } else { + family + }; + Message::SelectFont(selection_path.clone(), value) + }, + ) + .font(self.font) + .text_size(12) + .width(225), + ) + .push( + text_input("Default font", value) + .font(self.font) + .style(visuals::input_style(palette)) + .on_input(move |value| Message::Draft(path.clone(), value)) + .on_submit(move |_| Message::Commit(field.clone())) + .on_unfocus(Message::Commit(commit)) + .width(225) + .size(12), + ) + .into() + } + Kind::Toggle(default) => { + let enabled = self.draft.boolean(&path, default); + ferese_theme::controls::switch(enabled, palette) + .name(format!("{}: {}", field.label, if enabled { "on" } else { "off" })) + .on_press(Message::Change(set(&path, !enabled))) + .into() + } + Kind::Range { + default, + min, + max, + step, + suffix, + integer, + } => { + let value = self + .ranges + .get(&path) + .copied() + .unwrap_or_else(|| self.draft.number(&path, default)); + let display = if integer || step >= 1. { + format!("{value:.0}{suffix}") + } else { + format!("{value:.2}{suffix}") + }; + let release = field.clone(); + row([]) + .align_y(Alignment::Center) + .spacing(14) + .push( + slider(min..=max, value, move |value| Message::Range(field.clone(), value)) + .step(step) + .class(ferese_theme::menus::slider(palette.accent, 1.)) + .on_release(Message::Release(release)) + .width(145), + ) + .push(self.label(display, 12.).width(65)) + .into() + } + Kind::Choice { default, choices } => { + let value = self.draft.string(&path, default); + let mut options = row([]).spacing(2); + for (key, label) in choices { + options = options.push( + button::custom(self.label(*label, 12.)) + .padding([6, 9]) + .class(visuals::button_style(palette, value == *key)) + .on_press(Message::Change(set(&path, *key))), + ); + } + container(options) + .padding(3) + .class(visuals::surface(palette.sidebar, 10.)) + .into() + } + Kind::Text { default, argv } => { + let value = self.inputs.get(&path).cloned().unwrap_or_else(|| { + if argv { + self.draft.argv(&path) + } else { + self.draft.string(&path, default) + } + }); + let commit = field.clone(); + let is_color = path.starts_with("theme.colors."); + let swatch = visuals::color(&value, palette.accent); + let input = text_input(default, value) + .font(self.font) + .style(visuals::input_style(palette)) + .on_input(move |value| Message::Draft(path.clone(), value)) + .on_submit(move |_| Message::Commit(field.clone())) + .on_unfocus(Message::Commit(commit)) + .width(if is_color { 155 } else { 225 }) + .size(12); + if is_color { + row([]) + .spacing(8) + .align_y(Alignment::Center) + .push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.))) + .push(input) + .into() + } else { + input.into() + } + } + }; + container( + row([]) + .spacing(16) + .align_y(Alignment::Center) + .push(labels) + .push(control), + ) + .padding([12, 12]) + .width(Length::Fill) + .class(visuals::surface(palette.card, 0.)) + .into() + } + + pub(super) fn refresh_controls(&self, prefix: &str) -> Element<'_, Message> { + let matcher = self.draft.string(&format!("{prefix}.match"), ""); + let configured = self.draft.string(&format!("{prefix}.mode"), ""); + let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false); + let Some(display) = self + .displays + .iter() + .find(|display| display.connector == matcher || display.identity == matcher) + else { + return self.note("Connect this display to choose a supported refresh rate."); + }; + let modes = displays::choices(display, &configured); + let mut controls = row([]).spacing(8).align_y(Alignment::Center); + for mode in &modes { + let selected = !automatic + && configured + .split('@') + .nth(1) + .and_then(|rate| rate.parse::().ok()) + .is_some_and(|rate| (rate * 1000. - f64::from(mode.refresh)).abs() < 1.); + controls = controls.push( + ferese_theme::controls::text_button( + mode.label(), + self.font, + visuals::Palette::from_resolved(&self.resolved.presented), + selected, + ) + .on_press(Message::RefreshRate(prefix.to_owned(), *mode, false)), + ); + } + let supports_auto = modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh)); + if supports_auto && let Some(mode) = modes.last() { + controls = controls.push( + ferese_theme::controls::text_button( + "Auto", + self.font, + visuals::Palette::from_resolved(&self.resolved.presented), + automatic, + ) + .on_press(Message::RefreshRate(prefix.to_owned(), *mode, true)), + ); + } + let current = display.current.map(|mode| mode.label()).unwrap_or_else(|| "Off".into()); + let profile = display.profile.as_deref().unwrap_or("automatic configuration"); + column![ + self.label("Refresh rate", 14.), + self.note(&format!("Current: {current} · active profile: {profile}")), + controls, + self.note(if supports_auto { + "Auto uses 60 Hz below 30% on battery. Normal refresh returns on AC or at 35%. Switching may briefly blank the display. Choosing a rate turns Auto off." + } else { + "This resolution has no supported 60 Hz mode for automatic switching." + }), + ].spacing(8).into() + } +} diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index fd61775..c40ae87 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -1,3 +1,9 @@ +mod form_controls; +mod pages; +mod theme_controls; + +use theme_controls::*; + mod displays; mod fonts; mod schema; @@ -601,528 +607,7 @@ impl cosmic::Application for App { } fn view(&self) -> Element<'_, Message> { - let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let mut sidebar = column([]) - .spacing(3) - .push( - widget::mouse_area( - row([]) - .spacing(10) - .align_y(Alignment::Center) - .push(visuals::brand_icon()) - .push(self.label("Ferese", 16.)) - .width(Length::Fill), - ) - .on_press(Message::DragWindow) - .interaction(cosmic::iced::mouse::Interaction::Grab), - ) - .push(widget::Space::new().height(14)) - .push( - text_input("Search settings", self.search.clone()) - .on_input(Message::Search) - .font(self.font) - .style(visuals::input_style(palette)) - .size(12), - ) - .push(widget::Space::new().height(8)); - for page in Page::ALL { - sidebar = sidebar.push( - button::custom( - row([]) - .spacing(10) - .align_y(Alignment::Center) - .push(visuals::icon( - page, - if self.page == page { - palette.accent - } else { - palette.muted - }, - )) - .push(self.label(page.title(), 13.)), - ) - .width(Length::Fill) - .padding([8, 8]) - .class(visuals::navigation_style(palette, self.page == page)) - .on_press(Message::Page(page)), - ); - } - let sidebar = container(sidebar.push(widget::Space::new().height(Length::Fill))) - .width(204) - .height(Length::Fill) - .padding([20, 10]) - .class(visuals::surface(palette.sidebar, 0.)); - let mut heading = row([]).align_y(Alignment::Center).spacing(20).push( - column([]) - .spacing(5) - .push(self.label( - if self.search.is_empty() { - self.page.title() - } else { - "Search" - }, - 24., - )) - .push( - self.label( - if self.search.is_empty() { - self.page.subtitle() - } else { - "Find a setting for your desktop." - }, - 12., - ) - .class(cosmic::theme::Text::Color(palette.muted)), - ) - .width(Length::Fill), - ); - if self.page == Page::Appearance && self.search.is_empty() { - let automatic = self.draft.string("theme.mode", "dark") == "auto"; - heading = heading.push( - row([]) - .spacing(8) - .align_y(Alignment::Center) - .push(self.label("Auto", 12.)) - .push( - ferese_theme::controls::switch(automatic, palette) - .name(if automatic { - "Auto appearance: on" - } else { - "Auto appearance: off" - }) - .on_press(Message::AutoAppearance(!automatic)), - ), - ); - let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule"; - let source = if scheduled { "Schedule" } else { "System" }; - let label = if automatic { - format!( - "{source} · {}", - if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light { - "Light" - } else { - "Dark" - }, - ) - } else { - source.to_owned() - }; - let explanation = if scheduled { - format!( - "Auto uses Light from {} and Dark from {} ({})", - self.draft.string("theme.schedule.light_at", "07:00"), - self.draft.string("theme.schedule.dark_at", "19:00"), - self.draft.string("theme.schedule.timezone", "system"), - ) - } else { - "Auto follows the GTK/GNOME appearance preference".to_owned() - }; - heading = heading.push(widget::tooltip( - ferese_theme::controls::text_button(label, self.font, palette, self.auto_details) - .name("Automatic appearance settings") - .on_press(Message::AutoDetails(!self.auto_details)), - self.label(explanation, 12.), - widget::tooltip::Position::Bottom, - )); - } - - let mut body = column([]).spacing(12); - - if !self.search.is_empty() { - if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) { - body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion.")); - } - for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) { - body = body.push( - button::custom( - column([]) - .spacing(5) - .push(self.label(page.title(), 16.)) - .push(self.label(page.subtitle(), 12.)), - ) - .width(Length::Fill) - .padding(20) - .class(visuals::button_style(palette, false)) - .on_press(Message::Page(page)), - ); - } - } else { - if matches!(self.page, Page::Bar | Page::Windows) { - body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented)); - } - if self.page == Page::Appearance { - let split = visuals::split(&self.draft); - if split { - body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light))); - body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark))); - } else { - body = body.push(self.theme_gallery(None)); - } - body = body.push( - widget::checkbox(split) - .label("Use different themes for light and dark") - .text_size(12) - .font(self.font) - .on_toggle(Message::SplitThemes), - ); - if let Some(note) = &self.resolved.fallback_note { - body = body.push(self.note(note)); - } - } - - if self.page == Page::Wallpaper { - if let Some(handle) = &self.thumbnail { - body = body.push( - widget::image(handle.clone()) - .width(Length::Fill) - .height(190) - .content_fit(cosmic::iced::ContentFit::Contain), - ); - } else { - body = body.push(self.note(if self.thumbnail_loading { - "Loading wallpaper preview…" - } else { - self.thumbnail_error - .as_deref() - .unwrap_or("Choose an image to preview your wallpaper.") - })); - } - body = body.push(button::standard("Choose image…").on_press(Message::PickWallpaper)); - } - let fields = schema::fields(self.page); - - if !fields.is_empty() { - let mut group = column([]).spacing(1); - if self.page == Page::Appearance && self.auto_details { - group = group.push(self.auto_controls()); - } - for field in fields { - if self.page == Page::Appearance - && (field.path == "theme.mode" - || field.path.starts_with("theme.schedule.") - || field.path.ends_with(".file")) - { - continue; - } - group = group.push(self.field(field)); - } - if self.page == Page::Appearance { - group = group.push( - button::custom( - row([]) - .spacing(8) - .align_y(Alignment::Center) - .push(self.label("Advanced customization", 13.)) - .push(visuals::action_icon( - if self.advanced_theme { - "M6 15l6-6 6 6" - } else { - "M6 9l6 6 6-6" - }, - palette.muted, - )), - ) - .name(if self.advanced_theme { - "Collapse advanced customization" - } else { - "Expand advanced customization" - }) - .padding(12) - .width(Length::Fill) - .class(visuals::button_style(palette, false)) - .on_press(Message::AdvancedTheme(!self.advanced_theme)), - ); - if self.advanced_theme { - group = group.push(self.theme_file_picker()); - } - } - body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); - } - - match self.page { - Page::LockScreen => { - body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens.")); - body = body.push(button::standard("Preview lock screen").on_press(Message::PreviewLock)); - body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.")); - } - Page::Desktop => { - let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty); - body = body.push(self.label("Sticky notes", 16.)); - body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through.")); - for index in 0..self.draft.records("desktop_widgets.notes") { - let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note"); - let mut group = column([]).spacing(8); - - for field in schema::note_fields(index) { - group = group.push(self.field(field)); - } - - if let Some(editor) = self.note_editors.get(&id) { - let id = id.clone(); - group = group.push( - widget::TextEditor::new(&editor.content) - .height(160) - .font(self.font) - .size(14.) - .on_action(move |action| Message::NoteAction(id.clone(), action)), - ); - } - group = group.push(button::destructive("Remove note").on_press_maybe( - can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)), - )); - body = body.push(container(group).padding(12).class(visuals::surface(palette.card, 14.))); - } - body = body.push( - button::standard("Add note").on_press_maybe( - (can_change_list && self.draft.records("desktop_widgets.notes") < 32) - .then_some(Message::AddNote), - ), - ); - } - Page::Startup => { - body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.")); - - for index in 0..self.draft.records("autostart") { - let prefix = format!("autostart.{index}"); - let group = column([]) - .spacing(1) - .push(self.field(Field::new( - format!("{prefix}.command"), - "Program", - "A program and its arguments, not a shell script.", - Kind::Text { - default: "", - argv: true, - }, - ))) - .push(self.field(Field::new( - format!("{prefix}.enabled"), - "Open at login", - "", - Kind::Toggle(true), - ))) - .push(self.field(Field::new( - format!("{prefix}.restart"), - "Restart if it exits", - "Managed by the Ferese session.", - Kind::Toggle(true), - ))) - .push( - container(button::destructive("Remove item").on_press_maybe( - (!self.saving).then_some(Message::Remove("autostart".into(), index)), - )) - .padding(12), - ); - body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); - } - body = body.push( - row([]) - .spacing(10) - .push( - text_input("Program and arguments", self.new_command.clone()) - .on_input(Message::NewCommand), - ) - .push(button::standard("Add login item").on_press_maybe( - (!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand), - )), - ); - } - Page::Shortcuts => { - body = body.push(self.note("Keyboard and swipe bindings share the same actions. Use Swipe3Up, Swipe3Down, Swipe3Left, or Swipe3Right as a shortcut (also supports 4 or 5 fingers). Actions include toggle-overview, spawn, focus, move, workspace-next, workspace-previous, workspace-back-and-forth, and none. Leave Argument empty for actions such as toggle-overview or none.")); - let mut gestures = row([]).spacing(6); - for (keys, label) in [ - ("Swipe3Up", "Customize swipe up"), - ("Swipe3Down", "Customize swipe down"), - ("Swipe3Left", "Customize swipe left"), - ("Swipe3Right", "Customize swipe right"), - ] { - let exists = (0..self.draft.records("bindings")).any(|index| { - self.draft - .string(&format!("bindings.{index}.keys"), "") - .eq_ignore_ascii_case(keys) - }); - - if !exists { - gestures = gestures.push( - button::standard(label) - .on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))), - ); - } - } - - body = body.push(gestures); - - for index in 0..self.draft.records("bindings") { - let prefix = format!("bindings.{index}"); - let group = column([]) - .spacing(1) - .push(self.field(schema::text( - format!("{prefix}.keys"), - "Shortcut", - "For example: Super+Return or Swipe3Up", - "", - ))) - .push(self.field(schema::text( - format!("{prefix}.action"), - "Action", - "Action such as toggle-overview, spawn, focus, move, or none.", - "", - ))) - .push(self.field(schema::text( - format!("{prefix}.argument"), - "Argument", - "Command name, direction, or action argument.", - "", - ))); - body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); - } - if self.draft.records("bindings") == 0 { - body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")); - } - } - Page::Displays => { - body = body.push(self.note("Display profiles update connected outputs live. Keep your config open for adding profiles or changing display positions.")); - for profile in 0..self.draft.records("output_profiles") { - let prefix = format!("output_profiles.{profile}"); - body = - body.push(self.label(self.draft.string(&format!("{prefix}.name"), "Display profile"), 17.)); - - for output in 0..self.draft.records(&format!("{prefix}.outputs")) { - let prefix = format!("{prefix}.outputs.{output}"); - let group = column([]) - .spacing(1) - .push(self.refresh_controls(&prefix)) - .push(self.field(schema::text( - format!("{prefix}.match"), - "Display", - "Output name or matching pattern.", - "", - ))) - .push(self.field(schema::text( - format!("{prefix}.mode"), - "Resolution", - "For example: 2560x1440@60. Leave unchanged to retain automatic selection.", - "", - ))) - .push(self.field(schema::range( - format!("{prefix}.scale"), - "Scale", - "Logical size of text and controls.", - 1., - 0.75, - 3., - 0.25, - "×", - false, - ))); - body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); - } - } - if self.draft.records("output_profiles") == 0 { - body = - body.push(self.note( - "No saved profiles. Ferese currently configures connected displays automatically.", - )); - } - } - _ => {} - } - } - - let mut content = column([]).spacing(16).push( - widget::mouse_area(heading.width(Length::Fill)) - .on_press(Message::DragWindow) - .interaction(cosmic::iced::mouse::Interaction::Grab), - ); - - if let Some(error) = &self.error { - content = content.push( - container(self.label(error.clone(), 12.)) - .padding(12) - .width(Length::Fill) - .class(visuals::surface(palette.error, 10.)), - ); - } - - content = content.push( - scrollable( - container(body) - .padding(cosmic::iced::Padding { - top: 0., - right: 12., - bottom: 0., - left: 2., - }) - .width(Length::Fill), - ) - .direction(cosmic::iced::widget::scrollable::Direction::Vertical( - cosmic::iced::widget::scrollable::Scrollbar::new() - .width(10) - .scroller_width(3), - )) - .height(Length::Fill), - ); - let footer = row([]) - .spacing(4) - .align_y(Alignment::Center) - .push(widget::tooltip( - visuals::action_icon( - if self.saving { - "M12 3v9l5 3 M21 12a9 9 0 1 1-9-9" - } else { - "M9 12l2 2 4-4 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0" - }, - palette.muted, - ), - self.label(if self.saving { "Saving…" } else { &self.status }, 11.), - widget::tooltip::Position::Top, - )) - .push(widget::Space::new().width(Length::Fill)) - .push(widget::tooltip( - button::custom(visuals::action_icon( - "M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7", - if self.undo.is_some() && !self.saving { - palette.text - } else { - palette.muted - }, - )) - .padding(4) - .on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)), - self.label("Undo last change", 11.), - widget::tooltip::Position::Top, - )) - .push(widget::tooltip( - button::custom(visuals::action_icon( - "M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6", - palette.text, - )) - .padding(4) - .on_press_maybe((!self.saving).then_some(Message::Reload)), - self.label("Reload configuration", 11.), - widget::tooltip::Position::Top, - )); - row([]) - .push(sidebar) - .push( - container( - container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6)) - .padding(cosmic::iced::Padding { - top: 20., - right: 20., - bottom: 8., - left: 20., - }) - .max_width(840) - .width(Length::Fill) - .height(Length::Fill), - ) - .width(Length::Fill) - .center_x(Length::Fill) - .height(Length::Fill) - .class(visuals::surface(palette.background, 0.)), - ) - .into() + self.page_view() } } @@ -1213,242 +698,6 @@ impl App { }) } - fn label<'a>( - &self, - text: impl Into> + 'a, - size: f32, - ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { - ferese_theme::text(text, self.font).size(size) - } - - fn note(&self, text: &str) -> Element<'static, Message> { - let palette = visuals::Palette::from_resolved(&self.resolved.presented); - self.label(text.to_owned(), 12.) - .class(cosmic::theme::Text::Color(palette.muted)) - .into() - } - - fn theme_file_picker(&self) -> Element<'static, Message> { - let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let path = ["theme.file", "theme.light.file", "theme.dark.file"][self.theme_file_target]; - let file = self.draft.string(path, ""); - let filename = if file.is_empty() { - "No override file".to_owned() - } else { - PathBuf::from(&file) - .file_name() - .unwrap_or_default() - .to_string_lossy() - .into_owned() - }; - let labels = column([]) - .spacing(3) - .width(Length::Fill) - .push(self.label("Theme overrides", 13.)) - .push(widget::tooltip( - self.label(filename, 11.) - .class(cosmic::theme::Text::Color(palette.muted)), - self.label( - if file.is_empty() { - "Choose a partial KDL theme file".to_owned() - } else { - file.clone() - }, - 11., - ), - widget::tooltip::Position::Bottom, - )); - let mut scopes = row([]).spacing(2); - for (index, label) in ["Shared", "Light", "Dark"].into_iter().enumerate() { - scopes = scopes.push( - button::custom(self.label(label, 12.)) - .name(format!("Theme file: {label}")) - .padding([6, 8]) - .class(visuals::button_style(palette, index == self.theme_file_target)) - .on_press(Message::ThemeFileTarget(index)), - ); - } - let mut controls = row([]) - .spacing(8) - .align_y(Alignment::Center) - .push( - container(scopes) - .padding(3) - .class(visuals::surface(palette.sidebar, 10.)), - ) - .push( - ferese_theme::controls::text_button( - if file.is_empty() { "Choose…" } else { "Replace…" }, - self.font, - palette, - false, - ) - .on_press(Message::PickThemeFile(path)), - ); - if !file.is_empty() { - controls = controls.push(widget::tooltip( - button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted)) - .padding(5) - .on_press(Message::Change(Edit::Unset(path.into()))), - self.label("Remove override", 11.), - widget::tooltip::Position::Bottom, - )); - } - container( - row([]) - .spacing(16) - .align_y(Alignment::Center) - .push(labels) - .push(controls), - ) - .padding([12, 12]) - .width(Length::Fill) - .class(visuals::surface(palette.card, 0.)) - .into() - } - - fn auto_controls(&self) -> Element<'static, Message> { - let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule"; - let mut sources = row([]).spacing(4); - for (source, label) in [("system", "System"), ("schedule", "Schedule")] { - sources = sources.push( - ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule")) - .name(format!("Auto source: {label}")) - .on_press(Message::Change(set("theme.schedule.source", source))), - ); - } - let description = if scheduled { - "Switch between light and dark at set times." - } else { - "Follow the GTK/GNOME appearance preference." - }; - let mut options = column([]).spacing(8).push( - row([]) - .spacing(12) - .align_y(Alignment::Center) - .push( - column([]) - .spacing(3) - .width(Length::Fill) - .push(self.label("Automatic appearance", 13.)) - .push(self.note(description)), - ) - .push(sources), - ); - if scheduled { - let mut times = row([]).spacing(16).align_y(Alignment::Center); - for field in schema::fields(Page::Appearance) - .into_iter() - .filter(|field| field.path.starts_with("theme.schedule.")) - { - let path = field.path.clone(); - let Kind::Text { default, .. } = &field.kind else { - continue; - }; - let value = self - .inputs - .get(&path) - .cloned() - .unwrap_or_else(|| self.draft.string(&path, default)); - let submit = field.clone(); - let input = text_input(*default, value) - .font(self.font) - .size(12) - .style(visuals::input_style(palette)) - .on_input(move |value| Message::Draft(path.clone(), value)) - .on_submit(move |_| Message::Commit(submit.clone())) - .on_unfocus(Message::Commit(field.clone())) - .width(if field.path.ends_with("timezone") { 150 } else { 75 }); - times = times.push( - row([]) - .spacing(8) - .align_y(Alignment::Center) - .push(self.label(field.label, 12.)) - .push(input), - ); - } - options = options.push(times); - } - container(options) - .padding(12) - .width(Length::Fill) - .class(visuals::surface(palette.card, 0.)) - .into() - } - - fn theme_gallery(&self, appearance: Option) -> Element<'static, Message> { - use ferese_config::theme::Appearance::{Dark, Light}; - let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let selected_id = visuals::family_selection(&self.draft, appearance); - let choices = self.resolved.families.clone(); - let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect(); - let mut heading = row([]).align_y(Alignment::Center).spacing(12).push( - self.label( - match appearance { - Some(Light) => "Light theme", - Some(Dark) => "Dark theme", - None => "Theme", - }, - 15., - ) - .width(Length::Fill), - ); - if appearance != Some(Dark) { - if self.undo.is_some() { - heading = heading.push( - ferese_theme::controls::text_button("Undo", self.font, palette, false) - .on_press_maybe((!self.saving).then_some(Message::Undo)), - ); - } - heading = heading.push( - ferese_theme::controls::text_button("Import…", self.font, palette, false) - .on_press(Message::ImportTheme), - ); - } - let mut gallery = column([]).spacing(10).push(heading); - for (chunk, families) in choices.chunks(3).enumerate() { - let mut tiles = row([]).spacing(12); - for (offset, family) in families.iter().enumerate() { - let selected = family.id == selected_id; - let index = chunk * 3 + offset; - let id = family.id.clone(); - let active = selected - .then_some(self.resolved.theme.appearance) - .filter(|a| appearance.is_none_or(|variant| variant == *a)); - let ids = ids.clone(); - let tile_id = gallery_id(&id, appearance); - tiles = tiles.push(gallery::tile( - family, - appearance, - active, - selected, - palette, - self.font, - tile_id, - Message::Family(id, appearance), - move |key| { - gallery::neighbor(index, ids.len(), key) - .map(|index| Message::Family(ids[index].clone(), appearance)) - }, - )); - } - for _ in families.len()..3 { - tiles = tiles.push(widget::Space::new().width(Length::Fill)); - } - gallery = gallery.push(tiles); - } - gallery::group( - container(gallery).width(Length::Fill).max_width(640), - cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")), - match appearance { - Some(Light) => "Light theme", - Some(Dark) => "Dark theme", - None => "Theme", - }, - ) - } - fn edit_many(&mut self, edits: Vec) -> Task { for edit in edits { if let Err(error) = self.draft.edit(&edit) { @@ -1460,174 +709,6 @@ impl App { self.flush() } - fn field(&self, mut field: Field) -> Element<'static, Message> { - if field.path == "theme.material.opacity" { - let appearance = self.resolved.theme.appearance; - let name = match appearance { - ferese_config::theme::Appearance::Light => "light", - ferese_config::theme::Appearance::Dark => "dark", - }; - field.path = format!("theme.{name}.material.opacity"); - field.description = format!("Opacity for the current {name} appearance. Text and icons stay opaque."); - if let Kind::Range { default, .. } = &mut field.kind { - *default = self.resolved.theme.tokens.material.opacity; - } - } - let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.)); - if !field.description.is_empty() { - labels = labels.push( - self.label(field.description.clone(), 11.) - .class(cosmic::theme::Text::Color(palette.muted)), - ); - } - let labels = labels.width(Length::Fill); - let path = field.path.clone(); - let control: Element<'static, Message> = match field.kind.clone() { - Kind::Font => { - let value = self - .inputs - .get(&path) - .cloned() - .unwrap_or_else(|| self.draft.string(&path, "")); - let families = fonts::families(); - let selected = if value.is_empty() { - Some(0) - } else { - families.iter().position(|family| family == &value) - }; - let selection_path = path.clone(); - let commit = field.clone(); - column([]) - .spacing(6) - .push( - cosmic::iced::widget::pick_list( - families, - selected.map(|index| families[index].clone()), - move |family: String| { - let value = if family == "Default font" { - String::new() - } else { - family - }; - Message::SelectFont(selection_path.clone(), value) - }, - ) - .font(self.font) - .text_size(12) - .width(225), - ) - .push( - text_input("Default font", value) - .font(self.font) - .style(visuals::input_style(palette)) - .on_input(move |value| Message::Draft(path.clone(), value)) - .on_submit(move |_| Message::Commit(field.clone())) - .on_unfocus(Message::Commit(commit)) - .width(225) - .size(12), - ) - .into() - } - Kind::Toggle(default) => { - let enabled = self.draft.boolean(&path, default); - ferese_theme::controls::switch(enabled, palette) - .name(format!("{}: {}", field.label, if enabled { "on" } else { "off" })) - .on_press(Message::Change(set(&path, !enabled))) - .into() - } - Kind::Range { - default, - min, - max, - step, - suffix, - integer, - } => { - let value = self - .ranges - .get(&path) - .copied() - .unwrap_or_else(|| self.draft.number(&path, default)); - let display = if integer || step >= 1. { - format!("{value:.0}{suffix}") - } else { - format!("{value:.2}{suffix}") - }; - let release = field.clone(); - row([]) - .align_y(Alignment::Center) - .spacing(14) - .push( - slider(min..=max, value, move |value| Message::Range(field.clone(), value)) - .step(step) - .class(ferese_theme::menus::slider(palette.accent, 1.)) - .on_release(Message::Release(release)) - .width(145), - ) - .push(self.label(display, 12.).width(65)) - .into() - } - Kind::Choice { default, choices } => { - let value = self.draft.string(&path, default); - let mut options = row([]).spacing(2); - for (key, label) in choices { - options = options.push( - button::custom(self.label(*label, 12.)) - .padding([6, 9]) - .class(visuals::button_style(palette, value == *key)) - .on_press(Message::Change(set(&path, *key))), - ); - } - container(options) - .padding(3) - .class(visuals::surface(palette.sidebar, 10.)) - .into() - } - Kind::Text { default, argv } => { - let value = self.inputs.get(&path).cloned().unwrap_or_else(|| { - if argv { - self.draft.argv(&path) - } else { - self.draft.string(&path, default) - } - }); - let commit = field.clone(); - let is_color = path.starts_with("theme.colors."); - let swatch = visuals::color(&value, palette.accent); - let input = text_input(default, value) - .font(self.font) - .style(visuals::input_style(palette)) - .on_input(move |value| Message::Draft(path.clone(), value)) - .on_submit(move |_| Message::Commit(field.clone())) - .on_unfocus(Message::Commit(commit)) - .width(if is_color { 155 } else { 225 }) - .size(12); - if is_color { - row([]) - .spacing(8) - .align_y(Alignment::Center) - .push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.))) - .push(input) - .into() - } else { - input.into() - } - } - }; - container( - row([]) - .spacing(16) - .align_y(Alignment::Center) - .push(labels) - .push(control), - ) - .padding([12, 12]) - .width(Length::Fill) - .class(visuals::surface(palette.card, 0.)) - .into() - } - fn load_displays(&self) -> Task { cosmic::task::future(async { Message::DisplaysLoaded( @@ -1638,62 +719,6 @@ impl App { }) } - fn refresh_controls(&self, prefix: &str) -> Element<'_, Message> { - let matcher = self.draft.string(&format!("{prefix}.match"), ""); - let configured = self.draft.string(&format!("{prefix}.mode"), ""); - let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false); - let Some(display) = self - .displays - .iter() - .find(|display| display.connector == matcher || display.identity == matcher) - else { - return self.note("Connect this display to choose a supported refresh rate."); - }; - let modes = displays::choices(display, &configured); - let mut controls = row([]).spacing(8).align_y(Alignment::Center); - for mode in &modes { - let selected = !automatic - && configured - .split('@') - .nth(1) - .and_then(|rate| rate.parse::().ok()) - .is_some_and(|rate| (rate * 1000. - f64::from(mode.refresh)).abs() < 1.); - controls = controls.push( - ferese_theme::controls::text_button( - mode.label(), - self.font, - visuals::Palette::from_resolved(&self.resolved.presented), - selected, - ) - .on_press(Message::RefreshRate(prefix.to_owned(), *mode, false)), - ); - } - let supports_auto = modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh)); - if supports_auto && let Some(mode) = modes.last() { - controls = controls.push( - ferese_theme::controls::text_button( - "Auto", - self.font, - visuals::Palette::from_resolved(&self.resolved.presented), - automatic, - ) - .on_press(Message::RefreshRate(prefix.to_owned(), *mode, true)), - ); - } - let current = display.current.map(|mode| mode.label()).unwrap_or_else(|| "Off".into()); - let profile = display.profile.as_deref().unwrap_or("automatic configuration"); - column![ - self.label("Refresh rate", 14.), - self.note(&format!("Current: {current} · active profile: {profile}")), - controls, - self.note(if supports_auto { - "Auto uses 60 Hz below 30% on battery. Normal refresh returns on AC or at 35%. Switching may briefly blank the display. Choosing a rate turns Auto off." - } else { - "This resolution has no supported 60 Hz mode for automatic switching." - }), - ].spacing(8).into() - } - fn change(&mut self, edit: Edit) -> Task { match self.draft.edit(&edit) { Ok(()) => { @@ -1756,10 +781,6 @@ impl App { } } -fn gallery_id(id: &str, appearance: Option) -> cosmic::iced::advanced::widget::Id { - cosmic::iced::advanced::widget::Id::new(format!("theme-{appearance:?}-{id}")) -} - #[cfg(test)] mod tests { use cosmic::Application; diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs new file mode 100644 index 0000000..3a91cda --- /dev/null +++ b/shell/ferese-settings/src/pages.rs @@ -0,0 +1,531 @@ +use super::{ + Alignment, App, Element, Field, Kind, Length, Message, Page, button, column, container, row, schema, scrollable, + text_input, visuals, widget, +}; + +impl App { + pub(super) fn page_view(&self) -> Element<'_, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + let mut sidebar = column([]) + .spacing(3) + .push( + widget::mouse_area( + row([]) + .spacing(10) + .align_y(Alignment::Center) + .push(visuals::brand_icon()) + .push(self.label("Ferese", 16.)) + .width(Length::Fill), + ) + .on_press(Message::DragWindow) + .interaction(cosmic::iced::mouse::Interaction::Grab), + ) + .push(widget::Space::new().height(14)) + .push( + text_input("Search settings", self.search.clone()) + .on_input(Message::Search) + .font(self.font) + .style(visuals::input_style(palette)) + .size(12), + ) + .push(widget::Space::new().height(8)); + for page in Page::ALL { + sidebar = sidebar.push( + button::custom( + row([]) + .spacing(10) + .align_y(Alignment::Center) + .push(visuals::icon( + page, + if self.page == page { + palette.accent + } else { + palette.muted + }, + )) + .push(self.label(page.title(), 13.)), + ) + .width(Length::Fill) + .padding([8, 8]) + .class(visuals::navigation_style(palette, self.page == page)) + .on_press(Message::Page(page)), + ); + } + let sidebar = container(sidebar.push(widget::Space::new().height(Length::Fill))) + .width(204) + .height(Length::Fill) + .padding([20, 10]) + .class(visuals::surface(palette.sidebar, 0.)); + let mut heading = row([]).align_y(Alignment::Center).spacing(20).push( + column([]) + .spacing(5) + .push(self.label( + if self.search.is_empty() { + self.page.title() + } else { + "Search" + }, + 24., + )) + .push( + self.label( + if self.search.is_empty() { + self.page.subtitle() + } else { + "Find a setting for your desktop." + }, + 12., + ) + .class(cosmic::theme::Text::Color(palette.muted)), + ) + .width(Length::Fill), + ); + if self.page == Page::Appearance && self.search.is_empty() { + let automatic = self.draft.string("theme.mode", "dark") == "auto"; + heading = heading.push( + row([]) + .spacing(8) + .align_y(Alignment::Center) + .push(self.label("Auto", 12.)) + .push( + ferese_theme::controls::switch(automatic, palette) + .name(if automatic { + "Auto appearance: on" + } else { + "Auto appearance: off" + }) + .on_press(Message::AutoAppearance(!automatic)), + ), + ); + let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule"; + let source = if scheduled { "Schedule" } else { "System" }; + let label = if automatic { + format!( + "{source} · {}", + if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light { + "Light" + } else { + "Dark" + }, + ) + } else { + source.to_owned() + }; + let explanation = if scheduled { + format!( + "Auto uses Light from {} and Dark from {} ({})", + self.draft.string("theme.schedule.light_at", "07:00"), + self.draft.string("theme.schedule.dark_at", "19:00"), + self.draft.string("theme.schedule.timezone", "system"), + ) + } else { + "Auto follows the GTK/GNOME appearance preference".to_owned() + }; + heading = heading.push(widget::tooltip( + ferese_theme::controls::text_button(label, self.font, palette, self.auto_details) + .name("Automatic appearance settings") + .on_press(Message::AutoDetails(!self.auto_details)), + self.label(explanation, 12.), + widget::tooltip::Position::Bottom, + )); + } + + let mut body = column([]).spacing(12); + + if !self.search.is_empty() { + if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) { + body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion.")); + } + for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) { + body = body.push( + button::custom( + column([]) + .spacing(5) + .push(self.label(page.title(), 16.)) + .push(self.label(page.subtitle(), 12.)), + ) + .width(Length::Fill) + .padding(20) + .class(visuals::button_style(palette, false)) + .on_press(Message::Page(page)), + ); + } + } else { + if matches!(self.page, Page::Bar | Page::Windows) { + body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented)); + } + if self.page == Page::Appearance { + let split = visuals::split(&self.draft); + if split { + body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light))); + body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark))); + } else { + body = body.push(self.theme_gallery(None)); + } + body = body.push( + widget::checkbox(split) + .label("Use different themes for light and dark") + .text_size(12) + .font(self.font) + .on_toggle(Message::SplitThemes), + ); + if let Some(note) = &self.resolved.fallback_note { + body = body.push(self.note(note)); + } + } + + if self.page == Page::Wallpaper { + if let Some(handle) = &self.thumbnail { + body = body.push( + widget::image(handle.clone()) + .width(Length::Fill) + .height(190) + .content_fit(cosmic::iced::ContentFit::Contain), + ); + } else { + body = body.push(self.note(if self.thumbnail_loading { + "Loading wallpaper preview…" + } else { + self.thumbnail_error + .as_deref() + .unwrap_or("Choose an image to preview your wallpaper.") + })); + } + body = body.push(button::standard("Choose image…").on_press(Message::PickWallpaper)); + } + let fields = schema::fields(self.page); + + if !fields.is_empty() { + let mut group = column([]).spacing(1); + if self.page == Page::Appearance && self.auto_details { + group = group.push(self.auto_controls()); + } + for field in fields { + if self.page == Page::Appearance + && (field.path == "theme.mode" + || field.path.starts_with("theme.schedule.") + || field.path.ends_with(".file")) + { + continue; + } + group = group.push(self.field(field)); + } + if self.page == Page::Appearance { + group = group.push( + button::custom( + row([]) + .spacing(8) + .align_y(Alignment::Center) + .push(self.label("Advanced customization", 13.)) + .push(visuals::action_icon( + if self.advanced_theme { + "M6 15l6-6 6 6" + } else { + "M6 9l6 6 6-6" + }, + palette.muted, + )), + ) + .name(if self.advanced_theme { + "Collapse advanced customization" + } else { + "Expand advanced customization" + }) + .padding(12) + .width(Length::Fill) + .class(visuals::button_style(palette, false)) + .on_press(Message::AdvancedTheme(!self.advanced_theme)), + ); + if self.advanced_theme { + group = group.push(self.theme_file_picker()); + } + } + body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); + } + + match self.page { + Page::LockScreen => { + body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens.")); + body = body.push(button::standard("Preview lock screen").on_press(Message::PreviewLock)); + body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.")); + } + Page::Desktop => { + let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty); + body = body.push(self.label("Sticky notes", 16.)); + body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through.")); + for index in 0..self.draft.records("desktop_widgets.notes") { + let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note"); + let mut group = column([]).spacing(8); + + for field in schema::note_fields(index) { + group = group.push(self.field(field)); + } + + if let Some(editor) = self.note_editors.get(&id) { + let id = id.clone(); + group = group.push( + widget::TextEditor::new(&editor.content) + .height(160) + .font(self.font) + .size(14.) + .on_action(move |action| Message::NoteAction(id.clone(), action)), + ); + } + group = group.push(button::destructive("Remove note").on_press_maybe( + can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)), + )); + body = body.push(container(group).padding(12).class(visuals::surface(palette.card, 14.))); + } + body = body.push( + button::standard("Add note").on_press_maybe( + (can_change_list && self.draft.records("desktop_widgets.notes") < 32) + .then_some(Message::AddNote), + ), + ); + } + Page::Startup => { + body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.")); + + for index in 0..self.draft.records("autostart") { + let prefix = format!("autostart.{index}"); + let group = column([]) + .spacing(1) + .push(self.field(Field::new( + format!("{prefix}.command"), + "Program", + "A program and its arguments, not a shell script.", + Kind::Text { + default: "", + argv: true, + }, + ))) + .push(self.field(Field::new( + format!("{prefix}.enabled"), + "Open at login", + "", + Kind::Toggle(true), + ))) + .push(self.field(Field::new( + format!("{prefix}.restart"), + "Restart if it exits", + "Managed by the Ferese session.", + Kind::Toggle(true), + ))) + .push( + container(button::destructive("Remove item").on_press_maybe( + (!self.saving).then_some(Message::Remove("autostart".into(), index)), + )) + .padding(12), + ); + body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); + } + body = body.push( + row([]) + .spacing(10) + .push( + text_input("Program and arguments", self.new_command.clone()) + .on_input(Message::NewCommand), + ) + .push(button::standard("Add login item").on_press_maybe( + (!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand), + )), + ); + } + Page::Shortcuts => { + body = body.push(self.note("Keyboard and swipe bindings share the same actions. Use Swipe3Up, Swipe3Down, Swipe3Left, or Swipe3Right as a shortcut (also supports 4 or 5 fingers). Actions include toggle-overview, spawn, focus, move, workspace-next, workspace-previous, workspace-back-and-forth, and none. Leave Argument empty for actions such as toggle-overview or none.")); + let mut gestures = row([]).spacing(6); + for (keys, label) in [ + ("Swipe3Up", "Customize swipe up"), + ("Swipe3Down", "Customize swipe down"), + ("Swipe3Left", "Customize swipe left"), + ("Swipe3Right", "Customize swipe right"), + ] { + let exists = (0..self.draft.records("bindings")).any(|index| { + self.draft + .string(&format!("bindings.{index}.keys"), "") + .eq_ignore_ascii_case(keys) + }); + + if !exists { + gestures = gestures.push( + button::standard(label) + .on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))), + ); + } + } + + body = body.push(gestures); + + for index in 0..self.draft.records("bindings") { + let prefix = format!("bindings.{index}"); + let group = column([]) + .spacing(1) + .push(self.field(schema::text( + format!("{prefix}.keys"), + "Shortcut", + "For example: Super+Return or Swipe3Up", + "", + ))) + .push(self.field(schema::text( + format!("{prefix}.action"), + "Action", + "Action such as toggle-overview, spawn, focus, move, or none.", + "", + ))) + .push(self.field(schema::text( + format!("{prefix}.argument"), + "Argument", + "Command name, direction, or action argument.", + "", + ))); + body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); + } + if self.draft.records("bindings") == 0 { + body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")); + } + } + Page::Displays => { + body = body.push(self.note("Display profiles update connected outputs live. Keep your config open for adding profiles or changing display positions.")); + for profile in 0..self.draft.records("output_profiles") { + let prefix = format!("output_profiles.{profile}"); + body = + body.push(self.label(self.draft.string(&format!("{prefix}.name"), "Display profile"), 17.)); + + for output in 0..self.draft.records(&format!("{prefix}.outputs")) { + let prefix = format!("{prefix}.outputs.{output}"); + let group = column([]) + .spacing(1) + .push(self.refresh_controls(&prefix)) + .push(self.field(schema::text( + format!("{prefix}.match"), + "Display", + "Output name or matching pattern.", + "", + ))) + .push(self.field(schema::text( + format!("{prefix}.mode"), + "Resolution", + "For example: 2560x1440@60. Leave unchanged to retain automatic selection.", + "", + ))) + .push(self.field(schema::range( + format!("{prefix}.scale"), + "Scale", + "Logical size of text and controls.", + 1., + 0.75, + 3., + 0.25, + "×", + false, + ))); + body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); + } + } + if self.draft.records("output_profiles") == 0 { + body = + body.push(self.note( + "No saved profiles. Ferese currently configures connected displays automatically.", + )); + } + } + _ => {} + } + } + + let mut content = column([]).spacing(16).push( + widget::mouse_area(heading.width(Length::Fill)) + .on_press(Message::DragWindow) + .interaction(cosmic::iced::mouse::Interaction::Grab), + ); + + if let Some(error) = &self.error { + content = content.push( + container(self.label(error.clone(), 12.)) + .padding(12) + .width(Length::Fill) + .class(visuals::surface(palette.error, 10.)), + ); + } + + content = content.push( + scrollable( + container(body) + .padding(cosmic::iced::Padding { + top: 0., + right: 12., + bottom: 0., + left: 2., + }) + .width(Length::Fill), + ) + .direction(cosmic::iced::widget::scrollable::Direction::Vertical( + cosmic::iced::widget::scrollable::Scrollbar::new() + .width(10) + .scroller_width(3), + )) + .height(Length::Fill), + ); + let footer = row([]) + .spacing(4) + .align_y(Alignment::Center) + .push(widget::tooltip( + visuals::action_icon( + if self.saving { + "M12 3v9l5 3 M21 12a9 9 0 1 1-9-9" + } else { + "M9 12l2 2 4-4 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0" + }, + palette.muted, + ), + self.label(if self.saving { "Saving…" } else { &self.status }, 11.), + widget::tooltip::Position::Top, + )) + .push(widget::Space::new().width(Length::Fill)) + .push(widget::tooltip( + button::custom(visuals::action_icon( + "M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7", + if self.undo.is_some() && !self.saving { + palette.text + } else { + palette.muted + }, + )) + .padding(4) + .on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)), + self.label("Undo last change", 11.), + widget::tooltip::Position::Top, + )) + .push(widget::tooltip( + button::custom(visuals::action_icon( + "M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6", + palette.text, + )) + .padding(4) + .on_press_maybe((!self.saving).then_some(Message::Reload)), + self.label("Reload configuration", 11.), + widget::tooltip::Position::Top, + )); + row([]) + .push(sidebar) + .push( + container( + container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6)) + .padding(cosmic::iced::Padding { + top: 20., + right: 20., + bottom: 8., + left: 20., + }) + .max_width(840) + .width(Length::Fill) + .height(Length::Fill), + ) + .width(Length::Fill) + .center_x(Length::Fill) + .height(Length::Fill) + .class(visuals::surface(palette.background, 0.)), + ) + .into() + } +} diff --git a/shell/ferese-settings/src/theme_controls.rs b/shell/ferese-settings/src/theme_controls.rs new file mode 100644 index 0000000..590689b --- /dev/null +++ b/shell/ferese-settings/src/theme_controls.rs @@ -0,0 +1,237 @@ +use super::{ + Alignment, App, Edit, Element, Kind, Length, Message, Page, PathBuf, button, column, container, gallery, row, + schema, set, text_input, visuals, widget, +}; + +impl App { + pub(super) fn theme_file_picker(&self) -> Element<'static, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + let path = ["theme.file", "theme.light.file", "theme.dark.file"][self.theme_file_target]; + let file = self.draft.string(path, ""); + let filename = if file.is_empty() { + "No override file".to_owned() + } else { + PathBuf::from(&file) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }; + let labels = column([]) + .spacing(3) + .width(Length::Fill) + .push(self.label("Theme overrides", 13.)) + .push(widget::tooltip( + self.label(filename, 11.) + .class(cosmic::theme::Text::Color(palette.muted)), + self.label( + if file.is_empty() { + "Choose a partial KDL theme file".to_owned() + } else { + file.clone() + }, + 11., + ), + widget::tooltip::Position::Bottom, + )); + let mut scopes = row([]).spacing(2); + for (index, label) in ["Shared", "Light", "Dark"].into_iter().enumerate() { + scopes = scopes.push( + button::custom(self.label(label, 12.)) + .name(format!("Theme file: {label}")) + .padding([6, 8]) + .class(visuals::button_style(palette, index == self.theme_file_target)) + .on_press(Message::ThemeFileTarget(index)), + ); + } + let mut controls = row([]) + .spacing(8) + .align_y(Alignment::Center) + .push( + container(scopes) + .padding(3) + .class(visuals::surface(palette.sidebar, 10.)), + ) + .push( + ferese_theme::controls::text_button( + if file.is_empty() { "Choose…" } else { "Replace…" }, + self.font, + palette, + false, + ) + .on_press(Message::PickThemeFile(path)), + ); + if !file.is_empty() { + controls = controls.push(widget::tooltip( + button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted)) + .padding(5) + .on_press(Message::Change(Edit::Unset(path.into()))), + self.label("Remove override", 11.), + widget::tooltip::Position::Bottom, + )); + } + container( + row([]) + .spacing(16) + .align_y(Alignment::Center) + .push(labels) + .push(controls), + ) + .padding([12, 12]) + .width(Length::Fill) + .class(visuals::surface(palette.card, 0.)) + .into() + } + + pub(super) fn auto_controls(&self) -> Element<'static, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule"; + let mut sources = row([]).spacing(4); + for (source, label) in [("system", "System"), ("schedule", "Schedule")] { + sources = sources.push( + ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule")) + .name(format!("Auto source: {label}")) + .on_press(Message::Change(set("theme.schedule.source", source))), + ); + } + let description = if scheduled { + "Switch between light and dark at set times." + } else { + "Follow the GTK/GNOME appearance preference." + }; + let mut options = column([]).spacing(8).push( + row([]) + .spacing(12) + .align_y(Alignment::Center) + .push( + column([]) + .spacing(3) + .width(Length::Fill) + .push(self.label("Automatic appearance", 13.)) + .push(self.note(description)), + ) + .push(sources), + ); + if scheduled { + let mut times = row([]).spacing(16).align_y(Alignment::Center); + for field in schema::fields(Page::Appearance) + .into_iter() + .filter(|field| field.path.starts_with("theme.schedule.")) + { + let path = field.path.clone(); + let Kind::Text { default, .. } = &field.kind else { + continue; + }; + let value = self + .inputs + .get(&path) + .cloned() + .unwrap_or_else(|| self.draft.string(&path, default)); + let submit = field.clone(); + let input = text_input(*default, value) + .font(self.font) + .size(12) + .style(visuals::input_style(palette)) + .on_input(move |value| Message::Draft(path.clone(), value)) + .on_submit(move |_| Message::Commit(submit.clone())) + .on_unfocus(Message::Commit(field.clone())) + .width(if field.path.ends_with("timezone") { 150 } else { 75 }); + times = times.push( + row([]) + .spacing(8) + .align_y(Alignment::Center) + .push(self.label(field.label, 12.)) + .push(input), + ); + } + options = options.push(times); + } + container(options) + .padding(12) + .width(Length::Fill) + .class(visuals::surface(palette.card, 0.)) + .into() + } + + pub(super) fn theme_gallery( + &self, + appearance: Option, + ) -> Element<'static, Message> { + use ferese_config::theme::Appearance::{Dark, Light}; + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + let selected_id = visuals::family_selection(&self.draft, appearance); + let choices = self.resolved.families.clone(); + let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect(); + let mut heading = row([]).align_y(Alignment::Center).spacing(12).push( + self.label( + match appearance { + Some(Light) => "Light theme", + Some(Dark) => "Dark theme", + None => "Theme", + }, + 15., + ) + .width(Length::Fill), + ); + if appearance != Some(Dark) { + if self.undo.is_some() { + heading = heading.push( + ferese_theme::controls::text_button("Undo", self.font, palette, false) + .on_press_maybe((!self.saving).then_some(Message::Undo)), + ); + } + heading = heading.push( + ferese_theme::controls::text_button("Import…", self.font, palette, false) + .on_press(Message::ImportTheme), + ); + } + let mut gallery = column([]).spacing(10).push(heading); + for (chunk, families) in choices.chunks(3).enumerate() { + let mut tiles = row([]).spacing(12); + for (offset, family) in families.iter().enumerate() { + let selected = family.id == selected_id; + let index = chunk * 3 + offset; + let id = family.id.clone(); + let active = selected + .then_some(self.resolved.theme.appearance) + .filter(|a| appearance.is_none_or(|variant| variant == *a)); + let ids = ids.clone(); + let tile_id = gallery_id(&id, appearance); + tiles = tiles.push(gallery::tile( + family, + appearance, + active, + selected, + palette, + self.font, + tile_id, + Message::Family(id, appearance), + move |key| { + gallery::neighbor(index, ids.len(), key) + .map(|index| Message::Family(ids[index].clone(), appearance)) + }, + )); + } + for _ in families.len()..3 { + tiles = tiles.push(widget::Space::new().width(Length::Fill)); + } + gallery = gallery.push(tiles); + } + gallery::group( + container(gallery).width(Length::Fill).max_width(640), + cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")), + match appearance { + Some(Light) => "Light theme", + Some(Dark) => "Dark theme", + None => "Theme", + }, + ) + } +} + +pub(super) fn gallery_id( + id: &str, + appearance: Option, +) -> cosmic::iced::advanced::widget::Id { + cosmic::iced::advanced::widget::Id::new(format!("theme-{appearance:?}-{id}")) +} diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs new file mode 100644 index 0000000..82e4b11 --- /dev/null +++ b/shell/ferese-shell/src/bar.rs @@ -0,0 +1,337 @@ +use super::{ + Background, BarMetrics, Border, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment, + bar_icon, button, color, color_with_opacity, container, control, motion, row, status_ui, text, theme, window, +}; + +impl FereseShell { + pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> { + let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?; + self.snapshot.outputs.iter().find(|output| output.name == name) + } + + pub(super) fn bar_hidden(&self, id: window::Id) -> bool { + output_bar_hidden(&self.snapshot, self.output_for_bar(id).map(|output| output.id)) + } + + pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action> { + if self.bar_hidden(id) { + return container(text("")).width(Length::Fill).height(Length::Fill).into(); + } + + let focused_output = self.output_for_bar(id); + let shell_theme = self.config.theme.for_bar(); + let bar = BarMetrics::from(shell_theme); + let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) + .spacing(3) + .align_y(cosmic::iced::Alignment::Center); + + workspace_row = workspace_row.push(motion::button( + button::custom(overview_control(bar, color(shell_theme.accent))) + .height(bar.control_height) + .padding([0, 7]) + .on_press(cosmic::Action::App(Message::ToggleOverview)), + color(shell_theme.text_primary), + self.overview_active, + 1.0, + )); + let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len()) + .spacing(1) + .align_y(cosmic::iced::Alignment::Center); + + for workspace in &self.snapshot.workspaces { + let active = workspace_active_on_bar(workspace.id, focused_output); + let owner = workspace + .output + .and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id)); + let occupied = self + .snapshot + .windows + .iter() + .any(|window| window.workspace == workspace.id); + let indicator = workspace_indicator( + &workspace.name, + active, + workspace.active && !active, + occupied, + bar, + shell_theme, + ); + let workspace_button = button::custom(indicator) + .name(format!( + "Workspace {}{}{}", + workspace.name, + if active { ", active" } else { "" }, + owner.map_or(String::new(), |output| format!(", on {}", output.name)) + )) + .height(bar.control_height) + .padding(0) + .on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id))); + + let selector = motion::button( + workspace_button, + color(if active { + shell_theme.accent + } else { + shell_theme.text_primary + }), + false, + 1.0, + ); + // Iced tooltips are overlays inside this bar-height layer surface; + // viewport clamping puts them back over the selector. Keep the + // accessible button name, without an overlay stealing its target. + workspace_buttons = workspace_buttons.push(selector); + } + workspace_row = workspace_row.push( + container(workspace_buttons) + .padding([0, 3]) + .class(theme::Container::custom(move |_| bar_group_style(shell_theme))), + ); + + let foreground = color(shell_theme.text_primary); + let left = cosmic::iced::widget::scrollable(workspace_row) + .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( + cosmic::iced::widget::scrollable::Scrollbar::default() + .width(0) + .scroller_width(0), + )) + .width(Length::Fill) + .height(bar.control_height); + let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock)); + let clock = container(motion::button( + button::custom( + row![ + text(date).size(12).class(theme::Text::Color(foreground)), + text(time).size(bar.text_size).class(theme::Text::Color(foreground)), + ] + .spacing(8) + .align_y(cosmic::iced::Alignment::Center), + ) + .padding([4, 8]) + .height(bar.control_height) + .name("Open calendar") + .on_press_with_rectangle(move |offset, bounds| { + cosmic::Action::App(Message::OpenMenuOn( + id, + status_ui::Menu::Calendar, + cosmic::iced::Rectangle { + x: (bounds.x - offset.x).round() as i32, + y: (bounds.y - offset.y).round() as i32, + width: bounds.width.round() as i32, + height: bounds.height.round() as i32, + }, + )) + }), + foreground, + self.menu + .as_ref() + .is_some_and(|menu| menu.kind == status_ui::Menu::Calendar), + 1.0, + )) + .height(bar.control_height) + .align_y(alignment::Vertical::Center) + .class(theme::Container::custom(move |_| bar_group_style(shell_theme))); + let right = row![ + self.view_status_bar().map(move |action| match action { + cosmic::Action::App(Message::OpenMenu(kind, anchor)) => + cosmic::Action::App(Message::OpenMenuOn(id, kind, anchor)), + other => other, + }), + clock + ] + .spacing(8) + .align_y(cosmic::iced::Alignment::Center); + let available = self + .outputs + .iter() + .find(|output| output.bar == id) + .and_then(|output| output.size) + .map_or(0., |(width, _)| width as f32) + - 2. * (shell_theme.panel_padding + shell_theme.bar_margin_horizontal as f32); + let title_width = (available - 920.).clamp(0., 360.); + let title = if self.config.status.window_title && title_width > 0. { + focused_bar_title(&self.snapshot, focused_output) + } else { + "" + }; + let center = text(title) + .size(bar.text_size) + .width(title_width) + .height(bar.control_height) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + .wrapping(cosmic::iced::widget::text::Wrapping::None) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), + )) + .class(theme::Text::Color(foreground)); + let right = cosmic::iced::widget::scrollable(container(right).width(Length::Shrink)) + .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( + cosmic::iced::widget::scrollable::Scrollbar::default() + .width(0) + .scroller_width(0), + )) + .anchor_right() + .width(Length::Shrink) + .height(bar.control_height); + let content = row![ + container(left).width(Length::Fill), + center, + container(right) + .width(Length::Fill) + .align_x(alignment::Horizontal::Right), + ] + .spacing(8) + .align_y(cosmic::iced::Alignment::Center) + .height(Length::Fill); + + let compositor_material = self + .outputs + .iter() + .any(|entry| entry.bar == id && entry.effects.is_some()); + container(content) + .width(Length::Fill) + .height(Length::Fill) + .padding([0, shell_theme.panel_padding.round() as u16]) + .class(theme::Container::custom(move |_| { + bar_style(shell_theme, compositor_material) + })) + .into() + } +} + +pub(super) fn overview_control(bar: BarMetrics, foreground: Color) -> Element<'static, cosmic::Action> { + container(bar_icon( + ferese_theme::icons::FERESE, + bar.overview_icon_size, + foreground, + )) + .width(bar.overview_icon_size) + .height(bar.control_height) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + .into() +} + +#[cfg(test)] +pub(super) fn bar_hidden(snapshot: &ShellSnapshot) -> bool { + let output = snapshot + .outputs + .iter() + .find(|output| output.focused) + .or_else(|| snapshot.outputs.first()) + .map(|output| output.id); + output_bar_hidden(snapshot, output) +} + +pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option) -> bool { + let active_workspace = snapshot + .outputs + .iter() + .find(|candidate| Some(candidate.id) == output) + .map(|output| output.active_workspace); + + active_workspace.is_some_and(|workspace| { + snapshot + .windows + .iter() + .any(|window| window.workspace == workspace && window.fullscreen) + }) +} + +pub(super) fn workspace_indicator( + name: &str, + active: bool, + active_elsewhere: bool, + occupied: bool, + bar: BarMetrics, + shell_theme: ShellTheme, +) -> Element<'static, cosmic::Action> { + let foreground = if active { + shell_theme.accent + } else if occupied { + shell_theme.text_primary + } else { + shell_theme.text_muted + }; + container( + text(name.to_owned()) + .size(12) + .class(theme::Text::Color(color(foreground))), + ) + .width(24) + .height(bar.control_height) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + .class(theme::Container::custom(move |_| { + workspace_selector_style(active, active_elsewhere, occupied, shell_theme) + })) + .into() +} + +pub(super) fn focused_bar_title<'a>(snapshot: &'a ShellSnapshot, output: Option<&control::OutputSnapshot>) -> &'a str { + let Some(output) = output else { + return ""; + }; + snapshot + .windows + .iter() + .find(|window| window.focused && window.workspace == output.active_workspace) + .map_or("", |window| { + if window.title.trim().is_empty() { + &window.app_id + } else { + &window.title + } + }) +} + +pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool { + output.is_some_and(|output| output.active_workspace == workspace) +} + +pub(super) fn workspace_selector_style( + active: bool, + active_elsewhere: bool, + occupied: bool, + shell_theme: ShellTheme, +) -> container::Style { + container::Style { + background: if active { + Some(Background::Color(color_with_opacity(shell_theme.accent, 0.16))) + } else if occupied { + Some(Background::Color(color_with_opacity(shell_theme.text_primary, 0.04))) + } else { + None + }, + border: Border { + color: color_with_opacity(shell_theme.accent, 0.55), + width: if active_elsewhere { 1.0 } else { 0.0 }, + radius: shell_theme.material_radius.min(14.0).into(), + }, + ..Default::default() + } +} + +pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool) -> container::Style { + let mut style = ferese_theme::controls::surface_appearance(color(theme.bar_background), theme.bar_radius); + if compositor_material { + style.background = None; + } + style.text_color = Some(color(theme.text_primary)); + style.icon_color = style.text_color; + style.snap = true; + style +} + +pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style { + container::Style { + background: Some(Background::Color(color_with_opacity(theme.text_primary, 0.035))), + border: Border { + color: color_with_opacity(theme.text_muted, 0.12), + width: 1.0, + radius: theme.material_radius.min(16.0).into(), + }, + ..Default::default() + } +} diff --git a/shell/ferese-shell/src/desktop_widgets.rs b/shell/ferese-shell/src/desktop_widgets.rs new file mode 100644 index 0000000..22779dc --- /dev/null +++ b/shell/ferese-shell/src/desktop_widgets.rs @@ -0,0 +1,734 @@ +use super::{ + Anchor, Background, Border, Color, Element, FereseShell, IcedMargin, IcedOutput, KeyboardInteractivity, Layer, + Length, Limits, Message, NoteDrag, SctkLayerSurfaceSettings, Task, Zoned, alignment, button, color, config, + configured_font, container, destroy_layer_surface, format_bar_time, motion, note_store, row, set_anchor, + set_margin, stacked_clock_digits, text, theme, window, +}; + +impl FereseShell { + pub(super) fn flush_note_changes(&mut self) -> Task { + if self.note_saving || self.note_pending.is_empty() { + return Task::none(); + } + + let Some(path) = config::config_path() else { + self.note_error = Some("Configuration path unavailable.".into()); + return Task::none(); + }; + self.note_saving = true; + self.note_inflight = std::mem::take(&mut self.note_pending); + let edits = self.note_inflight.clone(); + cosmic::task::future(async move { + let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); + std::thread::spawn(move || { + let _ = send.send(note_store::save(&path, &edits)); + }); + Message::NotesStored( + receive + .await + .unwrap_or_else(|_| Err("Note save worker stopped.".into())), + ) + }) + } + + pub(super) fn set_note_text(&mut self, id: &str, value: String) -> Task { + if let Some(note) = self.config.desktop_widgets.notes.iter_mut().find(|note| note.id == id) + && (note.text != value || self.note_error.is_some()) + { + note.text = value.clone(); + self.note_pending.push(note_store::Edit::Text(id.to_owned(), value)); + } + self.flush_note_changes() + } + + pub(super) fn finish_note_edit(&mut self) -> Task { + if let Some(editor) = self.note_editor.take() { + self.set_note_text(&editor.id, editor.content.text()) + } else { + self.flush_note_changes() + } + } + + pub(super) fn begin_note_drag(&mut self, id: String, surface: window::Id) -> Task { + self.begin_widget_drag(Some(id), surface) + } + + pub(super) fn begin_widget_drag(&mut self, id: Option, surface: window::Id) -> Task { + if self.note_drag.is_some() { + return Task::none(); + } + let Some(entry) = self + .outputs + .iter() + .find(|entry| entry.clock == Some(surface) || entry.notes.iter().any(|(_, window)| *window == surface)) + else { + return Task::none(); + }; + let Some(size) = entry.size else { + return Task::none(); + }; + let (origin, dimensions) = if let Some(id) = &id { + let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| ¬e.id == id) else { + return Task::none(); + }; + (note_origin(note, size), (note.width, note.height)) + } else { + let clock = &self.config.desktop_widgets.clock; + ( + widget_origin( + clock.anchor, + clock.margin_x, + clock.margin_y, + (clock.width, clock.height), + size, + ), + (clock.width, clock.height), + ) + }; + let output = entry.output.clone(); + let mut obstacles = Vec::new(); + let clock = &self.config.desktop_widgets.clock; + + if id.is_some() && clock.on_output(entry.name.as_deref()) { + let position = widget_origin( + clock.anchor, + clock.margin_x, + clock.margin_y, + (clock.width, clock.height), + size, + ); + obstacles.push(cosmic::iced::Rectangle::new( + position, + cosmic::iced::Size::new(clock.width as f32, clock.height as f32), + )); + } + + for note in &self.config.desktop_widgets.notes { + if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) { + obstacles.push(cosmic::iced::Rectangle::new( + note_origin(note, size), + cosmic::iced::Size::new(note.width as f32, note.height as f32), + )); + } + } + + let overlay = window::Id::unique(); + self.note_drag = Some(NoteDrag { + id, + source: surface, + overlay, + origin, + position: origin, + start: self.note_pointer.get(&surface).copied().unwrap_or_default(), + output_size: size, + obstacles, + }); + let action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id: overlay, + layer: Layer::Overlay, + keyboard_interactivity: KeyboardInteractivity::None, + input_zone: Some(Vec::new()), + anchor: Anchor::TOP | Anchor::LEFT, + margin: IcedMargin { + top: origin.y.round() as i32, + left: origin.x.round() as i32, + ..Default::default() + }, + output: IcedOutput::Output(output.clone()), + namespace: "ferese-shell-note-drag".into(), + exclusive_zone: -1, + size: Some((Some(dimensions.0), Some(dimensions.1))), + size_limits: Limits::NONE, + }, + Some(Box::new(Self::view_note_drag)), + ); + + Task::batch([ + self.finish_note_edit(), + cosmic::task::message(cosmic::Action::Surface(action)), + ]) + } + + pub(super) fn view_note_drag(&self) -> Element<'_, cosmic::Action> { + let Some(drag) = &self.note_drag else { + return text("").into(); + }; + + if let Some(id) = &drag.id { + self.view_note_content(id, drag.source) + } else { + self.view_clock_content() + } + } + + pub(super) fn finish_note_drag(&mut self, save: bool) -> Task { + let Some(drag) = self.note_drag.take() else { + return Task::none(); + }; + let mut tasks = vec![destroy_layer_surface(drag.overlay)]; + + if save { + if drag.id.is_none() { + let clock = &mut self.config.desktop_widgets.clock; + clock.anchor = ferese_core::desktop::Anchor::TopLeft; + clock.margin_x = drag.position.x.round() as i32; + clock.margin_y = drag.position.y.round() as i32; + for entry in &self.outputs { + if let Some(id) = entry.clock { + tasks.push(set_anchor(id, Anchor::TOP | Anchor::LEFT)); + tasks.push(set_margin(id, clock.margin_y, 0, 0, clock.margin_x)); + } + } + self.note_pending + .push(note_store::Edit::ClockPosition(clock.margin_x, clock.margin_y)); + } + + if let Some(note) = self + .config + .desktop_widgets + .notes + .iter_mut() + .find(|note| Some(¬e.id) == drag.id.as_ref()) + { + note.anchor = ferese_core::desktop::Anchor::TopLeft; + note.margin_x = drag.position.x.round() as i32; + note.margin_y = drag.position.y.round() as i32; + + for entry in &self.outputs { + if let Some((_, id)) = entry.notes.iter().find(|(id, _)| Some(id) == drag.id.as_ref()) { + tasks.push(set_anchor(*id, Anchor::TOP | Anchor::LEFT)); + tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x)); + } + } + + self.note_pending.push(note_store::Edit::Position( + note.id.clone(), + note.margin_x, + note.margin_y, + )); + } + + tasks.push(self.flush_note_changes()); + } + Task::batch(tasks) + } + + pub(super) fn rebuild_notes(&mut self, reset: bool) -> Task { + let mut tasks = Vec::new(); + + for entry in &mut self.outputs { + if reset { + for (_, id) in entry.notes.drain(..) { + self.note_pointer.remove(&id); + tasks.push(destroy_layer_surface(id)); + } + } + + for note in &self.config.desktop_widgets.notes { + if !note.on_output(entry.name.as_deref()) || entry.notes.iter().any(|(id, _)| id == ¬e.id) { + continue; + } + + let id = window::Id::unique(); + entry.notes.push((note.id.clone(), id)); + let note = note.clone(); + let note_id = note.id.clone(); + let output = entry.output.clone(); + let action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| { + let (anchor, margin) = widget_placement(note.anchor, note.margin_x, note.margin_y); + SctkLayerSurfaceSettings { + id, + layer: Layer::Bottom, + keyboard_interactivity: if note.interactive { + KeyboardInteractivity::OnDemand + } else { + KeyboardInteractivity::None + }, + input_zone: if note.interactive { None } else { Some(Vec::new()) }, + anchor, + margin, + output: IcedOutput::Output(output.clone()), + namespace: "ferese-shell-sticky-note".into(), + exclusive_zone: -1, + size: Some((Some(note.width), Some(note.height))), + size_limits: Limits::NONE, + } + }, + Some(Box::new(move |app| app.view_note(¬e_id, id))), + ); + + tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); + } + } + + Task::batch(tasks) + } + + pub(super) fn view_note(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { + if self + .note_drag + .as_ref() + .is_some_and(|drag| drag.id.as_deref() == Some(id)) + { + return container(text("")).width(Length::Fill).height(Length::Fill).into(); + } + + self.view_note_content(id, surface) + } + + pub(super) fn view_note_content(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { + let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) else { + return text("").into(); + }; + let alignment = match note.alignment { + ferese_core::desktop::Alignment::Left => alignment::Horizontal::Left, + ferese_core::desktop::Alignment::Center => alignment::Horizontal::Center, + ferese_core::desktop::Alignment::Right => alignment::Horizontal::Right, + }; + let font = configured_font( + note.font_family + .as_deref() + .filter(|s| !s.trim().is_empty()) + .or(self.config.font_family.as_deref()), + ); + let mut foreground = color( + note.color + .as_deref() + .and_then(config::parse_color) + .unwrap_or(self.config.theme.text_primary), + ); + + foreground.a *= note.opacity; + + let background = if note.background.as_deref() == Some("") { + None + } else { + let mut tint = color( + note.background + .as_deref() + .and_then(config::parse_color) + .unwrap_or(self.config.theme.surface_base), + ); + tint.a *= note.opacity; + Some(Background::Color(tint)) + }; + let mut body = cosmic::widget::column([]).spacing(note.gap).align_x(alignment); + + if note.interactive { + let title = ferese_theme::text(if note.title.is_empty() { "⋮⋮" } else { ¬e.title }, font) + .size(note.title_size) + .class(theme::Text::Color(foreground)); + let header = cosmic::widget::mouse_area(container(title).width(Length::Fill)) + .interaction(cosmic::iced::mouse::Interaction::Grab) + .on_press(cosmic::Action::App(Message::BeginNoteDrag(id.to_owned(), surface))); + let editing = self.note_editor.as_ref().is_some_and(|editor| editor.id == id); + + body = body.push( + row([]).push(header).push(motion::button( + button::custom(text(if editing { "Done" } else { "Edit" })) + .padding([4, 8]) + .on_press(cosmic::Action::App(if editing { + Message::FinishNoteEdit + } else { + Message::BeginNoteEdit(id.to_owned()) + })), + foreground, + false, + 1.0, + )), + ); + } else if !note.title.is_empty() { + body = body.push( + ferese_theme::text( + note.title.clone(), + cosmic::font::Font { + weight: cosmic::iced::font::Weight::Semibold, + ..font + }, + ) + .size(note.title_size) + .width(Length::Fill) + .align_x(alignment) + .class(theme::Text::Color(foreground)), + ); + } + + if let Some(editor) = &self.note_editor + && editor.id == id + { + let id = id.to_owned(); + body = body.push( + cosmic::widget::TextEditor::new(&editor.content) + .style(|theme, status| { + use cosmic::iced::widget::text_editor::Catalog; + let mut style = theme.style(&::default(), status); + style.border.radius = motion::radius(f32::MAX).into(); + style + }) + .height(Length::Fill) + .font(font) + .size(note.text_size) + .on_action(move |action| cosmic::Action::App(Message::NoteAction(id.clone(), action))), + ); + } else { + body = body.push( + ferese_theme::text(note.text.clone(), font) + .size(note.text_size) + .width(Length::Fill) + .align_x(alignment) + .class(theme::Text::Color(foreground)), + ); + } + + if let Some(error) = &self.note_error { + body = body.push(text(error.clone()).size(10).class(theme::Text::Color(foreground))); + } + + let radius = self.config.theme.material_radius; + + container(body) + .padding(note.padding) + .width(Length::Fill) + .height(Length::Fill) + .class(theme::Container::custom(move |_| container::Style { + background, + border: Border { + radius: radius.into(), + ..Default::default() + }, + ..Default::default() + })) + .into() + } + + pub(super) fn rebuild_clocks(&mut self, reset: bool) -> Task { + self.desktop_clock = self + .config + .desktop_widgets + .clock + .labels(&Zoned::now()) + .unwrap_or_default(); + let mut tasks = Vec::new(); + + for entry in &mut self.outputs { + if !reset && entry.clock.is_some() { + continue; + } + + if let Some(id) = entry.clock.take() { + self.note_pointer.remove(&id); + tasks.push(destroy_layer_surface(id)); + } + + let clock = self.config.desktop_widgets.clock.clone(); + + if !clock.on_output(entry.name.as_deref()) { + continue; + } + + let id = window::Id::unique(); + entry.clock = Some(id); + let output = entry.output.clone(); + let action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| { + let (anchor, margin) = clock_placement(&clock); + SctkLayerSurfaceSettings { + id, + layer: Layer::Bottom, + keyboard_interactivity: KeyboardInteractivity::OnDemand, + input_zone: None, + anchor, + margin, + output: IcedOutput::Output(output.clone()), + namespace: "ferese-shell-desktop-clock".into(), + exclusive_zone: -1, + size: Some((Some(clock.width), Some(clock.height))), + size_limits: Limits::NONE, + } + }, + Some(Box::new(move |app| app.view_desktop_clock(id))), + ); + + tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); + } + + Task::batch(tasks) + } + + pub(super) fn view_desktop_clock(&self, id: window::Id) -> Element<'_, cosmic::Action> { + if self + .note_drag + .as_ref() + .is_some_and(|drag| drag.id.is_none() && drag.source == id) + { + return container(text("")).width(Length::Fill).height(Length::Fill).into(); + } + + cosmic::widget::mouse_area(self.view_clock_content()) + .on_press(cosmic::Action::App(Message::BeginClockDrag(id))) + .interaction(cosmic::iced::mouse::Interaction::Grab) + .into() + } + + pub(super) fn view_clock_content(&self) -> Element<'_, cosmic::Action> { + use ferese_core::desktop::Alignment as ClockAlignment; + let clock = &self.config.desktop_widgets.clock; + let alignment = match clock.alignment { + ClockAlignment::Left => alignment::Horizontal::Left, + ClockAlignment::Center => alignment::Horizontal::Center, + ClockAlignment::Right => alignment::Horizontal::Right, + }; + let pixel = clock.style == ferese_core::desktop::ClockStyle::Pixel; + let custom_font = clock.font_family.as_deref().filter(|family| !family.trim().is_empty()); + let mut font = configured_font(if pixel { + custom_font.or(Some("Cantarell")) + } else { + custom_font.or(self.config.font_family.as_deref()) + }); + if pixel && custom_font.is_none() { + font.weight = cosmic::iced::font::Weight::Black; + } else if clock.bold { + font.weight = cosmic::iced::font::Weight::Bold; + } + let date_font = configured_font(custom_font.or(self.config.font_family.as_deref())); + let tint = |custom: &Option, fallback| { + let mut tint = color(custom.as_deref().and_then(config::parse_color).unwrap_or(fallback)); + tint.a *= clock.opacity; + tint + }; + let foreground = tint(&clock.color, self.config.theme.text_primary); + let accent = color(self.config.theme.accent); + let custom_color = clock.color.as_deref().and_then(config::parse_color).is_some(); + let digit_colors = [0.8, 0.45].map(|text_mix| { + if custom_color { + return foreground; + } + Color { + r: foreground.r * text_mix + accent.r * (1.0 - text_mix), + g: foreground.g * text_mix + accent.g * (1.0 - text_mix), + b: foreground.b * text_mix + accent.b * (1.0 - text_mix), + a: foreground.a, + } + }); + let mut labels = cosmic::widget::column([]).spacing(clock.gap).align_x(alignment); + + if pixel && clock.show_date { + labels = labels.push( + ferese_theme::text(self.desktop_clock.1.clone(), date_font) + .size(clock.date_size) + .class(theme::Text::Color(tint( + &clock.date_color, + self.config.theme.text_primary, + ))), + ); + } + + if let Some(digits) = pixel.then(|| stacked_clock_digits(&self.desktop_clock.0)).flatten() { + let available_height = (clock.height as f32 + - clock.padding * 2.0 + - if clock.show_date { + clock.date_size * 1.3 + clock.gap + } else { + 0.0 + }) + .max(16.0); + let size = clock + .time_size + .min(available_height / 1.6) + .min((clock.width as f32 - clock.padding * 2.0).max(16.0) / 1.5); + let mut stack = cosmic::widget::column([]).spacing(0).align_x(alignment); + + for (line, digits) in digits.into_iter().enumerate() { + let mut digit_row = row([]).spacing(0); + + for (position, digit) in digits.chars().enumerate() { + digit_row = digit_row.push( + ferese_theme::text(digit.to_string(), font) + .size(size) + .line_height(cosmic::iced::widget::text::LineHeight::Relative(0.8)) + .class(theme::Text::Color(digit_colors[(line + position) % 2])), + ); + } + + stack = stack.push(digit_row); + } + + labels = labels.push(stack); + } else { + let characters = self.desktop_clock.0.chars().count().max(1) as f32; + let available_width = (clock.width as f32 - clock.padding * 2.0).max(1.0); + let available_height = (clock.height as f32 + - clock.padding * 2.0 + - if clock.show_date { + clock.date_size * 1.3 + clock.gap + } else { + 0.0 + }) + .max(1.0); + let size = clock + .time_size + .min(available_width / (characters * 0.75)) + .min(available_height / 1.3); + + labels = labels.push( + ferese_theme::text(self.desktop_clock.0.clone(), font) + .size(size) + .width(Length::Fill) + .align_x(alignment) + .class(theme::Text::Color(foreground)), + ); + } + + if !pixel && clock.show_date { + labels = labels.push( + ferese_theme::text(self.desktop_clock.1.clone(), font) + .size(clock.date_size) + .width(Length::Fill) + .align_x(alignment) + .class(theme::Text::Color(tint( + &clock.date_color, + self.config.theme.text_muted, + ))), + ); + } + let background = clock.background.as_deref().and_then(config::parse_color).map(|rgba| { + let mut tint = color(rgba); + tint.a *= clock.opacity; + Background::Color(tint) + }); + let radius = self.config.theme.material_radius; + + container(labels) + .padding(clock.padding) + .width(Length::Fill) + .height(Length::Fill) + .align_x(alignment) + .center_y(Length::Fill) + .class(theme::Container::custom(move |_| container::Style { + background, + border: Border { + radius: radius.into(), + ..Default::default() + }, + ..Default::default() + })) + .into() + } +} + +/// Sweep each axis, allowing edge sliding without tunnelling through a widget +/// when pointer events skip ahead. Coordinates match integer layer margins. +pub(super) fn avoid_widget_overlap( + previous: cosmic::iced::Point, + requested: cosmic::iced::Point, + size: (u32, u32), + obstacles: &[cosmic::iced::Rectangle], +) -> cosmic::iced::Point { + let mut position = previous; + let (width, height) = (size.0 as f32, size.1 as f32); + let mut x = requested.x.round(); + for obstacle in obstacles { + if position.y < obstacle.y + obstacle.height && position.y + height > obstacle.y { + if x > position.x && position.x + width <= obstacle.x { + x = x.min((obstacle.x - width).floor()); + } else if x < position.x && position.x >= obstacle.x + obstacle.width { + x = x.max((obstacle.x + obstacle.width).ceil()); + } + } + } + position.x = x; + let mut y = requested.y.round(); + for obstacle in obstacles { + if position.x < obstacle.x + obstacle.width && position.x + width > obstacle.x { + if y > position.y && position.y + height <= obstacle.y { + y = y.min((obstacle.y - height).floor()); + } else if y < position.y && position.y >= obstacle.y + obstacle.height { + y = y.max((obstacle.y + obstacle.height).ceil()); + } + } + } + position.y = y; + position +} + +pub(super) fn clamp_note_position( + position: cosmic::iced::Point, + output: (i32, i32), + size: (u32, u32), +) -> cosmic::iced::Point { + cosmic::iced::Point::new( + position.x.clamp(0., (output.0 as f32 - size.0 as f32).clamp(0., 8192.)), + position.y.clamp(0., (output.1 as f32 - size.1 as f32).clamp(0., 8192.)), + ) +} + +pub(super) fn note_origin(note: &ferese_core::desktop::StickyNote, output: (i32, i32)) -> cosmic::iced::Point { + widget_origin( + note.anchor, + note.margin_x, + note.margin_y, + (note.width, note.height), + output, + ) +} + +pub(super) fn widget_origin( + anchor: ferese_core::desktop::Anchor, + margin_x: i32, + margin_y: i32, + size: (u32, u32), + output: (i32, i32), +) -> cosmic::iced::Point { + use ferese_core::desktop::Anchor as A; + let x = match anchor { + A::TopLeft | A::CenterLeft | A::BottomLeft => margin_x as f32, + A::TopRight | A::CenterRight | A::BottomRight => output.0 as f32 - size.0 as f32 - margin_x as f32, + _ => (output.0 as f32 - size.0 as f32) / 2., + }; + let y = match anchor { + A::TopLeft | A::TopCenter | A::TopRight => margin_y as f32, + A::BottomLeft | A::BottomCenter | A::BottomRight => output.1 as f32 - size.1 as f32 - margin_y as f32, + _ => (output.1 as f32 - size.1 as f32) / 2., + }; + clamp_note_position(cosmic::iced::Point::new(x, y), output, size) +} + +pub(super) fn clock_placement(clock: &ferese_core::desktop::Clock) -> (Anchor, IcedMargin) { + widget_placement(clock.anchor, clock.margin_x, clock.margin_y) +} + +pub(super) fn widget_placement( + position: ferese_core::desktop::Anchor, + margin_x: i32, + margin_y: i32, +) -> (Anchor, IcedMargin) { + use ferese_core::desktop::Anchor as Position; + let horizontal = match position { + Position::TopLeft | Position::CenterLeft | Position::BottomLeft => Anchor::LEFT, + Position::TopRight | Position::CenterRight | Position::BottomRight => Anchor::RIGHT, + _ => Anchor::empty(), + }; + let vertical = match position { + Position::TopLeft | Position::TopCenter | Position::TopRight => Anchor::TOP, + Position::BottomLeft | Position::BottomCenter | Position::BottomRight => Anchor::BOTTOM, + _ => Anchor::empty(), + }; + ( + horizontal | vertical, + IcedMargin { + top: if vertical == Anchor::TOP { margin_y } else { 0 }, + bottom: if vertical == Anchor::BOTTOM { margin_y } else { 0 }, + left: if horizontal == Anchor::LEFT { margin_x } else { 0 }, + right: if horizontal == Anchor::RIGHT { margin_x } else { 0 }, + }, + ) +} + +pub(super) fn current_time() -> String { + format_bar_time(&Zoned::now()) +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 7b33612..d69e3a9 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1,3 +1,11 @@ +mod bar; +mod desktop_widgets; +mod surfaces; + +use bar::*; +use desktop_widgets::*; +use surfaces::*; + mod compositor_ipc; mod config; mod control; @@ -965,1450 +973,12 @@ impl FereseShell { Task::batch(tasks) } - - fn output_event(&mut self, event: wayland::OutputEvent, output: wl_output::WlOutput) -> Task { - if matches!(event, wayland::OutputEvent::Removed) { - if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) { - let entry = self.outputs.remove(index); - let menu = if self.bar_surface_id == entry.bar { - self.destroy_menu() - } else { - Task::none() - }; - - let mut tasks = vec![destroy_layer_surface(entry.bar), menu, self.rebuild_system_modal()]; - if self.note_drag.as_ref().is_some_and(|drag| { - entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source) - }) { - tasks.push(self.finish_note_drag(false)); - } - - if let Some(wallpaper) = entry.wallpaper { - tasks.push(destroy_layer_surface(wallpaper)); - } - - if let Some(clock) = entry.clock { - tasks.push(destroy_layer_surface(clock)); - } - - for (_, id) in entry.notes { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - - return Task::batch(tasks); - } - - return Task::none(); - } - - let size = match &event { - wayland::OutputEvent::Created(info) => info.as_ref().and_then(|info| info.logical_size), - wayland::OutputEvent::InfoUpdate(info) => info.logical_size, - _ => None, - }; - let name = match event { - wayland::OutputEvent::Created(info) => info.and_then(|info| info.name), - wayland::OutputEvent::InfoUpdate(info) => info.name, - _ => None, - }; - - if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) { - if size.is_some() { - entry.size = size; - } - - let changed = name.is_some() && entry.name != name; - if name.is_some() { - entry.name = name; - } - - return if changed { - Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)]) - } else { - Task::none() - }; - } - - let bar_surface_id = window::Id::unique(); - let wallpaper_surface_id = window::Id::unique(); - let shell_theme = self.config.theme; - let bar = BarMetrics::from(shell_theme); - let wallpaper_output = output.clone(); - let bar_output = output.clone(); - let hidden = output_bar_hidden( - &self.snapshot, - self.snapshot - .outputs - .iter() - .find(|output| Some(output.name.as_str()) == name.as_deref()) - .map(|output| output.id), - ); - self.outputs.push(OutputSurfaces { - output, - name, - bar: bar_surface_id, - wallpaper: std::env::var_os("FERESE_COMPOSITOR_WALLPAPER") - .is_none() - .then_some(wallpaper_surface_id), - effects: None, - clock: None, - notes: Vec::new(), - size, - hidden, - }); - let wallpaper_action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| SctkLayerSurfaceSettings { - id: wallpaper_surface_id, - layer: Layer::Background, - keyboard_interactivity: KeyboardInteractivity::None, - input_zone: Some(Vec::new()), - anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT, - output: IcedOutput::Output(wallpaper_output.clone()), - namespace: "ferese-shell-wallpaper".to_owned(), - // Wallpaper covers the full output, including beneath the bar - // and its floating margins. Zero would use the remaining workspace. - exclusive_zone: -1, - size: Some((None, None)), - size_limits: Limits::NONE, - ..Default::default() - }, - Some(Box::new(Self::view_wallpaper)), - ); - let bar_action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| SctkLayerSurfaceSettings { - id: bar_surface_id, - input_zone: hidden.then(Vec::new), - layer: Layer::Top, - keyboard_interactivity: KeyboardInteractivity::None, - anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, - output: IcedOutput::Output(bar_output.clone()), - namespace: "ferese-shell-top-bar".to_owned(), - margin: IcedMargin { - top: shell_theme.bar_margin_top, - right: shell_theme.bar_margin_horizontal, - bottom: 0, - left: shell_theme.bar_margin_horizontal, - }, - size: Some((None, Some(bar.height.round() as u32))), - size_limits: Limits::NONE, - // Reserve breathing room below the visible bar; layer-shell - // accounts for the top margin separately. - exclusive_zone: (bar.height.round() as i32).saturating_add(shell_theme.bar_window_gap), - }, - Some(Box::new(move |app| app.view_layer(bar_surface_id))), - ); - - // Submit the small interactive surface before the full-screen image. - let mut surfaces = vec![bar_action]; - if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() { - surfaces.push(wallpaper_action); - } - - let tasks = surfaces - .into_iter() - .map(cosmic::Action::Surface) - .map(cosmic::task::message); - - Task::batch([ - Task::batch(tasks), - self.rebuild_clocks(false), - self.rebuild_notes(false), - self.rebuild_system_modal(), - cosmic::task::message(cosmic::Action::App(Message::ShowGuide)), - ]) - } - - fn flush_note_changes(&mut self) -> Task { - if self.note_saving || self.note_pending.is_empty() { - return Task::none(); - } - - let Some(path) = config::config_path() else { - self.note_error = Some("Configuration path unavailable.".into()); - return Task::none(); - }; - self.note_saving = true; - self.note_inflight = std::mem::take(&mut self.note_pending); - let edits = self.note_inflight.clone(); - cosmic::task::future(async move { - let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); - std::thread::spawn(move || { - let _ = send.send(note_store::save(&path, &edits)); - }); - Message::NotesStored( - receive - .await - .unwrap_or_else(|_| Err("Note save worker stopped.".into())), - ) - }) - } - - fn set_note_text(&mut self, id: &str, value: String) -> Task { - if let Some(note) = self.config.desktop_widgets.notes.iter_mut().find(|note| note.id == id) - && (note.text != value || self.note_error.is_some()) - { - note.text = value.clone(); - self.note_pending.push(note_store::Edit::Text(id.to_owned(), value)); - } - self.flush_note_changes() - } - - fn finish_note_edit(&mut self) -> Task { - if let Some(editor) = self.note_editor.take() { - self.set_note_text(&editor.id, editor.content.text()) - } else { - self.flush_note_changes() - } - } - - fn begin_note_drag(&mut self, id: String, surface: window::Id) -> Task { - self.begin_widget_drag(Some(id), surface) - } - - fn begin_widget_drag(&mut self, id: Option, surface: window::Id) -> Task { - if self.note_drag.is_some() { - return Task::none(); - } - let Some(entry) = self - .outputs - .iter() - .find(|entry| entry.clock == Some(surface) || entry.notes.iter().any(|(_, window)| *window == surface)) - else { - return Task::none(); - }; - let Some(size) = entry.size else { - return Task::none(); - }; - let (origin, dimensions) = if let Some(id) = &id { - let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| ¬e.id == id) else { - return Task::none(); - }; - (note_origin(note, size), (note.width, note.height)) - } else { - let clock = &self.config.desktop_widgets.clock; - ( - widget_origin( - clock.anchor, - clock.margin_x, - clock.margin_y, - (clock.width, clock.height), - size, - ), - (clock.width, clock.height), - ) - }; - let output = entry.output.clone(); - let mut obstacles = Vec::new(); - let clock = &self.config.desktop_widgets.clock; - - if id.is_some() && clock.on_output(entry.name.as_deref()) { - let position = widget_origin( - clock.anchor, - clock.margin_x, - clock.margin_y, - (clock.width, clock.height), - size, - ); - obstacles.push(cosmic::iced::Rectangle::new( - position, - cosmic::iced::Size::new(clock.width as f32, clock.height as f32), - )); - } - - for note in &self.config.desktop_widgets.notes { - if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) { - obstacles.push(cosmic::iced::Rectangle::new( - note_origin(note, size), - cosmic::iced::Size::new(note.width as f32, note.height as f32), - )); - } - } - - let overlay = window::Id::unique(); - self.note_drag = Some(NoteDrag { - id, - source: surface, - overlay, - origin, - position: origin, - start: self.note_pointer.get(&surface).copied().unwrap_or_default(), - output_size: size, - obstacles, - }); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| SctkLayerSurfaceSettings { - id: overlay, - layer: Layer::Overlay, - keyboard_interactivity: KeyboardInteractivity::None, - input_zone: Some(Vec::new()), - anchor: Anchor::TOP | Anchor::LEFT, - margin: IcedMargin { - top: origin.y.round() as i32, - left: origin.x.round() as i32, - ..Default::default() - }, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-note-drag".into(), - exclusive_zone: -1, - size: Some((Some(dimensions.0), Some(dimensions.1))), - size_limits: Limits::NONE, - }, - Some(Box::new(Self::view_note_drag)), - ); - - Task::batch([ - self.finish_note_edit(), - cosmic::task::message(cosmic::Action::Surface(action)), - ]) - } - - fn view_note_drag(&self) -> Element<'_, cosmic::Action> { - let Some(drag) = &self.note_drag else { - return text("").into(); - }; - - if let Some(id) = &drag.id { - self.view_note_content(id, drag.source) - } else { - self.view_clock_content() - } - } - - fn finish_note_drag(&mut self, save: bool) -> Task { - let Some(drag) = self.note_drag.take() else { - return Task::none(); - }; - let mut tasks = vec![destroy_layer_surface(drag.overlay)]; - - if save { - if drag.id.is_none() { - let clock = &mut self.config.desktop_widgets.clock; - clock.anchor = ferese_core::desktop::Anchor::TopLeft; - clock.margin_x = drag.position.x.round() as i32; - clock.margin_y = drag.position.y.round() as i32; - for entry in &self.outputs { - if let Some(id) = entry.clock { - tasks.push(set_anchor(id, Anchor::TOP | Anchor::LEFT)); - tasks.push(set_margin(id, clock.margin_y, 0, 0, clock.margin_x)); - } - } - self.note_pending - .push(note_store::Edit::ClockPosition(clock.margin_x, clock.margin_y)); - } - - if let Some(note) = self - .config - .desktop_widgets - .notes - .iter_mut() - .find(|note| Some(¬e.id) == drag.id.as_ref()) - { - note.anchor = ferese_core::desktop::Anchor::TopLeft; - note.margin_x = drag.position.x.round() as i32; - note.margin_y = drag.position.y.round() as i32; - - for entry in &self.outputs { - if let Some((_, id)) = entry.notes.iter().find(|(id, _)| Some(id) == drag.id.as_ref()) { - tasks.push(set_anchor(*id, Anchor::TOP | Anchor::LEFT)); - tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x)); - } - } - - self.note_pending.push(note_store::Edit::Position( - note.id.clone(), - note.margin_x, - note.margin_y, - )); - } - - tasks.push(self.flush_note_changes()); - } - Task::batch(tasks) - } - - fn rebuild_notes(&mut self, reset: bool) -> Task { - let mut tasks = Vec::new(); - - for entry in &mut self.outputs { - if reset { - for (_, id) in entry.notes.drain(..) { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - } - - for note in &self.config.desktop_widgets.notes { - if !note.on_output(entry.name.as_deref()) || entry.notes.iter().any(|(id, _)| id == ¬e.id) { - continue; - } - - let id = window::Id::unique(); - entry.notes.push((note.id.clone(), id)); - let note = note.clone(); - let note_id = note.id.clone(); - let output = entry.output.clone(); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| { - let (anchor, margin) = widget_placement(note.anchor, note.margin_x, note.margin_y); - SctkLayerSurfaceSettings { - id, - layer: Layer::Bottom, - keyboard_interactivity: if note.interactive { - KeyboardInteractivity::OnDemand - } else { - KeyboardInteractivity::None - }, - input_zone: if note.interactive { None } else { Some(Vec::new()) }, - anchor, - margin, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-sticky-note".into(), - exclusive_zone: -1, - size: Some((Some(note.width), Some(note.height))), - size_limits: Limits::NONE, - } - }, - Some(Box::new(move |app| app.view_note(¬e_id, id))), - ); - - tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); - } - } - - Task::batch(tasks) - } - - fn view_note(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { - if self - .note_drag - .as_ref() - .is_some_and(|drag| drag.id.as_deref() == Some(id)) - { - return container(text("")).width(Length::Fill).height(Length::Fill).into(); - } - - self.view_note_content(id, surface) - } - - fn view_note_content(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { - let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) else { - return text("").into(); - }; - let alignment = match note.alignment { - ferese_core::desktop::Alignment::Left => alignment::Horizontal::Left, - ferese_core::desktop::Alignment::Center => alignment::Horizontal::Center, - ferese_core::desktop::Alignment::Right => alignment::Horizontal::Right, - }; - let font = configured_font( - note.font_family - .as_deref() - .filter(|s| !s.trim().is_empty()) - .or(self.config.font_family.as_deref()), - ); - let mut foreground = color( - note.color - .as_deref() - .and_then(config::parse_color) - .unwrap_or(self.config.theme.text_primary), - ); - - foreground.a *= note.opacity; - - let background = if note.background.as_deref() == Some("") { - None - } else { - let mut tint = color( - note.background - .as_deref() - .and_then(config::parse_color) - .unwrap_or(self.config.theme.surface_base), - ); - tint.a *= note.opacity; - Some(Background::Color(tint)) - }; - let mut body = cosmic::widget::column([]).spacing(note.gap).align_x(alignment); - - if note.interactive { - let title = ferese_theme::text(if note.title.is_empty() { "⋮⋮" } else { ¬e.title }, font) - .size(note.title_size) - .class(theme::Text::Color(foreground)); - let header = cosmic::widget::mouse_area(container(title).width(Length::Fill)) - .interaction(cosmic::iced::mouse::Interaction::Grab) - .on_press(cosmic::Action::App(Message::BeginNoteDrag(id.to_owned(), surface))); - let editing = self.note_editor.as_ref().is_some_and(|editor| editor.id == id); - - body = body.push( - row([]).push(header).push(motion::button( - button::custom(text(if editing { "Done" } else { "Edit" })) - .padding([4, 8]) - .on_press(cosmic::Action::App(if editing { - Message::FinishNoteEdit - } else { - Message::BeginNoteEdit(id.to_owned()) - })), - foreground, - false, - 1.0, - )), - ); - } else if !note.title.is_empty() { - body = body.push( - ferese_theme::text( - note.title.clone(), - cosmic::font::Font { - weight: cosmic::iced::font::Weight::Semibold, - ..font - }, - ) - .size(note.title_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if let Some(editor) = &self.note_editor - && editor.id == id - { - let id = id.to_owned(); - body = body.push( - cosmic::widget::TextEditor::new(&editor.content) - .style(|theme, status| { - use cosmic::iced::widget::text_editor::Catalog; - let mut style = theme.style(&::default(), status); - style.border.radius = motion::radius(f32::MAX).into(); - style - }) - .height(Length::Fill) - .font(font) - .size(note.text_size) - .on_action(move |action| cosmic::Action::App(Message::NoteAction(id.clone(), action))), - ); - } else { - body = body.push( - ferese_theme::text(note.text.clone(), font) - .size(note.text_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if let Some(error) = &self.note_error { - body = body.push(text(error.clone()).size(10).class(theme::Text::Color(foreground))); - } - - let radius = self.config.theme.material_radius; - - container(body) - .padding(note.padding) - .width(Length::Fill) - .height(Length::Fill) - .class(theme::Container::custom(move |_| container::Style { - background, - border: Border { - radius: radius.into(), - ..Default::default() - }, - ..Default::default() - })) - .into() - } - - fn rebuild_clocks(&mut self, reset: bool) -> Task { - self.desktop_clock = self - .config - .desktop_widgets - .clock - .labels(&Zoned::now()) - .unwrap_or_default(); - let mut tasks = Vec::new(); - - for entry in &mut self.outputs { - if !reset && entry.clock.is_some() { - continue; - } - - if let Some(id) = entry.clock.take() { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - - let clock = self.config.desktop_widgets.clock.clone(); - - if !clock.on_output(entry.name.as_deref()) { - continue; - } - - let id = window::Id::unique(); - entry.clock = Some(id); - let output = entry.output.clone(); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| { - let (anchor, margin) = clock_placement(&clock); - SctkLayerSurfaceSettings { - id, - layer: Layer::Bottom, - keyboard_interactivity: KeyboardInteractivity::OnDemand, - input_zone: None, - anchor, - margin, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-desktop-clock".into(), - exclusive_zone: -1, - size: Some((Some(clock.width), Some(clock.height))), - size_limits: Limits::NONE, - } - }, - Some(Box::new(move |app| app.view_desktop_clock(id))), - ); - - tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); - } - - Task::batch(tasks) - } - - fn view_desktop_clock(&self, id: window::Id) -> Element<'_, cosmic::Action> { - if self - .note_drag - .as_ref() - .is_some_and(|drag| drag.id.is_none() && drag.source == id) - { - return container(text("")).width(Length::Fill).height(Length::Fill).into(); - } - - cosmic::widget::mouse_area(self.view_clock_content()) - .on_press(cosmic::Action::App(Message::BeginClockDrag(id))) - .interaction(cosmic::iced::mouse::Interaction::Grab) - .into() - } - - fn view_clock_content(&self) -> Element<'_, cosmic::Action> { - use ferese_core::desktop::Alignment as ClockAlignment; - let clock = &self.config.desktop_widgets.clock; - let alignment = match clock.alignment { - ClockAlignment::Left => alignment::Horizontal::Left, - ClockAlignment::Center => alignment::Horizontal::Center, - ClockAlignment::Right => alignment::Horizontal::Right, - }; - let pixel = clock.style == ferese_core::desktop::ClockStyle::Pixel; - let custom_font = clock.font_family.as_deref().filter(|family| !family.trim().is_empty()); - let mut font = configured_font(if pixel { - custom_font.or(Some("Cantarell")) - } else { - custom_font.or(self.config.font_family.as_deref()) - }); - if pixel && custom_font.is_none() { - font.weight = cosmic::iced::font::Weight::Black; - } else if clock.bold { - font.weight = cosmic::iced::font::Weight::Bold; - } - let date_font = configured_font(custom_font.or(self.config.font_family.as_deref())); - let tint = |custom: &Option, fallback| { - let mut tint = color(custom.as_deref().and_then(config::parse_color).unwrap_or(fallback)); - tint.a *= clock.opacity; - tint - }; - let foreground = tint(&clock.color, self.config.theme.text_primary); - let accent = color(self.config.theme.accent); - let custom_color = clock.color.as_deref().and_then(config::parse_color).is_some(); - let digit_colors = [0.8, 0.45].map(|text_mix| { - if custom_color { - return foreground; - } - Color { - r: foreground.r * text_mix + accent.r * (1.0 - text_mix), - g: foreground.g * text_mix + accent.g * (1.0 - text_mix), - b: foreground.b * text_mix + accent.b * (1.0 - text_mix), - a: foreground.a, - } - }); - let mut labels = cosmic::widget::column([]).spacing(clock.gap).align_x(alignment); - - if pixel && clock.show_date { - labels = labels.push( - ferese_theme::text(self.desktop_clock.1.clone(), date_font) - .size(clock.date_size) - .class(theme::Text::Color(tint( - &clock.date_color, - self.config.theme.text_primary, - ))), - ); - } - - if let Some(digits) = pixel.then(|| stacked_clock_digits(&self.desktop_clock.0)).flatten() { - let available_height = (clock.height as f32 - - clock.padding * 2.0 - - if clock.show_date { - clock.date_size * 1.3 + clock.gap - } else { - 0.0 - }) - .max(16.0); - let size = clock - .time_size - .min(available_height / 1.6) - .min((clock.width as f32 - clock.padding * 2.0).max(16.0) / 1.5); - let mut stack = cosmic::widget::column([]).spacing(0).align_x(alignment); - - for (line, digits) in digits.into_iter().enumerate() { - let mut digit_row = row([]).spacing(0); - - for (position, digit) in digits.chars().enumerate() { - digit_row = digit_row.push( - ferese_theme::text(digit.to_string(), font) - .size(size) - .line_height(cosmic::iced::widget::text::LineHeight::Relative(0.8)) - .class(theme::Text::Color(digit_colors[(line + position) % 2])), - ); - } - - stack = stack.push(digit_row); - } - - labels = labels.push(stack); - } else { - let characters = self.desktop_clock.0.chars().count().max(1) as f32; - let available_width = (clock.width as f32 - clock.padding * 2.0).max(1.0); - let available_height = (clock.height as f32 - - clock.padding * 2.0 - - if clock.show_date { - clock.date_size * 1.3 + clock.gap - } else { - 0.0 - }) - .max(1.0); - let size = clock - .time_size - .min(available_width / (characters * 0.75)) - .min(available_height / 1.3); - - labels = labels.push( - ferese_theme::text(self.desktop_clock.0.clone(), font) - .size(size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if !pixel && clock.show_date { - labels = labels.push( - ferese_theme::text(self.desktop_clock.1.clone(), font) - .size(clock.date_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(tint( - &clock.date_color, - self.config.theme.text_muted, - ))), - ); - } - let background = clock.background.as_deref().and_then(config::parse_color).map(|rgba| { - let mut tint = color(rgba); - tint.a *= clock.opacity; - Background::Color(tint) - }); - let radius = self.config.theme.material_radius; - - container(labels) - .padding(clock.padding) - .width(Length::Fill) - .height(Length::Fill) - .align_x(alignment) - .center_y(Length::Fill) - .class(theme::Container::custom(move |_| container::Style { - background, - border: Border { - radius: radius.into(), - ..Default::default() - }, - ..Default::default() - })) - .into() - } - - fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> { - let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?; - self.snapshot.outputs.iter().find(|output| output.name == name) - } - - fn bar_hidden(&self, id: window::Id) -> bool { - output_bar_hidden(&self.snapshot, self.output_for_bar(id).map(|output| output.id)) - } - - fn handle_event(&mut self, event: Event, id: window::Id) -> Task { - if let Event::Mouse(mouse) = &event { - if !self - .outputs - .iter() - .any(|entry| entry.clock == Some(id) || entry.notes.iter().any(|(_, surface)| *surface == id)) - { - return Task::none(); - } - - match mouse { - cosmic::iced::mouse::Event::CursorMoved { position } => { - self.note_pointer.insert(id, *position); - - if let Some(drag) = &mut self.note_drag - && drag.source == id - { - let delta = *position - drag.start; - let dimensions = if let Some(id) = &drag.id { - self.config - .desktop_widgets - .notes - .iter() - .find(|note| ¬e.id == id) - .map(|note| (note.width, note.height)) - } else { - let clock = &self.config.desktop_widgets.clock; - Some((clock.width, clock.height)) - }; - - if let Some(dimensions) = dimensions { - let requested = clamp_note_position(drag.origin + delta, drag.output_size, dimensions); - drag.position = avoid_widget_overlap(drag.position, requested, dimensions, &drag.obstacles); - // Move a compact, cached buffer in the compositor instead of - // repainting an output-sized buffer for every pointer event. - return set_margin( - drag.overlay, - drag.position.y.round() as i32, - 0, - 0, - drag.position.x.round() as i32, - ); - } - } - } - - cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) - if self.note_drag.as_ref().is_some_and(|drag| drag.source == id) => - { - return self.finish_note_drag(true); - } - _ => {} - } - } - if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { - key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab), - modifiers, - .. - }) = &event - && !modifiers.control() - && self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) - { - return self.navigate_system_modal(modifiers.shift()); - } - if matches!( - &event, - Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { - key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), - .. - }) - ) { - if self.system_modal.is_some() { - return self.close_system_modal(); - } - if self.note_drag.is_some() { - return self.finish_note_drag(false); - } - - if self.note_editor.is_some() { - return self.finish_note_edit(); - } - - if self.notifications.history_open { - return self.close_notification_history(); - } - } - - match event { - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output))) => { - self.output_event(event, output) - } - Event::Window(window::Event::Opened { .. }) - if self.outputs.iter().any(|entry| entry.bar == id) - || self.menu.as_ref().is_some_and(|menu| menu.id == id) - || self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) - || self - .notification_surface - .as_ref() - .is_some_and(|surface| surface.id == id) => - { - Task::batch([ - self.focus_system_modal(id), - window::run(id, native_wayland_surface) - .map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))), - ]) - } - Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { - key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), - .. - }) if self.menu.is_some() => self.close_menu(), - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(event, surface, popup_id))) => { - if let Some(menu) = &mut self.menu - && menu.id == popup_id - { - match event { - wayland::PopupEvent::Done => { - if menu.kind == status_ui::Menu::Notifications { - self.notifications.history_open = false; - self.notifications.hovered = None; - } - self.menu = None; - EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); - return self.sync_notification_surface(); - } - wayland::PopupEvent::Configured { .. } => { - menu.prepare_surface(&surface); - } - _ => {} - } - if let Some(effects) = &menu.effects { - let _ = effects.set_opacity(menu.progress()); - } - } - Task::none() - } - Event::Window(window::Event::Closed) if self.menu.as_ref().is_some_and(|menu| menu.id == id) => { - self.menu = None; - EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); - Task::none() - } - Event::Window(window::Event::Closed) - if self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) => - { - self.close_system_modal() - } - Event::Window(window::Event::Closed) => Task::none(), - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id))) - if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => - { - if let Some(menu) = &mut self.menu - && menu.effects.is_none() - { - menu.prepare_surface(&surface); - } - Task::none() - } - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id))) - if self.outputs.iter().any(|entry| entry.bar == frame_id) => - { - self.attach_effects(frame_id, &surface); - Task::none() - } - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id))) - if self - .notification_surface - .as_ref() - .is_some_and(|entry| entry.id == layer_id) => - { - let entry = self.notification_surface.as_mut().unwrap(); - - if entry.effects.is_none() { - match EffectsBinding::attach_role(&surface, None, 1.0) { - Ok(binding) => entry.effects = Some(binding), - Err(error) => { - eprintln!("ferese-shell: notification effects unavailable: {error}") - } - } - } - Task::none() - } - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id))) - if self.outputs.iter().any(|entry| entry.bar == layer_id) => - { - self.attach_effects(layer_id, &surface); - self.attach_power_material(layer_id, &surface); - Task::none() - } - _ => Task::none(), - } - } - - fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) { - let hidden = self.bar_hidden(id); - let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else { - return; - }; - - if entry.effects.is_some() { - return; - } - - EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); - - match EffectsBinding::attach(surface, !hidden) { - Ok(binding) => entry.effects = Some(binding), - Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"), - } - } - - fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action> { - if self.bar_hidden(id) { - return container(text("")).width(Length::Fill).height(Length::Fill).into(); - } - - let focused_output = self.output_for_bar(id); - let shell_theme = self.config.theme.for_bar(); - let bar = BarMetrics::from(shell_theme); - let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) - .spacing(3) - .align_y(cosmic::iced::Alignment::Center); - - workspace_row = workspace_row.push(motion::button( - button::custom(overview_control(bar, color(shell_theme.accent))) - .height(bar.control_height) - .padding([0, 7]) - .on_press(cosmic::Action::App(Message::ToggleOverview)), - color(shell_theme.text_primary), - self.overview_active, - 1.0, - )); - let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len()) - .spacing(1) - .align_y(cosmic::iced::Alignment::Center); - - for workspace in &self.snapshot.workspaces { - let active = workspace_active_on_bar(workspace.id, focused_output); - let owner = workspace - .output - .and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id)); - let occupied = self - .snapshot - .windows - .iter() - .any(|window| window.workspace == workspace.id); - let indicator = workspace_indicator( - &workspace.name, - active, - workspace.active && !active, - occupied, - bar, - shell_theme, - ); - let workspace_button = button::custom(indicator) - .name(format!( - "Workspace {}{}{}", - workspace.name, - if active { ", active" } else { "" }, - owner.map_or(String::new(), |output| format!(", on {}", output.name)) - )) - .height(bar.control_height) - .padding(0) - .on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id))); - - let selector = motion::button( - workspace_button, - color(if active { - shell_theme.accent - } else { - shell_theme.text_primary - }), - false, - 1.0, - ); - // Iced tooltips are overlays inside this bar-height layer surface; - // viewport clamping puts them back over the selector. Keep the - // accessible button name, without an overlay stealing its target. - workspace_buttons = workspace_buttons.push(selector); - } - workspace_row = workspace_row.push( - container(workspace_buttons) - .padding([0, 3]) - .class(theme::Container::custom(move |_| bar_group_style(shell_theme))), - ); - - let foreground = color(shell_theme.text_primary); - let left = cosmic::iced::widget::scrollable(workspace_row) - .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( - cosmic::iced::widget::scrollable::Scrollbar::default() - .width(0) - .scroller_width(0), - )) - .width(Length::Fill) - .height(bar.control_height); - let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock)); - let clock = container(motion::button( - button::custom( - row![ - text(date).size(12).class(theme::Text::Color(foreground)), - text(time).size(bar.text_size).class(theme::Text::Color(foreground)), - ] - .spacing(8) - .align_y(cosmic::iced::Alignment::Center), - ) - .padding([4, 8]) - .height(bar.control_height) - .name("Open calendar") - .on_press_with_rectangle(move |offset, bounds| { - cosmic::Action::App(Message::OpenMenuOn( - id, - status_ui::Menu::Calendar, - cosmic::iced::Rectangle { - x: (bounds.x - offset.x).round() as i32, - y: (bounds.y - offset.y).round() as i32, - width: bounds.width.round() as i32, - height: bounds.height.round() as i32, - }, - )) - }), - foreground, - self.menu - .as_ref() - .is_some_and(|menu| menu.kind == status_ui::Menu::Calendar), - 1.0, - )) - .height(bar.control_height) - .align_y(alignment::Vertical::Center) - .class(theme::Container::custom(move |_| bar_group_style(shell_theme))); - let right = row![ - self.view_status_bar().map(move |action| match action { - cosmic::Action::App(Message::OpenMenu(kind, anchor)) => - cosmic::Action::App(Message::OpenMenuOn(id, kind, anchor)), - other => other, - }), - clock - ] - .spacing(8) - .align_y(cosmic::iced::Alignment::Center); - let available = self - .outputs - .iter() - .find(|output| output.bar == id) - .and_then(|output| output.size) - .map_or(0., |(width, _)| width as f32) - - 2. * (shell_theme.panel_padding + shell_theme.bar_margin_horizontal as f32); - let title_width = (available - 920.).clamp(0., 360.); - let title = if self.config.status.window_title && title_width > 0. { - focused_bar_title(&self.snapshot, focused_output) - } else { - "" - }; - let center = text(title) - .size(bar.text_size) - .width(title_width) - .height(bar.control_height) - .align_x(alignment::Horizontal::Center) - .align_y(alignment::Vertical::Center) - .wrapping(cosmic::iced::widget::text::Wrapping::None) - .ellipsize(cosmic::iced::widget::text::Ellipsize::End( - cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), - )) - .class(theme::Text::Color(foreground)); - let right = cosmic::iced::widget::scrollable(container(right).width(Length::Shrink)) - .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( - cosmic::iced::widget::scrollable::Scrollbar::default() - .width(0) - .scroller_width(0), - )) - .anchor_right() - .width(Length::Shrink) - .height(bar.control_height); - let content = row![ - container(left).width(Length::Fill), - center, - container(right) - .width(Length::Fill) - .align_x(alignment::Horizontal::Right), - ] - .spacing(8) - .align_y(cosmic::iced::Alignment::Center) - .height(Length::Fill); - - let compositor_material = self - .outputs - .iter() - .any(|entry| entry.bar == id && entry.effects.is_some()); - container(content) - .width(Length::Fill) - .height(Length::Fill) - .padding([0, shell_theme.panel_padding.round() as u16]) - .class(theme::Container::custom(move |_| { - bar_style(shell_theme, compositor_material) - })) - .into() - } - - fn view_wallpaper(&self) -> Element<'_, cosmic::Action> { - let Some(handle) = self.wallpaper.as_ref() else { - return container(text("")) - .width(Length::Fill) - .height(Length::Fill) - .class(theme::Container::custom(wallpaper_fallback_style)) - .into(); - }; - let content_fit = match self.config.wallpaper.mode { - WallpaperMode::Fill => ContentFit::Cover, - WallpaperMode::Fit => ContentFit::Contain, - }; - - container( - image(handle.clone()) - .width(Length::Fill) - .height(Length::Fill) - .content_fit(content_fit), - ) - .width(Length::Fill) - .height(Length::Fill) - .class(theme::Container::custom(wallpaper_fallback_style)) - .into() - } -} - -fn overview_control(bar: BarMetrics, foreground: Color) -> Element<'static, cosmic::Action> { - container(bar_icon( - ferese_theme::icons::FERESE, - bar.overview_icon_size, - foreground, - )) - .width(bar.overview_icon_size) - .height(bar.control_height) - .align_x(alignment::Horizontal::Center) - .align_y(alignment::Vertical::Center) - .into() -} - -#[cfg(test)] -fn bar_hidden(snapshot: &ShellSnapshot) -> bool { - let output = snapshot - .outputs - .iter() - .find(|output| output.focused) - .or_else(|| snapshot.outputs.first()) - .map(|output| output.id); - output_bar_hidden(snapshot, output) -} - -fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option) -> bool { - let active_workspace = snapshot - .outputs - .iter() - .find(|candidate| Some(candidate.id) == output) - .map(|output| output.active_workspace); - - active_workspace.is_some_and(|workspace| { - snapshot - .windows - .iter() - .any(|window| window.workspace == workspace && window.fullscreen) - }) -} - -fn workspace_indicator( - name: &str, - active: bool, - active_elsewhere: bool, - occupied: bool, - bar: BarMetrics, - shell_theme: ShellTheme, -) -> Element<'static, cosmic::Action> { - let foreground = if active { - shell_theme.accent - } else if occupied { - shell_theme.text_primary - } else { - shell_theme.text_muted - }; - container( - text(name.to_owned()) - .size(12) - .class(theme::Text::Color(color(foreground))), - ) - .width(24) - .height(bar.control_height) - .align_x(alignment::Horizontal::Center) - .align_y(alignment::Vertical::Center) - .class(theme::Container::custom(move |_| { - workspace_selector_style(active, active_elsewhere, occupied, shell_theme) - })) - .into() -} - -fn focused_bar_title<'a>(snapshot: &'a ShellSnapshot, output: Option<&control::OutputSnapshot>) -> &'a str { - let Some(output) = output else { - return ""; - }; - snapshot - .windows - .iter() - .find(|window| window.focused && window.workspace == output.active_workspace) - .map_or("", |window| { - if window.title.trim().is_empty() { - &window.app_id - } else { - &window.title - } - }) -} - -fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool { - output.is_some_and(|output| output.active_workspace == workspace) -} - -fn workspace_selector_style( - active: bool, - active_elsewhere: bool, - occupied: bool, - shell_theme: ShellTheme, -) -> container::Style { - container::Style { - background: if active { - Some(Background::Color(color_with_opacity(shell_theme.accent, 0.16))) - } else if occupied { - Some(Background::Color(color_with_opacity(shell_theme.text_primary, 0.04))) - } else { - None - }, - border: Border { - color: color_with_opacity(shell_theme.accent, 0.55), - width: if active_elsewhere { 1.0 } else { 0.0 }, - radius: shell_theme.material_radius.min(14.0).into(), - }, - ..Default::default() - } -} - -/// Sweep each axis, allowing edge sliding without tunnelling through a widget -/// when pointer events skip ahead. Coordinates match integer layer margins. -fn avoid_widget_overlap( - previous: cosmic::iced::Point, - requested: cosmic::iced::Point, - size: (u32, u32), - obstacles: &[cosmic::iced::Rectangle], -) -> cosmic::iced::Point { - let mut position = previous; - let (width, height) = (size.0 as f32, size.1 as f32); - let mut x = requested.x.round(); - for obstacle in obstacles { - if position.y < obstacle.y + obstacle.height && position.y + height > obstacle.y { - if x > position.x && position.x + width <= obstacle.x { - x = x.min((obstacle.x - width).floor()); - } else if x < position.x && position.x >= obstacle.x + obstacle.width { - x = x.max((obstacle.x + obstacle.width).ceil()); - } - } - } - position.x = x; - let mut y = requested.y.round(); - for obstacle in obstacles { - if position.x < obstacle.x + obstacle.width && position.x + width > obstacle.x { - if y > position.y && position.y + height <= obstacle.y { - y = y.min((obstacle.y - height).floor()); - } else if y < position.y && position.y >= obstacle.y + obstacle.height { - y = y.max((obstacle.y + obstacle.height).ceil()); - } - } - } - position.y = y; - position -} - -fn clamp_note_position(position: cosmic::iced::Point, output: (i32, i32), size: (u32, u32)) -> cosmic::iced::Point { - cosmic::iced::Point::new( - position.x.clamp(0., (output.0 as f32 - size.0 as f32).clamp(0., 8192.)), - position.y.clamp(0., (output.1 as f32 - size.1 as f32).clamp(0., 8192.)), - ) -} - -fn note_origin(note: &ferese_core::desktop::StickyNote, output: (i32, i32)) -> cosmic::iced::Point { - widget_origin( - note.anchor, - note.margin_x, - note.margin_y, - (note.width, note.height), - output, - ) -} - -fn widget_origin( - anchor: ferese_core::desktop::Anchor, - margin_x: i32, - margin_y: i32, - size: (u32, u32), - output: (i32, i32), -) -> cosmic::iced::Point { - use ferese_core::desktop::Anchor as A; - let x = match anchor { - A::TopLeft | A::CenterLeft | A::BottomLeft => margin_x as f32, - A::TopRight | A::CenterRight | A::BottomRight => output.0 as f32 - size.0 as f32 - margin_x as f32, - _ => (output.0 as f32 - size.0 as f32) / 2., - }; - let y = match anchor { - A::TopLeft | A::TopCenter | A::TopRight => margin_y as f32, - A::BottomLeft | A::BottomCenter | A::BottomRight => output.1 as f32 - size.1 as f32 - margin_y as f32, - _ => (output.1 as f32 - size.1 as f32) / 2., - }; - clamp_note_position(cosmic::iced::Point::new(x, y), output, size) -} - -fn clock_placement(clock: &ferese_core::desktop::Clock) -> (Anchor, IcedMargin) { - widget_placement(clock.anchor, clock.margin_x, clock.margin_y) -} - -fn widget_placement(position: ferese_core::desktop::Anchor, margin_x: i32, margin_y: i32) -> (Anchor, IcedMargin) { - use ferese_core::desktop::Anchor as Position; - let horizontal = match position { - Position::TopLeft | Position::CenterLeft | Position::BottomLeft => Anchor::LEFT, - Position::TopRight | Position::CenterRight | Position::BottomRight => Anchor::RIGHT, - _ => Anchor::empty(), - }; - let vertical = match position { - Position::TopLeft | Position::TopCenter | Position::TopRight => Anchor::TOP, - Position::BottomLeft | Position::BottomCenter | Position::BottomRight => Anchor::BOTTOM, - _ => Anchor::empty(), - }; - ( - horizontal | vertical, - IcedMargin { - top: if vertical == Anchor::TOP { margin_y } else { 0 }, - bottom: if vertical == Anchor::BOTTOM { margin_y } else { 0 }, - left: if horizontal == Anchor::LEFT { margin_x } else { 0 }, - right: if horizontal == Anchor::RIGHT { margin_x } else { 0 }, - }, - ) -} - -fn current_time() -> String { - format_bar_time(&Zoned::now()) } fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style { theme.palette().application_style(Color::TRANSPARENT) } -fn bar_style(theme: ShellTheme, compositor_material: bool) -> container::Style { - let mut style = ferese_theme::controls::surface_appearance(color(theme.bar_background), theme.bar_radius); - if compositor_material { - style.background = None; - } - style.text_color = Some(color(theme.text_primary)); - style.icon_color = style.text_color; - style.snap = true; - style -} - -fn bar_group_style(theme: ShellTheme) -> container::Style { - container::Style { - background: Some(Background::Color(color_with_opacity(theme.text_primary, 0.035))), - border: Border { - color: color_with_opacity(theme.text_muted, 0.12), - width: 1.0, - radius: theme.material_radius.min(16.0).into(), - }, - ..Default::default() - } -} - fn color([red, green, blue, alpha]: [u8; 4]) -> Color { Color::from_rgba8(red, green, blue, f32::from(alpha) / 255.0) } @@ -2418,163 +988,6 @@ fn color_with_opacity(mut value: [u8; 4], opacity: f32) -> Color { color(value) } -fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style { - let [red, green, blue] = config::default_background(); - - container::Style { - background: Some(Background::Color(Color::from_rgb8(red, green, blue))), - ..container::Style::default() - } -} - -// Resolve a surface while iced guarantees the native handles are alive. Keep -// the borrowed-display connection alive until the effects binding takes over. -fn native_wayland_surface( - window: &dyn cosmic::iced::window::Window, -) -> Result<(Connection, wl_surface::WlSurface), String> { - use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; - let display = window.display_handle().map_err(|error| error.to_string())?; - let handle = window.window_handle().map_err(|error| error.to_string())?; - let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) = (display.as_raw(), handle.as_raw()) - else { - return Err("shell material requires a Wayland surface".to_owned()); - }; - // SAFETY: iced lends matching, live Wayland handles for this callback. - // This backend borrows the display; it never disconnects iced's connection. - // Both handles belong to the runtime, which outlives the shell bindings. - let backend = unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) }; - let connection = Connection::from_backend(backend); - // SAFETY: raw-window-handle identifies this pointer as a live wl_surface. - let id = unsafe { - wayland_client::backend::ObjectId::from_ptr(wl_surface::WlSurface::interface(), handle.surface.as_ptr().cast()) - } - .map_err(|error| error.to_string())?; - let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?; - Ok((connection, surface)) -} - -struct EffectsBinding { - connection: Connection, - _manager: FereseEffectsManagerV1, - surface: FereseSurfaceEffectsV1, - _queue: EventQueue, - regions: std::cell::RefCell>>, - opacity: std::cell::Cell>, -} - -impl EffectsBinding { - fn attach(surface: &wl_surface::WlSurface, visible: bool) -> Result> { - Self::attach_role( - surface, - visible.then_some(ferese_surface_effects_v1::Role::Panel), - if visible { 1.0 } else { 0.0 }, - ) - } - - fn attach_role( - surface: &wl_surface::WlSurface, - role: Option, - opacity: f32, - ) -> Result> { - let backend = surface - .backend() - .upgrade() - .ok_or("libcosmic Wayland connection is no longer alive")?; - let connection = Connection::from_backend(backend); - let (globals, queue) = registry_queue_init::(&connection)?; - let qh = queue.handle(); - let manager = globals.bind::(&qh, 1..=3, ())?; - let effects = manager.get_surface_effects(surface, &qh, ()); - - let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; - if effects.version() >= 3 { - effects.set_opacity(opacity); - } - if let Some(role) = role { - effects.set_role(role); - } else { - effects.clear_role(); - } - connection.flush()?; - - Ok(Self { - connection, - _manager: manager, - surface: effects, - _queue: queue, - regions: Default::default(), - opacity: std::cell::Cell::new(Some(opacity)), - }) - } - - fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box> { - self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover) - } - - fn set_material_regions( - &self, - regions: &[[i32; 5]], - role: ferese_surface_effects_v1::Role, - ) -> Result<(), Box> { - if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) { - return Ok(()); - } - self.surface.set_regions( - regions - .iter() - .flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes())) - .collect(), - ); - if regions.is_empty() { - self.surface.clear_role(); - } else { - self.surface.set_role(role); - } - self.connection.flush()?; - *self.regions.borrow_mut() = Some(regions.to_vec()); - Ok(()) - } - - fn set_visible(&self, visible: bool) -> Result<(), Box> { - self.set_opacity(1.0)?; - if visible { - self.surface.set_role(ferese_surface_effects_v1::Role::Panel); - } else { - self.surface.clear_role(); - } - - self.connection.flush()?; - Ok(()) - } - - fn set_opacity(&self, opacity: f32) -> Result<(), Box> { - let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; - if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) { - self.surface.set_opacity(opacity); - self.connection.flush()?; - self.opacity.set(Some(opacity)); - } - Ok(()) - } -} - -struct EffectsState; - -impl Dispatch for EffectsState { - fn event( - _state: &mut Self, - _registry: &wl_registry::WlRegistry, - _event: wl_registry::Event, - _data: &GlobalListContents, - _connection: &Connection, - _qh: &QueueHandle, - ) { - } -} - -delegate_noop!(EffectsState: ignore FereseEffectsManagerV1); -delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1); - #[cfg(test)] mod tests { use super::*; diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs new file mode 100644 index 0000000..19d73cb --- /dev/null +++ b/shell/ferese-shell/src/surfaces.rs @@ -0,0 +1,556 @@ +use super::{ + Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING, Element, Event, + EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin, + IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific, + Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, avoid_widget_overlap, clamp_note_position, + config, container, delegate_noop, destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, + registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface, +}; + +impl FereseShell { + pub(super) fn output_event(&mut self, event: wayland::OutputEvent, output: wl_output::WlOutput) -> Task { + if matches!(event, wayland::OutputEvent::Removed) { + if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) { + let entry = self.outputs.remove(index); + let menu = if self.bar_surface_id == entry.bar { + self.destroy_menu() + } else { + Task::none() + }; + + let mut tasks = vec![destroy_layer_surface(entry.bar), menu, self.rebuild_system_modal()]; + if self.note_drag.as_ref().is_some_and(|drag| { + entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source) + }) { + tasks.push(self.finish_note_drag(false)); + } + + if let Some(wallpaper) = entry.wallpaper { + tasks.push(destroy_layer_surface(wallpaper)); + } + + if let Some(clock) = entry.clock { + tasks.push(destroy_layer_surface(clock)); + } + + for (_, id) in entry.notes { + self.note_pointer.remove(&id); + tasks.push(destroy_layer_surface(id)); + } + + return Task::batch(tasks); + } + + return Task::none(); + } + + let size = match &event { + wayland::OutputEvent::Created(info) => info.as_ref().and_then(|info| info.logical_size), + wayland::OutputEvent::InfoUpdate(info) => info.logical_size, + _ => None, + }; + let name = match event { + wayland::OutputEvent::Created(info) => info.and_then(|info| info.name), + wayland::OutputEvent::InfoUpdate(info) => info.name, + _ => None, + }; + + if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) { + if size.is_some() { + entry.size = size; + } + + let changed = name.is_some() && entry.name != name; + if name.is_some() { + entry.name = name; + } + + return if changed { + Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)]) + } else { + Task::none() + }; + } + + let bar_surface_id = window::Id::unique(); + let wallpaper_surface_id = window::Id::unique(); + let shell_theme = self.config.theme; + let bar = BarMetrics::from(shell_theme); + let wallpaper_output = output.clone(); + let bar_output = output.clone(); + let hidden = output_bar_hidden( + &self.snapshot, + self.snapshot + .outputs + .iter() + .find(|output| Some(output.name.as_str()) == name.as_deref()) + .map(|output| output.id), + ); + self.outputs.push(OutputSurfaces { + output, + name, + bar: bar_surface_id, + wallpaper: std::env::var_os("FERESE_COMPOSITOR_WALLPAPER") + .is_none() + .then_some(wallpaper_surface_id), + effects: None, + clock: None, + notes: Vec::new(), + size, + hidden, + }); + let wallpaper_action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id: wallpaper_surface_id, + layer: Layer::Background, + keyboard_interactivity: KeyboardInteractivity::None, + input_zone: Some(Vec::new()), + anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT, + output: IcedOutput::Output(wallpaper_output.clone()), + namespace: "ferese-shell-wallpaper".to_owned(), + // Wallpaper covers the full output, including beneath the bar + // and its floating margins. Zero would use the remaining workspace. + exclusive_zone: -1, + size: Some((None, None)), + size_limits: Limits::NONE, + ..Default::default() + }, + Some(Box::new(Self::view_wallpaper)), + ); + let bar_action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id: bar_surface_id, + input_zone: hidden.then(Vec::new), + layer: Layer::Top, + keyboard_interactivity: KeyboardInteractivity::None, + anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, + output: IcedOutput::Output(bar_output.clone()), + namespace: "ferese-shell-top-bar".to_owned(), + margin: IcedMargin { + top: shell_theme.bar_margin_top, + right: shell_theme.bar_margin_horizontal, + bottom: 0, + left: shell_theme.bar_margin_horizontal, + }, + size: Some((None, Some(bar.height.round() as u32))), + size_limits: Limits::NONE, + // Reserve breathing room below the visible bar; layer-shell + // accounts for the top margin separately. + exclusive_zone: (bar.height.round() as i32).saturating_add(shell_theme.bar_window_gap), + }, + Some(Box::new(move |app| app.view_layer(bar_surface_id))), + ); + + // Submit the small interactive surface before the full-screen image. + let mut surfaces = vec![bar_action]; + if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() { + surfaces.push(wallpaper_action); + } + + let tasks = surfaces + .into_iter() + .map(cosmic::Action::Surface) + .map(cosmic::task::message); + + Task::batch([ + Task::batch(tasks), + self.rebuild_clocks(false), + self.rebuild_notes(false), + self.rebuild_system_modal(), + cosmic::task::message(cosmic::Action::App(Message::ShowGuide)), + ]) + } + + pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task { + if let Event::Mouse(mouse) = &event { + if !self + .outputs + .iter() + .any(|entry| entry.clock == Some(id) || entry.notes.iter().any(|(_, surface)| *surface == id)) + { + return Task::none(); + } + + match mouse { + cosmic::iced::mouse::Event::CursorMoved { position } => { + self.note_pointer.insert(id, *position); + + if let Some(drag) = &mut self.note_drag + && drag.source == id + { + let delta = *position - drag.start; + let dimensions = if let Some(id) = &drag.id { + self.config + .desktop_widgets + .notes + .iter() + .find(|note| ¬e.id == id) + .map(|note| (note.width, note.height)) + } else { + let clock = &self.config.desktop_widgets.clock; + Some((clock.width, clock.height)) + }; + + if let Some(dimensions) = dimensions { + let requested = clamp_note_position(drag.origin + delta, drag.output_size, dimensions); + drag.position = avoid_widget_overlap(drag.position, requested, dimensions, &drag.obstacles); + // Move a compact, cached buffer in the compositor instead of + // repainting an output-sized buffer for every pointer event. + return set_margin( + drag.overlay, + drag.position.y.round() as i32, + 0, + 0, + drag.position.x.round() as i32, + ); + } + } + } + + cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) + if self.note_drag.as_ref().is_some_and(|drag| drag.source == id) => + { + return self.finish_note_drag(true); + } + _ => {} + } + } + if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab), + modifiers, + .. + }) = &event + && !modifiers.control() + && self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) + { + return self.navigate_system_modal(modifiers.shift()); + } + if matches!( + &event, + Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), + .. + }) + ) { + if self.system_modal.is_some() { + return self.close_system_modal(); + } + if self.note_drag.is_some() { + return self.finish_note_drag(false); + } + + if self.note_editor.is_some() { + return self.finish_note_edit(); + } + + if self.notifications.history_open { + return self.close_notification_history(); + } + } + + match event { + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output))) => { + self.output_event(event, output) + } + Event::Window(window::Event::Opened { .. }) + if self.outputs.iter().any(|entry| entry.bar == id) + || self.menu.as_ref().is_some_and(|menu| menu.id == id) + || self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) + || self + .notification_surface + .as_ref() + .is_some_and(|surface| surface.id == id) => + { + Task::batch([ + self.focus_system_modal(id), + window::run(id, native_wayland_surface) + .map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))), + ]) + } + Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), + .. + }) if self.menu.is_some() => self.close_menu(), + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(event, surface, popup_id))) => { + if let Some(menu) = &mut self.menu + && menu.id == popup_id + { + match event { + wayland::PopupEvent::Done => { + if menu.kind == status_ui::Menu::Notifications { + self.notifications.history_open = false; + self.notifications.hovered = None; + } + self.menu = None; + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + return self.sync_notification_surface(); + } + wayland::PopupEvent::Configured { .. } => { + menu.prepare_surface(&surface); + } + _ => {} + } + if let Some(effects) = &menu.effects { + let _ = effects.set_opacity(menu.progress()); + } + } + Task::none() + } + Event::Window(window::Event::Closed) if self.menu.as_ref().is_some_and(|menu| menu.id == id) => { + self.menu = None; + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + Task::none() + } + Event::Window(window::Event::Closed) + if self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) => + { + self.close_system_modal() + } + Event::Window(window::Event::Closed) => Task::none(), + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id))) + if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => + { + if let Some(menu) = &mut self.menu + && menu.effects.is_none() + { + menu.prepare_surface(&surface); + } + Task::none() + } + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id))) + if self.outputs.iter().any(|entry| entry.bar == frame_id) => + { + self.attach_effects(frame_id, &surface); + Task::none() + } + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id))) + if self + .notification_surface + .as_ref() + .is_some_and(|entry| entry.id == layer_id) => + { + let entry = self.notification_surface.as_mut().unwrap(); + + if entry.effects.is_none() { + match EffectsBinding::attach_role(&surface, None, 1.0) { + Ok(binding) => entry.effects = Some(binding), + Err(error) => { + eprintln!("ferese-shell: notification effects unavailable: {error}") + } + } + } + Task::none() + } + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id))) + if self.outputs.iter().any(|entry| entry.bar == layer_id) => + { + self.attach_effects(layer_id, &surface); + self.attach_power_material(layer_id, &surface); + Task::none() + } + _ => Task::none(), + } + } + + pub(super) fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) { + let hidden = self.bar_hidden(id); + let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else { + return; + }; + + if entry.effects.is_some() { + return; + } + + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + + match EffectsBinding::attach(surface, !hidden) { + Ok(binding) => entry.effects = Some(binding), + Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"), + } + } + + pub(super) fn view_wallpaper(&self) -> Element<'_, cosmic::Action> { + let Some(handle) = self.wallpaper.as_ref() else { + return container(text("")) + .width(Length::Fill) + .height(Length::Fill) + .class(theme::Container::custom(wallpaper_fallback_style)) + .into(); + }; + let content_fit = match self.config.wallpaper.mode { + WallpaperMode::Fill => ContentFit::Cover, + WallpaperMode::Fit => ContentFit::Contain, + }; + + container( + image(handle.clone()) + .width(Length::Fill) + .height(Length::Fill) + .content_fit(content_fit), + ) + .width(Length::Fill) + .height(Length::Fill) + .class(theme::Container::custom(wallpaper_fallback_style)) + .into() + } +} + +// Resolve a surface while iced guarantees the native handles are alive. Keep +// the borrowed-display connection alive until the effects binding takes over. +pub(super) fn native_wayland_surface( + window: &dyn cosmic::iced::window::Window, +) -> Result<(Connection, wl_surface::WlSurface), String> { + use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; + let display = window.display_handle().map_err(|error| error.to_string())?; + let handle = window.window_handle().map_err(|error| error.to_string())?; + let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) = (display.as_raw(), handle.as_raw()) + else { + return Err("shell material requires a Wayland surface".to_owned()); + }; + // SAFETY: iced lends matching, live Wayland handles for this callback. + // This backend borrows the display; it never disconnects iced's connection. + // Both handles belong to the runtime, which outlives the shell bindings. + let backend = unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) }; + let connection = Connection::from_backend(backend); + // SAFETY: raw-window-handle identifies this pointer as a live wl_surface. + let id = unsafe { + wayland_client::backend::ObjectId::from_ptr(wl_surface::WlSurface::interface(), handle.surface.as_ptr().cast()) + } + .map_err(|error| error.to_string())?; + let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?; + Ok((connection, surface)) +} + +pub(super) fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style { + let [red, green, blue] = config::default_background(); + + container::Style { + background: Some(Background::Color(Color::from_rgb8(red, green, blue))), + ..container::Style::default() + } +} + +pub(super) struct EffectsBinding { + connection: Connection, + _manager: FereseEffectsManagerV1, + pub(super) surface: FereseSurfaceEffectsV1, + _queue: EventQueue, + regions: std::cell::RefCell>>, + opacity: std::cell::Cell>, +} + +impl EffectsBinding { + pub(super) fn attach(surface: &wl_surface::WlSurface, visible: bool) -> Result> { + Self::attach_role( + surface, + visible.then_some(ferese_surface_effects_v1::Role::Panel), + if visible { 1.0 } else { 0.0 }, + ) + } + + pub(super) fn attach_role( + surface: &wl_surface::WlSurface, + role: Option, + opacity: f32, + ) -> Result> { + let backend = surface + .backend() + .upgrade() + .ok_or("libcosmic Wayland connection is no longer alive")?; + let connection = Connection::from_backend(backend); + let (globals, queue) = registry_queue_init::(&connection)?; + let qh = queue.handle(); + let manager = globals.bind::(&qh, 1..=3, ())?; + let effects = manager.get_surface_effects(surface, &qh, ()); + + let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; + if effects.version() >= 3 { + effects.set_opacity(opacity); + } + if let Some(role) = role { + effects.set_role(role); + } else { + effects.clear_role(); + } + connection.flush()?; + + Ok(Self { + connection, + _manager: manager, + surface: effects, + _queue: queue, + regions: Default::default(), + opacity: std::cell::Cell::new(Some(opacity)), + }) + } + + pub(super) fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box> { + self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover) + } + + pub(super) fn set_material_regions( + &self, + regions: &[[i32; 5]], + role: ferese_surface_effects_v1::Role, + ) -> Result<(), Box> { + if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) { + return Ok(()); + } + self.surface.set_regions( + regions + .iter() + .flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes())) + .collect(), + ); + if regions.is_empty() { + self.surface.clear_role(); + } else { + self.surface.set_role(role); + } + self.connection.flush()?; + *self.regions.borrow_mut() = Some(regions.to_vec()); + Ok(()) + } + + pub(super) fn set_visible(&self, visible: bool) -> Result<(), Box> { + self.set_opacity(1.0)?; + if visible { + self.surface.set_role(ferese_surface_effects_v1::Role::Panel); + } else { + self.surface.clear_role(); + } + + self.connection.flush()?; + Ok(()) + } + + pub(super) fn set_opacity(&self, opacity: f32) -> Result<(), Box> { + let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; + if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) { + self.surface.set_opacity(opacity); + self.connection.flush()?; + self.opacity.set(Some(opacity)); + } + Ok(()) + } +} + +struct EffectsState; + +impl Dispatch for EffectsState { + fn event( + _state: &mut Self, + _registry: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + _qh: &QueueHandle, + ) { + } +} + +delegate_noop!(EffectsState: ignore FereseEffectsManagerV1); +delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);