diff --git a/Cargo.lock b/Cargo.lock index 2830377..3f2193d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1745,6 +1745,7 @@ dependencies = [ "ferese-core", "ferese-ipc", "ferese-layout", + "ferese-panel", "ferese-protocols", "ferese-shape", "fontdb", @@ -1776,6 +1777,7 @@ dependencies = [ name = "ferese-config" version = "0.1.0-beta" dependencies = [ + "ferese-panel", "ferese-theme-model", "jiff", "kdl", @@ -1826,6 +1828,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ferese-panel" +version = "0.1.0-beta" +dependencies = [ + "ferese-animation", + "ferese-layout", + "serde", + "serde_json", +] + [[package]] name = "ferese-polkit-agent" version = "0.1.0-beta" @@ -1863,6 +1875,7 @@ version = "0.1.0-beta" dependencies = [ "ferese-config", "ferese-ipc", + "ferese-panel", "ferese-theme", "ferese-theme-client", "iced_accessibility", @@ -1890,6 +1903,8 @@ dependencies = [ "ferese-animation", "ferese-config", "ferese-ipc", + "ferese-layout", + "ferese-panel", "ferese-protocols", "ferese-theme", "ferese-theme-client", @@ -1915,6 +1930,8 @@ name = "ferese-theme" version = "0.1.0-beta" dependencies = [ "ferese-config", + "ferese-layout", + "ferese-panel", "ferese-theme-model", "iced_accessibility", "jiff", diff --git a/Cargo.toml b/Cargo.toml index 74e30d9..a81c385 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/ferese-theme-model", "crates/ferese-ipc", "crates/ferese-layout", + "crates/ferese-panel", "crates/ferese-protocols", "shell/ferese-shell", "shell/ferese-settings", diff --git a/README.md b/README.md index 2532929..7242916 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Ferese combines a window manager and desktop shell with one configuration. -![Ferese desktop with a translucent Now Playing popup showing Spotify artwork and playback controls](docs/images/screenshots/ferese-media.webp) +![Ferese desktop with Morph's music view open over a sunset wallpaper](docs/images/screenshots/ferese-morph.webp) [View more screenshots](docs/screenshots.md) @@ -21,7 +21,7 @@ Ferese combines a window manager and desktop shell with one configuration. - Scrolling columns, window groups, tree tiling and floating windows with saved size and position - Integrated shell with notifications, media controls, Settings and Control Center -- [Custom panels](docs/configuration.md#panel-composition) with editable items, groups, surfaces, and top or bottom placement +- [Custom panels](docs/panel-configuration.md) with seven styles, editable groups and items, and top or bottom placement - Workspace overview and recent-window switching - [Continuum animations](docs/configuration.md#animations): interruptible springs, gesture momentum and shared motion controls - Live wallpapers and blur, custom KDL themes, and Light/Dark/Auto modes @@ -76,7 +76,8 @@ guide](docs/installation.md) also walks you through previews, updates and recove ## Configure Configure Ferese through **Control Center → Settings** or `~/.config/ferese/config.kdl`. -Changes apply live. Invalid edits leave the last working configuration in place. +Changes apply live, and if an edit is invalid, the last working configuration stays +in place. The [configuration reference](docs/configuration.md) and [example config](packaging/config.kdl) cover appearance, window layouts, diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 06b6fe7..07a27b8 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-panel = { path = "../crates/ferese-panel" } ferese-shape.workspace = true ferese-config = { path = "../crates/ferese-config" } ab_glyph = "0.2.32" diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 1595ac2..f910e38 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -32,7 +32,7 @@ use crate::window_rules::{WindowRule, WindowRuleConfig}; #[derive(Debug, Default, Deserialize)] pub struct Config { #[serde(default)] - panels: Option>, + panels: Option>, #[serde(default)] notifications: ferese_config::notifications::NotificationConfig, #[serde(default)] @@ -114,7 +114,7 @@ impl Config { pub(crate) fn runtime_config(&self) -> Result { if let Some(panels) = &self.panels { - ferese_config::panel::validate(panels).map_err(ConfigError::Binding)?; + ferese_panel::validate(panels).map_err(ConfigError::Binding)?; } self.notifications.validate().map_err(ConfigError::Binding)?; self.xwayland.validate().map_err(ConfigError::Binding)?; @@ -136,6 +136,11 @@ impl Config { bindings, window_rules: self.window_rules()?, theme_settings: self.theme_settings()?, + panel_style: self + .panels + .as_ref() + .and_then(|panels| panels.first()) + .map_or(ferese_panel::PanelStyle::Flat, |panel| panel.style), panel_corner_radius: self .panels .as_ref() @@ -458,12 +463,17 @@ mod tests { #[test] fn panel_surfaces_are_validated_before_live_publication() { - for surface in ["none", "inset", "island"] { + for style in ferese_panel::PanelStyle::AVAILABLE { let source = format!( - "panel main {{ surface solid; group-surface {surface}; end {{ group status {{ surface {surface}; }}; }}; }}" + "panel main {{ style {}; end {{ group status {{ item clock kind=clock; }}; }}; }}", + style.key() ); Config::parse_source(&source).unwrap().runtime_config().unwrap(); } + for surface in ["none", "inset", "island"] { + let invalid = format!("panel main {{ end {{ group status {{ surface {surface}; }}; }}; }}"); + assert!(Config::parse_source(&invalid).is_err()); + } assert!(Config::parse_source("panel main { group-surface invalid; }").is_err()); assert!(Config::parse_source("panel main { background islands; }").is_err()); assert!(Config::parse_source("status { bar-layout islands; }").is_err()); @@ -817,6 +827,7 @@ mod tests { focus_ring_gradient: None, shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]), surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]), + surface_raised_color: RgbaColor([30.0 / 255.0, 37.0 / 255.0, 48.0 / 255.0, 1.0]), bar_background_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]), text_primary_color: RgbaColor([244.0 / 255.0, 247.0 / 255.0, 251.0 / 255.0, 1.0]), shell_opacity: 0.78, @@ -906,11 +917,15 @@ mod tests { #[test] fn material_color_comes_from_surface_base() { - let theme = parse("theme {\n colors {\n surface-base \"#000000\"\n }\n surface {\n bar {\n background \"#FFFFFF\"\n }\n }\n}\n") + let theme = parse("theme {\n colors {\n surface-base \"#000000\"\n surface-raised \"#314159\"\n }\n surface {\n bar {\n background \"#FFFFFF\"\n }\n }\n}\n") .theme_settings() .unwrap(); assert_eq!(theme.surface_base_color, RgbaColor([0.0, 0.0, 0.0, 1.0])); assert_eq!(theme.bar_background_color, RgbaColor([1.0, 1.0, 1.0, 1.0])); + assert_eq!( + theme.surface_raised_color, + RgbaColor([49. / 255., 65. / 255., 89. / 255., 1.]) + ); } #[test] diff --git a/compositor/src/config/appearance.rs b/compositor/src/config/appearance.rs index 3cd5afe..bc6d095 100644 --- a/compositor/src/config/appearance.rs +++ b/compositor/src/config/appearance.rs @@ -35,6 +35,7 @@ impl Config { 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")?, + surface_raised_color: parse_color(&self.theme.colors.surface_raised, "colors.surface_raised")?, bar_background_color: parse_color( self.theme .surface @@ -141,6 +142,10 @@ pub(super) fn default_surface_base_color() -> String { "#111821".to_owned() } +pub(super) fn default_surface_raised_color() -> String { + ferese_config::theme::default_tokens().colors.surface_raised +} + pub(super) fn default_accent_color() -> String { "#3D7BE6".to_owned() } @@ -215,6 +220,7 @@ pub struct ThemeSettings { pub focus_ring_gradient: Option, pub shadow_color: RgbaColor, pub surface_base_color: RgbaColor, + pub surface_raised_color: RgbaColor, pub bar_background_color: RgbaColor, pub text_primary_color: RgbaColor, pub shell_opacity: f64, @@ -355,6 +361,8 @@ pub(super) struct ThemeColorsConfig { pub(super) text_primary: String, #[serde(default = "default_surface_base_color")] pub(super) surface_base: String, + #[serde(default = "default_surface_raised_color")] + pub(super) surface_raised: String, #[serde(default = "default_border_color")] pub(super) border: String, #[serde(default = "default_accent_color")] @@ -367,6 +375,7 @@ impl Default for ThemeColorsConfig { fn default() -> Self { Self { surface_base: default_surface_base_color(), + surface_raised: default_surface_raised_color(), text_primary: default_text_primary_color(), border: default_border_color(), accent: default_accent_color(), diff --git a/compositor/src/daemon.rs b/compositor/src/daemon.rs index 3635d3f..36d6a3c 100644 --- a/compositor/src/daemon.rs +++ b/compositor/src/daemon.rs @@ -1,4 +1,3 @@ -//! Session-owned services. Never grant shell/effects privileges to daemons. use std::cell::RefCell; use std::collections::HashMap; use std::process::Child; diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs index f0db0f8..7d52724 100644 --- a/compositor/src/effects.rs +++ b/compositor/src/effects.rs @@ -51,8 +51,15 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity ResolvedMaterial { style, opacity, shadow } } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PanelPresentation { + pub shapes: std::sync::Arc<[ferese_panel::scene::Primitive]>, + pub fusion: f64, +} + #[derive(Debug)] struct PendingPresentation { + panel: Option, role: SemanticRole, regions: Vec<[f64; 5]>, opacity: u32, @@ -67,6 +74,7 @@ struct SurfaceEffectsState { regions: Mutex>>, opacity: Mutex, pending: Mutex>, + panel: Mutex>, region_opacities: Mutex>, presentation_supported: AtomicBool, dismissing: AtomicBool, @@ -81,6 +89,7 @@ impl SurfaceEffectsState { regions: Mutex::new(None), opacity: Mutex::new(1000), pending: Mutex::new(None), + panel: Mutex::new(None), region_opacities: Mutex::new(Vec::new()), presentation_supported: AtomicBool::new(false), dismissing: AtomicBool::new(false), @@ -92,6 +101,7 @@ impl SurfaceEffectsState { return; }; *self.role.lock().unwrap() = Some(pending.role); + *self.panel.lock().unwrap() = pending.panel; *self.regions.lock().unwrap() = Some(pending.regions); *self.region_opacities.lock().unwrap() = pending.region_opacities; if !self.dismissing.load(Ordering::Acquire) { @@ -101,6 +111,7 @@ impl SurfaceEffectsState { } fn set_role(&self, role: Option) -> bool { + self.pending.lock().unwrap().take(); let mut current = self.role.lock().unwrap(); if *current != role { *current = role; @@ -126,7 +137,7 @@ impl SurfaceEffectsUserData { } pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(5, ()); + display.create_global::(6, ()); display.create_global::(1, ()); } @@ -228,6 +239,44 @@ pub(crate) fn surface_regions(surface: &WlSurface) -> Option> { }) } +pub(crate) fn surface_panel_presentation(surface: &WlSurface) -> Option { + with_states(surface, |states| { + states + .data_map + .get::()? + .panel + .lock() + .unwrap() + .clone() + }) +} + +fn decode_panel_shapes(bytes: &[u8], fusion: f64) -> Option { + if !bytes.len().is_multiple_of(40) + || bytes.len() > ferese_panel::scene::MAX_PRIMITIVES * 40 + || !fusion.is_finite() + || !(0. ..=256.).contains(&fusion) + { + return None; + } + let shapes: Vec<_> = bytes + .as_chunks::<40>() + .0 + .iter() + .map(|tuple| { + let values = std::array::from_fn(|i| f32::from_ne_bytes(tuple.as_chunks::<4>().0[i])); + ferese_panel::scene::Primitive::from_packet(values).ok() + }) + .collect::>()?; + if shapes.iter().filter(|p| p.operation == 1).count() > 1 { + return None; + } + Some(PanelPresentation { + shapes: shapes.into(), + fusion, + }) +} + pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec { with_states(surface, |states| { states @@ -361,6 +410,31 @@ impl Dispatch for Ferese { }; match request { + ferese_surface_effects_v1::Request::SetPanelPresentation { + regions, + shapes, + fusion, + opacity, + } => { + let (Some(regions), Some(panel)) = (decode_regions(®ions), decode_panel_shapes(&shapes, fusion)) + else { + return; + }; + if panel.shapes.is_empty() != regions.is_empty() { + return; + } + with_states(&surface, |states| { + if let Some(effects) = states.data_map.get::() { + *effects.pending.lock().unwrap() = Some(PendingPresentation { + panel: Some(panel), + role: SemanticRole::Panel, + region_opacities: vec![1000; regions.len()], + regions, + opacity: opacity.min(1000), + }); + } + }); + } ferese_surface_effects_v1::Request::SetPresentation { role, regions, @@ -377,6 +451,7 @@ impl Dispatch for Ferese { with_states(&surface, |states| { if let Some(effects) = states.data_map.get::() { *effects.pending.lock().unwrap() = Some(PendingPresentation { + panel: None, role, regions, opacity: opacity.min(1000), @@ -510,6 +585,7 @@ fn detach(surface: &WlSurface) -> bool { let role_changed = effects.set_role(None); effects.region_opacities.lock().unwrap().clear(); effects.pending.lock().unwrap().take(); + effects.panel.lock().unwrap().take(); effects.dismissing.store(false, Ordering::Release); let mut opacity = effects.opacity.lock().unwrap(); let opacity_changed = *opacity != 1000; @@ -551,10 +627,82 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) { mod tests { use super::*; + fn shape_bytes(operation: f32) -> Vec { + [1.25f32, 2.5, 100., 36., 0., 0., 18., 18., operation, 0.] + .into_iter() + .flat_map(f32::to_ne_bytes) + .collect() + } + + #[test] + fn panel_shapes_reject_malformed_packets_and_duplicate_cutouts() { + let bytes = shape_bytes(0.); + let decoded = decode_panel_shapes(&bytes, 12.).unwrap(); + assert_eq!(decoded.shapes.len(), 1); + assert_eq!(decoded.shapes[0].bounds.x, 1.25); + assert_eq!(decoded.fusion, 12.); + assert!(decode_panel_shapes(&[], 0.).unwrap().shapes.is_empty()); + assert!(decode_panel_shapes(&bytes[..39], 0.).is_none()); + assert!(decode_panel_shapes(&bytes.repeat(33), 0.).is_none()); + assert!(decode_panel_shapes(&shape_bytes(1.).repeat(2), 0.).is_none()); + for invalid in [f64::NAN, f64::INFINITY, -1., 257.] { + assert!(decode_panel_shapes(&bytes, invalid).is_none()); + } + for invalid in [f32::NAN, 0.5, 6.] { + assert!(decode_panel_shapes(&shape_bytes(invalid), 0.).is_none()); + } + } + + #[test] + fn panel_shape_and_capture_regions_commit_together_and_clear_for_popovers() { + let state = SurfaceEffectsState::new(); + *state.pending.lock().unwrap() = Some(PendingPresentation { + panel: decode_panel_shapes(&shape_bytes(0.), 12.), + role: SemanticRole::Panel, + regions: vec![[1.25, 2.5, 100., 36., 0.]], + opacity: 1000, + region_opacities: vec![1000], + }); + assert!(state.panel.lock().unwrap().is_none()); + assert!(state.regions.lock().unwrap().is_none()); + state.commit_presentation(); + assert_eq!(state.panel.lock().unwrap().as_ref().unwrap().fusion, 12.); + assert_eq!(state.regions.lock().unwrap().as_ref().unwrap().len(), 1); + *state.pending.lock().unwrap() = Some(PendingPresentation { + panel: None, + role: SemanticRole::Popover, + regions: vec![[0., 0., 200., 100., 14.]], + opacity: 800, + region_opacities: vec![1000], + }); + state.commit_presentation(); + assert!(state.panel.lock().unwrap().is_none()); + assert_eq!(*state.role.lock().unwrap(), Some(SemanticRole::Popover)); + assert_eq!(*state.opacity.lock().unwrap(), 800); + } + + #[test] + fn hiding_a_panel_cancels_its_queued_presentation() { + let state = SurfaceEffectsState::new(); + state.set_role(Some(SemanticRole::Panel)); + *state.pending.lock().unwrap() = Some(PendingPresentation { + panel: decode_panel_shapes(&shape_bytes(0.), 0.), + role: SemanticRole::Panel, + regions: vec![[0., 0., 100., 36., 0.]], + opacity: 1000, + region_opacities: vec![1000], + }); + state.set_role(None); + state.commit_presentation(); + assert_eq!(*state.role.lock().unwrap(), None); + assert!(state.panel.lock().unwrap().is_none()); + } + #[test] fn presentation_waits_for_buffer_commit_and_keeps_dismissal_ownership() { let state = SurfaceEffectsState::new(); let queue = |opacity| PendingPresentation { + panel: None, role: SemanticRole::Popover, regions: vec![[1.25, 2.5, 100.0, 80.0, 12.0]], opacity, @@ -618,7 +766,7 @@ mod tests { #[test] fn maximum_panel_groups_and_overflow_fit_one_effects_publication() { - let count = ferese_config::panel::MAX_GROUPS + 1; + let count = ferese_panel::MAX_GROUPS + 1; assert_eq!(count, ferese_protocols::effects::v1::MAX_REGIONS); let region = [0.0f32, 0., 12., 36., 4.]; let bytes: Vec<_> = region.into_iter().flat_map(f32::to_ne_bytes).collect(); diff --git a/compositor/src/render/closing.rs b/compositor/src/render/closing.rs index 010a649..8ffde14 100644 --- a/compositor/src/render/closing.rs +++ b/compositor/src/render/closing.rs @@ -183,6 +183,7 @@ pub(super) fn grouped_elements( output, surface, MaterialSurface { + shape_origin: constrain.loc, geometry: constrain, corners, index: 0, diff --git a/compositor/src/render/corner_tests.rs b/compositor/src/render/corner_tests.rs index 7faa43a..6a83e37 100644 --- a/compositor/src/render/corner_tests.rs +++ b/compositor/src/render/corner_tests.rs @@ -286,6 +286,105 @@ fn rasterize(renderer: &mut GlesRenderer, program: &GlesPixelProgram, uniforms: renderer.map_texture(&mapping).unwrap().to_vec() } +#[test] +#[ignore = "requires an EGL rendering device"] +fn panel_shader_matches_core_outlines_and_resets_for_popovers() { + use ferese_panel::scene::{GroupBounds, Parameters, Scene, derive}; + use ferese_panel::shape::{PixelGrid, Style, ZonePosition}; + use smithay::backend::renderer::gles::UniformValue; + let mut renderer = renderer(); + let mut resources = RenderResources::default(); + let program = material_program_for_corners(&mut resources, &mut renderer, CornerShape::Circular).unwrap(); + let bounds = ferese_layout::Rect::new(0., 0., 64., 64.); + let groups = [ + GroupBounds { + zone: ZonePosition::Start, + bounds: ferese_layout::Rect::new(3., 0., 18., 16.), + }, + GroupBounds { + zone: ZonePosition::Start, + bounds: ferese_layout::Rect::new(25., 0., 18., 16.), + }, + ]; + let mut scenes = Vec::new(); + for style in [ + Style::Floating, + Style::Capsules, + Style::Notch, + Style::Fused, + Style::Morph, + Style::Flat, + Style::Frame, + Style::CornerWrap, + ] { + for edge in [ferese_panel::Edge::Top, ferese_panel::Edge::Bottom] { + for scale in [1., 1.25, 1.5] { + scenes.push( + derive( + Parameters { + style, + edge, + height: 16., + gap: 4., + frame_insets: [16., 4., 4., 4.], + frame_radius: 10., + wrap_size: [40., 42.], + wrap_width: 8., + fillet: 6., + ..Default::default() + }, + bounds, + &groups, + PixelGrid::new(scale).unwrap(), + ) + .unwrap(), + ); + } + } + } + for scene in scenes.iter().chain(std::iter::once(&Scene::default())) { + let matrices = scene + .packets() + .into_iter() + .map(|p| [p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8] * 4. + p[9]]) + .collect::>(); + let mut uniforms = vec![ + Uniform::new("visible_rect", [0.0f32, 0., 64., 64.]), + Uniform::new("material_radii", [12.0f32; 4]), + Uniform::new("tint", [1.0f32; 4]), + Uniform::new("paint_mode", 0.0f32), + Uniform::new("panel_count", matrices.len() as f32), + Uniform::new("panel_fusion", scene.fusion as f32), + ]; + if !matrices.is_empty() { + uniforms.push(Uniform::new( + "panel_shapes[0]", + UniformValue::Matrix3x3 { + matrices, + transpose: false, + }, + )); + } + let pixels = rasterize(&mut renderer, &program.0, &uniforms); + for y in 0..64 { + for x in 0..64 { + let point = [x as f64 + 0.5, y as f64 + 0.5]; + let distance = if scene.paint.is_empty() { + reference_distance(point, [0., 0., 64., 64.], 12., CornerShape::Circular) + } else { + scene.distance(point) + }; + let expected = (coverage(distance) * 255.).round() as u8; + let actual = pixels[(y * 64 + x) * 4 + 3]; + assert!( + actual.abs_diff(expected) <= 2, + "panel mask mismatch at ({x},{y}): {actual} != {expected}, {scene:?}" + ); + } + } + } +} + #[test] #[ignore = "requires an EGL rendering device"] fn apple_window_fill_and_inset_border_match_path_reference_at_fractional_scales() { @@ -412,10 +511,8 @@ fn docked_panel_fill_covers_the_entire_attached_edge() { let mut renderer = renderer(); let mut resources = RenderResources::default(); let material = material_program_for_corners(&mut resources, &mut renderer, CornerShape::Circular).unwrap(); - for edge in [ferese_config::panel::Edge::Top, ferese_config::panel::Edge::Bottom] { - let radii = ferese_config::panel::CornerRadii([14., 8., 12., 20.]) - .at_edge(edge, true) - .0; + for edge in [ferese_panel::Edge::Top, ferese_panel::Edge::Bottom] { + let radii = ferese_panel::CornerRadii([14., 8., 12., 20.]).at_edge(edge, true).0; let pixels = rasterize( &mut renderer, &material.0, @@ -428,7 +525,7 @@ fn docked_panel_fill_covers_the_entire_attached_edge() { Uniform::new("shadow_values", [8.0f32, 1.0]), ], ); - let (attached_row, free_row) = if edge == ferese_config::panel::Edge::Top { + let (attached_row, free_row) = if edge == ferese_panel::Edge::Top { (0, 35) } else { (35, 0) diff --git a/compositor/src/render/materials.rs b/compositor/src/render/materials.rs index 9090546..fe66d55 100644 --- a/compositor/src/render/materials.rs +++ b/compositor/src/render/materials.rs @@ -46,6 +46,7 @@ pub(super) fn append_material_surface( output, surface, MaterialSurface { + shape_origin: content_origin, geometry: *rect, corners: *corners, index, @@ -148,6 +149,7 @@ fn framebuffer_corner_radii(radii: [f32; 4], transform: Transform) -> [f32; 4] { } pub(super) struct MaterialSurface { + pub shape_origin: Point, pub geometry: Rectangle, pub corners: RoundedRect, pub index: usize, @@ -177,6 +179,7 @@ fn resolve_surface_material( role: crate::effects::SemanticRole, settings: crate::config::ThemeSettings, panel_opacity: Option, + panel_style: ferese_panel::PanelStyle, ) -> crate::effects::ResolvedMaterial { let mut material = crate::effects::resolve_material(role, settings.material_style, settings.shell_opacity as f32); if role == crate::effects::SemanticRole::Panel { @@ -185,6 +188,9 @@ fn resolve_surface_material( panel_opacity, settings.reduce_transparency, ); + if panel_style == ferese_panel::PanelStyle::Morph { + material.shadow = [0.; 3]; + } } material } @@ -198,13 +204,19 @@ pub(super) fn material_element_with_role( (role, generation, opacity): (crate::effects::SemanticRole, u64, f32), ) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { let MaterialSurface { + shape_origin, geometry, corners, index, capture_geometry, alpha, } = surface_geometry; - let material = resolve_surface_material(role, state.theme_settings, state.panel_background_opacity); + let material = resolve_surface_material( + role, + state.theme_settings, + state.panel_background_opacity, + state.panel_style, + ); let presentation_alpha = opacity * alpha; let mode = output.current_mode()?; let scale = output.current_scale().fractional_scale(); @@ -214,6 +226,12 @@ pub(super) fn material_element_with_role( } else { state.theme_settings.surface_base_color }; + let morph = role == crate::effects::SemanticRole::Panel && state.panel_style == ferese_panel::PanelStyle::Morph; + let background = if morph { + state.theme_settings.surface_raised_color + } else { + background + }; let [red, green, blue, _] = background.0; let logical_radii = if role == crate::effects::SemanticRole::Panel { state.panel_corner_radius @@ -234,9 +252,9 @@ pub(super) fn material_element_with_role( let touches_top = role == crate::effects::SemanticRole::Panel && geometry.loc.y == 0; let touches_bottom = role == crate::effects::SemanticRole::Panel && geometry.loc.y + geometry.size.h >= output_size.h; - let radii = ferese_config::panel::CornerRadii(radii) + let radii = ferese_panel::CornerRadii(radii) .at_top_edge(touches_top) - .at_edge(ferese_config::panel::Edge::Bottom, touches_bottom) + .at_edge(ferese_panel::Edge::Bottom, touches_bottom) .0; let radius = radii.into_iter().fold(0., f32::max); let corner_radii = framebuffer_corner_radii(radii, transform); @@ -264,7 +282,13 @@ pub(super) fn material_element_with_role( let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur); 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 panel = (role == crate::effects::SemanticRole::Panel) + .then(|| crate::effects::surface_panel_presentation(surface)) + .flatten() + .map(|presentation| panel_matrices(&presentation, shape_origin, scale, mode.size, transform)); + let opaque = panel.is_none() && material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0; let mut parameters = MaterialParameters { + panel, corner_radii, presentation_alpha, background_opacity, @@ -278,7 +302,7 @@ pub(super) fn material_element_with_role( geometry, tint: [red, green, blue, material.opacity * presentation_alpha], generation, - opaque: material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0, + opaque, visible_framebuffer: framebuffer_clip_rect(corners.rect, mode.size, transform), }; let context = renderer.context_id().erased(); @@ -377,6 +401,7 @@ pub(super) fn material_element_with_role( || cached.parameters.shadow_bounds != parameters.shadow_bounds || cached.parameters.corner_radii != parameters.corner_radii || cached.parameters.visible_framebuffer != parameters.visible_framebuffer + || cached.parameters.panel != parameters.panel { cached.shadow.resize(parameters.shadow_bounds, None); cached.shadow.update_uniforms(decoration_uniforms(¶meters, 2.0)); @@ -437,8 +462,8 @@ pub(super) fn blur_program(resources: &mut RenderResources, renderer: &mut GlesR } } -pub(super) fn blur_uniform_names() -> [UniformName<'static>; 8] { - [ +pub(super) fn blur_uniform_names() -> Vec> { + let mut names = vec![ UniformName::new("visible_rect", UniformType::_4f), UniformName::new("material_radii", UniformType::_4f), UniformName::new("texture_size", UniformType::_2f), @@ -447,11 +472,13 @@ pub(super) fn blur_uniform_names() -> [UniformName<'static>; 8] { UniformName::new("presentation_alpha", UniformType::_1f), UniformName::new("background_opacity", UniformType::_1f), UniformName::new("tint", UniformType::_4f), - ] + ]; + names.extend(panel_uniform_names()); + names } pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec> { - vec![ + let mut uniforms = vec![ Uniform::new("visible_rect", p.visible_framebuffer), Uniform::new("material_radii", p.corner_radii), Uniform::new("texture_size", [p.sample_framebuffer[2], p.sample_framebuffer[3]]), @@ -460,7 +487,9 @@ pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec> { Uniform::new("presentation_alpha", p.presentation_alpha), Uniform::new("background_opacity", p.background_opacity), Uniform::new("tint", p.tint), - ] + ]; + uniforms.extend(panel_uniforms(p)); + uniforms } pub(super) fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] { @@ -502,7 +531,7 @@ pub(super) fn material_program_for_corners( return Some(program.clone()); } - let uniforms = [ + let mut uniforms = vec![ UniformName::new("visible_rect", UniformType::_4f), UniformName::new("material_radii", UniformType::_4f), UniformName::new("tint", UniformType::_4f), @@ -510,6 +539,7 @@ pub(super) fn material_program_for_corners( UniformName::new("shadow_rect", UniformType::_4f), UniformName::new("shadow_values", UniformType::_2f), ]; + uniforms.extend(panel_uniform_names()); match renderer.compile_custom_pixel_shader(corner_shader_for(MATERIAL_SHADER, shape), &uniforms) { Ok(program) => { let program = MaterialProgram(program); @@ -532,16 +562,110 @@ pub(super) fn material_uniforms(parameters: &MaterialParameters) -> Vec Vec> { - vec![ + let mut uniforms = vec![ Uniform::new("paint_mode", paint_mode), Uniform::new("shadow_rect", parameters.shadow_rect), Uniform::new("shadow_values", parameters.shadow_values), Uniform::new("visible_rect", parameters.visible_framebuffer), Uniform::new("material_radii", parameters.corner_radii), Uniform::new("tint", parameters.tint), + ]; + uniforms.extend(panel_uniforms(parameters)); + uniforms +} + +fn panel_uniform_names() -> [UniformName<'static>; 3] { + [ + UniformName::new("panel_count", UniformType::_1f), + UniformName::new("panel_fusion", UniformType::_1f), + UniformName::new("panel_shapes[0]", UniformType::Matrix3x3), ] } +fn panel_uniforms(parameters: &MaterialParameters) -> Vec> { + use smithay::backend::renderer::gles::UniformValue; + let mut uniforms = vec![ + Uniform::new( + "panel_count", + parameters.panel.as_ref().map_or(0., |p| p.shapes.len() as f32), + ), + Uniform::new("panel_fusion", parameters.panel.as_ref().map_or(0., |p| p.fusion)), + ]; + if let Some(panel) = ¶meters.panel + && !panel.shapes.is_empty() + { + uniforms.push(Uniform::new( + "panel_shapes[0]", + UniformValue::Matrix3x3 { + matrices: panel.shapes.clone(), + transpose: false, + }, + )); + } + uniforms +} + +fn panel_matrices( + presentation: &crate::effects::PanelPresentation, + origin: Point, + scale: f64, + size: Size, + transform: Transform, +) -> PanelMatrices { + let permutation = framebuffer_corner_radii([0., 1., 2., 3.], transform); + let shapes = presentation + .shapes + .iter() + .map(|primitive| { + let b = primitive.bounds; + let left = ((b.x + f64::from(origin.x)) * scale).round() as i32; + let top = ((b.y + f64::from(origin.y)) * scale).round() as i32; + let right = ((b.x + b.width + f64::from(origin.x)) * scale).round() as i32; + let bottom = ((b.y + b.height + f64::from(origin.y)) * scale).round() as i32; + let physical = Rectangle::new((left, top).into(), (right - left, bottom - top).into()); + let rect = framebuffer_clip_rect(physical, size, transform); + let radii = primitive.radii.0.map(|r| (f64::from(r) * scale) as f32); + let radii = framebuffer_corner_radii( + ferese_panel::CornerRadii(radii) + .normalized(physical.size.w as f32, physical.size.h as f32) + .0, + transform, + ); + let operation = if primitive.operation >= 2 { + let old_corner = match primitive.operation { + 2 => 3, + 3 => 2, + 4 => 1, + _ => 0, + }; + match permutation.iter().position(|i| *i == old_corner as f32).unwrap() { + 0 => 5, + 1 => 4, + 2 => 3, + _ => 2, + } + } else { + primitive.operation + }; + [ + rect[0], + rect[1], + rect[2], + rect[3], + radii[0], + radii[1], + radii[2], + radii[3], + f32::from(operation * 4 + primitive.blend_zone), + ] + }) + .collect(); + PanelMatrices { + shapes, + fusion: (presentation.fusion * scale) as f32, + } +} + pub(super) fn blur_damage( size: Size, current: CommitCounter, @@ -562,17 +686,18 @@ mod region_geometry_tests { #[test] fn reduce_transparency_wins_over_continuous_and_island_panel_materials() { use crate::effects::SemanticRole; - for surface in ["solid", "none"] { + for surface in ["flat", "floating", "morph"] { for opacity in [0., 0.4, 1.] { for reduce in [false, true] { let source = format!( - "theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ surface {surface}; group-surface island; background-opacity {opacity}; }}" + "theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ style {surface}; background-opacity {opacity}; }}" ); let (_, runtime, candidate) = crate::theme::prepare(&source, std::path::Path::new("/tmp")).unwrap(); let material = resolve_surface_material( SemanticRole::Panel, runtime.theme_settings, runtime.panel_background_opacity, + runtime.panel_style, ); assert_eq!(material.opacity, if reduce { 1. } else { opacity }); assert_eq!(material.opacity, candidate.theme.panel_opacity(Some(opacity))); @@ -587,13 +712,43 @@ mod region_geometry_tests { if reduce { assert_eq!(runtime.theme_settings.backdrop_blur, 0.); } - let popover = resolve_surface_material(SemanticRole::Popover, runtime.theme_settings, Some(0.)); + let popover = resolve_surface_material( + SemanticRole::Popover, + runtime.theme_settings, + Some(0.), + runtime.panel_style, + ); assert!((popover.opacity - if reduce { 1. } else { 0.7 }).abs() < 0.001); } } } } + #[test] + fn morph_inherits_translucency_and_blur_without_adding_a_shadow() { + use crate::effects::SemanticRole; + for reduce in [false, true] { + let source = format!( + "theme {{ material {{ style translucent; opacity 0.6; blur-radius 20; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ style morph; }}" + ); + let (_, runtime, candidate) = crate::theme::prepare(&source, std::path::Path::new("/tmp")).unwrap(); + let material = + resolve_surface_material(SemanticRole::Panel, runtime.theme_settings, None, runtime.panel_style); + assert_eq!(material.shadow, [0.; 3]); + assert_eq!(material.opacity, candidate.theme.panel_opacity(None)); + assert_eq!( + material.style, + if reduce { + crate::config::MaterialStyle::Solid + } else { + crate::config::MaterialStyle::Translucent + } + ); + let blur = material_blur_radius(material.style, material.opacity, runtime.theme_settings.backdrop_blur); + assert_eq!(blur > 0., !reduce); + } + } + #[test] fn framebuffer_transforms_preserve_each_logical_corner() { let radii = [1., 2., 3., 4.]; diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index 70420fc..f66b228 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -136,6 +136,7 @@ fn corner_shader_for(source: &str, shape: CornerShape) -> String { .replace("precision mediump float;", "precision highp float;"), } .replace("//_MATERIAL_CORNERS_", include_str!("shaders/material_corners.glsl")) + .replace("//_PANEL_MASK_", include_str!("shaders/panel_mask.glsl")) } pub(crate) fn window_corner_shape(_window: &smithay::desktop::Window) -> CornerShape { @@ -338,8 +339,15 @@ pub(crate) struct OverviewScrim { #[derive(Clone, Debug)] pub(crate) struct MaterialProgram(GlesPixelProgram); +#[derive(Clone, Debug, PartialEq)] +struct PanelMatrices { + shapes: Vec<[f32; 9]>, + fusion: f32, +} + #[derive(Clone, Debug, PartialEq)] struct MaterialParameters { + panel: Option, corner_radii: [f32; 4], presentation_alpha: f32, background_opacity: f32, diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index b99dc39..9d29e3d 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -328,6 +328,7 @@ fn scene_elements( output, surface, MaterialSurface { + shape_origin: constrain.loc, geometry: constrain, corners, index: 0, diff --git a/compositor/src/render/shaders/blur_shader.frag b/compositor/src/render/shaders/blur_shader.frag index 236cc14..ce8d49f 100644 --- a/compositor/src/render/shaders/blur_shader.frag +++ b/compositor/src/render/shaders/blur_shader.frag @@ -21,9 +21,10 @@ uniform vec4 tint; varying vec2 v_coords; //_CORNERS_ //_MATERIAL_CORNERS_ +//_PANEL_MASK_ void main() { - float sdf = rounded_rect_corners_distance(gl_FragCoord.xy, visible_rect, material_radii); + float sdf = material_distance(gl_FragCoord.xy, visible_rect, material_radii); float coverage = alpha * presentation_alpha * edge_coverage(sdf); if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; } vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size; diff --git a/compositor/src/render/shaders/material_shader.frag b/compositor/src/render/shaders/material_shader.frag index 1dfae9b..6effa26 100644 --- a/compositor/src/render/shaders/material_shader.frag +++ b/compositor/src/render/shaders/material_shader.frag @@ -11,15 +11,16 @@ varying vec2 v_coords; //_CORNERS_ //_MATERIAL_CORNERS_ +//_PANEL_MASK_ void main() { - float sdf = rounded_rect_corners_distance(gl_FragCoord.xy, visible_rect, material_radii); + float sdf = material_distance(gl_FragCoord.xy, visible_rect, material_radii); float coverage = edge_coverage(sdf); if (paint_mode > 0.5) { // The shadow pass is multiplied by (1 - coverage). Fully covered // pixels need neither a second contour solve nor a Gaussian sample. if (coverage >= 1.0) { gl_FragColor = vec4(0.0); return; } - float distance = max(rounded_rect_corners_distance(gl_FragCoord.xy, shadow_rect, material_radii), 0.0); + float distance = max(material_distance(gl_FragCoord.xy - (shadow_rect.xy - visible_rect.xy), visible_rect, material_radii), 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; diff --git a/compositor/src/render/shaders/panel_mask.glsl b/compositor/src/render/shaders/panel_mask.glsl new file mode 100644 index 0000000..545f56b --- /dev/null +++ b/compositor/src/render/shaders/panel_mask.glsl @@ -0,0 +1,58 @@ +// The same signed-distance mask is used by tint, blur and shadow. Shapes are +// bounded surface-local geometry transformed into framebuffer coordinates. +uniform float panel_count; +uniform float panel_fusion; +uniform mat3 panel_shapes[32]; + +float panel_box_distance(vec2 p, vec4 b) { + vec2 q = abs(p - b.xy - b.zw * 0.5) - b.zw * 0.5; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0); +} +float panel_round_distance(vec2 p, vec4 b, vec4 r) { + vec2 q = p - b.xy; + float d = panel_box_distance(p, b); + vec2 a = q; + if (r.x > 0.0 && a.x < r.x && a.y < r.x) d = max(d, length(a - r.x) - r.x); + a = vec2(b.z - q.x, q.y); + if (r.y > 0.0 && a.x < r.y && a.y < r.y) d = max(d, length(a - r.y) - r.y); + a = b.zw - q; + if (r.z > 0.0 && a.x < r.z && a.y < r.z) d = max(d, length(a - r.z) - r.z); + a = vec2(q.x, b.w - q.y); + if (r.w > 0.0 && a.x < r.w && a.y < r.w) d = max(d, length(a - r.w) - r.w); + return d; +} +float panel_smin(float a, float b) { + if (panel_fusion <= 0.0) return min(a, b); + float h = max(panel_fusion - abs(a - b), 0.0) / panel_fusion; + return min(a, b) - h * h * panel_fusion * 0.25; +} +float panel_distance(vec2 p) { + vec4 unions = vec4(1.0e8); + float cutout = 1.0e8; + for (int i = 0; i < 32; i++) { + if (float(i) >= panel_count) break; + mat3 m = panel_shapes[i]; + vec4 b = vec4(m[0].xyz, m[1].x); + vec4 r = vec4(m[1].yz, m[2].xy); + float op = floor(m[2].z / 4.0); + float zone = mod(m[2].z, 4.0); + float d = panel_round_distance(p, b, r); + if (op > 1.5) { + vec2 c = b.xy; + if (op < 2.5) c.y += b.w; + else if (op < 3.5) c += b.zw; + else if (op < 4.5) c.x += b.z; + d = max(panel_box_distance(p, b), min(b.z, b.w) - length(p - c)); + } + if (op > 0.5 && op < 1.5) cutout = min(cutout, d); + else if (zone < 0.5) unions.x = min(unions.x, d); + else if (zone < 1.5) unions.y = panel_smin(unions.y, d); + else if (zone < 2.5) unions.z = panel_smin(unions.z, d); + else unions.w = panel_smin(unions.w, d); + } + return max(min(min(unions.x, unions.y), min(unions.z, unions.w)), -cutout); +} +float material_distance(vec2 p, vec4 b, vec4 r) { + if (panel_count > 0.5) return panel_distance(p); + return rounded_rect_corners_distance(p, b, r); +} diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 6fd1e7a..1910620 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities; use crate::state::ClientState; pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(6, ()); + display.create_global::(8, ()); } pub(crate) fn config_chunks(source: &str) -> Vec<&str> { @@ -120,6 +120,18 @@ impl Dispatch for Ferese { send_request_failed(shell, ferese_shell_v1::FailedRequest::ActivateWorkspace, workspace.0); } } + ferese_shell_v1::Request::MoveWindowToWorkspace { + window_hi, + window_lo, + workspace_hi, + workspace_lo, + } => { + let window = join_id(window_hi, window_lo); + let workspace = WorkspaceId(join_u64(workspace_hi, workspace_lo)); + if !state.move_managed_window_to_workspace(window, workspace) { + send_request_failed(shell, ferese_shell_v1::FailedRequest::MoveWindowToWorkspace, window.0); + } + } ferese_shell_v1::Request::ConfirmLogout { serial } => { let snapshot = state.portal_session.snapshot(); let token = snapshot["query-token"].as_u64().unwrap_or(0) as u32; @@ -327,12 +339,21 @@ impl Ferese { } fn shell_snapshot_matches(&self, snapshot: &ShellSnapshot) -> bool { + if snapshot.workspace_preview_revision != self.workspace_preview_revision { + return false; + } let mut outputs = self.output_workspaces.connected_outputs(); for previous in &snapshot.outputs { if outputs.next() != Some(previous.id) || self.output_names.get(&previous.id) != Some(&previous.name) || self.output_workspaces.active_workspace(previous.id) != Some(previous.active_workspace) + || self + .output_ids + .iter() + .find(|(_, id)| **id == previous.id) + .and_then(|(output, _)| self.output_bounds_for(output)) + != previous.bounds || (self.output_workspaces.focused_output() == Some(previous.id)) != previous.focused { return false; @@ -377,6 +398,7 @@ impl Ferese { || previous.app_id != app_id || previous.title != title || previous.state != state + || previous.source_geometry != self.shell_window_geometry(id) { return false; } @@ -387,6 +409,7 @@ impl Ferese { fn shell_snapshot(&self) -> ShellSnapshot { ShellSnapshot { + workspace_preview_revision: self.workspace_preview_revision, outputs: self.output_snapshots(), workspaces: self.workspace_snapshots(), windows: self.managed_window_snapshots(), @@ -416,6 +439,7 @@ impl Ferese { name: output.name(), active_workspace: self.output_workspaces.active_workspace(*id)?, focused: focused == Some(*id), + bounds: self.output_bounds_for(output), }) }) .collect::>(); @@ -445,6 +469,9 @@ impl Ferese { self.windows.window(id)?.toplevel()?; let mut state = ferese_shell_v1::WindowState::empty(); + if record.maximized { + state |= ferese_shell_v1::WindowState::Maximized; + } if self.focused_window == Some(id) { state |= ferese_shell_v1::WindowState::Focused; } @@ -467,7 +494,13 @@ impl Ferese { Some((id, workspace.0, &record.app_id, &record.title, state)) } + fn shell_window_geometry(&self, id: WindowId) -> Option { + let rect = self.windows.geometry(&id)?.visual.target; + (rect.width > 0. && rect.height > 0.).then_some(rect) + } + fn managed_window_snapshots(&self) -> Vec { + let previews = self.workspace_preview_rects(); self.windows .ordered_ids() .filter_map(|id| self.shell_window_data(id)) @@ -477,6 +510,8 @@ impl Ferese { app_id: app_id.to_owned(), title: title.to_owned(), state, + source_geometry: self.shell_window_geometry(id), + geometry: previews.get(&id).copied().or_else(|| self.shell_window_geometry(id)), }) .collect() } @@ -518,6 +553,7 @@ enum ShellWindowAction { #[derive(Clone, Debug, PartialEq)] pub(crate) struct ShellSnapshot { + workspace_preview_revision: u64, outputs: Vec, workspaces: Vec, windows: Vec, @@ -568,6 +604,11 @@ impl ShellSnapshot { snapshot.title.clone(), snapshot.state, ); + if shell.version() >= 8 + && let Some(rect) = snapshot.geometry + { + shell.window_geometry(window_hi, window_lo, rect.x, rect.y, rect.width, rect.height); + } } shell.snapshot_end(serial); } @@ -580,10 +621,13 @@ struct ManagedWindowSnapshot { app_id: String, title: String, state: ferese_shell_v1::WindowState, + geometry: Option, + source_geometry: Option, } #[derive(Clone, Debug, PartialEq)] struct OutputSnapshot { + bounds: Option, id: OutputId, name: String, active_workspace: WorkspaceId, @@ -661,17 +705,22 @@ mod tests { state.space.map_output(&output, (0, 0)); state.register_output(&output, "snapshot-test".into()); let original = state.shell_snapshot(); + let initial_cache = state.preview_cache_stats(); for _ in 0..100 { + // Building a snapshot for metadata changes must reuse layout geometry. + assert_eq!(state.shell_snapshot(), original); assert!(state.shell_snapshot_matches(&original)); } + assert_eq!(state.preview_cache_stats(), initial_cache); state .workspaces .insert_window(WindowId(99), ferese_layout::Axis::Horizontal, 0.5) .unwrap(); assert!(!state.shell_snapshot_matches(&original)); let occupied = state.shell_snapshot(); + assert_eq!(state.preview_cache_stats().0, initial_cache.0 + 1); assert!(state.shell_snapshot_matches(&occupied)); let output_id = state.output_ids[&output]; let next = state.workspaces.create_workspace(); @@ -682,8 +731,60 @@ mod tests { assert!(!state.shell_snapshot_matches(&assigned)); let selected = state.shell_snapshot(); assert!(state.shell_snapshot_matches(&selected)); + assert_eq!(state.output_workspaces.active_workspace(output_id), Some(next)); + assert_eq!(state.workspaces.workspace(next).unwrap().layout.window_ids().count(), 0); + let hidden_before = state.workspace_preview_rects()[&WindowId(99)]; + let before = state.preview_cache_stats(); + state.output_names.insert(output_id, "Renamed".into()); + state.shell_snapshot(); + assert_eq!(state.preview_cache_stats(), before, "names do not affect previews"); + state.output_names.insert(output_id, "snapshot-test".into()); + // The second workspace is empty; modifying its layout should not solve the hidden one. + state + .workspaces + .workspace_mut(next) + .unwrap() + .layout + .insert(WindowId(100), None, ferese_layout::Axis::Horizontal, 0.5) + .unwrap(); + state.workspace_preview_rects(); + assert_eq!(state.preview_cache_stats().0, before.0 + 1); + state + .workspaces + .workspace_mut(next) + .unwrap() + .layout + .remove(WindowId(100)) + .unwrap(); + state.workspace_preview_rects(); + let selected = state.shell_snapshot(); + state + .reload_config_source("layout { inner-gap 30; outer-gap 40; smart-gaps #false; }".into()) + .unwrap(); + assert!( + !state.shell_snapshot_matches(&selected), + "hidden previews must invalidate even with no visible window geometry to change" + ); + assert_ne!(state.workspace_preview_rects()[&WindowId(99)], hidden_before); + let refreshed = state.shell_snapshot(); + assert!(state.shell_snapshot_matches(&refreshed)); + state + .reload_config_source( + "layout { inner-gap 30; outer-gap 40; smart-gaps #false; }; input { repeat-rate 35; }".into(), + ) + .unwrap(); + assert_eq!( + state.workspace_preview_revision, refreshed.workspace_preview_revision, + "unrelated settings do not invalidate layouts" + ); state.unregister_output(&output); - assert!(!state.shell_snapshot_matches(&selected)); + assert!(!state.shell_snapshot_matches(&refreshed)); + state.workspace_preview_rects(); + assert_eq!( + state.preview_cache_stats().1, + 0, + "removed outputs release cached previews" + ); } #[test] @@ -708,7 +809,9 @@ mod tests { #[test] fn snapshot_deduplication_preserves_shell_visible_changes() { let original = ShellSnapshot { + workspace_preview_revision: 0, outputs: vec![OutputSnapshot { + bounds: None, id: OutputId(1), name: "display".into(), active_workspace: WorkspaceId(1), @@ -729,10 +832,13 @@ mod tests { app_id: "terminal".into(), title: "shell".into(), state: ferese_shell_v1::WindowState::Focused, + geometry: None, + source_geometry: None, }], }; assert_eq!(original, original.clone()); let changes: &[fn(&mut ShellSnapshot)] = &[ + |s| s.workspace_preview_revision += 1, |s| s.outputs[0].focused = false, |s| s.outputs[0].active_workspace = WorkspaceId(2), |s| s.outputs.clear(), diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 0bbe6ac..cbaf75b 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -464,8 +464,9 @@ pub struct Ferese { workspace_auto_back_and_forth: bool, pub(crate) window_rules: Vec, pub(crate) theme_settings: ThemeSettings, - pub(crate) panel_corner_radius: Option, + pub(crate) panel_corner_radius: Option, pub(crate) panel_background_opacity: Option, + pub(crate) panel_style: ferese_panel::PanelStyle, pub(crate) focus_effect: crate::config::FocusEffectSettings, animations_enabled: bool, animation_speed: f64, @@ -505,6 +506,8 @@ pub struct Ferese { pub(crate) shell_resources: Vec>, pub(crate) shell_snapshot_serial: u32, pub(crate) last_shell_snapshot: Option, + pub(crate) workspace_preview_revision: u64, + workspace_previews: std::cell::RefCell, pub(crate) overview: OverviewState, next_output_id: u64, last_animation_tick: Instant, @@ -557,8 +560,9 @@ pub struct RuntimeConfig { pub workspace_auto_back_and_forth: bool, pub window_rules: Vec, pub theme_settings: ThemeSettings, - pub(crate) panel_corner_radius: Option, + pub(crate) panel_corner_radius: Option, pub(crate) panel_background_opacity: Option, + pub(crate) panel_style: ferese_panel::PanelStyle, pub focus_effect: crate::config::FocusEffectSettings, pub default_column_width: ColumnWidth, pub scrolling_focus_strategy: ViewportFocusStrategy, @@ -746,6 +750,7 @@ impl Ferese { theme_settings: config.theme_settings, panel_corner_radius: config.panel_corner_radius, panel_background_opacity: config.panel_background_opacity, + panel_style: config.panel_style, focus_effect: config.focus_effect, animations_enabled: config.animations_enabled, animation_speed: config.animation_speed, @@ -780,6 +785,8 @@ impl Ferese { shell_resources: Vec::new(), shell_snapshot_serial: 0, last_shell_snapshot: None, + workspace_preview_revision: 0, + workspace_previews: Default::default(), overview: OverviewState::with_font_family(config.overview_font_family), next_output_id: 1, last_animation_tick: start_time, @@ -849,7 +856,9 @@ impl Ferese { let input_changed = changed(&["input"]); let daemons_changed = changed(&["autostart"]); let lock_changed = changed(&["lock_screen"]); - let non_output_scene_changed = layout_changed || changed(&["animations", "appearance", "window_rules"]); + let panel_layout_changed = self.panel_style != config.panel_style; + let non_output_scene_changed = + layout_changed || panel_layout_changed || changed(&["animations", "appearance", "window_rules"]); let scene_changed = non_output_scene_changed || changed(&["output_profiles"]); if self.output_profiles != config.output_profiles { @@ -921,6 +930,10 @@ impl Ferese { ) .map_err(|error| error.to_string())?; } + if layout_changed || self.gap_config != config.gap_config || panel_layout_changed { + // Hidden layouts are resolved independently of presented geometry. + self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1); + } self.gap_config = config.gap_config; self.input_settings = config.input_settings; if bindings_changed { @@ -936,6 +949,7 @@ impl Ferese { self.theme_settings = config.theme_settings; self.panel_corner_radius = config.panel_corner_radius; self.panel_background_opacity = config.panel_background_opacity; + self.panel_style = config.panel_style; self.focus_effect = config.focus_effect; self.column_width_presets = config.column_width_presets; self.animations_enabled = config.animations_enabled; diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index a37c2e4..e92b41b 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -1,6 +1,98 @@ use super::*; +#[derive(Default)] +pub(super) struct PreviewCache { + entries: HashMap, + #[cfg(test)] + pub(super) resolutions: usize, +} + +struct Preview { + layout: WorkspaceLayout, + input: PreviewInput, + rectangles: HashMap, +} + +#[derive(PartialEq)] +struct PreviewInput { + bounds: Rect, + maximized_bounds: Rect, + fullscreen_bounds: Rect, + gaps: GapConfig, + focused: Option, + fullscreen: Option, + windows: Vec, +} + +#[derive(PartialEq)] +struct PreviewWindow { + id: WindowId, + constraints: SizeConstraints, + floating: Option, + maximized: bool, +} + impl Ferese { + /// Morph owns the gap on its reserved edge. Keep the layout's outer gap + /// on the other three edges, and keep floating work-area bounds unchanged. + pub(super) fn morph_layout_bounds(&self, mut bounds: Rect, outer: f64) -> Rect { + use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone}; + if self.panel_style != ferese_panel::PanelStyle::Morph || outer <= 0. || !outer.is_finite() { + return bounds; + } + let Some(output) = self + .space + .outputs() + .find(|output| self.output_bounds_for(output) == Some(bounds)) + else { + return bounds; + }; + let Some(output_geometry) = self.space.output_geometry(output) else { + return bounds; + }; + let map = layer_map_for_output(output); + for layer in map.layers().filter(|layer| layer.namespace() == "ferese-shell-top-bar") { + let policy = layer.cached_state(); + let ExclusiveZone::Exclusive(amount) = policy.exclusive_zone else { + continue; + }; + if amount == 0 { + continue; + } + let Some(geometry) = map.layer_geometry(layer) else { + continue; + }; + let top = f64::from(output_geometry.loc.y + geometry.loc.y); + let bottom = top + f64::from(geometry.size.h); + // Only remove a gap if Morph actually borders the usable area. + // Another reserved bar on this edge must retain its own spacing. + if policy.anchor.contains(Anchor::TOP) + && !policy.anchor.contains(Anchor::BOTTOM) + && (bounds.y - (top + f64::from(amount))).abs() < 0.01 + { + bounds.y -= outer; + bounds.height += outer; + break; + } + if policy.anchor.contains(Anchor::BOTTOM) + && !policy.anchor.contains(Anchor::TOP) + && (bounds.y + bounds.height - (bottom - f64::from(amount))).abs() < 0.01 + { + bounds.height += outer; + break; + } + } + bounds + } + + pub(super) fn workspace_layout_bounds(&self, bounds: Rect, layout: &WorkspaceLayout) -> Rect { + let outer = if self.gap_config.smart && layout.window_ids().count() == 1 { + 0. + } else { + self.gap_config.outer + }; + self.morph_layout_bounds(bounds, outer) + } pub(super) fn visible_workspace_ids(&self) -> HashSet { let mut visible = self .output_workspaces @@ -228,6 +320,8 @@ impl Ferese { continue; }; + let layout_bounds = self.workspace_layout_bounds(bounds, &workspace.layout); + let maximized_bounds = self.morph_layout_bounds(bounds, self.gap_config.outer); let workspace_fullscreen = workspace.fullscreen; let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_)); let workspace_focus = workspace.last_focused; @@ -239,17 +333,18 @@ impl Ferese { continue; }; - let layout = - match workspace - .layout - .resolve_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 layout = match workspace.layout.resolve_geometry_with_constraints( + layout_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 @@ -288,7 +383,7 @@ impl Ferese { let rect = if workspace_fullscreen == Some(*id) { fullscreen_bounds } else if is_maximized && (!is_scrolling_layout || is_floating) { - maximized_rect(bounds, self.gap_config.outer) + maximized_rect(maximized_bounds, self.gap_config.outer) } else { match placement { Some(WindowPlacement::Tiled) => { @@ -597,6 +692,7 @@ impl Ferese { pub(super) fn tiled_layout(&mut self, bounds: Rect) -> Result { let constraints = self.window_constraints(); let focused = self.focused_window; + let bounds = self.workspace_layout_bounds(bounds, &self.workspaces.active().layout); self.workspaces.active_mut().layout.resolve_geometry_with_constraints( bounds, self.gap_config, @@ -605,6 +701,105 @@ impl Ferese { ) } + #[cfg(test)] + pub(crate) fn preview_cache_stats(&self) -> (usize, usize) { + let cache = self.workspace_previews.borrow(); + (cache.resolutions, cache.entries.len()) + } + + pub(crate) fn workspace_preview_rects(&self) -> HashMap { + let constraints = self.window_constraints(); + let mut cache = self.workspace_previews.borrow_mut(); + let mut live = HashSet::new(); + let mut previews = HashMap::new(); + for view in self.output_workspaces.workspace_views_iter(&self.workspaces) { + let Some(workspace) = self.workspaces.workspace(view.id) else { + continue; + }; + let Some(output) = self + .output_ids + .iter() + .find(|(_, id)| **id == view.output) + .map(|(o, _)| o) + else { + continue; + }; + let Some(bounds) = self.output_bounds_for(output) else { + continue; + }; + live.insert(view.id); + let mut windows: Vec<_> = workspace + .layout + .window_ids() + .map(|id| (id, false)) + .chain(workspace.floating.iter().map(|&id| (id, true))) + .map(|(id, floating)| PreviewWindow { + id, + constraints: constraints.get(&id).copied().unwrap_or_default(), + floating: if floating { + match self.workspaces.placement(id) { + Some(ferese_core::WindowPlacement::Floating { rect }) => Some(rect), + _ => None, + } + } else { + None + }, + maximized: self.windows.record(id).is_some_and(|w| w.maximized), + }) + .collect(); + windows.sort_by_key(|w| w.id.0); + let input = PreviewInput { + bounds: self.workspace_layout_bounds(bounds, &workspace.layout), + maximized_bounds: self.morph_layout_bounds(bounds, self.gap_config.outer), + fullscreen_bounds: self.full_output_bounds_for(output).unwrap_or(bounds), + gaps: self.gap_config, + focused: workspace.last_focused, + fullscreen: workspace.fullscreen, + windows, + }; + if !cache + .entries + .get(&view.id) + .is_some_and(|cached| cached.input == input && cached.layout == workspace.layout) + { + // Only clone and solve the changed workspace. Titles, urgency and + // other shell metadata never invalidate geometry. + let mut layout = workspace.layout.clone(); + let mut rectangles = layout + .resolve_geometry_with_constraints(input.bounds, input.gaps, &constraints, input.focused) + .map(|resolved| resolved.geometry) + .unwrap_or_default(); + for window in &input.windows { + if let Some(rect) = window.floating { + rectangles.insert(window.id, constrained_floating_rect(rect, window.constraints)); + } + if input.fullscreen == Some(window.id) { + rectangles.insert(window.id, input.fullscreen_bounds); + } else if window.maximized + && (window.floating.is_some() || !matches!(workspace.layout, WorkspaceLayout::Scrolling(_))) + { + rectangles.insert(window.id, maximized_rect(input.maximized_bounds, input.gaps.outer)); + } + } + cache.entries.insert( + view.id, + Preview { + layout: workspace.layout.clone(), + input, + rectangles, + }, + ); + #[cfg(test)] + { + cache.resolutions += 1; + } + } + previews.extend(cache.entries[&view.id].rectangles.iter().map(|(&id, &rect)| (id, rect))); + } + cache.entries.retain(|id, _| live.contains(id)); + previews + } + pub(super) fn window_constraints(&self) -> HashMap { self.windows .ids() diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 7bd17aa..0990124 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -52,8 +52,9 @@ impl Ferese { { return; } + let layout_bounds = self.workspace_layout_bounds(bounds, &candidate); if candidate - .resolve_geometry_with_constraints(bounds, self.gap_config, &self.window_constraints(), Some(to)) + .resolve_geometry_with_constraints(layout_bounds, self.gap_config, &self.window_constraints(), Some(to)) .is_err() { return; @@ -338,6 +339,7 @@ impl Ferese { let Some(bounds) = self.output_bounds() else { return; }; + let bounds = self.workspace_layout_bounds(bounds, &self.workspaces.active().layout); let constraints = self.window_constraints(); match self @@ -614,6 +616,48 @@ impl Ferese { } } + pub(crate) fn move_managed_window_to_workspace(&mut self, window: WindowId, destination: WorkspaceId) -> bool { + if self.session_lock.active() || self.windows.window(window).and_then(|w| w.toplevel()).is_none() { + return false; + } + let Some(source) = self.workspaces.workspace_for_window(window) else { + return false; + }; + let Some(output_id) = self.output_workspaces.output_for_workspace(destination) else { + return false; + }; + if self.output_workspaces.output_for_workspace(source) != Some(output_id) { + return false; + } + if source == destination { + return true; + } + let bounds = self + .output_ids + .iter() + .find(|(_, id)| **id == output_id) + .and_then(|(output, _)| self.output_bounds_for(output)); + let axis = bounds + .and_then(|bounds| { + let workspace = self.workspaces.workspace(destination)?; + workspace.layout.automatic_axis(workspace.last_focused, bounds).ok() + }) + .unwrap_or(Axis::Horizontal); + if self + .workspaces + .move_window_to_workspace(window, destination, axis, 0.5) + .is_err() + { + return false; + } + if self.focused_window == Some(window) { + self.focused_window = self.workspaces.active().last_focused; + } + self.relayout(); + // The shell keeps keyboard focus while a drag updates the overview. + true + } + pub fn move_focused_to_workspace(&mut self, index: u32) { let Some(window) = self.focused_window else { return; diff --git a/crates/ferese-animation/src/gesture.rs b/crates/ferese-animation/src/gesture.rs index 0c4f56a..a8e502a 100644 --- a/crates/ferese-animation/src/gesture.rs +++ b/crates/ferese-animation/src/gesture.rs @@ -1,4 +1,3 @@ -//! Input estimation and destination selection; all post-release motion uses springs. use std::{collections::VecDeque, time::Duration}; #[derive(Default, Debug)] diff --git a/crates/ferese-animation/src/spring.rs b/crates/ferese-animation/src/spring.rs index 3801fe5..051d97d 100644 --- a/crates/ferese-animation/src/spring.rs +++ b/crates/ferese-animation/src/spring.rs @@ -1,4 +1,3 @@ -//! Exact physical spring motion. Presentation policy is deliberately separate. use super::SpringConfig; /// Solve m y'' + c y' + k y = 0, with y = current - target. diff --git a/crates/ferese-animation/src/traces.rs b/crates/ferese-animation/src/traces.rs index 73f3cea..d7654da 100644 --- a/crates/ferese-animation/src/traces.rs +++ b/crates/ferese-animation/src/traces.rs @@ -1,4 +1,3 @@ -//! Deterministic elapsed-time traces and the frozen pre-analytic oracle. use super::*; #[derive(Debug, Clone, Copy)] diff --git a/crates/ferese-config/Cargo.toml b/crates/ferese-config/Cargo.toml index 5380ca6..2accec3 100644 --- a/crates/ferese-config/Cargo.toml +++ b/crates/ferese-config/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-panel = { path = "../ferese-panel" } ferese-theme-model = { path = "../ferese-theme-model" } jiff.workspace = true kdl = "6.7.1" diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index 1f76d8c..feda6e5 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -1,7 +1,7 @@ pub mod families; pub mod file; pub mod notifications; -pub mod panel; +pub use ferese_panel::panel; pub mod presets; pub mod theme; @@ -14,17 +14,7 @@ use serde_json::{Map, Value}; pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78; -#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum PanelPreset { - #[default] - Continuous, - Islands, -} - -pub const fn default_island_padding() -> f32 { - 4.0 -} +pub use ferese_panel::{PanelStyle, default_island_padding}; #[derive(Debug)] pub struct Error(String); diff --git a/crates/ferese-config/src/panel/geometry.rs b/crates/ferese-config/src/panel/geometry.rs deleted file mode 100644 index 3bf5f33..0000000 --- a/crates/ferese-config/src/panel/geometry.rs +++ /dev/null @@ -1,69 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Logical dimensions owned by a panel, independent of its color theme or edge. -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] -#[serde(default, deny_unknown_fields)] -pub struct PanelGeometry { - pub height: f32, - pub edge_margin: i32, - pub side_margins: i32, - pub window_clearance: i32, - pub inner_padding: f32, -} - -impl Default for PanelGeometry { - fn default() -> Self { - Self { - height: 28., - edge_margin: 0, - side_margins: 0, - window_clearance: 0, - inner_padding: 12., - } - } -} - -impl PanelGeometry { - pub fn validate(self) -> Result<(), String> { - if !self.height.is_finite() - || !(24. ..=128.).contains(&self.height) - || !self.inner_padding.is_finite() - || !(0. ..=64.).contains(&self.inner_padding) - || [self.edge_margin, self.side_margins, self.window_clearance] - .into_iter() - .any(|value| !(0..=4096).contains(&value)) - { - return Err("panel height must be 24..128, inner padding 0..64, and margins/clearance 0..4096".into()); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn invalid_geometry_rejects_nonfinite_dimensions_and_negative_clearances() { - for geometry in [ - PanelGeometry { - height: f32::NAN, - ..Default::default() - }, - PanelGeometry { - edge_margin: -1, - ..Default::default() - }, - PanelGeometry { - height: 12., - ..Default::default() - }, - PanelGeometry { - inner_padding: f32::INFINITY, - ..Default::default() - }, - ] { - assert!(geometry.validate().is_err()); - } - } -} diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index 7f7650d..cd513c9 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -784,7 +784,13 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec Result { )?; let mut value = token_value(&base)?; merge(&mut value, layer, "", false)?; - let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?; + let mut tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?; + if layer.get("colors").and_then(|colors| colors.get("border")).is_none() { + // A new family without an authored border follows its own foreground, + // rather than inheriting the unrelated Ferese Blue border. + let mut border = rgba(&tokens.colors.text_primary)?; + border[3] = 0.10; + tokens.colors.border = hex(border); + } validate(&tokens)?; warnings.extend(authored_warnings(&tokens)); variants[index] = Some(tokens); @@ -1159,6 +1172,7 @@ mod tests { Appearance::Dark => format!("theme {{ mode \"dark\"; dark {{ preset \"{}\"; }}; }}", entry.id), }; let resolved = candidate(&source).unwrap().theme; + assert_eq!(resolved.tokens.colors.border, tokens.colors.border); assert_eq!(resolved.tokens.colors.surface_raised, tokens.colors.surface_raised); assert_eq!( resolved.tokens.colors.application_background, @@ -1305,6 +1319,61 @@ mod tests { assert!(import_family("custom-test", "colors { accent \"#FFFFFF\"; }").is_err()); } + #[test] + fn imported_borders_follow_the_foreground_only_when_not_authored() { + let (_, (light, dark), _) = import_family( + "custom-border", + r##"theme { + light { colors { text-primary "#213040"; }; } + dark { colors { text-primary "#F3E2D1"; }; } + }"##, + ) + .unwrap(); + assert_eq!(light.unwrap().colors.border, "#2130401A"); + assert_eq!(dark.unwrap().colors.border, "#F3E2D11A"); + + for border in ["#12345600", "#12345680", "#123456FF"] { + let (_, (_, dark), _) = import_family( + "custom-border", + &format!(r##"theme {{ dark {{ colors {{ border "{border}"; }}; }}; }}"##), + ) + .unwrap(); + assert_eq!(dark.unwrap().colors.border, border); + } + } + + #[test] + fn high_contrast_borders_cover_each_bundled_surface() { + for preset in &crate::presets::PRESETS { + let mode = if preset.appearance == Appearance::Light { + "light" + } else { + "dark" + }; + let theme = candidate(&format!( + r#"theme {{ mode "{mode}"; {mode} {{ preset "{}"; }}; accessibility {{ increase-contrast #true; }}; }}"#, + preset.id, + )) + .unwrap() + .theme; + let colors = &theme.tokens.colors; + let border = rgba(&colors.border).unwrap(); + assert_eq!(border[3], 1.); + for surface in [ + &colors.surface_base, + &colors.surface_raised, + &colors.application_background, + &theme.tokens.surface.bar.background, + ] { + assert!( + contrast(border, rgba(surface).unwrap()) >= 3., + "{} on {surface}", + preset.id + ); + } + } + } + #[test] fn system_auto_is_context_driven_and_has_no_polling_deadline() { let document = Document::parse(r#"theme { mode "auto"; schedule { source "system"; }; }"#).unwrap(); diff --git a/crates/ferese-config/tests/panel.rs b/crates/ferese-config/tests/panel.rs new file mode 100644 index 0000000..621abbf --- /dev/null +++ b/crates/ferese-config/tests/panel.rs @@ -0,0 +1,240 @@ +use ferese_config::PanelStyle; +use ferese_config::panel::*; + +#[test] +fn removed_panel_surface_fields_are_rejected() { + for source in [ + "surface solid;", + "group-surface island;", + "background islands;", + "style continuous;", + "style islands;", + ] { + let document = ferese_config::Document::parse(&format!("panel main {{ {source} }}")).unwrap(); + assert!( + serde_json::from_value::>(document.get("panels").unwrap().clone()).is_err(), + "{source}" + ); + } +} + +#[test] +fn panel_surfaces_round_trip_without_group_overrides() { + let source = "// keep\npanel main { style floating; start { group nav { item overview kind=overview; }; }; center { group title { item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }"; + let mut doc = ferese_config::Document::parse(source).unwrap(); + let panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); + let panel = &panels[0]; + assert_eq!(panel.group_surface(), GroupSurface::Island); + assert_eq!(panel.preset(), Some(PanelStyle::Floating)); + doc.set("panels", serde_json::to_value(&panels).unwrap()).unwrap(); + let restored = ferese_config::Document::parse(&doc.to_string()).unwrap(); + let restored: Vec = serde_json::from_value(restored.get("panels").unwrap().clone()).unwrap(); + assert_eq!(restored, panels); + assert!(doc.to_string().contains("// keep")); + let mut preset = panel.clone(); + let groups = [&panel.start, &panel.center, &panel.end].map(|zone| zone.groups.clone()); + preset.apply_preset(PanelStyle::Flat); + assert_eq!(preset.preset(), Some(PanelStyle::Flat)); + assert_eq!( + [&preset.start, &preset.center, &preset.end].map(|zone| zone.groups.clone()), + groups + ); +} + +#[test] +fn group_surface_options_are_rejected_instead_of_inherited_or_ignored() { + for surface in ["none", "inset", "island", "default", "unset"] { + let doc = ferese_config::Document::parse(&format!( + "panel main {{ start {{ group nav {{ surface {surface}; item clock kind=clock; }} }} }}" + )) + .unwrap(); + let error = serde_json::from_value::>(doc.get("panels").unwrap().clone()) + .unwrap_err() + .to_string(); + assert!(error.contains("unknown field `surface`"), "{error}"); + } +} + +#[test] +fn composition_round_trips_kdl_and_edits_keep_ids_order_and_comments() { + let source = r#"// panel comment +panel "main" { + style "floating" + end { + group "status" { + padding 2 3 + // network comment + item "network" kind="network" overflow="always" + item "local-clock" kind="clock" + item "second-clock" kind="clock" representation="compact" + } + } +} +animations { speed 0.8; } +"#; + let mut doc = ferese_config::Document::parse(source).unwrap(); + let panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); + validate(&panels).unwrap(); + assert_eq!(panels[0].end.groups[0].padding, [2, 3]); + assert!(panels[0].background_opacity.is_none()); + assert_eq!(panels[0].end.groups[0].items[0].overflow, OverflowPolicy::Always); + doc.set("panels.0.end.groups.0.items.0.overflow", "never".into()) + .unwrap(); + let path = "panels.0.end.groups.0.items"; + let mut items = doc.get(path).unwrap().as_array().unwrap().clone(); + items.swap(0, 2); + doc.set(path, items.into()).unwrap(); + let formatted = doc.to_string(); + assert!(formatted.contains("// panel comment")); + assert!(formatted.contains("// network comment")); + let parsed = ferese_config::Document::parse(&formatted).unwrap(); + let panels: Vec = serde_json::from_value(parsed.get("panels").unwrap().clone()).unwrap(); + validate(&panels).unwrap(); + assert_eq!( + panels[0].end.groups[0] + .items + .iter() + .map(|item| item.id.0.as_str()) + .collect::>(), + ["second-clock", "local-clock", "network"] + ); + assert_eq!( + panels[0].item(&ItemId("network".into())).unwrap().overflow, + OverflowPolicy::Never + ); + assert_eq!(parsed.get("animations.speed").unwrap(), &serde_json::json!(0.8)); + let value = serde_json::to_value(&panels).unwrap(); + doc.set("panels", value.clone()).unwrap(); + let round_trip = ferese_config::Document::parse(&doc.to_string()).unwrap(); + let restored: Vec = serde_json::from_value(round_trip.get("panels").unwrap().clone()).unwrap(); + assert_eq!(restored, panels); + doc.set(path, Vec::::new().into()).unwrap(); + assert!(doc.to_string().contains("// network comment")); + let restored: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); + assert!(restored[0].end.groups[0].items.is_empty()); + assert_eq!(restored[0].end.spacing, 8.0); +} + +#[test] +fn panel_border_defaults_off_and_round_trips_an_explicit_enabled_value() { + let mut doc = + ferese_config::Document::parse("panel main { end { group status { item clock kind=clock; }; }; }\n").unwrap(); + let panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); + assert!(!panels[0].border); + doc.set("panels.0.border", true.into()).unwrap(); + let doc = ferese_config::Document::parse(&doc.to_string()).unwrap(); + let panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); + assert!(panels[0].border); + assert_eq!(panels[0].end.groups[0].items[0].id.0, "clock"); +} + +#[test] +fn panel_background_opacity_inherits_until_overridden_and_validates_bounds() { + let doc = ferese_config::Document::parse("panel main { end { group status { }; }; }").unwrap(); + let mut panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); + assert_eq!(panels[0].background_opacity, None); + assert!( + serde_json::to_value(&panels[0]).unwrap()["end"]["groups"][0] + .get("background_opacity") + .is_none() + ); + for value in [0., 0.5, 1.] { + panels[0].background_opacity = Some(value); + validate(&panels).unwrap(); + } + for value in [-0.1, 1.1, f32::NAN, f32::INFINITY] { + panels[0].background_opacity = Some(value); + assert!(validate(&panels).is_err()); + } +} + +#[test] +fn panel_edge_defaults_to_top_and_round_trips_both_positions() { + for (source, expected) in [ + ("", Edge::Top), + ("edge top;", Edge::Top), + ("edge bottom;", Edge::Bottom), + ] { + let document = ferese_config::Document::parse(&format!("panel main {{ {source} }}")).unwrap(); + let panels: Vec = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap(); + validate(&panels).unwrap(); + assert_eq!(panels[0].edge, expected); + let restored: Vec = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap(); + assert_eq!(restored[0].edge, expected); + let mut moved = panels[0].clone(); + moved.edge = if expected == Edge::Top { Edge::Bottom } else { Edge::Top }; + assert!(!moved.same_composition(&panels[0])); + } +} + +#[test] +fn validation_rejects_duplicate_ids_and_unsupported_surface_configuration() { + let mut panel = Panel::default(); + validate(std::slice::from_ref(&panel)).unwrap(); + panel.end.groups[0].items[1].id = panel.start.groups[0].items[0].id.clone(); + assert!(validate(std::slice::from_ref(&panel)).is_err()); + panel.end.groups[0].items[1].id = ItemId("_overflow".into()); + assert!(validate(std::slice::from_ref(&panel)).is_err()); + assert!(validate(&[]).is_err()); + assert!(validate(&[panel.clone(), panel]).is_err()); + assert!(ferese_config::from_str::("panel \"main\" { edge \"bottom\"; }").is_ok()); + let doc = ferese_config::Document::parse("panel \"main\" { edge \"left\"; }").unwrap(); + assert!(serde_json::from_value::>(doc.get("panels").unwrap().clone()).is_err()); +} +#[test] +fn workspace_styles_round_trip_and_only_apply_to_workspaces() { + for style in WorkspaceStyle::ALL { + let source = format!( + "panel main {{ start {{ group nav {{ item spaces kind=\"workspaces\" style=\"{}\"; }} }} }}", + style.key() + ); + let document = ferese_config::Document::parse(&source).unwrap(); + let panels: Vec = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap(); + validate(&panels).unwrap(); + assert_eq!(panels[0].start.groups[0].items[0].kind, ItemKind::Workspaces { style }); + let mut wrong_kind = serde_json::to_value(&panels[0].start.groups[0].items[0]).unwrap(); + wrong_kind["kind"] = "clock".into(); + assert!(serde_json::from_value::(wrong_kind).is_err()); + let restored: Vec = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap(); + assert_eq!(restored, panels); + } + let mut item = serde_json::json!({"id": "spaces", "kind": "workspaces"}); + assert_eq!( + serde_json::from_value::(item.clone()).unwrap().kind, + ItemKind::Workspaces { + style: WorkspaceStyle::Dots + } + ); + item["style"] = "unsupported".into(); + assert!(serde_json::from_value::(item).is_err()); +} + +#[test] +fn item_options_are_strict_and_metadata_is_explicit() { + for key in ["ovreflow", "visibile", "custom_note"] { + let mut value = serde_json::json!({"id":"network", "kind":"network"}); + value[key] = "always".into(); + let error = serde_json::from_value::(value).unwrap_err().to_string(); + assert!(error.contains(key) && error.contains("unknown field"), "{error}"); + } + let source = "panel main { start { group nav { item network kind=\"network\" { metadata { custom-note \"keep\"; overflow \"authored note\"; }; }; }; }; }"; + let panel: Vec = serde_json::from_value( + ferese_config::Document::parse(source) + .unwrap() + .get("panels") + .unwrap() + .clone(), + ) + .unwrap(); + let item = &panel[0].start.groups[0].items[0]; + assert_eq!(item.metadata["custom_note"], "keep"); + assert_eq!(item.overflow, OverflowPolicy::Auto); + assert_eq!( + serde_json::from_value::(serde_json::to_value(item).unwrap()).unwrap(), + *item + ); + assert!( + serde_json::from_value::(serde_json::json!({"id":"network", "kind":"network", "metadata":"wrong"})) + .is_err() + ); +} diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 2af63e6..3094d29 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -26,7 +26,7 @@ pub enum LayoutMode { Tree, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum WorkspaceLayout { Scrolling(ScrollingLayout), Tree(LayoutTree), diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index bdb4f1b..ffac1f3 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -139,7 +139,7 @@ impl fmt::Display for LayoutError { impl Error for LayoutError {} -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct LayoutTree { root: Option, nodes: HashMap, diff --git a/crates/ferese-panel/Cargo.toml b/crates/ferese-panel/Cargo.toml new file mode 100644 index 0000000..a9d79a8 --- /dev/null +++ b/crates/ferese-panel/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ferese-panel" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +ferese-animation = { path = "../ferese-animation" } +ferese-layout = { path = "../ferese-layout" } +serde.workspace = true +serde_json.workspace = true diff --git a/crates/ferese-panel/src/adaptive.rs b/crates/ferese-panel/src/adaptive.rs new file mode 100644 index 0000000..3d7b075 --- /dev/null +++ b/crates/ferese-panel/src/adaptive.rs @@ -0,0 +1,147 @@ +use std::time::Duration; + +use crate::PanelStyle; + +pub const HYSTERESIS: Duration = Duration::from_millis(150); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WindowState { + pub maximized: bool, + pub fullscreen: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Presentation { + Floating, + Flat, + Hidden, +} + +impl Presentation { + pub fn style(self) -> Option { + match self { + Self::Floating => Some(PanelStyle::Floating), + Self::Flat => Some(PanelStyle::Flat), + Self::Hidden => None, + } + } +} + +impl From for Presentation { + fn from(state: WindowState) -> Self { + if state.fullscreen { + Self::Hidden + } else if state.maximized { + Self::Flat + } else { + Self::Floating + } + } +} + +/// One instance belongs to one output. A deadline exists only while a new +/// ordinary window state remains stable; fullscreen takes effect immediately. +#[derive(Clone, Debug)] +pub struct Adaptive { + current: Presentation, + pending: Option<(Presentation, Duration)>, +} + +impl Adaptive { + pub fn new(state: WindowState) -> Self { + Self { + current: state.into(), + pending: None, + } + } + + pub fn current(&self) -> Presentation { + self.current + } + + pub fn observe(&mut self, state: WindowState, now: Duration) -> bool { + let desired = Presentation::from(state); + if desired == self.current { + self.pending = None; + return false; + } + if desired == Presentation::Hidden || self.current == Presentation::Hidden { + self.current = desired; + self.pending = None; + return true; + } + match self.pending { + Some((target, since)) if target == desired && now >= since => { + if now - since >= HYSTERESIS { + self.current = target; + self.pending = None; + true + } else { + false + } + } + _ => { + self.pending = Some((desired, now)); + false + } + } + } + + pub fn deadline(&self) -> Option { + self.pending.map(|(_, since)| since.saturating_add(HYSTERESIS)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(ms: u64) -> Duration { + Duration::from_millis(ms) + } + fn maximized() -> WindowState { + WindowState { + maximized: true, + fullscreen: false, + } + } + + #[test] + fn boundary_flapping_cancels_the_pending_style() { + let mut state = Adaptive::new(WindowState::default()); + assert!(!state.observe(maximized(), at(0))); + assert!(!state.observe(WindowState::default(), at(149))); + assert_eq!(state.deadline(), None); + assert!(!state.observe(maximized(), at(150))); + assert!(!state.observe(maximized(), at(299))); + assert!(state.observe(maximized(), at(300))); + assert_eq!(state.current(), Presentation::Flat); + } + + #[test] + fn fullscreen_cancels_pending_work_and_restores_without_delay() { + let mut state = Adaptive::new(WindowState::default()); + state.observe(maximized(), at(0)); + assert!(state.observe( + WindowState { + fullscreen: true, + ..maximized() + }, + at(1) + )); + assert_eq!(state.current().style(), None); + assert_eq!(state.deadline(), None); + assert!(state.observe(maximized(), at(2))); + assert_eq!(state.current().style(), Some(PanelStyle::Flat)); + } + + #[test] + fn outputs_change_independently() { + let mut left = Adaptive::new(WindowState::default()); + let right = Adaptive::new(WindowState::default()); + left.observe(maximized(), at(0)); + left.observe(maximized(), at(150)); + assert_eq!(left.current(), Presentation::Flat); + assert_eq!(right.current(), Presentation::Floating); + } +} diff --git a/crates/ferese-panel/src/lib.rs b/crates/ferese-panel/src/lib.rs new file mode 100644 index 0000000..9162337 --- /dev/null +++ b/crates/ferese-panel/src/lib.rs @@ -0,0 +1,14 @@ +pub mod adaptive; +pub mod morph; +pub mod motion; +pub mod panel; +pub use panel::*; +pub mod outline; +pub mod scene; +pub mod shape; + +pub use shape::Style as PanelStyle; + +pub const fn default_island_padding() -> f32 { + 4.0 +} diff --git a/crates/ferese-panel/src/morph.rs b/crates/ferese-panel/src/morph.rs new file mode 100644 index 0000000..b01cea5 --- /dev/null +++ b/crates/ferese-panel/src/morph.rs @@ -0,0 +1,492 @@ +use std::time::Duration; + +use ferese_layout::Rect; +use serde::{Deserialize, Serialize}; + +use crate::motion::{Geometry, Motion}; + +const FADE: Duration = Duration::from_millis(120); +const CONTENT_DELAY: Duration = Duration::from_millis(130); + +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Profile { + pub peek_on_hover: bool, + pub attached: bool, + pub idle_width: f64, + pub peek_width: f64, + pub peek_height: f64, + pub toast_width: f64, + pub toast_height: f64, + pub expanded_width: f64, + pub workspaces_height: f64, + pub media_height: f64, + pub calendar_height: f64, + pub notifications_height: f64, + pub controls_height: f64, + pub expanded_radius: f64, +} + +impl Default for Profile { + fn default() -> Self { + Self { + peek_on_hover: true, + attached: false, + idle_width: 150., + peek_width: 260., + peek_height: 44., + toast_width: 320., + toast_height: 48., + expanded_width: 340., + workspaces_height: 176., + media_height: 170., + calendar_height: 230., + notifications_height: 176., + controls_height: 248., + expanded_radius: 26., + } + } +} + +impl Profile { + pub fn validate(self) -> Result<(), &'static str> { + let sizes = [ + self.idle_width, + self.peek_width, + self.peek_height, + self.toast_width, + self.toast_height, + self.expanded_width, + self.workspaces_height, + self.media_height, + self.calendar_height, + self.notifications_height, + self.controls_height, + ]; + if sizes + .iter() + .any(|value| !value.is_finite() || !(24. ..=1024.).contains(value)) + || !self.expanded_radius.is_finite() + || !(0. ..=128.).contains(&self.expanded_radius) + { + Err("hub dimensions must be 24..1024 and its expanded radius 0..128") + } else { + Ok(()) + } + } + + pub fn size(self, mode: Mode, collapsed_height: f64) -> [f64; 2] { + match mode { + Mode::Idle => [self.idle_width, collapsed_height], + Mode::Peek => [self.peek_width, self.peek_height], + Mode::Toast => [self.toast_width, self.toast_height], + Mode::Expanded(content) => [ + self.expanded_width, + match content { + Content::Workspaces => self.workspaces_height, + Content::Media => self.media_height, + Content::Calendar => self.calendar_height, + Content::Notifications => self.notifications_height, + Content::Controls => self.controls_height, + }, + ], + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Content { + Workspaces, + Media, + Calendar, + Notifications, + Controls, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Mode { + #[default] + Idle, + Peek, + Toast, + Expanded(Content), +} + +impl Mode { + pub fn expanded(self) -> bool { + matches!(self, Self::Expanded(_)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Sample { + pub geometry: Geometry, + pub previous: Mode, + pub previous_opacity: f32, + pub current: Mode, + pub opacity: f32, +} + +/// The exclusive zone is deliberately absent. Expansion changes the painted +/// island and its hit target; the caller retains the collapsed reservation. +#[derive(Clone, Debug)] +pub struct Hub { + profile: Profile, + pins: Option<[f64; 4]>, + height: f64, + motion: Motion, + mode: Mode, + previous: Mode, + previous_opacity: f32, + initial_opacity: f32, + elapsed: Duration, + available: [f64; 2], +} + +impl Hub { + pub fn new(profile: Profile, collapsed_height: f64) -> Result { + profile.validate()?; + if !collapsed_height.is_finite() || !(24. ..=128.).contains(&collapsed_height) { + return Err("hub height must be 24..128"); + } + let size = profile.size(Mode::Idle, collapsed_height); + Ok(Self { + profile, + pins: None, + height: collapsed_height, + motion: Motion::with_spring( + Geometry::rounded(Rect::new(0., 0., size[0], size[1]), size[1] / 2.), + ferese_animation::SpringConfig { + stiffness: 300., + damping: 2. * 0.7 * 300_f64.sqrt(), + position_tolerance: 0.05, + velocity_tolerance: 0.05, + crossing: ferese_animation::CrossingPolicy::AllowOvershoot, + ..Default::default() + }, + )?, + mode: Mode::Idle, + previous: Mode::Idle, + previous_opacity: 0., + initial_opacity: 1., + elapsed: FADE, + available: [f64::MAX; 2], + }) + } + + pub fn pin_radii(&mut self, pins: Option<[f64; 4]>) -> Result<(), &'static str> { + if pins.is_some_and(|pins| { + pins.iter() + .any(|value| !value.is_finite() || !(0. ..=128.).contains(value)) + }) { + return Err("hub radius pins must be finite values between 0 and 128"); + } + self.pins = pins; + let mut target = self.motion.target(); + target.radii = pins.unwrap_or( + [if self.mode.expanded() { + self.profile.expanded_radius + } else { + target.bounds.height / 2. + }; 4], + ); + self.motion.retarget(target) + } + + pub fn mode(&self) -> Mode { + self.mode + } + + pub fn retarget_peek(&mut self, width: f64, available: [f64; 2]) -> Result<(), &'static str> { + if !width.is_finite() || !(24. ..=1024.).contains(&width) { + return Err("peek width must be 24..1024"); + } + let configured = self.profile.peek_width; + self.profile.peek_width = width; + let result = self.retarget(Mode::Peek, available); + self.profile.peek_width = configured; + result + } + + pub fn retarget_idle(&mut self, width: f64, available: [f64; 2]) -> Result<(), &'static str> { + if !width.is_finite() || !(24. ..=1024.).contains(&width) { + return Err("idle width must be 24..1024"); + } + let configured = self.profile.idle_width; + self.profile.idle_width = width; + let result = self.retarget(Mode::Idle, available); + self.profile.idle_width = configured; + result + } + + pub fn retarget(&mut self, mode: Mode, available: [f64; 2]) -> Result<(), &'static str> { + if available.iter().any(|value| !value.is_finite() || *value <= 0.) { + return Err("hub requires a finite positive available area"); + } + let [width, height] = self.profile.size(mode, self.height); + let width = width.min(available[0]); + let height = height.min(available[1]); + let radius = if mode.expanded() { + self.profile.expanded_radius + } else { + height / 2. + }; + let mut geometry = Geometry::rounded(Rect::new(0., 0., width, height), radius); + geometry.radii = self.pins.unwrap_or(geometry.radii); + if mode == self.mode { + self.available = available; + return self.motion.retarget(geometry); + } + let sample = self.sample(); + self.available = available; + if sample.previous_opacity > sample.opacity { + self.previous = sample.previous; + self.previous_opacity = sample.previous_opacity; + } else { + self.previous = sample.current; + self.previous_opacity = sample.opacity; + } + self.initial_opacity = if mode == sample.current { + sample.opacity + } else if mode == sample.previous { + sample.previous_opacity + } else { + 0. + }; + if self.previous == mode { + self.previous_opacity = 0.; + } + self.mode = mode; + self.elapsed = Duration::ZERO; + self.motion.retarget(geometry) + } + + /// Fit the expanded shell to measured content without restarting its content fade. + pub fn fit_content_height(&mut self, height: f64) -> Result<(), &'static str> { + if !height.is_finite() || height <= 0. { + return Err("content height must be finite and positive"); + } + if self.mode.expanded() { + let mut target = self.motion.target(); + target.bounds.height = height + .max(24.) + .min(self.profile.size(self.mode, self.height)[1]) + .min(self.available[1]); + self.motion.retarget(target)?; + } + Ok(()) + } + + /// Replace content inside an already settled shape without restarting its springs. + pub fn replace_content(&mut self) { + self.previous = self.mode; + self.previous_opacity = self.sample().opacity; + self.initial_opacity = 0.; + self.elapsed = Duration::ZERO; + } + + pub fn advance(&mut self, elapsed: Duration, reduced_motion: bool) -> bool { + self.elapsed = self.elapsed.saturating_add(elapsed); + self.motion.advance(elapsed, reduced_motion); + if reduced_motion { + self.previous_opacity = 0.; + self.initial_opacity = 1.; + } + self.active() + } + + pub fn active(&self) -> bool { + self.motion.active() || self.sample().previous_opacity > 0. || self.sample().opacity < 1. + } + + pub fn sample(&self) -> Sample { + let fade = |elapsed: Duration| (elapsed.as_secs_f32() / FADE.as_secs_f32()).clamp(0., 1.); + let opacity = + self.initial_opacity + (1. - self.initial_opacity) * fade(self.elapsed.saturating_sub(CONTENT_DELAY)); + let mut geometry = self.motion.current(); + geometry.bounds.width = geometry.bounds.width.clamp(0., self.available[0]); + geometry.bounds.height = geometry.bounds.height.clamp(0., self.available[1]); + geometry.radii = geometry.radii.map(|radius| radius.max(0.)); + Sample { + geometry, + previous: self.previous, + previous_opacity: self.previous_opacity * (1. - fade(self.elapsed)), + current: self.mode, + opacity, + } + } + + pub fn allocation_size(&self) -> [f64; 2] { + let maximum = self.motion.maximum_size(); + std::array::from_fn(|i| maximum[i].min(self.available[i])) + } + + pub fn target_size(&self) -> [f64; 2] { + let target = self.motion.target().bounds; + [target.width, target.height] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_width_changes_preserve_content_and_motion() { + let mut hub = Hub::new(Profile::default(), 34.).unwrap(); + hub.retarget_idle(270., [800., 600.]).unwrap(); + hub.advance(Duration::from_millis(60), false); + let before = hub.sample(); + assert_eq!(before.opacity, 1.); + hub.retarget_idle(300., [800., 600.]).unwrap(); + assert_eq!(hub.sample(), before); + assert_eq!(hub.target_size(), [300., 34.]); + hub.advance(Duration::from_secs(2), false); + assert_eq!(hub.sample().geometry.bounds.width, 300.); + assert_eq!(hub.sample().opacity, 1.); + hub.retarget_peek(360., [320., 600.]).unwrap(); + assert_eq!(hub.target_size(), [320., 44.]); + } + + #[test] + fn measured_height_preserves_fade_and_motion_and_caps_long_content() { + let mut hub = Hub::new(Profile::default(), 34.).unwrap(); + hub.retarget(Mode::Expanded(Content::Controls), [800., 600.]).unwrap(); + hub.advance(Duration::from_millis(180), false); + let before = hub.sample(); + hub.fit_content_height(148.).unwrap(); + assert_eq!(hub.target_size(), [340., 148.]); + assert_eq!(hub.sample().geometry, before.geometry); + assert_eq!(hub.sample().opacity, before.opacity); + hub.advance(Duration::from_secs(2), false); + assert_eq!(hub.sample().geometry.bounds.height, 148.); + assert_eq!(hub.sample().opacity, 1.); + hub.fit_content_height(900.).unwrap(); + assert_eq!(hub.target_size(), [340., Profile::default().controls_height]); + hub.retarget(Mode::Idle, [800., 600.]).unwrap(); + hub.fit_content_height(100.).unwrap(); + assert_eq!(hub.target_size(), [150., 34.]); + } + + #[test] + fn a_content_swap_fades_without_restarting_the_geometry() { + let mut hub = Hub::new(Profile::default(), 34.).unwrap(); + hub.retarget(Mode::Expanded(Content::Controls), [800., 600.]).unwrap(); + hub.advance(Duration::from_secs(2), false); + hub.advance(Duration::from_secs(1), false); + let geometry = hub.sample().geometry; + hub.replace_content(); + assert_eq!(hub.sample().geometry, geometry); + assert_eq!(hub.sample().opacity, 0.); + assert_eq!(hub.sample().previous_opacity, 1.); + hub.advance(Duration::from_millis(60), false); + assert_eq!(hub.sample().opacity, 0.); + assert!((hub.sample().previous_opacity - 0.5).abs() < 0.001); + hub.advance(Duration::from_millis(130), false); + assert!((hub.sample().opacity - 0.5).abs() < 0.001); + assert_eq!(hub.sample().previous_opacity, 0.); + hub.advance(Duration::from_millis(60), false); + assert!(!hub.active()); + } + + #[test] + fn content_swap_uses_a_fixed_delay_instead_of_waiting_for_spring_settlement() { + let mut hub = Hub::new(Profile::default(), 34.).unwrap(); + assert!(!hub.active()); + hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap(); + assert_eq!(hub.target_size(), [340., 230.]); + hub.advance(Duration::from_millis(120), false); + assert_eq!(hub.sample().opacity, 0.); + assert_eq!(hub.sample().previous_opacity, 0.); + hub.advance(Duration::from_millis(70), false); + assert!((hub.sample().opacity - 0.5).abs() < 0.001); + hub.advance(Duration::from_millis(60), false); + assert_eq!(hub.sample().opacity, 1.); + assert!(hub.active(), "content finishes while the shape is still settling"); + for _ in 0..50 { + hub.advance(Duration::from_millis(16), false); + } + assert_eq!(hub.sample().opacity, 1.); + assert_eq!(hub.sample().previous_opacity, 0.); + assert!(!hub.active()); + } + + #[test] + fn an_interrupted_content_delay_never_reveals_the_cancelled_panel() { + let mut hub = Hub::new(Profile::default(), 34.).unwrap(); + hub.retarget(Mode::Expanded(Content::Media), [800., 600.]).unwrap(); + hub.advance(Duration::from_millis(80), false); + hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap(); + for _ in 0..130 { + hub.advance(Duration::from_millis(8), false); + let sample = hub.sample(); + assert_eq!(sample.current, Mode::Expanded(Content::Calendar)); + assert_eq!(sample.previous, Mode::Idle); + } + assert!(!hub.active()); + assert_eq!(hub.sample().opacity, 1.); + } + + #[test] + fn spring_overshoots_then_settles_within_its_reserved_surface() { + let mut hub = Hub::new(Profile::default(), 34.).unwrap(); + hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap(); + let allocation = hub.allocation_size(); + let mut peak = 340_f64; + for _ in 0..200 { + hub.advance(Duration::from_millis(8), false); + let sample = hub.sample(); + peak = peak.max(sample.geometry.bounds.width); + assert!(sample.geometry.bounds.width <= allocation[0] + 1e-9); + assert!(sample.geometry.bounds.height <= allocation[1] + 1e-9); + } + let overshoot = (peak - 340.) / (340. - 150.); + assert!((0.04..0.05).contains(&overshoot)); + assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 340., 230.)); + assert!(!hub.active()); + } + + #[test] + fn overshoot_is_clipped_to_small_outputs_and_reduced_motion_removes_all_fades() { + let mut hub = Hub::new(Profile::default(), 34.).unwrap(); + hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.]).unwrap(); + for _ in 0..60 { + hub.advance(Duration::from_millis(8), false); + let bounds = hub.sample().geometry.bounds; + assert!(bounds.width <= 240. && bounds.height <= 180.); + } + hub.retarget(Mode::Idle, [240., 180.]).unwrap(); + hub.advance(Duration::ZERO, true); + assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 150., 34.)); + assert_eq!(hub.sample().previous_opacity, 0.); + assert_eq!(hub.sample().opacity, 1.); + assert!(!hub.active()); + } + + #[test] + fn interrupted_expansion_keeps_the_presented_shape() { + let mut hub = Hub::new(Profile::default(), 34.).unwrap(); + hub.retarget(Mode::Expanded(Content::Media), [800., 600.]).unwrap(); + hub.advance(Duration::from_millis(40), false); + let before = hub.sample().geometry; + hub.retarget(Mode::Idle, [800., 600.]).unwrap(); + assert_eq!(hub.sample().geometry, before); + for _ in 0..50 { + hub.advance(Duration::from_millis(16), false); + } + assert_eq!(hub.target_size(), [150., 34.]); + assert!(!hub.active()); + } + + #[test] + fn narrow_outputs_and_reduced_motion_keep_the_hub_bounded() { + let mut hub = Hub::new(Profile::default(), 30.).unwrap(); + hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.]).unwrap(); + hub.advance(Duration::ZERO, true); + assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 240., 180.)); + assert_eq!(hub.sample().opacity, 1.); + assert!(!hub.active()); + assert!(hub.retarget(Mode::Idle, [f64::NAN, 180.]).is_err()); + assert_eq!(hub.mode(), Mode::Expanded(Content::Calendar)); + } +} diff --git a/crates/ferese-panel/src/motion.rs b/crates/ferese-panel/src/motion.rs new file mode 100644 index 0000000..d011198 --- /dev/null +++ b/crates/ferese-panel/src/motion.rs @@ -0,0 +1,257 @@ +use std::time::Duration; + +use ferese_animation::{AnimatedValue, CrossingPolicy, SpringConfig}; +use ferese_layout::Rect; + +/// Unsnapped logical geometry. Pixel snapping and radius normalization belong +/// to scene derivation, so neither changes the spring's trajectory. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Geometry { + pub bounds: Rect, + pub radii: [f64; 4], + pub margin: [f64; 4], + pub thickness: [f64; 4], + pub gap: f64, + pub fusion: f64, +} + +const COUNT: usize = 18; + +impl Geometry { + pub fn rounded(bounds: Rect, radius: f64) -> Self { + Self { + bounds, + radii: [radius; 4], + margin: [0.; 4], + thickness: [0.; 4], + gap: 0., + fusion: 0., + } + } + + fn values(self) -> [f64; COUNT] { + let mut values = [0.; COUNT]; + values[..4].copy_from_slice(&[self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height]); + values[4..8].copy_from_slice(&self.radii); + values[8..12].copy_from_slice(&self.margin); + values[12..16].copy_from_slice(&self.thickness); + values[16] = self.gap; + values[17] = self.fusion; + values + } + + fn from_values(values: [f64; COUNT]) -> Self { + Self { + bounds: Rect::new(values[0], values[1], values[2], values[3]), + radii: values[4..8].try_into().unwrap(), + margin: values[8..12].try_into().unwrap(), + thickness: values[12..16].try_into().unwrap(), + gap: values[16], + fusion: values[17], + } + } + + fn validate(self) -> Result { + let values = self.values(); + if values.iter().any(|value| !value.is_finite()) || values[2..].iter().any(|value| *value < 0.) { + Err("panel animation geometry must be finite with nonnegative extents") + } else { + Ok(self) + } + } +} + +#[derive(Clone, Debug)] +pub struct Motion { + values: [AnimatedValue; COUNT], + spring: SpringConfig, +} + +impl Motion { + pub fn new(geometry: Geometry) -> Result { + Self::with_spring( + geometry, + SpringConfig { + stiffness: 6400., + damping: 160., + position_tolerance: 0.05, + velocity_tolerance: 0.1, + crossing: CrossingPolicy::NoCrossing, + ..Default::default() + }, + ) + } + + pub(crate) fn with_spring(geometry: Geometry, spring: SpringConfig) -> Result { + Ok(Self { + values: geometry.validate()?.values().map(AnimatedValue::new), + spring, + }) + } + + /// Largest future dimensions, including the momentum carried through a retarget. + pub(crate) fn maximum_size(&self) -> [f64; 2] { + let omega2 = self.spring.stiffness / self.spring.mass; + let decay = self.spring.damping / (2. * self.spring.mass); + [self.values[2], self.values[3]].map(|value| { + let displacement = value.current - value.target; + let mut maximum = value.current.max(value.target); + if omega2 > decay * decay { + let frequency = (omega2 - decay * decay).sqrt(); + let sine = (value.velocity + decay * displacement) / frequency; + let derivative_sine = -(decay * value.velocity + omega2 * displacement) / frequency; + let first = (-value.velocity) + .atan2(derivative_sine) + .rem_euclid(std::f64::consts::PI); + // Extrema alternate; later peaks decay, so the first two suffice. + for phase in [first, first + std::f64::consts::PI] { + let t = phase / frequency; + let position = + value.target + (-decay * t).exp() * (displacement * phase.cos() + sine * phase.sin()); + maximum = maximum.max(position); + } + } else { + // Total spring energy bounds displacement for any nonnegative damping. + maximum = maximum.max( + value.target + (displacement * displacement + value.velocity * value.velocity / omega2).sqrt(), + ); + } + maximum + }) + } + + pub fn current(&self) -> Geometry { + Geometry::from_values(self.values.map(|value| value.current)) + } + + pub fn target(&self) -> Geometry { + Geometry::from_values(self.values.map(|value| value.target)) + } + + pub fn retarget(&mut self, geometry: Geometry) -> Result<(), &'static str> { + let target = geometry.validate()?.values(); + for (value, target) in self.values.iter_mut().zip(target) { + value.retarget_preserving_motion(target); + } + Ok(()) + } + + pub fn active(&self) -> bool { + self.values.iter().any(AnimatedValue::is_animating) + } + + /// Returns whether another frame is needed. Reduced motion also clears + /// residual velocity, including when enabled halfway through an animation. + pub fn advance(&mut self, elapsed: Duration, reduced_motion: bool) -> bool { + let mut active = false; + for value in &mut self.values { + if reduced_motion { + value.snap(); + } else { + active |= value.advance(elapsed, self.spring); + } + } + active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collapsed() -> Geometry { + Geometry::rounded(Rect::new(0., 0., 150., 34.), 17.) + } + fn expanded() -> Geometry { + Geometry::rounded(Rect::new(-95., 0., 340., 230.), 26.) + } + + #[test] + fn interruption_keeps_the_current_geometry_and_velocity() { + let mut motion = Motion::new(collapsed()).unwrap(); + motion.retarget(expanded()).unwrap(); + motion.advance(Duration::from_millis(16), false); + let before = motion.current(); + let velocity = motion.values.map(|value| value.velocity); + motion.retarget(collapsed()).unwrap(); + assert_eq!(motion.current(), before); + assert_eq!(motion.values.map(|value| value.velocity), velocity); + for _ in 0..30 { + motion.advance(Duration::from_millis(16), false); + } + assert_eq!(motion.current(), collapsed()); + assert!(!motion.active()); + } + + #[test] + fn dimensions_share_one_trajectory_and_stop_requesting_frames() { + let mut motion = Motion::new(collapsed()).unwrap(); + motion.retarget(expanded()).unwrap(); + for _ in 0..12 { + motion.advance(Duration::from_millis(16), false); + let geometry = motion.current(); + let width_progress = (geometry.bounds.width - 150.) / 190.; + let height_progress = (geometry.bounds.height - 34.) / 196.; + let radius_progress = (geometry.radii[0] - 17.) / 9.; + // A dimension may snap at its final subpixel tolerance first. + assert!((width_progress - height_progress).abs() < 0.0005); + assert!((width_progress - radius_progress).abs() < 0.0005); + } + for _ in 0..12 { + motion.advance(Duration::from_millis(16), false); + } + assert_eq!(motion.current(), expanded()); + assert!(!motion.advance(Duration::from_secs(1), false)); + } + + #[test] + fn reduced_motion_snaps_an_inflight_animation() { + let mut motion = Motion::new(collapsed()).unwrap(); + motion.retarget(expanded()).unwrap(); + motion.advance(Duration::from_millis(16), false); + assert!(!motion.advance(Duration::ZERO, true)); + assert_eq!(motion.current(), expanded()); + } + + #[test] + fn underdamped_reversals_preserve_velocity_and_fit_the_predicted_extrema() { + let mut motion = Motion::with_spring( + collapsed(), + SpringConfig { + stiffness: 300., + damping: 2. * 0.7 * 300_f64.sqrt(), + crossing: CrossingPolicy::AllowOvershoot, + ..Default::default() + }, + ) + .unwrap(); + for turn in 0..60 { + let before = motion.current(); + let velocity = motion.values.map(|value| value.velocity); + motion + .retarget(if turn % 2 == 0 { expanded() } else { collapsed() }) + .unwrap(); + assert_eq!(motion.current(), before); + assert_eq!(motion.values.map(|value| value.velocity), velocity); + let bound = motion.maximum_size(); + let mut prediction = motion.clone(); + for _ in 0..200 { + prediction.advance(Duration::from_millis(8), false); + let geometry = prediction.current(); + assert!(geometry.bounds.width <= bound[0] + 1e-9); + assert!(geometry.bounds.height <= bound[1] + 1e-9); + } + motion.advance(Duration::from_millis(16 + turn * 7), false); + } + } + + #[test] + fn invalid_retarget_leaves_motion_untouched() { + let mut motion = Motion::new(collapsed()).unwrap(); + let mut invalid = expanded(); + invalid.bounds.height = f64::NAN; + assert!(motion.retarget(invalid).is_err()); + assert_eq!(motion.target(), collapsed()); + assert!(!motion.active()); + } +} diff --git a/crates/ferese-panel/src/outline.rs b/crates/ferese-panel/src/outline.rs new file mode 100644 index 0000000..ac4d1e6 --- /dev/null +++ b/crates/ferese-panel/src/outline.rs @@ -0,0 +1,388 @@ +use crate::scene::{Primitive, Scene, smooth_min}; +use std::f64::consts::{FRAC_PI_2, PI}; + +pub type Contour = Vec<[f64; 2]>; + +fn arc(points: &mut Contour, center: [f64; 2], radius: f64, start: f64, tolerance: f64) { + let segments = ((FRAC_PI_2 / (8. * tolerance / radius.max(tolerance)).sqrt()).ceil() as usize).clamp(1, 128); + for i in 0..=segments { + let angle = start + FRAC_PI_2 * i as f64 / segments as f64; + points.push([center[0] + radius * angle.cos(), center[1] + radius * angle.sin()]); + } +} + +fn clockwise(points: &mut Contour, cutout: bool) { + let area = points + .iter() + .zip(points.iter().cycle().skip(1)) + .take(points.len()) + .map(|(a, b)| a[0] * b[1] - b[0] * a[1]) + .sum::(); + if (area < 0.) != cutout { + points.reverse(); + } +} + +fn primitive_outline(primitive: Primitive, tolerance: f64) -> Contour { + let b = primitive.bounds; + let (l, t, r, d) = (b.x, b.y, b.x + b.width, b.y + b.height); + let mut points = Vec::new(); + if primitive.operation >= 2 { + let radius = b.width.min(b.height); + let (center, start, remaining) = match primitive.operation { + 2 => ([l, d], -FRAC_PI_2, [[r, d], [r, t], [l, t]]), + 3 => ([r, d], PI, [[r, t], [l, t], [l, d]]), + 4 => ([r, t], FRAC_PI_2, [[l, t], [l, d], [r, d]]), + _ => ([l, t], 0., [[l, d], [r, d], [r, t]]), + }; + arc(&mut points, center, radius, start, tolerance); + points.extend(remaining); + } else { + let [tl, tr, br, bl] = primitive.radii.0.map(f64::from); + for (center, radius, start) in [ + ([l + tl, t + tl], tl, PI), + ([r - tr, t + tr], tr, PI + FRAC_PI_2), + ([r - br, d - br], br, 0.), + ([l + bl, d - bl], bl, FRAC_PI_2), + ] { + arc(&mut points, center, radius, start, tolerance); + } + } + clockwise(&mut points, primitive.operation == 1); + points +} + +/// Paths for vector backends. Additive contours share one nonzero fill, so +/// touching groups and fillets have no separately anti-aliased seam. +pub fn contours(scene: &Scene, tolerance: f64) -> Result, &'static str> { + if !tolerance.is_finite() || !(0.01..=1.).contains(&tolerance) { + return Err("outline tolerance must be between 0.01 and 1 logical pixels"); + } + let mut result: Vec<_> = scene + .paint + .iter() + .filter(|p| scene.fusion == 0. || p.blend_zone == 0) + .map(|p| primitive_outline(*p, tolerance)) + .collect(); + if scene.fusion > 0. { + for zone in 1..=3 { + let shapes: Vec<_> = scene.paint.iter().filter(|p| p.blend_zone == zone).copied().collect(); + if shapes.is_empty() { + continue; + } + // Fused rows share their vertical baseline. Each component is + // vertically convex; solve its upper and lower boundary, rather + // than sampling every pixel of a full-output bitmap. + let top = shapes.iter().map(|p| p.bounds.y).fold(f64::INFINITY, f64::min); + let bottom = shapes + .iter() + .map(|p| p.bounds.y + p.bounds.height) + .fold(f64::NEG_INFINITY, f64::max); + let mid = (top + bottom) * 0.5; + let left = shapes.iter().map(|p| p.bounds.x).fold(f64::INFINITY, f64::min) - scene.fusion; + let right = shapes + .iter() + .map(|p| p.bounds.x + p.bounds.width) + .fold(f64::NEG_INFINITY, f64::max) + + scene.fusion; + let distance = |x, y| { + shapes + .iter() + .map(|p| p.distance([x, y])) + .fold(f64::INFINITY, |a, b| smooth_min(a, b, scene.fusion)) + }; + let step = tolerance * 2.; + let count = ((right - left) / step).ceil() as usize; + if count > 1_000_000 { + return Err("fused outline exceeds sampling capacity"); + } + let mut upper = Vec::new(); + let mut lower = Vec::new(); + let finish = |upper: &mut Contour, lower: &mut Contour, result: &mut Vec| { + if !upper.is_empty() { + upper.extend(lower.drain(..).rev()); + clockwise(upper, false); + result.push(std::mem::take(upper)); + } + }; + for i in 0..=count { + let x = left + (right - left) * i as f64 / count.max(1) as f64; + if distance(x, mid) >= 0. { + finish(&mut upper, &mut lower, &mut result); + continue; + } + let root = |outside: f64| { + let mut a = outside; + let mut b = mid; + for _ in 0..16 { + let y = (a + b) * 0.5; + if distance(x, y) < 0. { + b = y; + } else { + a = y; + } + } + (a + b) * 0.5 + }; + upper.push([x, root(top - scene.fusion)]); + lower.push([x, root(bottom + scene.fusion)]); + } + finish(&mut upper, &mut lower, &mut result); + } + } + Ok(result) +} + +/// Open border paths for screen-attached notches. Split shared straight edges +/// before cancelling them, so the body and its fillets have one continuous rim. +pub fn notch_border(scene: &Scene, tolerance: f64, edge: crate::Edge) -> Result, &'static str> { + let contours = contours(scene, tolerance)?; + let vertices: Vec<_> = contours.iter().flatten().copied().collect(); + let screen_edge = vertices.iter().map(|p| p[1]).fold( + if edge == crate::Edge::Top { + f64::INFINITY + } else { + f64::NEG_INFINITY + }, + if edge == crate::Edge::Top { f64::min } else { f64::max }, + ); + let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; + let mut segments: Vec<([f64; 2], [f64; 2])> = Vec::new(); + for contour in &contours { + for (&a, &b) in contour.iter().zip(contour.iter().cycle().skip(1)).take(contour.len()) { + if close(a, b) || ((a[1] - screen_edge).abs() < 1e-6 && (b[1] - screen_edge).abs() < 1e-6) { + continue; + } + let delta = [b[0] - a[0], b[1] - a[1]]; + let length_squared = delta[0] * delta[0] + delta[1] * delta[1]; + let mut cuts = vec![(0., a), (1., b)]; + for &point in &vertices { + let offset = [point[0] - a[0], point[1] - a[1]]; + let t = (offset[0] * delta[0] + offset[1] * delta[1]) / length_squared; + if t > 0. && t < 1. && (offset[0] * delta[1] - offset[1] * delta[0]).abs() < 1e-6 { + cuts.push((t, point)); + } + } + cuts.sort_by(|a, b| a.0.total_cmp(&b.0)); + cuts.dedup_by(|a, b| close(a.1, b.1)); + for pair in cuts.windows(2) { + let segment = (pair[0].1, pair[1].1); + if let Some(index) = segments + .iter() + .position(|&(a, b)| close(a, segment.1) && close(b, segment.0)) + { + segments.swap_remove(index); + } else { + segments.push(segment); + } + } + } + } + let mut paths = Vec::new(); + while let Some((a, b)) = segments.pop() { + let mut path = vec![a, b]; + while let Some(index) = segments.iter().position(|&(a, _)| close(a, *path.last().unwrap())) { + path.push(segments.swap_remove(index).1); + } + while let Some(index) = segments.iter().position(|&(_, b)| close(b, path[0])) { + path.insert(0, segments.swap_remove(index).0); + } + paths.push(path); + } + Ok(paths) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::{GroupBounds, Parameters, derive}; + use crate::shape::{PixelGrid, Style, ZonePosition}; + use ferese_layout::Rect; + + fn winding(contours: &[Contour], point: [f64; 2]) -> i32 { + let mut winding = 0; + for points in contours { + for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) { + let cross = (b[0] - a[0]) * (point[1] - a[1]) - (point[0] - a[0]) * (b[1] - a[1]); + if a[1] <= point[1] && b[1] > point[1] && cross > 0. { + winding += 1; + } + if a[1] > point[1] && b[1] <= point[1] && cross < 0. { + winding -= 1; + } + } + } + winding + } + + #[test] + fn notch_rim_connects_both_fillets_without_shared_or_screen_edges() { + for scale in [1., 1.25, 1.5, 2.] { + for edge in [crate::Edge::Top, crate::Edge::Bottom] { + let scene = derive( + Parameters { + style: Style::Notch, + edge, + height: 44., + fillet: 12., + ..Default::default() + }, + Rect::new(0., 0., 340., 44.), + &[GroupBounds { + zone: ZonePosition::Center, + bounds: Rect::new(70.3, 0., 160.4, 44.), + }], + PixelGrid::new(scale).unwrap(), + ) + .unwrap(); + let paths = notch_border(&scene, 0.1 / scale, edge).unwrap(); + assert_eq!(paths.len(), 1, "{edge:?} at {scale}"); + let path = &paths[0]; + let screen_y = if edge == crate::Edge::Top { 0. } else { 44. }; + assert!((path.first().unwrap()[1] - screen_y).abs() < 1e-6); + assert!((path.last().unwrap()[1] - screen_y).abs() < 1e-6); + let body = scene.paint.iter().find(|p| p.operation == 0).unwrap().bounds; + assert!(path.iter().any(|p| p[0] < body.x)); + assert!(path.iter().any(|p| p[0] > body.x + body.width)); + for segment in path.windows(2) { + let [a, b] = [segment[0], segment[1]]; + assert!((a[1] - screen_y).abs() > 1e-6 || (b[1] - screen_y).abs() > 1e-6); + let mid = [(a[0] + b[0]) / 2., (a[1] + b[1]) / 2.]; + assert!(scene.distance(mid).abs() < 0.12 / scale, "off outline: {mid:?}"); + // A shared body/fillet seam has fill on both sides, unlike a visible rim. + let delta = [b[0] - a[0], b[1] - a[1]]; + let length = delta[0].hypot(delta[1]); + let normal = [-delta[1] / length * 0.25, delta[0] / length * 0.25]; + assert!( + scene.distance([mid[0] + normal[0], mid[1] + normal[1]]) > 0. + || scene.distance([mid[0] - normal[0], mid[1] - normal[1]]) > 0. + ); + } + } + } + } + + #[test] + fn attached_morph_rim_follows_animated_body_at_fractional_scales() { + for scale in [1., 1.25, 1.5, 2.] { + for edge in [crate::Edge::Top, crate::Edge::Bottom] { + for (width, height, radius) in [(150., 34., 17.), (218.3, 41.7, 20.85), (340., 230., 26.)] { + let scene = derive( + Parameters { + style: Style::Morph, + attached: true, + edge, + fillet: 12., + radius: Some(crate::shape::AnchoredRadii([radius; 4])), + ..Default::default() + }, + Rect::new(30.3, 0., width, height), + &[], + PixelGrid::new(scale).unwrap(), + ) + .unwrap(); + let body = scene.paint[0]; + assert_eq!(scene.paint.len(), 3); + assert_eq!(scene.input.len(), 1, "decorative shoulders must not take pointer input"); + let screen_y = if edge == crate::Edge::Top { + body.bounds.y + } else { + body.bounds.y + body.bounds.height + }; + let paths = notch_border(&scene, 0.1 / scale, edge).unwrap(); + assert_eq!( + paths.len(), + 1, + "one continuous rim: {scale}, {edge:?}, {width}, {height}" + ); + let path = &paths[0]; + assert!((path.first().unwrap()[1] - screen_y).abs() < 1e-6); + assert!((path.last().unwrap()[1] - screen_y).abs() < 1e-6); + assert!(path.iter().any(|p| p[0] < body.bounds.x)); + assert!(path.iter().any(|p| p[0] > body.bounds.x + body.bounds.width)); + for segment in path.windows(2) { + let [a, b] = [segment[0], segment[1]]; + assert!( + (a[1] - screen_y).abs() > 1e-6 || (b[1] - screen_y).abs() > 1e-6, + "no stroke on the screen edge" + ); + let mid = [(a[0] + b[0]) / 2., (a[1] + b[1]) / 2.]; + assert!(scene.distance(mid).abs() < 0.12 / scale); + } + let contours = contours(&scene, 0.1 / scale).unwrap(); + for x in 0..((width + 60.) as usize) { + for y in 0..(height as usize) { + let p = [x as f64 + 0.37, y as f64 + 0.37]; + if scene.distance(p).abs() > 0.15 / scale { + assert_eq!( + winding(&contours, p) != 0, + scene.distance(p) < 0., + "native and fallback must agree" + ); + } + } + } + } + } + } + } + + #[test] + fn vector_union_and_cutout_match_implicit_shapes() { + let groups = [ + GroupBounds { + zone: ZonePosition::Start, + bounds: Rect::new(4., 0., 20., 16.), + }, + GroupBounds { + zone: ZonePosition::Start, + bounds: Rect::new(28., 0., 20., 16.), + }, + GroupBounds { + zone: ZonePosition::Center, + bounds: Rect::new(52., 0., 20., 16.), + }, + ]; + for style in [ + Style::Floating, + Style::Capsules, + Style::Notch, + Style::Flat, + Style::Frame, + Style::CornerWrap, + Style::Fused, + ] { + let scene = derive( + Parameters { + style, + height: 16., + gap: 4., + frame_insets: [16., 4., 4., 4.], + frame_radius: 8., + wrap_size: [40., 42.], + wrap_width: 8., + fillet: 6., + ..Default::default() + }, + Rect::new(0., 0., 96., 64.), + &groups, + PixelGrid::new(1.25).unwrap(), + ) + .unwrap(); + let contours = contours(&scene, 0.1).unwrap(); + for y in 0..64 { + for x in 0..96 { + let point = [x as f64 + 0.5, y as f64 + 0.5]; + let distance = scene.distance(point); + if distance.abs() > 0.3 { + assert_eq!( + winding(&contours, point) != 0, + distance < 0., + "{style:?} at {point:?}, {distance}" + ); + } + } + } + } + } +} diff --git a/crates/ferese-config/src/panel.rs b/crates/ferese-panel/src/panel.rs similarity index 64% rename from crates/ferese-config/src/panel.rs rename to crates/ferese-panel/src/panel.rs index 1b10109..529a6dd 100644 --- a/crates/ferese-config/src/panel.rs +++ b/crates/ferese-panel/src/panel.rs @@ -1,9 +1,8 @@ -//! Panel composition, independent of widgets, services, and Wayland surfaces. pub(crate) mod geometry; pub mod layout; -pub use geometry::PanelGeometry; +pub use geometry::{GeometryIntent, PanelGeometry}; mod radius; -use crate::PanelPreset; +use crate::PanelStyle; pub use radius::CornerRadii; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashSet}; @@ -349,8 +348,6 @@ pub struct Group { pub id: GroupId, #[serde(default)] pub items: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub surface: Option, #[serde(default = "spacing")] pub spacing: f32, #[serde(default = "padding")] @@ -364,7 +361,6 @@ impl Group { Self { id: GroupId(id.into()), items, - surface: None, spacing: 1.0, padding, island_padding, @@ -386,11 +382,11 @@ pub struct Panel { #[serde(default)] pub edge: Edge, #[serde(default)] - pub geometry: PanelGeometry, + pub geometry: GeometryIntent, #[serde(default)] - pub surface: PanelSurface, - #[serde(default)] - pub group_surface: GroupSurface, + pub style: PanelStyle, + #[serde(default, skip_serializing_if = "default_morph")] + pub morph: crate::morph::Profile, #[serde(default)] pub border: bool, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -414,6 +410,10 @@ impl Default for Zone { } } +fn default_morph(profile: &crate::morph::Profile) -> bool { + *profile == crate::morph::Profile::default() +} + pub fn validate(panels: &[Panel]) -> Result<(), String> { if panels.len() != 1 { return Err("configure exactly one panel; multiple panels are not supported yet".into()); @@ -428,7 +428,11 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> { }; let mut panel_ids = HashSet::new(); for panel in panels { + if !PanelStyle::AVAILABLE.contains(&panel.style) { + return Err(format!("panel style {} is not available yet", panel.style.key())); + } panel.geometry.validate()?; + panel.morph.validate()?; if panel.group_count() > MAX_GROUPS { return Err(format!( "a panel supports at most {MAX_GROUPS} groups, reserving one region for overflow" @@ -437,7 +441,7 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> { if let Some(opacity) = panel.background_opacity && (!opacity.is_finite() || !(0.0..=1.0).contains(&opacity)) { - return Err("panel group background opacity must be between 0 and 1".into()); + return Err("panel background opacity must be between 0 and 1".into()); } if let Some(radius) = panel.corner_radius { radius.validate()?; @@ -486,40 +490,69 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> { impl Default for Panel { fn default() -> Self { - Self::from_preset(PanelPreset::Continuous) + Self::from_preset(PanelStyle::Flat) } } impl Panel { - pub fn resolved_surface(&self, group: &Group) -> GroupSurface { - group.surface.unwrap_or(self.group_surface) + pub fn preset(&self) -> Option { + Some(self.style) } - pub fn preset(&self) -> Option { - if [&self.start, &self.center, &self.end] - .into_iter() - .flat_map(|zone| &zone.groups) - .any(|group| group.surface.is_some_and(|surface| surface != self.group_surface)) - { - return None; - } - match (self.surface, self.group_surface) { - (PanelSurface::Solid, GroupSurface::None) => Some(PanelPreset::Continuous), - (PanelSurface::None, GroupSurface::Island) => Some(PanelPreset::Islands), - _ => None, + pub fn apply_preset(&mut self, style: PanelStyle) { + self.style = style; + } + + pub fn surface(&self) -> PanelSurface { + if matches!(self.style, PanelStyle::Flat | PanelStyle::Frame) { + PanelSurface::Solid + } else { + PanelSurface::None } } - pub fn apply_preset(&mut self, preset: PanelPreset) { - (self.surface, self.group_surface) = match preset { - PanelPreset::Continuous => (PanelSurface::Solid, GroupSurface::None), - PanelPreset::Islands => (PanelSurface::None, GroupSurface::Island), - }; - for group in [&mut self.start, &mut self.center, &mut self.end] - .into_iter() - .flat_map(|zone| &mut zone.groups) - { - group.surface = None; + pub fn group_surface(&self) -> GroupSurface { + if self.surface() == PanelSurface::Solid { + GroupSurface::None + } else { + GroupSurface::Island + } + } + + pub fn zone_spacing(&self, zone: &Zone) -> f32 { + if self.style.connected() { 0. } else { zone.spacing } + } + + pub fn shape_parameters(&self) -> crate::scene::Parameters { + crate::scene::Parameters { + style: self.style, + attached: self.style == PanelStyle::Morph && self.morph.attached, + fillet: if self.style == PanelStyle::Morph { + 12. + } else { + crate::scene::Parameters::default().fillet + }, + edge: self.edge, + height: self.resolved_geometry().height.into(), + radius: self.corner_radius.map(|r| crate::shape::AnchoredRadii(r.0)), + ..Default::default() + } + } + + pub fn resolved_geometry(&self) -> PanelGeometry { + let mut geometry = self.geometry.resolve(self.style); + if self.style == PanelStyle::Morph && self.morph.attached { + geometry.edge_margin = 0; + } + geometry + } + + pub fn exclusive_zone(&self, hidden: bool) -> i32 { + if hidden { + 0 + } else { + let geometry = self.resolved_geometry(); + (geometry.height.round() as i32).saturating_add(geometry.window_clearance) } } @@ -541,13 +574,14 @@ impl Panel { && left.spacing == right.spacing && left.padding == right.padding && left.island_padding == right.island_padding - && (self.resolved_surface(left) == GroupSurface::Island) - == (other.resolved_surface(right) == GroupSurface::Island) + && (self.group_surface() == GroupSurface::Island) + == (other.group_surface() == GroupSurface::Island) }) }; self.id == other.id && self.edge == other.edge - && self.surface == other.surface + && self.style == other.style + && self.morph == other.morph && same_zone(&self.start, &other.start) && same_zone(&self.center, &other.center) && same_zone(&self.end, &other.end) @@ -571,7 +605,6 @@ impl Panel { overflow: OverflowPolicy::Never, priority: 100, }], - surface: None, spacing: 0.0, padding: [2, 3], island_padding: self @@ -591,7 +624,7 @@ impl Panel { } /// Build a stable initial composition; presets are an editor convenience. - pub fn from_preset(preset: PanelPreset) -> Self { + pub fn from_preset(preset: PanelStyle) -> Self { use ItemKind::*; let item = Item::new; @@ -617,7 +650,7 @@ impl Panel { let display = item("display-mode", DisplayMode); let (start, center, end) = match preset { - PanelPreset::Continuous => ( + PanelStyle::Flat => ( vec![ Group::new("overview", vec![overview], [0, 0], padding), Group::new("workspaces", vec![workspaces], [2, 3], padding), @@ -629,7 +662,7 @@ impl Panel { Group::new("display", vec![display], [0, 0], padding), ], ), - PanelPreset::Islands => { + _ => { let mut controls = controls; controls.extend([clock, display]); ( @@ -643,23 +676,15 @@ impl Panel { Self { id: PanelId("main".into()), edge: Edge::Top, - geometry: PanelGeometry::default(), - surface: if preset == PanelPreset::Continuous { - PanelSurface::Solid - } else { - PanelSurface::None - }, - group_surface: if preset == PanelPreset::Continuous { - GroupSurface::None - } else { - GroupSurface::Island - }, + geometry: GeometryIntent::default(), + style: preset, + morph: Default::default(), border: false, background_opacity: Option::None, corner_radius: Option::None, start: Zone { groups: start, - spacing: if preset == PanelPreset::Islands { 8.0 } else { 3.0 }, + spacing: if preset == PanelStyle::Floating { 8.0 } else { 3.0 }, }, center: Zone { groups: vec![center], @@ -677,9 +702,27 @@ impl Panel { mod tests { use super::*; + #[test] + fn fullscreen_releases_the_entire_reservation_without_discarding_pins() { + let mut panel = Panel::from_preset(PanelStyle::Floating); + panel.geometry.height = Some(36.); + panel.geometry.window_clearance = Some(4); + assert_eq!(panel.exclusive_zone(false), 40); + assert_eq!(panel.exclusive_zone(true), 0); + assert_eq!(panel.geometry.window_clearance, Some(4)); + } + + fn items(panel: &Panel) -> Vec<&Item> { + [&panel.start, &panel.center, &panel.end] + .into_iter() + .flat_map(|zone| &zone.groups) + .flat_map(|group| &group.items) + .collect() + } + #[test] fn group_capacity_reserves_overflow_even_for_empty_groups() { - for background in [PanelPreset::Continuous, PanelPreset::Islands] { + for background in [PanelStyle::Flat, PanelStyle::Floating] { let mut panel = Panel::from_preset(background); panel.start.groups.clear(); panel.center.groups.clear(); @@ -696,46 +739,10 @@ mod tests { } } - #[test] - fn group_surfaces_round_trip_and_overrides_are_preserved() { - let source = "// keep\npanel main { surface none; group-surface island; start { group nav { item overview kind=overview; }; }; center { group title { surface none; item title kind=focused-window; }; }; end { group media { surface inset; item media kind=media; }; }; }"; - let mut doc = crate::Document::parse(source).unwrap(); - let panel: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); - let panel = &panel[0]; - assert_eq!(panel.resolved_surface(&panel.start.groups[0]), GroupSurface::Island); - assert_eq!(panel.resolved_surface(&panel.center.groups[0]), GroupSurface::None); - assert_eq!(panel.resolved_surface(&panel.end.groups[0]), GroupSurface::Inset); - assert_eq!(panel.preset(), None); - doc.set("panels", serde_json::to_value(vec![panel]).unwrap()).unwrap(); - let restored = crate::Document::parse(&doc.to_string()).unwrap(); - let panels: Vec = serde_json::from_value(restored.get("panels").unwrap().clone()).unwrap(); - assert_eq!(&panels[0], panel); - assert!(doc.to_string().contains("// keep")); - let mut preset = panel.clone(); - let ids: Vec<_> = items(panel).iter().map(|item| item.id.clone()).collect(); - preset.apply_preset(PanelPreset::Continuous); - assert_eq!(preset.preset(), Some(PanelPreset::Continuous)); - assert_eq!( - items(&preset).iter().map(|item| item.id.clone()).collect::>(), - ids - ); - assert!(preset.center.groups[0].surface.is_none()); - preset.apply_preset(PanelPreset::Islands); - assert_eq!(preset.preset(), Some(PanelPreset::Islands)); - } - - fn items(panel: &Panel) -> Vec<&Item> { - [&panel.start, &panel.center, &panel.end] - .into_iter() - .flat_map(|zone| &zone.groups) - .flat_map(|group| &group.items) - .collect() - } - #[test] fn defaults_preserve_order_and_ids_across_background_changes() { let continuous = Panel::default(); - let islands = Panel::from_preset(PanelPreset::Islands); + let islands = Panel::from_preset(PanelStyle::Floating); let expected = [ "overview", "workspaces", @@ -799,7 +806,7 @@ mod tests { #[test] fn settings_map_to_instances_and_group_presentation() { - let mut panel = Panel::from_preset(PanelPreset::Islands); + let mut panel = Panel::from_preset(PanelStyle::Floating); panel.center.groups[0].items[0].visible = false; let battery = panel.end.groups[0] .items @@ -835,162 +842,6 @@ mod tests { assert_eq!(first.kind, second.kind); assert_ne!(first.id, second.id); } - #[test] - fn composition_round_trips_kdl_and_edits_keep_ids_order_and_comments() { - let source = r#"// panel comment -panel "main" { - surface "none" - group-surface "island" - end { - group "status" { - surface "island" - padding 2 3 - // network comment - item "network" kind="network" overflow="always" - item "local-clock" kind="clock" - item "second-clock" kind="clock" representation="compact" - } - } -} -animations { speed 0.8; } -"#; - let mut doc = crate::Document::parse(source).unwrap(); - let panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); - validate(&panels).unwrap(); - assert_eq!(panels[0].end.groups[0].padding, [2, 3]); - assert!(panels[0].background_opacity.is_none()); - assert_eq!(panels[0].end.groups[0].items[0].overflow, OverflowPolicy::Always); - doc.set("panels.0.end.groups.0.items.0.overflow", "never".into()) - .unwrap(); - let path = "panels.0.end.groups.0.items"; - let mut items = doc.get(path).unwrap().as_array().unwrap().clone(); - items.swap(0, 2); - doc.set(path, items.into()).unwrap(); - let formatted = doc.to_string(); - assert!(formatted.contains("// panel comment")); - assert!(formatted.contains("// network comment")); - let parsed = crate::Document::parse(&formatted).unwrap(); - let panels: Vec = serde_json::from_value(parsed.get("panels").unwrap().clone()).unwrap(); - validate(&panels).unwrap(); - assert_eq!( - panels[0].end.groups[0] - .items - .iter() - .map(|item| item.id.0.as_str()) - .collect::>(), - ["second-clock", "local-clock", "network"] - ); - assert_eq!( - panels[0].item(&ItemId("network".into())).unwrap().overflow, - OverflowPolicy::Never - ); - assert_eq!(parsed.get("animations.speed").unwrap(), &serde_json::json!(0.8)); - let value = serde_json::to_value(&panels).unwrap(); - doc.set("panels", value.clone()).unwrap(); - let round_trip = crate::Document::parse(&doc.to_string()).unwrap(); - let restored: Vec = serde_json::from_value(round_trip.get("panels").unwrap().clone()).unwrap(); - assert_eq!(restored, panels); - doc.set(path, Vec::::new().into()).unwrap(); - assert!(doc.to_string().contains("// network comment")); - let restored: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); - assert!(restored[0].end.groups[0].items.is_empty()); - assert_eq!(restored[0].end.spacing, 8.0); - } - - #[test] - fn panel_border_defaults_off_and_round_trips_an_explicit_enabled_value() { - let mut doc = - crate::Document::parse("panel main { end { group status { item clock kind=clock; }; }; }\n").unwrap(); - let panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); - assert!(!panels[0].border); - doc.set("panels.0.border", true.into()).unwrap(); - let doc = crate::Document::parse(&doc.to_string()).unwrap(); - let panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); - assert!(panels[0].border); - assert_eq!(panels[0].end.groups[0].items[0].id.0, "clock"); - } - - #[test] - fn panel_background_opacity_inherits_until_overridden_and_validates_bounds() { - let doc = crate::Document::parse("panel main { end { group status { surface inset; }; }; }").unwrap(); - let mut panels: Vec = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap(); - assert_eq!(panels[0].background_opacity, None); - assert!( - serde_json::to_value(&panels[0]).unwrap()["end"]["groups"][0] - .get("background_opacity") - .is_none() - ); - for value in [0., 0.5, 1.] { - panels[0].background_opacity = Some(value); - validate(&panels).unwrap(); - } - for value in [-0.1, 1.1, f32::NAN, f32::INFINITY] { - panels[0].background_opacity = Some(value); - assert!(validate(&panels).is_err()); - } - } - - #[test] - fn panel_edge_defaults_to_top_and_round_trips_both_positions() { - for (source, expected) in [ - ("", Edge::Top), - ("edge top;", Edge::Top), - ("edge bottom;", Edge::Bottom), - ] { - let document = crate::Document::parse(&format!("panel main {{ {source} }}")).unwrap(); - let panels: Vec = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap(); - validate(&panels).unwrap(); - assert_eq!(panels[0].edge, expected); - let restored: Vec = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap(); - assert_eq!(restored[0].edge, expected); - let mut moved = panels[0].clone(); - moved.edge = if expected == Edge::Top { Edge::Bottom } else { Edge::Top }; - assert!(!moved.same_composition(&panels[0])); - } - } - - #[test] - fn validation_rejects_duplicate_ids_and_unsupported_surface_configuration() { - let mut panel = Panel::default(); - validate(std::slice::from_ref(&panel)).unwrap(); - panel.end.groups[0].items[1].id = panel.start.groups[0].items[0].id.clone(); - assert!(validate(std::slice::from_ref(&panel)).is_err()); - panel.end.groups[0].items[1].id = ItemId("_overflow".into()); - assert!(validate(std::slice::from_ref(&panel)).is_err()); - assert!(validate(&[]).is_err()); - assert!(validate(&[panel.clone(), panel]).is_err()); - assert!(crate::from_str::("panel \"main\" { edge \"bottom\"; }").is_ok()); - let doc = crate::Document::parse("panel \"main\" { edge \"left\"; }").unwrap(); - assert!(serde_json::from_value::>(doc.get("panels").unwrap().clone()).is_err()); - } - #[test] - fn workspace_styles_round_trip_and_only_apply_to_workspaces() { - for style in WorkspaceStyle::ALL { - let source = format!( - "panel main {{ start {{ group nav {{ item spaces kind=\"workspaces\" style=\"{}\"; }} }} }}", - style.key() - ); - let document = crate::Document::parse(&source).unwrap(); - let panels: Vec = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap(); - validate(&panels).unwrap(); - assert_eq!(panels[0].start.groups[0].items[0].kind, ItemKind::Workspaces { style }); - let mut wrong_kind = serde_json::to_value(&panels[0].start.groups[0].items[0]).unwrap(); - wrong_kind["kind"] = "clock".into(); - assert!(serde_json::from_value::(wrong_kind).is_err()); - let restored: Vec = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap(); - assert_eq!(restored, panels); - } - let mut item = serde_json::json!({"id": "spaces", "kind": "workspaces"}); - assert_eq!( - serde_json::from_value::(item.clone()).unwrap().kind, - ItemKind::Workspaces { - style: WorkspaceStyle::Dots - } - ); - item["style"] = "unsupported".into(); - assert!(serde_json::from_value::(item).is_err()); - } - #[test] fn removed_item_fields_are_rejected() { for value in [ @@ -1001,38 +852,15 @@ animations { speed 0.8; } } } - #[test] - fn item_options_are_strict_and_metadata_is_explicit() { - for key in ["ovreflow", "visibile", "custom_note"] { - let mut value = serde_json::json!({"id":"network", "kind":"network"}); - value[key] = "always".into(); - let error = serde_json::from_value::(value).unwrap_err().to_string(); - assert!(error.contains(key) && error.contains("unknown field"), "{error}"); - } - let source = "panel main { start { group nav { item network kind=\"network\" { metadata { custom-note \"keep\"; overflow \"authored note\"; }; }; }; }; }"; - let panel: Vec = - serde_json::from_value(crate::Document::parse(source).unwrap().get("panels").unwrap().clone()).unwrap(); - let item = &panel[0].start.groups[0].items[0]; - assert_eq!(item.metadata["custom_note"], "keep"); - assert_eq!(item.overflow, OverflowPolicy::Auto); - assert_eq!( - serde_json::from_value::(serde_json::to_value(item).unwrap()).unwrap(), - *item - ); - assert!( - serde_json::from_value::(serde_json::json!({"id":"network", "kind":"network", "metadata":"wrong"})) - .is_err() - ); - } #[test] fn surface_changes_only_replace_composition_when_item_geometry_changes() { let panel = Panel::default(); let mut changed = panel.clone(); - changed.group_surface = GroupSurface::Inset; + changed.style = PanelStyle::Flat; assert!(panel.same_composition(&changed)); - changed.end.groups[0].surface = Some(GroupSurface::Island); + changed.style = PanelStyle::Floating; assert!(!panel.same_composition(&changed)); - changed.end.groups[0].surface = Some(GroupSurface::None); + changed.style = PanelStyle::Flat; assert!(panel.same_composition(&changed)); changed.end.groups[0].padding[1] += 1; assert!(!panel.same_composition(&changed)); diff --git a/crates/ferese-panel/src/panel/geometry.rs b/crates/ferese-panel/src/panel/geometry.rs new file mode 100644 index 0000000..85bbb12 --- /dev/null +++ b/crates/ferese-panel/src/panel/geometry.rs @@ -0,0 +1,138 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct GeometryIntent { + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub edge_margin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub side_margins: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_clearance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inner_padding: Option, +} + +impl GeometryIntent { + pub fn resolve(self, style: crate::PanelStyle) -> PanelGeometry { + use crate::PanelStyle::*; + let flush = matches!(style, Notch | Flat | Frame | CornerWrap); + PanelGeometry { + height: self.height.unwrap_or(match style { + Frame => 40., + Morph => 34., + _ => 36., + }), + edge_margin: if flush { 0 } else { self.edge_margin.unwrap_or(8) }, + side_margins: if flush { 0 } else { self.side_margins.unwrap_or(8) }, + window_clearance: self.window_clearance.unwrap_or(0), + inner_padding: self.inner_padding.unwrap_or(12.), + } + } + + pub fn validate(self) -> Result<(), String> { + // Validate pins even when a flush style temporarily overrides margins. + let mut geometry = self.resolve(crate::PanelStyle::Floating); + geometry.edge_margin = self.edge_margin.unwrap_or(0); + geometry.side_margins = self.side_margins.unwrap_or(0); + geometry.validate() + } +} + +/// Logical dimensions owned by a panel, independent of its color theme or edge. +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct PanelGeometry { + pub height: f32, + pub edge_margin: i32, + pub side_margins: i32, + pub window_clearance: i32, + pub inner_padding: f32, +} + +impl Default for PanelGeometry { + fn default() -> Self { + Self { + height: 36., + edge_margin: 0, + side_margins: 0, + window_clearance: 0, + inner_padding: 12., + } + } +} + +impl PanelGeometry { + pub fn validate(self) -> Result<(), String> { + if !self.height.is_finite() + || !(24. ..=128.).contains(&self.height) + || !self.inner_padding.is_finite() + || !(0. ..=64.).contains(&self.inner_padding) + || [self.edge_margin, self.side_margins, self.window_clearance] + .into_iter() + .any(|value| !(0..=4096).contains(&value)) + { + return Err("panel height must be 24..128, inner padding 0..64, and margins/clearance 0..4096".into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_defaults_and_user_pins_have_separate_ownership() { + use crate::PanelStyle::*; + let intent = GeometryIntent::default(); + assert_eq!(intent.resolve(Floating).edge_margin, 8); + assert_eq!(intent.resolve(Flat).edge_margin, 0); + assert_eq!(intent.resolve(Morph).height, 34.); + let pins = GeometryIntent { + height: Some(32.), + edge_margin: Some(6), + side_margins: Some(18), + ..intent + }; + assert_eq!(pins.resolve(Flat).height, 32.); + assert_eq!(pins.resolve(Flat).side_margins, 0); + assert_eq!(pins.resolve(Floating).side_margins, 18); + assert_eq!(pins.resolve(Notch).edge_margin, 0); + assert!(serde_json::to_value(intent).unwrap().as_object().unwrap().is_empty()); + assert!( + GeometryIntent { + edge_margin: Some(-1), + ..intent + } + .validate() + .is_err() + ); + } + + #[test] + fn invalid_geometry_rejects_nonfinite_dimensions_and_negative_clearances() { + for geometry in [ + PanelGeometry { + height: f32::NAN, + ..Default::default() + }, + PanelGeometry { + edge_margin: -1, + ..Default::default() + }, + PanelGeometry { + height: 12., + ..Default::default() + }, + PanelGeometry { + inner_padding: f32::INFINITY, + ..Default::default() + }, + ] { + assert!(geometry.validate().is_err()); + } + } +} diff --git a/crates/ferese-config/src/panel/layout.rs b/crates/ferese-panel/src/panel/layout.rs similarity index 95% rename from crates/ferese-config/src/panel/layout.rs rename to crates/ferese-panel/src/panel/layout.rs index 7edb39d..a47825b 100644 --- a/crates/ferese-config/src/panel/layout.rs +++ b/crates/ferese-panel/src/panel/layout.rs @@ -1,4 +1,3 @@ -//! Measured panel allocation. This module has no frame timing or service ownership. use super::{ItemId, OverflowPolicy, Panel, Representation, Zone}; use std::collections::BTreeMap; @@ -47,7 +46,7 @@ fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap) - } if occupied { let padding = 2.0 * f32::from(group.padding[1]); - let island = if panel.resolved_surface(group) == super::GroupSurface::Island { + let island = if panel.group_surface() == super::GroupSurface::Island { 2.0 * group.island_padding } else { 0.0 @@ -56,7 +55,7 @@ fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap) - group_count += 1; } } - width + zone.spacing * group_count.saturating_sub(1) as f32 + width + panel.zone_spacing(zone) * group_count.saturating_sub(1) as f32 } impl Resolution { @@ -69,7 +68,7 @@ impl Resolution { if !self.overflow.is_empty() { self.zone_widths[2] += overflow_width + if self.zone_widths[2] > 0.0 { - panel.end.spacing + panel.zone_spacing(&panel.end) } else { 0.0 }; @@ -297,7 +296,7 @@ mod tests { #[test] fn zone_width_ignores_empty_groups_and_gaps_before_hidden_items() { - use super::super::{Group, GroupSurface, Item, ItemKind}; + use super::super::{Group, Item, ItemKind}; let mut panel = Panel::default(); let mut first = Item::new("first", ItemKind::Overview); first.gap_before = Some(99.); @@ -305,7 +304,7 @@ mod tests { let mut last = Item::new("last", ItemKind::Clock); last.gap_before = Some(7.); let mut group = Group::new("content", vec![first, hidden, last], [0, 3], 5.); - group.surface = Some(GroupSurface::Island); + panel.style = crate::PanelStyle::Floating; group.island_padding = 4.; let empty = Group::new("empty", vec![Item::new("missing", ItemKind::Media)], [0, 99], 99.); let trailing = Group::new( @@ -341,7 +340,7 @@ mod tests { ), ] .into(); - assert_eq!(zone_width(&panel, &panel.start, &items), 96.); + assert_eq!(zone_width(&panel, &panel.start, &items), 106.); } #[test] @@ -583,7 +582,7 @@ mod tests { #[test] fn narrow_panels_adapt_deterministically_and_keep_every_item_reachable() { - for background in [crate::PanelPreset::Continuous, crate::PanelPreset::Islands] { + for background in [crate::PanelStyle::Flat, crate::PanelStyle::Floating] { let panel = Panel::from_preset(background); let measured = measurements(&panel); for width in [2400., 1200., 800., 400., 160., 80., 32.] { @@ -603,7 +602,7 @@ mod tests { #[test] fn decoration_and_borders_do_not_change_background_arrangement_or_allocation() { - for background in [crate::PanelPreset::Continuous, crate::PanelPreset::Islands] { + for background in [crate::PanelStyle::Flat, crate::PanelStyle::Floating] { let mut panel = Panel::from_preset(background); let measurements = measurements(&panel); let plain = resolve(&panel, &measurements, 1000., 32.); @@ -717,20 +716,19 @@ mod tests { } } #[test] - fn only_island_groups_reserve_island_padding() { - let mut panel = Panel::from_preset(crate::PanelPreset::Islands); + fn panel_surface_policy_controls_padding_for_every_group() { + let mut panel = Panel::from_preset(crate::PanelStyle::Floating); let measured = measurements(&panel); let initial = resolve(&panel, &measured, 2400., 32.); - let title_padding = 2. * panel.center.groups[0].island_padding; - panel.center.groups[0].surface = Some(super::super::GroupSurface::None); + panel.style = crate::PanelStyle::Flat; let plain = resolve(&panel, &measured, 2400., 32.); - assert_eq!(plain.zone_widths[1], initial.zone_widths[1] - title_padding); - assert_eq!(plain.zone_widths[0], initial.zone_widths[0]); - assert_eq!(plain.zone_widths[2], initial.zone_widths[2]); - panel.center.groups[0].surface = Some(super::super::GroupSurface::Inset); + for (i, zone) in [&panel.start, &panel.center, &panel.end].into_iter().enumerate() { + let padding: f32 = zone.groups.iter().map(|group| 2. * group.island_padding).sum(); + assert_eq!(plain.zone_widths[i], initial.zone_widths[i] - padding); + } + panel.style = crate::PanelStyle::Flat; assert_eq!(resolve(&panel, &measured, 2400., 32.), plain); - // A parent panel fill cannot change a group's allocation policy. - panel.surface = super::super::PanelSurface::Solid; + panel.style = crate::PanelStyle::Flat; assert_eq!(resolve(&panel, &measured, 2400., 32.), plain); } } diff --git a/crates/ferese-config/src/panel/radius.rs b/crates/ferese-panel/src/panel/radius.rs similarity index 86% rename from crates/ferese-config/src/panel/radius.rs rename to crates/ferese-panel/src/panel/radius.rs index 80ea2d3..65a3cc5 100644 --- a/crates/ferese-config/src/panel/radius.rs +++ b/crates/ferese-panel/src/panel/radius.rs @@ -48,6 +48,25 @@ impl CornerRadii { let limit = width.min(height).max(0.) / 2.; Self(self.0.map(|value| value.min(limit))) } + + /// Scale all corners together when their sums exceed an adjacent edge. + /// Unlike the symmetric round-box clamp, this permits a large free-side + /// corner when the corner on the anchored side is square. + pub fn normalized(self, width: f32, height: f32) -> Self { + let [tl, tr, br, bl] = self.0; + let mut factor = 1.0_f64; + for (length, sum) in [ + (width, f64::from(tl) + f64::from(tr)), + (width, f64::from(bl) + f64::from(br)), + (height, f64::from(tl) + f64::from(bl)), + (height, f64::from(tr) + f64::from(br)), + ] { + if sum > 0. { + factor = factor.min(f64::from(length.max(0.)) / sum); + } + } + Self(self.0.map(|radius| (f64::from(radius) * factor) as f32)) + } } impl std::str::FromStr for CornerRadii { diff --git a/crates/ferese-panel/src/scene.rs b/crates/ferese-panel/src/scene.rs new file mode 100644 index 0000000..4f9abd2 --- /dev/null +++ b/crates/ferese-panel/src/scene.rs @@ -0,0 +1,716 @@ +use crate::shape::{AnchoredRadii, PixelGrid, RoundedSurface, Style, ZonePosition}; +use crate::{CornerRadii, Edge}; +use ferese_layout::Rect; + +pub const MAX_PRIMITIVES: usize = 32; + +/// Additive rounded rectangles, one optional cutout, and concave corner patches. +/// Patch codes name the circle's center: bottom-left, bottom-right, top-right, +/// top-left. A patch is its rectangle minus that circle, rather than a hot area. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Primitive { + pub bounds: Rect, + pub radii: CornerRadii, + pub operation: u8, + pub blend_zone: u8, +} + +impl Primitive { + pub fn packet(self) -> [f32; 10] { + let Rect { x, y, width, height } = self.bounds; + let [tl, tr, br, bl] = self.radii.0; + [ + x as f32, + y as f32, + width as f32, + height as f32, + tl, + tr, + br, + bl, + self.operation as f32, + self.blend_zone as f32, + ] + } + + pub fn from_packet(values: [f32; 10]) -> Result { + if !values.into_iter().all(|v| v.is_finite() && v.abs() <= 32768.) + || values[2] <= 0. + || values[3] <= 0. + || values[4..8].iter().any(|v| *v < 0.) + || values[8].fract() != 0. + || !(0. ..=5.).contains(&values[8]) + || values[9].fract() != 0. + || !(0. ..=3.).contains(&values[9]) + { + return Err("invalid panel shape primitive"); + } + Ok(Self { + bounds: Rect::new(values[0].into(), values[1].into(), values[2].into(), values[3].into()), + radii: CornerRadii([values[4], values[5], values[6], values[7]]).normalized(values[2], values[3]), + operation: values[8] as u8, + blend_zone: values[9] as u8, + }) + } + + pub fn distance(self, point: [f64; 2]) -> f64 { + let rect = rectangle_distance(point, self.bounds); + if self.operation >= 2 { + let b = self.bounds; + let center = match self.operation { + 2 => [b.x, b.y + b.height], + 3 => [b.x + b.width, b.y + b.height], + 4 => [b.x + b.width, b.y], + _ => [b.x, b.y], + }; + return rect.max(b.width.min(b.height) - (point[0] - center[0]).hypot(point[1] - center[1])); + } + let b = self.bounds; + let (x, y) = (point[0] - b.x, point[1] - b.y); + let mut distance = rect; + for (r, dx, dy) in [ + (self.radii.0[0], x, y), + (self.radii.0[1], b.width - x, y), + (self.radii.0[2], b.width - x, b.height - y), + (self.radii.0[3], x, b.height - y), + ] { + let r = f64::from(r); + if r > 0. && dx < r && dy < r { + distance = distance.max((dx - r).hypot(dy - r) - r); + } + } + distance + } +} + +fn rectangle_distance(point: [f64; 2], b: Rect) -> f64 { + let x = (point[0] - b.x - b.width / 2.).abs() - b.width / 2.; + let y = (point[1] - b.y - b.height / 2.).abs() - b.height / 2.; + x.max(0.).hypot(y.max(0.)) + x.max(y).min(0.) +} + +pub fn smooth_min(a: f64, b: f64, strength: f64) -> f64 { + if strength <= 0. { + return a.min(b); + } + let h = ((strength - (a - b).abs()) / strength).max(0.); + a.min(b) - h * h * strength / 4. +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Scene { + pub paint: Vec, + pub input: Vec, + /// Bounded drawing/capture regions. Frame uses strips, not the output area. + pub damage: Vec, + pub dividers: Vec<[f64; 4]>, + pub fusion: f64, +} + +impl Scene { + pub fn distance(&self, point: [f64; 2]) -> f64 { + let mut zones = [f64::INFINITY; 4]; + let mut cutout = f64::INFINITY; + for primitive in &self.paint { + let distance = primitive.distance(point); + if primitive.operation == 1 { + cutout = cutout.min(distance); + } else { + let zone = usize::from(primitive.blend_zone); + zones[zone] = if zone > 0 { + smooth_min(zones[zone], distance, self.fusion) + } else { + zones[zone].min(distance) + }; + } + } + zones.into_iter().fold(f64::INFINITY, f64::min).max(-cutout) + } + + pub fn contains(&self, point: [f64; 2]) -> bool { + self.input.iter().any(|shape| shape.contains(point)) + } + + pub fn packets(&self) -> Vec<[f32; 10]> { + self.paint.iter().map(|p| p.packet()).collect() + } + + /// Horizontal runs at physical-pixel centers. Corners and animated gaps + /// stay click-through; adjacent equal runs are coalesced into one rectangle. + pub fn input_rectangles(&self, scale: f64) -> Vec<[i32; 4]> { + if !scale.is_finite() || scale <= 0. { + return Vec::new(); + } + let mut result: Vec<[i32; 4]> = Vec::new(); + for shape in &self.input { + let b = shape.bounds(); + let left = (b.x * scale).floor() as i32; + let right = ((b.x + b.width) * scale).ceil() as i32; + let top = (b.y * scale).floor() as i32; + let bottom = ((b.y + b.height) * scale).ceil() as i32; + for y in top..bottom { + let mut first = left; + while first < right && !shape.contains([(f64::from(first) + 0.5) / scale, (f64::from(y) + 0.5) / scale]) + { + first += 1; + } + let mut last = right; + while last > first && !shape.contains([(f64::from(last) - 0.5) / scale, (f64::from(y) + 0.5) / scale]) { + last -= 1; + } + if first == last { + continue; + } + if let Some(previous) = result.last_mut() + && previous[0] == first + && previous[2] == last - first + && previous[1] + previous[3] == y + { + previous[3] += 1; + } else { + result.push([first, y, last - first, 1]); + } + } + } + result + } +} + +#[derive(Clone, Copy, Debug)] +pub struct GroupBounds { + pub zone: ZonePosition, + pub bounds: Rect, +} + +#[derive(Clone, Copy, Debug)] +pub struct Parameters { + pub style: Style, + pub attached: bool, + pub edge: Edge, + pub height: f64, + pub gap: f64, + pub radius: Option, + pub frame_insets: [f64; 4], + pub frame_radius: f64, + pub wrap_size: [f64; 2], + pub wrap_width: f64, + pub fillet: f64, +} + +impl Default for Parameters { + fn default() -> Self { + Self { + style: Style::Flat, + attached: false, + edge: Edge::Top, + height: 36., + gap: 8., + radius: None, + frame_insets: [40., 8., 8., 8.], + frame_radius: 14., + wrap_size: [560., 380.], + wrap_width: 36., + fillet: 10., + } + } +} + +fn add( + scene: &mut Scene, + grid: PixelGrid, + rect: Rect, + radii: CornerRadii, + input: bool, + zone: u8, +) -> Result<(), &'static str> { + let rounded = RoundedSurface::new(rect, radii, grid)?; + if rounded.bounds().width == 0. || rounded.bounds().height == 0. { + return Ok(()); + } + scene.paint.push(Primitive { + bounds: rounded.bounds(), + radii: rounded.radii(), + operation: 0, + blend_zone: zone, + }); + if input { + scene.input.push(rounded); + } + scene.damage.push(rounded.bounds()); + Ok(()) +} + +pub fn derive( + parameters: Parameters, + bounds: Rect, + groups: &[GroupBounds], + grid: PixelGrid, +) -> Result { + let bounds = grid.rect(bounds)?; + if bounds.width == 0. || bounds.height == 0. { + return Ok(Scene::default()); + } + if ![ + parameters.height, + parameters.gap, + parameters.frame_radius, + parameters.wrap_width, + parameters.fillet, + ] + .into_iter() + .chain(parameters.frame_insets) + .chain(parameters.wrap_size) + .all(|v| v.is_finite() && (0. ..=32768.).contains(&v)) + || parameters + .radius + .is_some_and(|r| r.screen_space(parameters.edge).validate().is_err()) + { + return Err("invalid panel shape parameters"); + } + let mut scene = Scene::default(); + match parameters.style { + Style::Flat => add(&mut scene, grid, bounds, CornerRadii::uniform(0.), true, 0)?, + Style::Frame => { + add(&mut scene, grid, bounds, CornerRadii::uniform(0.), false, 0)?; + let [top, right, bottom, left] = parameters.frame_insets; + let left = left.min(bounds.width / 3.); + let right = right.min(bounds.width / 3.); + let top = top.min(bounds.height / 3.); + let bottom = bottom.min(bounds.height / 3.); + let inner = grid.rect(Rect::new( + bounds.x + left, + bounds.y + top, + bounds.width - left - right, + bounds.height - top - bottom, + ))?; + let r = parameters.frame_radius as f32; + let radii = CornerRadii([ + if left > 0.5 && top > 0.5 { r } else { 0. }, + if right > 0.5 && top > 0.5 { r } else { 0. }, + if right > 0.5 && bottom > 0.5 { r } else { 0. }, + if left > 0.5 && bottom > 0.5 { r } else { 0. }, + ]) + .normalized(inner.width as f32, inner.height as f32); + scene.paint.push(Primitive { + bounds: inner, + radii, + operation: 1, + blend_zone: 0, + }); + scene.input.push(RoundedSurface::new( + Rect::new(bounds.x, bounds.y, bounds.width, top), + CornerRadii::uniform(0.), + grid, + )?); + let radius = f64::from(radii.max()); + scene.damage = vec![ + Rect::new(bounds.x, bounds.y, bounds.width, top + radius), + Rect::new( + bounds.x, + bounds.y + bounds.height - bottom - radius, + bounds.width, + bottom + radius, + ), + Rect::new( + bounds.x, + bounds.y + top + radius, + left + radius, + (bounds.height - top - bottom - 2. * radius).max(0.), + ), + Rect::new( + bounds.x + bounds.width - right - radius, + bounds.y + top + radius, + right + radius, + (bounds.height - top - bottom - 2. * radius).max(0.), + ), + ]; + } + Style::CornerWrap => { + let h = parameters.height.min(bounds.height); + let w = parameters.wrap_width.min(bounds.width); + let horizontal = Rect::new(bounds.x, bounds.y, parameters.wrap_size[0].min(bounds.width), h); + let vertical = Rect::new(bounds.x, bounds.y, w, parameters.wrap_size[1].min(bounds.height)); + add( + &mut scene, + grid, + horizontal, + AnchoredRadii([0., 0., 14., 0.]).screen_space(Edge::Top), + true, + 0, + )?; + add(&mut scene, grid, vertical, CornerRadii([0., 0., 14., 0.]), true, 0)?; + // Capture the union once: overlapping translucent captures darken the elbow. + scene.damage = vec![ + horizontal, + Rect::new(vertical.x, vertical.y + h, w, (vertical.height - h).max(0.)), + ]; + let r = parameters + .fillet + .min((horizontal.width - w).max(0.)) + .min((vertical.height - h).max(0.)); + if r > 0. { + let patch = grid.rect(Rect::new(bounds.x + w, bounds.y + h, r, r))?; + scene.damage.push(patch); + scene.paint.push(Primitive { + bounds: patch, + radii: CornerRadii::uniform(0.), + operation: 4, + blend_zone: 0, + }); + } + } + Style::Morph | Style::WindowHug => { + let attached = parameters.style == Style::Morph && parameters.attached; + let radii = parameters + .radius + .map_or(CornerRadii::uniform((bounds.height / 2.) as f32), |r| { + r.screen_space(parameters.edge) + }) + .at_edge(parameters.edge, attached); + add(&mut scene, grid, bounds, radii, true, 0)?; + if attached { + let r = grid.snap(parameters.fillet.min(bounds.height)); + if r > 0. { + let (y, left, right) = if parameters.edge == Edge::Top { + (bounds.y, 2, 3) + } else { + (bounds.y + bounds.height - r, 5, 4) + }; + for (x, operation) in [(bounds.x - r, left), (bounds.x + bounds.width, right)] { + let patch = grid.rect(Rect::new(x, y, r, r))?; + scene.paint.push(Primitive { + bounds: patch, + radii: CornerRadii::uniform(0.), + operation, + blend_zone: 0, + }); + scene.damage.push(patch); + } + } + } + } + style => { + for (zone_index, zone) in [ZonePosition::Start, ZonePosition::Center, ZonePosition::End] + .into_iter() + .enumerate() + { + let occupied: Vec<_> = groups + .iter() + .filter(|g| g.zone == zone && g.bounds.width > 0. && g.bounds.height > 0.) + .collect(); + if occupied.is_empty() { + continue; + } + if style.connected() { + let x = occupied.iter().map(|g| g.bounds.x).fold(f64::INFINITY, f64::min); + let end = occupied + .iter() + .map(|g| g.bounds.x + g.bounds.width) + .fold(f64::NEG_INFINITY, f64::max); + let (x, end) = if style == Style::Notch { + match zone { + ZonePosition::Start => (bounds.x, end), + ZonePosition::End => (x, bounds.x + bounds.width), + ZonePosition::Center => (x, end), + } + } else { + (x, end) + }; + let body = Rect::new(x, bounds.y, end - x, bounds.height); + let radii = parameters.radius.map_or_else( + || { + crate::shape::effective_radii( + style, + zone, + parameters.edge, + [body.width as f32, body.height as f32], + 0, + 1, + ) + }, + |r| { + Ok(r.screen_space(parameters.edge) + .normalized(body.width as f32, body.height as f32)) + }, + )?; + add(&mut scene, grid, body, radii, true, 0)?; + for group in occupied.iter().take(occupied.len() - 1) { + let seam = grid.snap(group.bounds.x + group.bounds.width); + scene + .dividers + .push([seam, bounds.y + 8., seam, bounds.y + bounds.height - 8.]); + } + if style == Style::Notch && zone == ZonePosition::Center { + let r = parameters + .fillet + .min(bounds.height) + .min((x - bounds.x).max(0.)) + .min((bounds.x + bounds.width - end).max(0.)); + if r > 0. { + let (y, left, right) = if parameters.edge == Edge::Top { + (bounds.y, 2, 3) + } else { + (bounds.y + bounds.height - r, 5, 4) + }; + for (x, operation) in [(x - r, left), (end, right)] { + scene.paint.push(Primitive { + bounds: grid.rect(Rect::new(x, y, r, r))?, + radii: CornerRadii::uniform(0.), + operation, + blend_zone: 0, + }); + scene.damage.push(Rect::new(x, y, r, r)); + } + } + } + } else { + for group in occupied { + let body = Rect::new(group.bounds.x, bounds.y, group.bounds.width, bounds.height); + let radii = parameters + .radius + .map_or(CornerRadii::uniform((bounds.height / 2.) as f32), |r| { + r.screen_space(parameters.edge) + }); + add( + &mut scene, + grid, + body, + radii, + true, + if style == Style::Fused { + (zone_index + 1) as u8 + } else { + 0 + }, + )?; + } + } + } + // Polynomial smooth-min only bridges when k > 2*gap. A strength + // of 0.8*gap cannot form a neck with this kernel. + if style == Style::Fused { + scene.fusion = parameters.gap * 2.4; + scene.damage.clear(); + for zone in 1..=3 { + let mut shapes = scene.paint.iter().filter(|p| p.blend_zone == zone); + if let Some(first) = shapes.next() { + let mut region = first.bounds; + for shape in shapes { + let right = (region.x + region.width).max(shape.bounds.x + shape.bounds.width); + region.x = region.x.min(shape.bounds.x); + region.width = right - region.x; + } + scene.damage.push(region); + } + } + } + } + } + if parameters.edge == Edge::Bottom && matches!(parameters.style, Style::Frame | Style::CornerWrap) { + let mirror = |mut r: Rect| { + r.y = 2. * bounds.y + bounds.height - r.y - r.height; + r + }; + for primitive in &mut scene.paint { + primitive.bounds = mirror(primitive.bounds); + let [tl, tr, br, bl] = primitive.radii.0; + primitive.radii = CornerRadii([bl, br, tr, tl]); + primitive.operation = match primitive.operation { + 2 => 5, + 3 => 4, + 4 => 3, + 5 => 2, + op => op, + }; + } + for input in &mut scene.input { + let [tl, tr, br, bl] = input.radii().0; + *input = RoundedSurface::new(mirror(input.bounds()), CornerRadii([bl, br, tr, tl]), grid)?; + } + for damage in &mut scene.damage { + *damage = mirror(*damage); + } + } + if scene.paint.len() > MAX_PRIMITIVES { + return Err("panel shape exceeds renderer primitive capacity"); + } + scene.damage.retain(|r| r.width > 0. && r.height > 0.); + Ok(scene) +} + +#[cfg(test)] +mod tests { + use super::*; + fn groups() -> [GroupBounds; 3] { + [ + GroupBounds { + zone: ZonePosition::Start, + bounds: Rect::new(8., 0., 100., 36.), + }, + GroupBounds { + zone: ZonePosition::Start, + bounds: Rect::new(116., 0., 80., 36.), + }, + GroupBounds { + zone: ZonePosition::Center, + bounds: Rect::new(350., 0., 100., 36.), + }, + ] + } + fn scene(style: Style) -> Scene { + derive( + Parameters { + style, + ..Default::default() + }, + Rect::new(0., 0., 800., 36.), + &groups(), + PixelGrid::new(1.25).unwrap(), + ) + .unwrap() + } + #[test] + fn floating_gaps_are_unpainted_and_click_through() { + let scene = scene(Style::Floating); + assert_eq!(scene.paint.len(), 3); + assert!(scene.distance([112., 18.]) > 0. && !scene.contains([112., 18.])); + assert!(scene.contains([50., 18.])); + } + #[test] + fn capsules_use_one_outline_per_occupied_zone() { + let scene = scene(Style::Capsules); + assert_eq!(scene.paint.len(), 2); + assert_eq!(scene.dividers.len(), 1); + assert!(scene.distance([112., 18.]) < 0.); + } + #[test] + fn notch_fillets_are_painted_but_never_interactive() { + let scene = scene(Style::Notch); + assert!(scene.distance([349., 1.]) < 0.); + assert!(!scene.contains([349., 1.])); + assert!(scene.distance([340.5, 9.5]) > 0.); + assert_eq!(scene.paint[0].bounds.x, 0.); + } + #[test] + fn fused_neighbors_grow_a_neck_without_capturing_the_gap() { + let scene = scene(Style::Fused); + assert!(scene.distance([112., 18.]) < 0.); + assert!(!scene.contains([112., 18.])); + assert!(scene.distance([250., 18.]) > 0.); + } + #[test] + fn fused_capture_includes_the_neck() { + let scene = scene(Style::Fused); + assert!( + scene + .damage + .iter() + .any(|r| r.x <= 112. && r.x + r.width > 112. && r.y <= 18. && r.y + r.height > 18.) + ); + } + + #[test] + fn corner_wrap_capture_is_disjoint_and_the_workspace_arm_is_interactive() { + let scene = derive( + Parameters { + style: Style::CornerWrap, + ..Default::default() + }, + Rect::new(0., 0., 800., 600.), + &[], + PixelGrid::new(1.25).unwrap(), + ) + .unwrap(); + assert!(scene.contains([18., 200.])); + assert!(!scene.contains([38., 38.])); // the decorative elbow is not input + for (i, a) in scene.damage.iter().enumerate() { + for b in &scene.damage[i + 1..] { + assert!(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y); + } + } + for x in (0..800).step_by(3) { + for y in (0..600).step_by(3) { + let point = [x as f64 + 0.5, y as f64 + 0.5]; + if scene.distance(point) < 0. { + assert!(scene.damage.iter().any(|r| point[0] >= r.x + && point[0] < r.x + r.width + && point[1] >= r.y + && point[1] < r.y + r.height)); + } + } + } + } + + #[test] + fn frame_has_a_cutout_and_bounded_damage() { + let scene = derive( + Parameters { + style: Style::Frame, + ..Default::default() + }, + Rect::new(0., 0., 1600., 1000.), + &[], + PixelGrid::new(1.5).unwrap(), + ) + .unwrap(); + assert!(scene.distance([800., 20.]) < 0. && scene.distance([800., 500.]) > 0.); + assert!(scene.contains([800., 20.]) && !scene.contains([2., 500.])); + assert!(scene.damage.iter().map(|r| r.width * r.height).sum::() < 1600. * 1000. / 5.); + } + #[test] + fn primitive_packets_reject_malformed_geometry() { + let mut packet = scene(Style::Floating).packets()[0]; + for (index, value) in [(0, f32::NAN), (2, 0.), (4, -1.), (8, 2.5), (8, 6.), (9, 4.)] { + let previous = packet[index]; + packet[index] = value; + assert!(Primitive::from_packet(packet).is_err()); + packet[index] = previous; + } + } + #[test] + fn physical_input_runs_exclude_corners_and_coalesce() { + let scene = scene(Style::Floating); + let rects = scene.input_rectangles(1.25); + assert!(rects.len() < 3 * 45); + assert!( + !rects + .iter() + .any(|r| r[0] <= 140 && 140 < r[0] + r[2] && r[1] <= 22 && 22 < r[1] + r[3]) + ); + } + #[test] + fn attached_styles_mirror_the_whole_outline_at_the_bottom() { + for style in [Style::Notch, Style::Frame, Style::CornerWrap] { + let bounds = Rect::new(0., 0., 800., if style == Style::Notch { 36. } else { 600. }); + let grid = PixelGrid::new(1.).unwrap(); + let top = derive( + Parameters { + style, + ..Default::default() + }, + bounds, + &groups(), + grid, + ) + .unwrap(); + let bottom = derive( + Parameters { + style, + edge: Edge::Bottom, + ..Default::default() + }, + bounds, + &groups(), + grid, + ) + .unwrap(); + for x in (0..800).step_by(13) { + for y in (0..bounds.height as usize).step_by(11) { + let a = top.distance([x as f64 + 0.5, y as f64 + 0.5]); + let b = bottom.distance([x as f64 + 0.5, bounds.height - y as f64 - 0.5]); + assert!((a - b).abs() < 1e-6, "{style:?}: {x},{y}: {a} != {b}"); + } + } + } + } +} diff --git a/crates/ferese-panel/src/shape.rs b/crates/ferese-panel/src/shape.rs new file mode 100644 index 0000000..93a2c66 --- /dev/null +++ b/crates/ferese-panel/src/shape.rs @@ -0,0 +1,379 @@ +use crate::{CornerRadii, Edge}; +use ferese_layout::Rect; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Style { + Floating, + Capsules, + Notch, + #[default] + Flat, + Frame, + CornerWrap, + Fused, + Morph, + Adaptive, + WindowHug, +} + +impl Style { + /// Styles with a complete shell surface contract. + pub const AVAILABLE: [Self; 7] = [ + Self::Floating, + Self::Capsules, + Self::Notch, + Self::Flat, + Self::Fused, + Self::Adaptive, + Self::Morph, + ]; + + pub const ALL: [Self; 10] = [ + Self::Floating, + Self::Capsules, + Self::Notch, + Self::Flat, + Self::Adaptive, + Self::Morph, + Self::Fused, + Self::Frame, + Self::CornerWrap, + Self::WindowHug, + ]; + + pub const fn key(self) -> &'static str { + match self { + Self::Floating => "floating", + Self::Capsules => "capsules", + Self::Notch => "notch", + Self::Flat => "flat", + Self::Frame => "frame", + Self::CornerWrap => "corner-wrap", + Self::Fused => "fused", + Self::Morph => "morph", + Self::Adaptive => "adaptive", + Self::WindowHug => "window-hug", + } + } + pub const fn label(self) -> &'static str { + match self { + Self::Floating => "Floating", + Self::Capsules => "Capsules", + Self::Notch => "Notch", + Self::Flat => "Edge to edge", + Self::Frame => "Frame", + Self::CornerWrap => "Corner wrap", + Self::Fused => "Fused islands", + Self::Morph => "Morph", + Self::Adaptive => "Adaptive", + Self::WindowHug => "Window hug", + } + } + pub const fn connected(self) -> bool { + matches!(self, Self::Capsules | Self::Notch) + } + pub const fn detached(self) -> bool { + matches!( + self, + Self::Floating | Self::Capsules | Self::Fused | Self::Morph | Self::Adaptive | Self::WindowHug + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ZonePosition { + Start, + Center, + End, +} + +/// Clockwise from the left corner on the anchored edge. The same values are +/// used for top and bottom panels; only conversion to screen space differs. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchoredRadii(pub [f32; 4]); + +impl AnchoredRadii { + pub fn screen_space(self, edge: Edge) -> CornerRadii { + let [flat_left, flat_right, free_right, free_left] = self.0; + CornerRadii(match edge { + Edge::Top => [flat_left, flat_right, free_right, free_left], + Edge::Bottom => [free_left, free_right, flat_right, flat_left], + }) + } + + /// Membership refers to the occupied groups after availability and + /// overflow have been resolved, never to the stored group count. + pub fn joined(mut self, index: usize, count: usize) -> Result { + if index >= count { + return Err("a joined group must belong to a nonempty zone"); + } + if index > 0 { + self.0[0] = 0.; + self.0[3] = 0.; + } + if index + 1 < count { + self.0[1] = 0.; + self.0[2] = 0.; + } + Ok(self) + } +} + +pub fn effective_radii( + style: Style, + zone: ZonePosition, + edge: Edge, + size: [f32; 2], + index: usize, + count: usize, +) -> Result { + if !size.into_iter().all(|value| value.is_finite() && value >= 0.) { + return Err("panel dimensions must be finite and nonnegative"); + } + if index >= count { + return Err("a group must belong to a nonempty zone"); + } + let [width, height] = size; + let base = AnchoredRadii(match style { + Style::Floating | Style::Capsules | Style::Fused | Style::Morph | Style::Adaptive | Style::WindowHug => { + [height / 2.; 4] + } + Style::Notch => match zone { + ZonePosition::Start => [0., 0., 14., 0.], + ZonePosition::Center => [0., 0., 20., 20.], + ZonePosition::End => [0., 0., 0., 14.], + }, + Style::Flat | Style::Frame | Style::CornerWrap => [0.; 4], + }); + let radii = if matches!(style, Style::Capsules | Style::Notch) { + base.joined(index, count)? + } else { + base + }; + Ok(radii.screen_space(edge).normalized(width, height)) +} + +/// Snap rendered boundaries, without quantizing the stored animation state. +#[derive(Clone, Copy, Debug)] +pub struct PixelGrid { + scale: f64, +} + +impl PixelGrid { + pub fn new(scale: f64) -> Result { + if !scale.is_finite() || scale <= 0. { + return Err("output scale must be finite and positive"); + } + Ok(Self { scale }) + } + + pub fn snap(self, position: f64) -> f64 { + (position * self.scale).round() / self.scale + } + + pub fn rect(self, bounds: Rect) -> Result { + if ![bounds.x, bounds.y, bounds.width, bounds.height] + .into_iter() + .all(f64::is_finite) + || bounds.width < 0. + || bounds.height < 0. + { + return Err("surface bounds must be finite with nonnegative dimensions"); + } + let left = self.snap(bounds.x); + let top = self.snap(bounds.y); + let right = self.snap(bounds.x + bounds.width); + let bottom = self.snap(bounds.y + bounds.height); + if ![left, top, right, bottom].into_iter().all(f64::is_finite) { + return Err("surface bounds exceed the physical pixel grid"); + } + Ok(Rect::new(left, top, (right - left).max(0.), (bottom - top).max(0.))) + } +} + +/// Exact circular-corner hit testing, including asymmetric radii larger than +/// half the surface height. Decorative fillets and shadows are separate shapes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RoundedSurface { + bounds: Rect, + radii: CornerRadii, +} + +impl RoundedSurface { + pub fn new(bounds: Rect, radii: CornerRadii, grid: PixelGrid) -> Result { + if radii.validate().is_err() { + return Err("surface radii must be finite and nonnegative"); + } + let bounds = grid.rect(bounds)?; + if bounds.width > f64::from(f32::MAX) || bounds.height > f64::from(f32::MAX) { + return Err("surface dimensions exceed the radius coordinate range"); + } + let radii = radii.normalized(bounds.width as f32, bounds.height as f32); + Ok(Self { bounds, radii }) + } + + pub fn bounds(self) -> Rect { + self.bounds + } + + pub fn radii(self) -> CornerRadii { + self.radii + } + + /// Right and bottom boundaries are excluded so a shared seam belongs to + /// exactly one surface, rather than both neighbors. + pub fn contains(self, point: [f64; 2]) -> bool { + let [x, y] = [point[0] - self.bounds.x, point[1] - self.bounds.y]; + let (width, height) = (self.bounds.width, self.bounds.height); + if !(x >= 0. && y >= 0. && x < width && y < height) { + return false; + } + for (radius, dx, dy) in [ + (self.radii.0[0], x, y), + (self.radii.0[1], width - x, y), + (self.radii.0[2], width - x, height - y), + (self.radii.0[3], x, height - y), + ] { + let radius = f64::from(radius); + if dx < radius && dy < radius && (dx - radius).hypot(dy - radius) > radius { + return false; + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hiding_the_last_group_rounds_the_new_end() { + let radius = |index, count| { + effective_radii( + Style::Capsules, + ZonePosition::Start, + Edge::Top, + [100., 36.], + index, + count, + ) + .unwrap() + }; + assert_eq!(radius(1, 3).0, [0.; 4]); + assert_eq!(radius(1, 2).0, [0., 18., 18., 0.]); + assert_eq!(radius(0, 1).0, [18.; 4]); + assert_eq!(radius(0, 2).0, [18., 0., 0., 18.]); + assert!(effective_radii(Style::Capsules, ZonePosition::Start, Edge::Top, [100., 36.], 0, 0).is_err()); + } + + #[test] + fn anchor_flip_mirrors_all_styles_and_zone_positions() { + for style in [Style::Floating, Style::Capsules, Style::Notch, Style::Flat] { + for zone in [ZonePosition::Start, ZonePosition::Center, ZonePosition::End] { + for index in 0..3 { + let top = effective_radii(style, zone, Edge::Top, [100., 28.], index, 3) + .unwrap() + .0; + let bottom = effective_radii(style, zone, Edge::Bottom, [100., 28.], index, 3) + .unwrap() + .0; + assert_eq!(bottom, [top[3], top[2], top[1], top[0]]); + } + } + } + } + + #[test] + fn asymmetric_corners_preserve_notch_shape_and_scale_proportionally() { + let notch = effective_radii(Style::Notch, ZonePosition::Center, Edge::Top, [100., 28.], 0, 1).unwrap(); + assert_eq!(notch.0, [0., 0., 20., 20.]); + assert_eq!(notch.normalized(30., 28.).0, [0., 0., 15., 15.]); + assert_eq!( + CornerRadii([10., 20., 40., 30.]).normalized(35., 25.).0, + [25. / 6., 25. / 3., 50. / 3., 12.5] + ); + assert_eq!(CornerRadii::uniform(f32::MAX).normalized(40., 28.).0, [14.; 4]); + assert_eq!(notch.normalized(0., 28.).0, [0.; 4]); + } + + #[test] + fn shared_boundaries_snap_without_seams_at_fractional_scales() { + for scale in [1., 1.25, 1.5, 2.] { + let grid = PixelGrid::new(scale).unwrap(); + for origin in [-1800.3, -0.4, 0., 23.3] { + let left = grid.rect(Rect::new(origin, 0., 33.3, 36.)).unwrap(); + let right = grid.rect(Rect::new(origin + 33.3, 0., 42.7, 36.)).unwrap(); + assert!((left.x + left.width - right.x).abs() < 1e-9); + for boundary in [left.x, left.x + left.width, right.x + right.width] { + assert!((boundary * scale - (boundary * scale).round()).abs() < 1e-9); + } + } + } + } + + #[test] + fn floating_gaps_and_round_corners_do_not_capture_input() { + let grid = PixelGrid::new(1.25).unwrap(); + let left = RoundedSurface::new(Rect::new(8., 8., 100., 36.), CornerRadii::uniform(18.), grid).unwrap(); + let right = RoundedSurface::new(Rect::new(116., 8., 100., 36.), CornerRadii::uniform(18.), grid).unwrap(); + for point in [[0., 0.], [8., 8.], [112., 26.], [216., 26.], [f64::NAN, 26.]] { + assert!(!left.contains(point) && !right.contains(point), "{point:?}"); + } + assert!(left.contains([30., 26.])); + assert!(right.contains([150., 26.])); + } + + #[test] + fn asymmetric_hit_testing_matches_a_vertically_mirrored_surface() { + let grid = PixelGrid::new(1.).unwrap(); + let bounds = Rect::new(0., 0., 100., 28.); + let top = RoundedSurface::new(bounds, AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Top), grid).unwrap(); + let bottom = RoundedSurface::new( + bounds, + AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Bottom), + grid, + ) + .unwrap(); + assert!(top.contains([1., 1.])); + assert!(!top.contains([1., 27.])); + for x in 0..100 { + for y in 0..28 { + assert_eq!( + top.contains([x as f64 + 0.5, y as f64 + 0.5]), + bottom.contains([x as f64 + 0.5, 27.5 - y as f64]) + ); + } + } + } + + #[test] + fn a_connected_seam_has_exactly_one_input_owner() { + let grid = PixelGrid::new(1.5).unwrap(); + let left = RoundedSurface::new(Rect::new(0., 0., 30., 36.), CornerRadii([18., 0., 0., 18.]), grid).unwrap(); + let right = RoundedSurface::new(Rect::new(30., 0., 40., 36.), CornerRadii([0., 18., 18., 0.]), grid).unwrap(); + for y in [0., 18., 35.9] { + assert!(!left.contains([30., y])); + assert!(right.contains([30., y])); + } + } + + #[test] + fn invalid_runtime_geometry_is_rejected() { + for scale in [0., -1., f64::NAN, f64::INFINITY] { + assert!(PixelGrid::new(scale).is_err()); + } + let grid = PixelGrid::new(1.).unwrap(); + for bounds in [ + Rect::new(0., 0., -1., 28.), + Rect::new(f64::NAN, 0., 10., 28.), + Rect::new(f64::MAX, 0., f64::MAX, 28.), + ] { + assert!(grid.rect(bounds).is_err()); + } + assert!(RoundedSurface::new(Rect::new(0., 0., 10., 28.), CornerRadii::uniform(-1.), grid).is_err()); + assert!(effective_radii(Style::Floating, ZonePosition::Start, Edge::Top, [100., f32::NAN], 0, 1).is_err()); + } +} diff --git a/crates/ferese-protocols/src/lib.rs b/crates/ferese-protocols/src/lib.rs index c463920..56530de 100644 --- a/crates/ferese-protocols/src/lib.rs +++ b/crates/ferese-protocols/src/lib.rs @@ -1,5 +1,3 @@ -//! Generated bindings for Ferese Wayland protocols. - #![forbid(unsafe_code)] /// Semantic surface effects protocol. diff --git a/crates/ferese-theme-client/src/lib.rs b/crates/ferese-theme-client/src/lib.rs index b5360ab..98d20a7 100644 --- a/crates/ferese-theme-client/src/lib.rs +++ b/crates/ferese-theme-client/src/lib.rs @@ -1,4 +1,2 @@ -//! Theme subscriptions and window-scoped material bindings for Ferese clients. -//! Applications own subscription cancellation and keep bindings within window lifetimes. pub mod material; pub mod service; diff --git a/crates/ferese-theme-model/src/lib.rs b/crates/ferese-theme-model/src/lib.rs index be29055..ed68ee3 100644 --- a/crates/ferese-theme-model/src/lib.rs +++ b/crates/ferese-theme-model/src/lib.rs @@ -1,4 +1,3 @@ -//! Resolved theme values and pure color operations. No configuration or runtime access. use serde::{Deserialize, Serialize}; use std::path::PathBuf; mod color; diff --git a/crates/ferese-theme/Cargo.toml b/crates/ferese-theme/Cargo.toml index 6168a4c..61aaa98 100644 --- a/crates/ferese-theme/Cargo.toml +++ b/crates/ferese-theme/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-layout = { path = "../ferese-layout" } +ferese-panel = { path = "../ferese-panel" } jiff.workspace = true ferese-theme-model = { path = "../ferese-theme-model" } ferese-config = { path = "../ferese-config" } diff --git a/crates/ferese-theme/src/calendar.rs b/crates/ferese-theme/src/calendar.rs index 01feffc..ced2517 100644 --- a/crates/ferese-theme/src/calendar.rs +++ b/crates/ferese-theme/src/calendar.rs @@ -38,6 +38,32 @@ pub fn grid<'a, M: Clone + 'a>( opacity: f32, navigation: [Element<'a, M>; 3], ) -> Element<'a, M> { + grid_with_density(today, offset, palette, font, opacity, navigation, false) +} + +pub fn compact_grid<'a, M: Clone + 'a>( + today: jiff::civil::Date, + offset: i32, + palette: crate::Palette, + font: cosmic::font::Font, + opacity: f32, + navigation: [Element<'a, M>; 3], +) -> Element<'a, M> { + grid_with_density(today, offset, palette, font, opacity, navigation, true) +} + +fn grid_with_density<'a, M: Clone + 'a>( + today: jiff::civil::Date, + offset: i32, + palette: crate::Palette, + font: cosmic::font::Font, + opacity: f32, + navigation: [Element<'a, M>; 3], + compact: bool, +) -> Element<'a, M> { + let cell_width = if compact { Length::Fill } else { Length::Fixed(30.) }; + let cell_height = if compact { 20. } else { 25. }; + let spacing = if compact { 2. } else { 3. }; let months = [ "January", "February", @@ -60,7 +86,7 @@ pub fn grid<'a, M: Clone + 'a>( let [previous, today_button, next] = navigation; let header = row![ crate::text(format!("{} {year}", months[(month - 1) as usize]), font) - .size(15) + .size(if compact { 13 } else { 15 }) .width(Length::Fill), previous, today_button, @@ -68,18 +94,31 @@ pub fn grid<'a, M: Clone + 'a>( ] .align_y(Alignment::Center) .spacing(2); - let mut grid = column::with_capacity(8).spacing(3).push(header); - let mut weekdays = row::with_capacity(7).spacing(3); + let mut grid = column::with_capacity(8) + .spacing(spacing) + .width(if compact { Length::Fill } else { Length::Shrink }) + .push(header); + let mut weekdays = + row::with_capacity(7) + .spacing(spacing) + .width(if compact { Length::Fill } else { Length::Shrink }); for label in ["M", "T", "W", "T", "F", "S", "S"] { weekdays = weekdays.push( - container(crate::text(label, font).size(11).class(theme::Text::Color(muted))) - .width(30) - .center_x(30), + container( + crate::text(label, font) + .size(if compact { 9 } else { 11 }) + .class(theme::Text::Color(muted)), + ) + .width(cell_width) + .center_x(cell_width), ); } grid = grid.push(weekdays); for week in 0..(start + days).div_ceil(7) { - let mut dates = row::with_capacity(7).spacing(3); + let mut dates = + row::with_capacity(7) + .spacing(spacing) + .width(if compact { Length::Fill } else { Length::Shrink }); for weekday in 0..7 { let cell = week * 7 + weekday; let day = cell.checked_sub(start).map(|day| day + 1).filter(|day| *day <= days); @@ -91,10 +130,10 @@ pub fn grid<'a, M: Clone + 'a>( } else { muted }))) - .width(30) - .height(25) - .center_x(30) - .center_y(25) + .width(cell_width) + .height(cell_height) + .center_x(cell_width) + .center_y(cell_height) .class(theme::Container::custom(move |_| container::Style { background: is_today.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))), border: Border { diff --git a/crates/ferese-theme/src/lib.rs b/crates/ferese-theme/src/lib.rs index a3e3a61..a33d5d8 100644 --- a/crates/ferese-theme/src/lib.rs +++ b/crates/ferese-theme/src/lib.rs @@ -9,6 +9,7 @@ pub mod icons; pub mod menus; mod palette; pub mod panel; +pub mod panel_surface; mod typography; pub mod workspaces; diff --git a/crates/ferese-theme/src/panel.rs b/crates/ferese-theme/src/panel.rs index 3866fd5..17f9784 100644 --- a/crates/ferese-theme/src/panel.rs +++ b/crates/ferese-theme/src/panel.rs @@ -2,8 +2,8 @@ use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; use cosmic::iced::{Event, Length, Rectangle, Size, Vector}; use cosmic::{Element, Theme}; -use ferese_config::panel::layout::{Measurement, Resolution, resolve_with_gap}; -use ferese_config::panel::{ItemId, Panel, Representation}; +use ferese_panel::layout::{Measurement, Resolution, resolve_with_gap}; +use ferese_panel::{ItemId, Panel, Representation}; use std::borrow::Cow; /// Group outlines never paint a second fill over the panel background. @@ -250,8 +250,8 @@ mod tests { use cosmic::iced::advanced::{clipboard, renderer::Headless}; use cosmic::iced::{Font, Pixels, Point}; use cosmic::widget::{button, text}; - use ferese_config::panel::OverflowPolicy; - use ferese_config::panel::layout::Placement; + use ferese_panel::OverflowPolicy; + use ferese_panel::layout::Placement; #[derive(Clone, Debug)] enum Probe { diff --git a/crates/ferese-theme/src/panel_surface.rs b/crates/ferese-theme/src/panel_surface.rs new file mode 100644 index 0000000..ca1ece0 --- /dev/null +++ b/crates/ferese-theme/src/panel_surface.rs @@ -0,0 +1,578 @@ +use cosmic::iced::advanced::Renderer as _; +use cosmic::iced::advanced::graphics::geometry::{Cache, Path, Renderer as _, Stroke}; +use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; +use cosmic::iced::{Color, Event, Length, Point, Rectangle, Size, Vector}; +use cosmic::{Element, Theme}; +use ferese_panel::scene::{GroupBounds, Parameters, Scene}; +use ferese_panel::shape::{PixelGrid, ZonePosition}; + +pub struct Options { + pub parameters: Parameters, + pub bounds: Option, + pub groups: Vec<(widget::Id, ZonePosition)>, + pub scale: f64, + pub high_contrast: bool, + pub background: Color, + pub border: Color, + pub divider: Color, + pub native: bool, +} + +pub fn frame<'a, M: 'a>( + content: Element<'a, M>, + options: Options, + present: impl Fn(&Scene) + 'a, + changed: Option>, +) -> Element<'a, M> { + Element::new(Surface { + content, + options, + present: Box::new(present), + changed, + }) +} + +type Present<'a> = Box; +type Changed<'a, M> = Box M + 'a>; + +struct Surface<'a, M> { + content: Element<'a, M>, + options: Options, + present: Present<'a>, + changed: Option>, +} + +#[derive(Default)] +struct State { + scene: Scene, + cache: Cache, + colors: Option<(Color, Color, Color, bool, u64, bool)>, + published: Option, +} + +struct Collect<'a> { + targets: &'a [(widget::Id, ZonePosition)], + origin: Point, + groups: Vec, + bounds_id: Option<&'a widget::Id>, + bounds: Option, +} + +impl widget::Operation for Collect<'_> { + fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { + children(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if self.bounds_id.is_some() && id == self.bounds_id { + self.bounds = Some(ferese_layout::Rect::new( + (bounds.x - self.origin.x).into(), + (bounds.y - self.origin.y).into(), + bounds.width.into(), + bounds.height.into(), + )); + } + if let Some((_, zone)) = self.targets.iter().find(|(target, _)| Some(target) == id) { + self.groups.push(GroupBounds { + zone: *zone, + bounds: ferese_layout::Rect::new( + (bounds.x - self.origin.x).into(), + (bounds.y - self.origin.y).into(), + bounds.width.into(), + bounds.height.into(), + ), + }); + } + } +} + +impl Surface<'_, M> { + fn measure(&mut self, tree: &mut widget::Tree, layout: Layout<'_>, renderer: &cosmic::Renderer) { + let mut collector = Collect { + targets: &self.options.groups, + origin: layout.bounds().position(), + groups: Vec::new(), + bounds_id: self.options.bounds.as_ref(), + bounds: None, + }; + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, &mut collector); + let size = layout.bounds().size(); + let scene = PixelGrid::new(self.options.scale) + .and_then(|grid| { + ferese_panel::scene::derive( + self.options.parameters, + collector + .bounds + .unwrap_or_else(|| ferese_layout::Rect::new(0., 0., size.width.into(), size.height.into())), + &collector.groups, + grid, + ) + }) + .unwrap_or_default(); + let state = tree.state.downcast_mut::(); + let colors = ( + self.options.background, + self.options.border, + self.options.divider, + self.options.native, + self.options.scale.to_bits(), + self.options.high_contrast, + ); + if state.scene != scene || state.colors != Some(colors) { + state.scene = scene; + state.colors = Some(colors); + state.cache.clear(); + } + } +} + +impl Widget for Surface<'_, M> { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::default()) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.content)] + } + fn diff(&mut self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &cosmic::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let node = self + .content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits); + self.measure(tree, Layout::new(&node), renderer); + node + } + fn a11y_nodes( + &self, + layout: Layout<'_>, + tree: &widget::Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &cosmic::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, M>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + if matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) && !shell.is_layout_invalid() + { + self.measure(tree, layout, renderer); + (self.present)(&tree.state.downcast_ref::().scene); + let state = tree.state.downcast_mut::(); + if state.published.as_ref() != Some(&state.scene) { + if let Some(changed) = &self.changed { + shell.publish(changed(&state.scene)); + } + state.published = Some(state.scene.clone()); + } + } + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut cosmic::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + if !self.options.native + || self.options.border.a > 0. + || (!state.scene.dividers.is_empty() && self.options.divider.a > 0.) + { + let geometry = state.cache.draw(renderer, bounds.size(), |frame| { + let scale = if self.options.scale.is_finite() && self.options.scale > 0. { + self.options.scale + } else { + 1. + }; + let hairline = 1. / scale as f32; + let tolerance = (0.2 / scale).clamp(0.01, 1.); + if let Ok(contours) = ferese_panel::outline::contours(&state.scene, tolerance) { + let path = Path::new(|builder| { + for contour in contours { + if let Some((first, rest)) = contour.split_first() { + builder.move_to(Point::new(first[0] as f32, first[1] as f32)); + for point in rest { + builder.line_to(Point::new(point[0] as f32, point[1] as f32)); + } + builder.close(); + } + } + }); + if !self.options.native { + frame.fill(&path, self.options.background); + } + if self.options.border.a > 0. { + let width = if self.options.high_contrast { + hairline.max(1.) + } else { + hairline + }; + if self.options.parameters.style == ferese_panel::PanelStyle::Flat { + let y = match self.options.parameters.edge { + ferese_panel::Edge::Top => bounds.height - width / 2., + ferese_panel::Edge::Bottom => width / 2., + }; + frame.stroke( + &Path::line(Point::new(0., y), Point::new(bounds.width, y)), + Stroke::default().with_color(self.options.border).with_width(width), + ); + } else if self.options.parameters.style == ferese_panel::PanelStyle::Notch + || (self.options.parameters.style == ferese_panel::PanelStyle::Morph + && self.options.parameters.attached) + { + if let Ok(contours) = ferese_panel::outline::notch_border( + &state.scene, + tolerance, + self.options.parameters.edge, + ) { + let border = Path::new(|builder| { + for contour in contours { + if let Some((first, rest)) = contour.split_first() { + builder.move_to(Point::new(first[0] as f32, first[1] as f32)); + for point in rest { + builder.line_to(Point::new(point[0] as f32, point[1] as f32)); + } + } + } + }); + frame.stroke( + &border, + Stroke::default().with_color(self.options.border).with_width(width), + ); + } + } else { + frame.stroke( + &path, + Stroke::default().with_color(self.options.border).with_width(width), + ); + } + } + } + for [x1, y1, x2, y2] in &state.scene.dividers { + frame.stroke( + &Path::line(Point::new(*x1 as f32, *y1 as f32), Point::new(*x2 as f32, *y2 as f32)), + Stroke::default().with_color(self.options.divider).with_width(hairline), + ); + } + }); + renderer.with_layer(*viewport, |renderer| { + renderer.with_translation(Vector::new(bounds.x, bounds.y), |renderer| { + renderer.draw_geometry(geometry) + }) + }); + } + // Keep content after cached vector geometry in the renderer's layer order. + // Parent-layer text can otherwise be batched underneath the background. + renderer.with_layer(*viewport, |renderer| { + self.content + .as_widget() + .draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport); + }); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &cosmic::Renderer, + ) -> mouse::Interaction { + self.content + .as_widget() + .mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer) + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &cosmic::Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &cosmic::Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(&mut tree.children[0], layout, renderer, viewport, translation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::advanced::renderer::Headless; + use cosmic::iced::{Font, Pixels}; + use ferese_panel::shape::Style; + + #[test] + fn border_coverage_tracks_physical_scale_and_high_contrast() { + let mut renderer = cosmic::iced::futures::executor::block_on(::new( + Font::default(), + Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + for scale in [0.75, 1., 1.25, 1.5, 2.] { + for high_contrast in [false, true] { + let mut view: Element<'_, ()> = frame( + cosmic::widget::Space::new().width(64).height(32).into(), + Options { + parameters: Parameters { + style: Style::Flat, + edge: ferese_panel::Edge::Bottom, + ..Default::default() + }, + bounds: None, + groups: Vec::new(), + scale, + high_contrast, + background: Color::TRANSPARENT, + border: Color::WHITE, + divider: Color::TRANSPARENT, + native: true, + }, + |_| {}, + None, + ); + let mut tree = widget::Tree::new(&view); + let bounds = Rectangle::with_size(Size::new(64., 32.)); + let node = + view.as_widget_mut() + .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size())); + renderer.reset(bounds); + view.as_widget().draw( + &tree, + &mut renderer, + &Theme::default(), + &renderer::Style { + text_color: Color::WHITE, + icon_color: Color::WHITE, + scale_factor: scale, + }, + Layout::new(&node), + mouse::Cursor::Unavailable, + &bounds, + ); + let size = Size::new((64. * scale) as u32, (32. * scale) as u32); + let pixels = renderer.screenshot(size, scale as f32, Color::TRANSPARENT); + let coverage: f64 = (0..size.height) + .map(|y| f64::from(pixels[((y * size.width + size.width / 2) * 4 + 3) as usize]) / 255.) + .sum(); + let expected = if high_contrast { scale.max(1.) } else { 1. }; + assert!( + (coverage - expected).abs() < 0.03, + "scale {scale}, high contrast {high_contrast}: {coverage} physical pixels, expected {expected}" + ); + } + } + } + + #[test] + fn the_surface_background_keeps_parent_layer_text_visible() { + let mut renderer = cosmic::iced::futures::executor::block_on(::new( + Font::default(), + Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + for scale in [1., 1.25, 1.5] { + let content = cosmic::widget::container( + cosmic::widget::text("10:09").class(cosmic::theme::Text::Color(Color::WHITE)), + ) + .center_x(150) + .center_y(34); + let mut view: Element<'_, ()> = frame( + content.into(), + Options { + parameters: Parameters { + style: Style::Morph, + height: 34., + ..Default::default() + }, + bounds: None, + groups: Vec::new(), + scale, + high_contrast: false, + background: Color::BLACK, + border: Color::TRANSPARENT, + divider: Color::TRANSPARENT, + native: false, + }, + |_| {}, + None, + ); + let mut tree = widget::Tree::new(&view); + let viewport = Rectangle::with_size(Size::new(150., 34.)); + let node = + view.as_widget_mut() + .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size())); + renderer.reset(viewport); + view.as_widget().draw( + &tree, + &mut renderer, + &Theme::default(), + &renderer::Style { + text_color: Color::WHITE, + icon_color: Color::WHITE, + scale_factor: scale, + }, + Layout::new(&node), + mouse::Cursor::Unavailable, + &viewport, + ); + let pixels = renderer.screenshot( + Size::new((150. * scale) as u32, (34. * scale).ceil() as u32), + scale as f32, + Color::BLACK, + ); + let ink = pixels.chunks_exact(4).filter(|pixel| pixel[0] > 128).count(); + assert!( + ink > 25, + "clock text was covered by the surface background at scale {scale}" + ); + } + } + + #[test] + fn shared_surface_draws_core_outlines_at_fractional_scales() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let mut renderer = runtime + .block_on(::new( + Font::default(), + Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let theme = Theme::default(); + for style in [ + Style::Floating, + Style::Capsules, + Style::Notch, + Style::Flat, + Style::Frame, + Style::CornerWrap, + Style::Fused, + ] { + for scale in [1., 1.25, 1.5] { + let a = widget::Id::unique(); + let b = widget::Id::unique(); + let content = cosmic::widget::container(cosmic::widget::row![ + cosmic::widget::Space::new().width(4), + cosmic::widget::container(cosmic::widget::Space::new().width(20).height(16)).id(a.clone()), + cosmic::widget::Space::new().width(4), + cosmic::widget::container(cosmic::widget::Space::new().width(20).height(16)).id(b.clone()), + ]) + .width(96) + .height(64) + .into(); + let mut view: Element<'_, ()> = frame( + content, + Options { + parameters: Parameters { + style, + height: 16., + gap: 4., + frame_insets: [16., 4., 4., 4.], + frame_radius: 8., + wrap_size: [40., 42.], + wrap_width: 8., + fillet: 6., + ..Default::default() + }, + bounds: None, + groups: vec![(a, ZonePosition::Start), (b, ZonePosition::Start)], + scale, + high_contrast: false, + background: Color::WHITE, + border: Color::TRANSPARENT, + divider: Color::TRANSPARENT, + native: false, + }, + |_| {}, + None, + ); + let mut tree = widget::Tree::new(&view); + let bounds = Rectangle::new(Point::ORIGIN, Size::new(96., 64.)); + let node = + view.as_widget_mut() + .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size())); + renderer.reset(bounds); + view.as_widget().draw( + &tree, + &mut renderer, + &theme, + &renderer::Style { + text_color: Color::WHITE, + icon_color: Color::WHITE, + scale_factor: scale, + }, + Layout::new(&node), + mouse::Cursor::Unavailable, + &bounds, + ); + let size = Size::new((96. * scale) as u32, (64. * scale) as u32); + let pixels = Headless::screenshot(&mut renderer, size, scale as f32, Color::BLACK); + let scene = &tree.state.downcast_ref::().scene; + assert!(!scene.paint.is_empty()); + for y in 0..size.height { + for x in 0..size.width { + let point = [(f64::from(x) + 0.5) / scale, (f64::from(y) + 0.5) / scale]; + let distance = scene.distance(point); + if distance.abs() > 1. / scale { + let ink = pixels[((y * size.width + x) * 4) as usize] > 128; + assert_eq!(ink, distance < 0., "{style:?} scale {scale} at {point:?}"); + } + } + } + } + } + } +} diff --git a/crates/ferese-theme/src/workspaces.rs b/crates/ferese-theme/src/workspaces.rs index f1bc3d8..4f90095 100644 --- a/crates/ferese-theme/src/workspaces.rs +++ b/crates/ferese-theme/src/workspaces.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use cosmic::iced::{Alignment, Color, Length}; use cosmic::widget::{Space, container, row}; use cosmic::{Element, theme}; -use ferese_config::panel::WorkspaceStyle; +use ferese_panel::WorkspaceStyle; use crate::Palette; diff --git a/docs/README.md b/docs/README.md index 415f17e..f6c5b6e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ - [Installation](installation.md): install dependencies, log in, preview changes, update and recover a session. - [Configuration](configuration.md): configure layouts, input, shortcuts and displays in KDL. +- [Panel configuration](panel-configuration.md): choose a panel style, arrange groups and items, and configure the Morph hub. - [Why Ferese](why-ferese.md): window layouts and a shared theme for the desktop. - [Screenshots](screenshots.md): see the themes, overview and shell controls. @@ -21,5 +22,4 @@ - [Development](development.md): build Ferese, run tests and check portal contracts. - [Nested resize checks](nested-resize-testing.md). - [Client soak testing](native-soak-testing.md). -- [Performance measurements](performance-baseline.md). - [Website development](../site/README.md). diff --git a/docs/animation-model.md b/docs/animation-model.md index 7b85394..6207928 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -69,6 +69,12 @@ The main spring responds more quickly than the viewport spring, allowing a windo Bounce must be strictly between −1 and 1. Negative values produce overdamping; positive values require `overshoot #true` so the crossing policy permits the requested bounce. Duration and speed must be finite and positive, and the resolved stiffness and damping must remain finite and positive too. If the configuration fails these checks, Ferese keeps the last accepted settings. +The [Morph panel](panel-configuration.md#morph) uses the same spring solver, +with its own response and the shared speed and reduced-motion settings. Content +is laid out at the destination size while the shape expands around it, and +fades handle changes between views. The space kept clear of windows stays +unchanged throughout; regular status popups continue to use the main shell spring. + The global speed setting scales elapsed spring time, so 0.5 takes twice as long and 2.0 takes half as long. It applies to both compositor and Ferese shell motion. Setting `reduced-motion #true` takes precedence over enabled animations and applies the destination directly. Neither setting controls animations that applications draw inside their own windows. ## Passing a gesture’s velocity into the release @@ -107,7 +113,7 @@ These slides use the viewport spring through [workspace navigation](../composito An application’s surface lifetime does not always match the duration of its visible exit. Window entrance starts when content is available, but on unmap the live surface can disappear before a closing animation would finish. Ferese retains content for that exit, moving toward 98% of the current bounds while opacity, emphasis, and shadow fade to zero. -Closing during resize also retains the resize snapshots and their handoffs. Their progress must continue during close so the old buffer does not remain frozen over the exiting image. Retaining a picture is only part of the work; the presentation state that determines how it blends with the newer content has to continue advancing too. +Closing during resize also retains the resize snapshots and their handoffs. Their progress must continue during close so the old buffer does not remain frozen over the exiting image. The presentation state must keep advancing so the retained image can blend with the newer content. Shell popups keep their surface until the closing motion settles, then destroy it. If you reopen the same panel during close, Ferese retargets that existing motion. Switching from Battery to Wi-Fi is different: the surface stays, but the new panel gets its own entrance. This behavior lives in [panel lifecycle handling](../shell/ferese-shell/src/status_ui/lifecycle.rs) and avoids a destroy-then-create gap between panels. diff --git a/docs/configuration.md b/docs/configuration.md index aa1cae8..21320d3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,15 +40,16 @@ output-profile "docked" { ``` Lists use repeated `panel`, `group`, `item`, `binding`, `window-rule`, -`output-profile`, `output`, `note`, and `autostart` nodes. Bindings take their keys, -action, and argument positionally; profiles, outputs, and notes take a positional -name, match, or ID. Panels, groups, and items use positional IDs, while autostart +`output-profile`, `output`, and `autostart` nodes. Bindings take their keys, +action, and argument positionally; profiles and outputs take a positional +name or match. Panels, groups, and items use positional IDs, while autostart commands use positional arguments. Other fields can be properties or child nodes, but duplicate fields are rejected. The dotted paths in the reference tables below describe nested sections. -Settings preserves comments and custom fields when editing, while normalizing -indentation. +Settings preserves comments and untouched configuration content when editing, +while normalizing indentation. Panel item annotations belong in an explicit +`metadata` section; unrecognized built-in options are rejected. The Settings sidebar groups pages under Desktop, Interaction, and System, with Motion under Windows and Shortcuts under Keyboard & mouse. Search includes both @@ -57,10 +58,10 @@ views and opens the matching setting directly. ## Saving and validation Settings saves text when you press Enter or leave the field, and sliders when -you release them. Undo restores the previous save. Reload reads external edits -without merging unfinished drafts. Wallpaper browsing needs `zenity`, but you -can also enter a path directly. -Settings keeps `config.kdl.settings-backup` before saving. +you release them, keeping `config.kdl.settings-backup` before each save. Use +Undo to restore the previous save, or Reload to read external edits without +merging unfinished drafts. To browse for a wallpaper, install `zenity`; you can +also enter its path directly. File edits reload automatically, including atomic editor saves, so changes to appearance, wallpaper, motion, input, bindings, rules, layouts, displays, @@ -93,10 +94,10 @@ cancels pending pairing. Passwords are sent to NetworkManager, not saved in KDL; pairing keys are managed by BlueZ. These controls affect the system even when Settings is opened with a separate preview config. -Defaults below are built-in defaults, not your personal or packaged overrides. -Dimensions are logical pixels unless noted; numbers must be finite. Optional -values are omitted, not `null`. Colors use `#RRGGBB` or `#RRGGBBAA`. -Live config is limited to 60 KiB. +The tables below give built-in defaults; your configuration or the packaged +example may override them. Dimensions use logical pixels unless noted, and +numbers must be finite. Omit optional values rather than writing `null`, use +`#RRGGBB` or `#RRGGBBAA` for colors, and keep the live config within 60 KiB. ## Layout @@ -186,6 +187,10 @@ immediate. Reduced motion takes precedence over `enabled #true`; setting `speed 0` is invalid. These settings control Ferese's animations, not animations inside other applications. +Morph animates its shape with its own spring and fades between views, while +following the shared speed and reduced-motion policy; see the +[panel guide](panel-configuration.md#morph) for its states and size controls. + Shell animations sample motion on each surface's redraw, paced by its Wayland frame callbacks. They request animation frames only while moving. Menus, modals, notifications and hover fades use this path instead of a fixed @@ -326,6 +331,7 @@ is set. | `theme.colors` key | Default | Meaning | | --- | --- | --- | | `surface-base` | `"#111821"` | Material/card base color | +| `surface-raised` | `"#1E2530"` | Raised surfaces, including the Morph hub | | `text-primary` | `"#F4F7FB"` | Main text | | `text-muted` | `"#8793A2"` | Secondary text | | `accent` | `"#3D7BE6"` | Active controls and solid focus ring | @@ -339,14 +345,14 @@ Filled controls choose a contrasting text color automatically. | `border-width` | number ≥ 0 | `1` | Window border thickness | | `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness | | `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell | -| `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards and interaction backgrounds; fractional radii are preserved; 0 makes them square | -| `control-gap` | number ≥ 0 | `12` | Right-side control spacing | +| `shell-radius` | number ≥ 0 | `14` | Shell surfaces and controls, apart from panel shapes; fractional radii are preserved; 0 makes them square | +| `control-gap` | number ≥ 0 | `12` | Shared control spacing token; panel spacing belongs to its groups | | Section / key | Type | Default | Meaning | | --- | --- | --- | --- | -| `theme.surface.bar.background` | color | `"#1C202EF2"` | Shell fallback background; compositor materials use `surface-base` | -| `theme.surface.bar.text-primary` | color | `"#F0F3FA"` | Bar text/icons | -| `theme.surface.bar.text-muted` | color | `"#AAB4C7"` | Inactive bar foreground | +| `theme.surface.bar.background` | color | `"#111821"` | Regular panel background color; Morph uses `surface-raised` | +| `theme.surface.bar.text-primary` | color | `"#F4F7FB"` | Panel text/icons | +| `theme.surface.bar.text-muted` | color | `"#8793A2"` | Inactive panel foreground | | `theme.shadow.soft.offset-y` | number | `4` | Window shadow vertical offset | | `theme.shadow.soft.blur` | number ≥ 0 | `18` | Window shadow softness | | `theme.shadow.soft.opacity` | number 0–1 | `0.20` | Window shadow strength | @@ -808,116 +814,43 @@ intentionally turning off the last working one. ## Panel composition -Ferese shows one panel on each output, at the top by default. You can use the -default arrangement or define a `panel` block with its own geometry, surface -defaults, ordered groups, and item settings. +Ferese uses one panel configuration across all outputs, with each output laying +out its own workspace controls and focused-window title. Open **Settings → +Panel & Shell** to choose a style and position, then **Edit panel** to arrange +items in Start, Center and End groups. The composition preview stays visible +while you edit, and changes save automatically. -In Settings → **Panel & Shell**, **Panels** contains the position, background, -size, spacing, and corner-radius controls. Choose **Edit panel** to open -**Arrange**, where you can organize groups and items while the composition preview -stays visible above the independently scrolling editor and inspector. -**Back to Panels** returns to the panel controls. +Choose style, opacity and corners for the whole panel; groups control layout and +spacing. The available styles are Floating, Capsules, Notch, Edge to edge, +Fused islands, Adaptive and Morph. Capsules and Fused islands join neighboring +**groups within a zone**, so a zone with one group remains a single island. +Adaptive follows window state independently on each output, while Morph combines +the panel into a centered hub with expandable workspace, media and status views. -Use **Position** to choose Top or Bottom, or set `edge "top"` or `edge "bottom"` -inside the KDL panel block. Menus and notifications open toward the desktop, with -window clearance and **Edge margin** following that screen edge. Panel dimensions -live in a separate `geometry` block, so appearance changes do not resize the -panel. Its fields are `height` (24–128), `edge-margin`, `side-margins`, and -`window-clearance` (0–4096), plus `inner-padding` (0–64); their defaults are 28, 0, -0, 0, and 12 logical pixels respectively. - -Each group has an **Add item** menu. Select a control to open its inspector, where -you can change visibility, overflow, preferred size, and placement, or remove the -item. To reorder it, use the **Up / Down** arrow buttons or drag its handle; dragging -also lets you move it to another group, and dropping into an empty zone creates a -group. Hidden controls remain in the editor with faded icons and labels. These -changes use the existing save and Undo controls, and `ferese-settings --page bar` -opens the page directly. - -Use **Add group** in Start, Center, or End to create an empty group, then select -its name to change placement, surface, spacing, and padding. Arrow buttons reorder -groups within a zone; before removing a group, move or remove its controls. - -The **Background** presets set the panel fill and default group surface together: -Continuous uses `solid` for the panel and `none` for groups, while Islands uses -`none` for the panel and `island` for groups. Applying either preset clears group -surface overrides while keeping your arrangement intact. - -Use **Default group surface** to choose what groups inherit, or select Default, -None, Inset, or Island in an individual group's inspector. None adds no fill, -Inset wraps the padded content in a rounded surface, and Island adds a full-height -surface with island padding. Groups on a filled panel use a subtle local tint, -allowing arrangements such as a continuous panel with one media island or -navigation and status islands around an undecorated title. With mixed settings, -neither preset button is selected. - -**Group borders** controls the outlines of all groups and is off by default, -while opacity and corner radii apply across the panel. - -**Background opacity** overrides shell opacity for the panel background in either -arrangement, without fading controls or changing popups. **Use shell opacity** -restores inheritance. - -Choose **Customize corners** to override the radius for the whole panel, -including its groups. The four sliders control top left, top right, bottom right, -and bottom left, saving each change when you release the slider. - -In KDL, pixel values follow CSS shorthand: one value sets all corners; two set -top-left/bottom-right and top-right/bottom-left; three set -top-left, top-right/bottom-left, and bottom-right; four run clockwise from -top-left. For example, `4px 8px 12px 16px` gives each corner a different radius. -Values must be finite and nonnegative; percentages and elliptical `/` syntax are -not supported. Radii are clamped to half the shorter surface dimension. - -At zero edge margin, panel backgrounds reach the selected screen edge with square -corners where they touch it, without changing the saved radii. Islands preserve -the gaps between groups, while Continuous fills the whole panel. - -**Side margins** moves the left and right ends inward together over a range of -0–4096 px. The shell limits the requested inset for each output using measured -content widths: controls may compact, but the inset stops before causing -additional overflow. As content grows or the output gets smaller, the applied -inset shrinks to make room. If the controls cannot fit even at zero margin, the -panel uses its normal overflow behavior. - -The panel follows the shell radius unless you set an override, which stays in -place when the shell radius changes. **Use shell radius** clears that override -without changing the arrangement; **Remove custom panel** restores both the -default arrangement and its inherited radius. - -Dragging changes the drop hint until release, then saves the complete move once. -Escape, focus loss, and release outside a drop target cancel without saving. - -The composition preview follows your draft and adapts to the Settings window's -width using sample content with every service available. Select a preview control -to edit that instance, or use the chevron to reveal overflow items. Browsing and -selection do not save anything: the first edit saves the composition and the -change together. The preview shares the shell's sizing resolver, while live -service state and exact vertical styling can differ. +Most panels default to 36 logical pixels high, Morph to 34, and detached styles +use 8-pixel margins unless your configuration overrides them. The packaged +configuration includes its own geometry overrides. For the complete options and +examples, see the [panel configuration guide](panel-configuration.md). ```kdl panel "main" { - surface "none" - group-surface "island" - geometry { height 30; edge-margin 6; side-margins 18; window-clearance 4; } - border #false - background-opacity 0.85 + edge "top" + style "floating" + geometry { height 36; edge-margin 8; window-clearance 4; } start { group "navigation" { item "overview" kind="overview" overflow="never" - item "workspaces" kind="workspaces" overflow="never" + item "workspaces" kind="workspaces" style="dots" overflow="never" } } center { group "title" { - surface "none" item "title" kind="focused-window" overflow="never" } } end { group "status" { - item "network" kind="network" overflow="always" - item "audio" kind="audio" + item "quick-settings" kind="quick-settings" item "battery" kind="battery" percentage=#true item "clock" kind="clock" representation="compact" } @@ -925,64 +858,11 @@ panel "main" { } ``` -Start, center, and end contain ordered groups of item instances. Their IDs must -be unique within the panel, contain 1–128 ASCII letters/digits or `-_.:@`, and not -start with `_`. Exactly one panel can be configured, without per-output overrides. - -Built-in kinds are `overview`, `workspaces`, `focused-window`, `media`, -`quick-settings`, `network`, `audio`, `recording`, `notifications`, `battery`, -`clock`, and `display-mode`. Availability still follows the associated service -or hardware. Duplicate kinds are allowed when their IDs differ; clock instances -currently share the same clock format and timezone. - -| Scope / key | Values / default | Meaning | -| --- | --- | --- | -| Panel `surface` | `solid` (default), `none` | Full panel fill or transparent space between decorated groups | -| Panel `group-surface` | `none` (default), `inset`, `island` | Inherited group surface | -| Group `surface` | omit to inherit, `none`, `inset`, `island` | Override surface independently of other groups | -| Panel `geometry` | height 28, edge/side margins 0, window clearance 0, inner padding 12 | Dimensions independent of the theme | -| Panel `border` | boolean; default `false` | Show group outlines | -| Panel `corner-radius` | optional px string, e.g. `"4px 8px"` | Override shell radius for all panel surfaces | -| Zone `spacing` | 0–64; default 8 | Gap between groups | -| Panel `background-opacity` | optional 0–1; inherits shell opacity | Override panel background opacity in either arrangement | -| Groups per panel | at most 31 | Reserves one of the 32 material regions for overflow | -| Group `spacing` | 0–64; default 1 | Gap between items | -| Group `padding` | two integers 0–32; default `2 3` | Vertical and horizontal inner padding | -| Group `island-padding` | 0–32; default 4 | Additional horizontal padding for islands | -| Item `visible` | boolean; default `true` | Allow the item when available | -| Item `overflow` | `auto` (default), `never`, `always` | Move when needed, keep visible, or place in overflow | -| Item `priority` | integer 0–100; default 50 | Lower priorities yield space first | -| Item `representation` | omit, `wide`, `compact`, `icon` | Preferred supported form; smaller forms may be used | -| Item `gap-before` | 0–64; unset | Override the preceding gap within a group | -| Item `metadata` | optional section | Custom data preserved by the editor; no shell behavior | -| Workspaces `style` | `numbers`, `dots` (default), `tabs`, `window-stacks`, `app-icons` | Workspace indicators; choose in the Workspaces item inspector | -| Battery `percentage` | boolean; default `true` | Include percentage in its full form | -| Focused window `visible` | boolean; default `true` | Include the title | - -Media and clock support all three representations. Battery with a percentage -supports `wide` and `icon`; other controls keep their existing form. Unsupported -preferences use the kind's normal form. The title can shrink and ellipsize; -workspace controls scroll within their allocation. The panel itself does not scroll. - -Workspace indicators keep the same width as their state changes. Dots use a pill -for the active workspace, a filled dot for an occupied workspace, and an outline -for an empty one. Tabs show workspace names, window stacks show up to three -window outlines, and app icons show up to two distinct running applications. -Hover for the workspace name and window count. Active transitions follow the -animation and reduced-motion settings. Explicitly set `style="numbers"` -to retain numbered indicators. - -Item-specific settings belong to their kind: Workspaces uses `style`, Battery -uses `percentage`, and Focused window uses the shared `visible` flag. Unrecognized -options, including misspellings, are rejected, so custom data belongs in an -explicit `metadata` section such as -`item "clock" kind="clock" { metadata { note "Desk clock"; }; }`. - -The center stays screen-centered. When controls cannot fit, lower-priority items -adapt first, with definition order breaking ties. The overflow chevron appears -before the end controls and opens displaced items through their usual menus. -`never` moves to overflow only when the mandatory controls cannot fit. A surface -narrower than the chevron's padded minimum clips that control. +If you omit the panel block, Ferese uses its default composition; if you write +one, its groups and items define the contents, and empty zones stay empty. Only +one panel block is accepted, and group and item IDs must be unique within it. +Use `edge "bottom"` to move the same arrangement to the bottom, with popups +opening toward the desktop and edge-relative corners mirroring vertically. ## Status controls @@ -992,15 +872,16 @@ narrower than the chevron's padded minimum clips that control. | `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold | | `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action | -Configure panel surfaces in **Panel & Shell** or in the `panel` block above. -Transparent gaps pass clicks through. Group islands use panel opacity and corner -radii; changing their surface leaves modal transparency and window clearance -unchanged. Island padding belongs to each group and applies only to islands. +Configure the panel in **Panel & Shell** or its KDL block, as described in the +[panel guide](panel-configuration.md). The panel's appearance applies to every +group, while popup and notification backgrounds follow the shared shell theme. +Transparent gaps between islands pass clicks through to the windows beneath. ### Now Playing -The media control on the right shows artwork, the track title and a play/pause -button. Click the artwork or title to open playback controls for MPRIS players. +The Media item can show artwork, the track title and a play/pause button wherever +you place it in the panel. Click the artwork or title to open playback controls +for MPRIS players; Morph presents playback in its Music view. Scroll over the control to change the player's volume, or middle-click to raise its window when supported. The menu has previous/next buttons, a seek slider when supported, and a player chooser. It uses the same @@ -1140,7 +1021,8 @@ and blur masks. Small controls cap the radius to fit their size; circles and pil keep circular outlines. The measured profile has small tangent and curvature discontinuities at its internal segment joins. Window rounding remains under Settings → Windows. Shell rounding comes from `theme.geometry.shell-radius`, -which defaults to 14 px, with an optional corner override for each panel. +which defaults to 14 px. Panel corners follow their selected style and height, +with optional overrides in the [panel configuration](panel-configuration.md#background-outline-and-corners). ## X11 support (xwayland-satellite) diff --git a/docs/development.md b/docs/development.md index 389efa1..39205c6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,11 +19,11 @@ Run CI manually when a change needs it. Ordinary pushes and pull requests do not start build runners, and website deployment is manual. Release tags and pull requests labeled `release` run the release checks and package builds. -CI checks formatting, lints and tests across the whole workspace, including the -shell, settings, portal and CLI. The Fedora workspace job also builds desktop -binaries and runs installer, session-recovery and private portal-contract checks. The -compositor job runs offscreen capture regressions with software Mesa. Opt-in -nested and hardware checks remain separate. +The workspace workflow checks formatting, lints and tests on Ubuntu, including +the shell, Settings, portal and CLI. It builds the desktop binaries, runs the +Python integration suite and checks offscreen capture with software Mesa, then +uses those binaries for isolated panel and XWayland checks on a headless Wayland +host. Hardware checks remain separate. ## Test guides @@ -32,7 +32,6 @@ nested and hardware checks remain separate. - [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits. - [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness. - [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth. -- [Performance measurements](performance-baseline.md): sampling and historical results. ## Portal checks diff --git a/docs/images/screenshots/ferese-morph.webp b/docs/images/screenshots/ferese-morph.webp new file mode 100644 index 0000000..00572ba Binary files /dev/null and b/docs/images/screenshots/ferese-morph.webp differ diff --git a/docs/installation.md b/docs/installation.md index af5225e..8cdec19 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -61,22 +61,23 @@ libraries. To run X11-only applications, Ferese launches [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite), -which in turn needs an `Xwayland` binary. Both are runtime dependencies, not -build dependencies, and neither is needed to build Ferese. +which in turn needs an `Xwayland` binary. Both are runtime dependencies, so +neither is needed to build Ferese. - `Xwayland` ships with the X.Org server packages: `xorg-x11-server-Xwayland` on Fedora and Debian/Ubuntu, `xorg-xwayland` on Arch. - `xwayland-satellite` is not packaged by most distributions. Build or install it from its upstream releases **with its `systemd` feature enabled** - (`cargo build --release --features systemd`). That feature is what sends the - `READY=1` readiness notification Ferese waits for. Upstream's default feature - set is empty, so a default build never sends it, Ferese's fixed 10-second - startup budget expires, and the service reports a startup failure instead of - a display. A spawned process that is still alive is not readiness. + (`cargo build --release --features systemd`), which sends the `READY=1` + notification Ferese waits for. Upstream's default feature set is empty, so a + default build never sends that notification: even if the process remains alive, + Ferese's fixed 10-second startup budget expires and the service reports a + startup failure instead of a display. Both the compositor's default and the packaged configuration ship with X11 enabled, so a session with the two pieces above needs no configuration change. -If you maintain your own `config.kdl`, the block to keep is +If you maintain your own `config.kdl`, keep this block in +`~/.config/ferese/config.kdl`: ```kdl xwayland { @@ -86,8 +87,7 @@ xwayland { } ``` -in `~/.config/ferese/config.kdl` and starting a new session, then check -`feresectl xwayland status`. See +Start a new session, then check `feresectl xwayland status`. See [X11 support](configuration.md#x11-support-xwayland-satellite) for the full key list, diagnostics, and known limitations. diff --git a/docs/nested-resize-testing.md b/docs/nested-resize-testing.md index 40b6c20..7582fbc 100644 --- a/docs/nested-resize-testing.md +++ b/docs/nested-resize-testing.md @@ -16,10 +16,10 @@ this at fractional scaling too. ## Compare renderers Repeat the check with `ICED_BACKEND=wgpu` and `ICED_BACKEND=tiny-skia` to compare -renderers. The shell and Settings prefer software rendering with GPU fallback, -which keeps Settings labels visible inside scrolling content but can be slower -when resampling wallpaper. Setting `ICED_BACKEND` explicitly overrides that -preference. +renderers. The shell prefers tiny-skia with a wgpu fallback for its small +surfaces, while Settings prefers wgpu with a tiny-skia fallback for larger +scrolling pages. An explicit `ICED_BACKEND` selects the requested backend, and +the shell's automatic preference is not passed to Settings when it launches it. Follow the trace from host resize through layer configure, client acknowledgement, new buffer commit and compositor rendering. A quick acknowledgement does not mean diff --git a/docs/panel-configuration.md b/docs/panel-configuration.md new file mode 100644 index 0000000..e3c9e14 --- /dev/null +++ b/docs/panel-configuration.md @@ -0,0 +1,382 @@ +# Panel configuration + +Use **Settings → Panel & Shell** to change the panel's style, position and +contents, or edit the `panel` block in `~/.config/ferese/config.kdl`. Settings +writes to this file, so you can arrange controls visually and then adjust their +values in KDL. Changes apply live, and an invalid edit leaves the last working +configuration active. + +Ferese uses one panel configuration across all outputs. Each output lays out +its own panel using the space and services available there; workspace controls +and the focused-window title belong to that output. If you omit the `panel` +block, Ferese supplies its default composition. Once you write one, its groups +and items define the composition, including any zones you leave empty. + +## Arrange the panel in Settings + +The composition preview stays above the editor while you change the panel. +It uses sample content, so it can show a battery, media player or external +display control even when that service is unavailable in your session. Its +layout adapts to the width of the Settings window using the same allocation +rules as the shell. + +Choose a **Style** from the compact dropdown, then use **Position** to place the +panel at the top or bottom. The appearance controls set its background opacity, +outline and corners, while **Size & spacing** controls its height and distance +from windows and screen edges. A reset button beside a dimension clears that +override; **Reset to style** clears geometry and corner overrides together. + +Choose **Edit panel** to open Arrange, where **Add group** creates a group in +Start, Center or End. Use its **Add item** menu to fill it, then select an item +to change its placement, visibility, overflow, preferred size or options +specific to that control. Select the group itself to change its placement, +item spacing and padding; before removing it, move or remove its items. + +Use the drag handles to move items within or between groups, or the arrow +buttons to reorder them. A drop hint shows where the item will go, and dropping +it into an empty zone creates a group there. The move saves on release; +Escape, focus loss or a release outside a valid target cancels it. + +Other controls save automatically, with sliders saving when you release them. +**Undo** restores the previous save, **Reload configuration** reads external +edits, and **Back to Panels** returns to the appearance controls. + +**Remove custom panel** restores the built-in composition, style and geometry; +Undo brings back the composition you removed. + +You can open this page directly with `ferese-settings --page bar`. + +## Choose a style + +Choose the style for the whole panel; its groups share that appearance and +control only arrangement and spacing. + +| Settings name | KDL value | Appearance and behavior | +| --- | --- | --- | +| Floating | `floating` | Separate rounded islands for occupied groups, with transparent gaps between them | +| Capsules | `capsules` | One rounded capsule per occupied zone, with dividers between its groups | +| Notch | `notch` | Groups attached to the screen edge, with curved shoulders around the centered group | +| Edge to edge | `flat` | A continuous full-width background with square outer corners | +| Fused islands | `fused` | Rounded groups with connecting necks where neighboring groups are close enough | +| Adaptive | `adaptive` | Floating with ordinary windows, edge to edge when maximized, and hidden in fullscreen | +| Morph | `morph` | One centered hub that expands into workspace, media and status controls | + +Capsules and Fused islands join groups **within the same zone**, so a zone with +one group appears as a single pill in either style. To see the joins, put +Overview and Workspaces in separate Start groups, or separate the status controls +and Clock into two End groups. With Capsules, those groups touch and have a +divider between them; Fused islands keeps their configured spacing and blends +nearby shapes, while Floating leaves the gaps open. + +When a group has no visible items, its background disappears, and the remaining +shapes adjust as items become unavailable or move into overflow. Clicks in +transparent gaps reach the windows underneath. If the active workspace contains +a fullscreen window, the panel hides on that output and releases its reserved +space. + +Changing the style preserves your composition and saved appearance overrides. +Adaptive evaluates each output independently, with a 150 ms delay between +ordinary and maximized presentations to avoid switching back and forth during +brief state changes. Entering or leaving fullscreen takes effect immediately. + +## Position, size and spacing + +Set `edge "top"` or `edge "bottom"` on the panel. Its controls keep the same +order, while edge-relative corners and attached shapes mirror vertically. +Popups and notifications open toward the desktop. + +Dimensions use logical pixels, so output scaling applies afterward. The +`geometry` block contains optional overrides; omitting a value lets the selected +style supply it. + +| `geometry` key | Default | Accepted values | Meaning | +| --- | --- | --- | --- | +| `height` | 36; Morph uses 34 | Number 24–128 | Panel height, or Morph's collapsed height | +| `edge-margin` | 8 for detached styles; 0 for Notch and Edge to edge | Integer 0–4096 | Distance from the selected screen edge | +| `side-margins` | 8 for detached styles; 0 for Notch and Edge to edge | Integer 0–4096 | Requested inset at both ends of the panel | +| `window-clearance` | 0 | Integer 0–4096 | Additional reserved space between the panel and windows | +| `inner-padding` | 12 | Number 0–64 | Space inside the row before its first and after its last group | + +Notch and Edge to edge use zero edge and side margins even if you have saved +overrides for them. Those values remain in the file and apply again when you +choose a detached style. Attached Morph also uses zero edge margin, while its +saved floating margin returns when attachment is disabled. + +Ferese uses the requested side margins only while the content fits. It measures +the controls on each output and reduces the inset when the output narrows or +more content appears, so the margins do not cause additional overflow. Settings +limits the slider to the available display and preview measurements; KDL accepts +the wider range shown above. + +Morph reserves space for its collapsed height plus window clearance. Its +expanded views overlay windows, so increasing their height does not push windows +down. At the panel edge, Morph accounts for the layout's outer gap rather than +adding the two gaps together. + +## Background, outline and corners + +`background-opacity` accepts a number from 0 to 1 and affects the panel +background without fading its text or icons. Omit it to inherit shell opacity, +or use the reset control beside **Background opacity** in Settings. It does not +change the opacity of popups, notifications or application windows. + +The panel uses the shared material settings under `theme.material`, including +translucency and backdrop blur. Morph uses the theme's `surface-raised` color +and suppresses the material shadow. **Reduce Transparency** takes precedence +over a panel opacity override, making the background opaque and removing blur. +See [Appearance](configuration.md#appearance) for the shared theme controls. + +Set `border #true` to draw the panel outline. Its color comes from +`theme.colors.border`, and Capsules also uses that color for its dividers. +Leave it at the default `#false` for no outline. + +Automatic corners follow the style and panel height. Choose **Custom** to pin +them, or return to **Automatic** to remove the override. The four controls are +**Edge left**, **Edge right**, **Outer left** and **Outer right**, so they retain +their meaning when you move the panel to the bottom. + +In KDL, `corner-radius` is a pixel string using CSS-style shorthand. One value +sets every corner; two set opposite pairs; three set the first corner, the +second/fourth pair and the third; four give each corner its own value. The +stored order is edge-left, edge-right, outer-right, outer-left: + +```kdl +corner-radius "4px 8px 12px 16px" +``` + +On a top panel, that order runs clockwise from top-left. On a bottom panel, +the same values mirror vertically. Values must be finite and nonnegative, with +`px` units except for a bare `0`; percentages and elliptical `/` syntax are +not supported. Ferese scales radii to fit when adjacent corners would overlap, +and Edge to edge keeps its outer corners square. Corner pins stay saved when +you switch styles. + +## Groups and items + +Place groups in Start on the left, Center in the middle or End on the right, +then arrange items within them. Center stays centered on the panel even when +the two sides have different widths. Each group and item has an ID, so Settings +keeps track of the one you are editing when you move it. + +Panel IDs, group IDs and item IDs contain 1–128 ASCII letters or digits, or +`-_.:@`, and cannot start with `_`. Group IDs must be unique across the panel, +as must item IDs; different instances of the same item kind are allowed when +their IDs differ. A panel supports up to 31 authored groups. + +| Scope / key | Default | Accepted values | Meaning | +| --- | --- | --- | --- | +| Zone `spacing` | 8 | Number 0–64 | Gap between groups; Capsules and Notch use zero | +| Group `spacing` | 1 | Number 0–64 | Gap between items | +| Group `padding` | `2 3` | Two integers 0–32 | Vertical padding followed by horizontal padding | +| Group `island-padding` | 4 | Number 0–32 | Extra horizontal padding for island styles | +| Item `visible` | `#true` | Boolean | Show the item when its service or hardware is available | +| Item `overflow` | `auto` | `auto`, `never`, `always` | Overflow when needed, keep visible, or always use overflow | +| Item `priority` | 50 | Integer 0–100 | Lower values yield space first | +| Item `representation` | Preferred supported form | `wide`, `compact`, `icon`, or omit | Starting form before adapting to available space | +| Item `gap-before` | Unset | Number 0–64 | Override the gap before this item within its group | +| Item `metadata` | Empty | Nested section | Custom data preserved by Settings, without shell behavior | + +Horizontal padding and island padding add together for island styles. Group +padding changes the space around that group's controls; it does not give the +group a separate height, style or set of corners. +`gap-before` has no effect on the first visible item in a group. + +### Item catalog + +The Add item catalog contains these twelve kinds. Service-dependent controls +appear when their service or hardware is available, and a focused-window item +disappears when that output has no window title to show. + +| Settings name | `kind` | Item-specific options | +| --- | --- | --- | +| Overview | `overview` | Opens the workspace overview | +| Workspaces | `workspaces` | `style`: `numbers`, `dots`, `tabs`, `window-stacks`, `app-icons`; default `dots` | +| Focused window | `focused-window` | Use the shared `visible` field to show or hide the title | +| Media | `media` | Full, Compact and Icon representations | +| Quick Settings | `quick-settings` | Opens Control Center | +| Network | `network` | Opens Wi-Fi controls | +| Audio | `audio` | Opens audio controls | +| Recording | `recording` | Opens recording controls or stops the active recording | +| Notifications | `notifications` | Opens notifications | +| Battery | `battery` | `percentage`: boolean, default `#true` | +| Clock | `clock` | Full, Compact and Icon representations | +| Display mode | `display-mode` | Appears when an external display is connected | + +Workspace styles change the regular panel's indicators. Numbers shows workspace +numbers and Tabs shows their names, while Dots uses a pill for the active +workspace. Window stacks uses +small window outlines, and App icons shows up to two distinct running apps. +Hovering an indicator shows its workspace name and window count. Clock +instances share the shell's clock format and timezone. + +Unknown options are rejected, so put custom annotations inside `metadata`: + +```kdl +item "desk-clock" kind="clock" { + metadata { note "Clock beside the audio control"; } +} +``` + +### Adaptation and overflow + +In the inspector, **Full**, **Compact** and **Icon** correspond to `wide`, +`compact` and `icon`. Media and Clock support all three; Battery with percentage +supports Full and Icon. Workspaces and Focused window have one form that can +shrink, while other controls use icons. An unsupported preference falls back +to the item's normal form. + +When space runs short, lower-priority items yield first, with their order in the +composition breaking ties. Flexible content can shrink, and items can switch +to smaller supported forms; overflow keeps the remaining controls accessible +from the chevron. **Keep visible** maps to `never`, **When needed** to `auto`, +and **Always** to `always`. Even `never` can move into overflow if the mandatory +controls cannot fit at their minimum sizes. + +The overflow chevron appears before the End controls, and selecting an entry +opens that item's usual controls. The panel itself does not scroll; workspace +indicators may scroll within the space they receive, while long window titles +end with an ellipsis. + +## Morph + +Morph combines the panel into one centered hub. Alongside a compact workspace +strip, it shows artwork, the track title and an equalizer during playback. +Otherwise it shows the focused-window title, falling back to the date when +there is no title. Recording and low-battery states take priority over ordinary +content. Hovering can expand a preview, and clicking opens the hub's Workspaces, +Music, Calendar, +Notifications or Controls view. Escape or the close button collapses it. + +The Workspaces view shows miniature window layouts. Click a tile to switch +workspace, use Tab and the arrow keys to navigate it, or drag a miniature window +to another tile on the same output. Scrolling over the collapsed hub changes +workspace, with a brief workspace preview after a switch. + +Morph keeps your saved composition for when you switch back to a regular panel. +The hub uses its own layouts and compact workspace dots, so Start/Center/End +positions, group padding and the regular workspace indicator style do not apply +inside it. Item settings still determine which configured controls appear in +its Controls view and shortcuts. + +Turn on **Attach to screen edge** to join the hub to the chosen edge with curved +shoulders, or set `morph { attached #true; }`. **Preview on hover** maps to +`peek-on-hover`; disabling it leaves click-to-open behavior available. These +two switches appear in Settings when Morph is selected, while the size values +below can be adjusted in KDL. + +| `morph` key | Default | Meaning | +| --- | --- | --- | +| `attached` | `#false` | Join the hub to the selected screen edge | +| `peek-on-hover` | `#true` | Show a preview when the pointer enters | +| `idle-width` | 150 | Collapsed content width budget | +| `peek-width` | 260 | Hover preview width budget | +| `peek-height` | 44 | Hover preview height | +| `toast-width` | 320 | Notification toast width | +| `toast-height` | 48 | Notification toast height | +| `expanded-width` | 340 | Width of expanded views | +| `workspaces-height` | 176 | Workspace view height | +| `media-height` | 170 | Music view height | +| `calendar-height` | 230 | Calendar view height | +| `notifications-height` | 176 | Notifications view height | +| `controls-height` | 248 | Maximum Controls view height before scrolling | +| `expanded-radius` | 26 | Default corners for expanded views | + +All width and height values must be finite numbers from 24 to 1024, and +`expanded-radius` accepts 0–128. The collapsed hub and workspace feedback fit +their content, Controls may open at a shorter height, and all views stay within +the available output area. A configured width therefore allows room for content +without forcing empty space around it. + +For ordinary media and date previews, the maximum width is `peek-width` clamped +to 200–280 pixels. Activity previews choose their own width, and workspace +feedback can be narrower. If you have pinned the panel's corners, those values +override `expanded-radius`. + +Morph follows animation speed and reduced motion. As it expands, the area that +accepts clicks follows its shape while the space kept clear of windows stays +unchanged; if you interrupt a transition, it continues from the shape already +on screen. + +## Examples + +### Joined groups with a centered title + +This composition puts Overview and Workspaces in separate Start groups so +Capsules can join them with a divider. The title stays centered, and the two +End groups keep status controls together while separating the clock. + +```kdl +panel "main" { + edge "top" + style "capsules" + border #true + background-opacity 0.85 + geometry { + height 36 + edge-margin 8 + side-margins 8 + window-clearance 4 + } + start { + group "overview" { + item "overview" kind="overview" overflow="never" priority=100 + } + group "navigation" { + item "workspaces" kind="workspaces" style="dots" overflow="never" priority=100 + } + } + center { + group "title" { + padding 0 0 + item "title" kind="focused-window" overflow="never" + } + } + end { + group "status" { + item "media" kind="media" representation="wide" + item "quick-settings" kind="quick-settings" + item "network" kind="network" + item "audio" kind="audio" + item "recording" kind="recording" + item "notifications" kind="notifications" + item "battery" kind="battery" percentage=#true + } + group "time" { + item "clock" kind="clock" representation="compact" + item "display" kind="display-mode" + } + } +} +``` + +Change only `style` to `floating` or `fused` to compare the same composition +with separate or blended islands, or use `adaptive` to let window state choose +between Floating and Edge to edge. + +### An attached Morph hub + +Keep the groups from the previous example, set `style "morph"`, and add this +section inside the same panel block. Changing `edge` to `bottom` mirrors the +attached shape without changing the profile. + +```kdl +morph { + attached #true + peek-on-hover #true + expanded-width 360 + media-height 180 + controls-height 248 +} +``` + +To return to a floating hub, set `attached #false`; its saved edge margin then +applies again. The profile stays in the file when you choose another style, so +you can return to Morph without re-entering these values. + +## Validate a file + +Save your edit and run `ferese --check-config ~/.config/ferese/config.kdl` to +validate it without applying changes. The file accepts one `panel` block; +unknown fields, duplicate IDs, unavailable styles and out-of-range dimensions +are errors. See [Saving and validation](configuration.md#saving-and-validation) +for how Settings and live reloads handle a failed validation. diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 2c7b3f7..da81c94 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -1,12 +1,12 @@ # Screen sharing and recording Share a monitor or window from a Wayland-compatible recording or conferencing app, -or save a video using the top bar. [Installation](installation.md) includes the -portal and PipeWire dependencies. +or save a video using the panel's Recording control. The +[installation guide](installation.md) covers the portal and PipeWire dependencies. -## Record from the top bar +## Record from the panel -1. Click the recording icon. +1. Click the Recording item in the panel, or open Controls → Recording in Morph. 2. Choose a display and an audio source: **Off**, **System sound**, **Microphone**, or **Both**. Audio is off by default; the other choices use your default output or input device from audio settings. @@ -38,9 +38,9 @@ The picker follows your shell colors, font, corners, opacity, and blur. ## Stop sharing Click **Stop sharing** or close the sharing window. For the built-in recorder, -use the stop button in the top bar. Sharing also ends when the app closes or -Ferese locks. If you disconnect a shared monitor or change its capture size, -its stream ends and you need to start sharing again. +use the panel's stop button, which Morph also shows while recording. Sharing +also ends when the app closes or Ferese locks. If you disconnect a shared monitor +or change its capture size, its stream ends and you need to start sharing again. ## Remember permissions diff --git a/docs/screenshots.md b/docs/screenshots.md index e2cdd0a..73d0f2f 100644 --- a/docs/screenshots.md +++ b/docs/screenshots.md @@ -34,7 +34,8 @@ Six themes, each with paired light and dark colours. ## Panel & Shell -Panel composition, position and surface settings. +The composition preview and panel controls in Settings. See the +[panel configuration guide](panel-configuration.md) for the current styles and options. [![Panel and Shell settings with a composition preview and island surfaces.](images/screenshots/ferese-panel-settings.webp)](images/screenshots/ferese-panel-settings.webp) diff --git a/docs/why-ferese.md b/docs/why-ferese.md index 1633507..2ed71ce 100644 --- a/docs/why-ferese.md +++ b/docs/why-ferese.md @@ -1,16 +1,16 @@ # Why Ferese -When you use a window manager, arranging windows is only part of using the desktop. You still need to connect to Wi-Fi, change an audio device, adjust a display, read notifications and lock the screen. Those tasks involve different components, but you encounter them as parts of the same environment. When their settings and behaviour are maintained separately, making them fit together becomes another job. +Alongside a window manager, you need Wi-Fi, audio and display controls, notifications and a lock screen. Choosing separate desktop components remains useful when you want that independence, though keeping them consistent becomes another job when their settings and behaviour are maintained separately. -Ferese brings window management and desktop controls into one project. It includes a Wayland compositor, a desktop shell, Settings, Control Center and a lock screen, with configuration and theming shared across its components. Building them together makes it possible to decide how a desktop change should work across those components, rather than leaving each one to interpret it independently. +Ferese includes a Wayland compositor, desktop shell, Settings, Control Center and a lock screen with shared configuration and theming. It keeps window management configurable while defining how a desktop change applies across those components. ## Settings that apply beyond one component -Changing an accent colour should affect the places where Ferese uses that colour. Changing motion speed should affect both window transitions and shell transitions. These are desktop preferences, even though the compositor and shell run in separate processes. Ferese’s shared theme and motion settings give those processes a common definition to work from. +An accent-colour change should reach the places where Ferese uses it, just as a motion-speed change should affect both window and shell transitions. The compositor and shell run in separate processes, but their shared theme and motion settings give them the same values to work from. -Settings and `~/.config/ferese/config.kdl` are two ways to edit the same configuration. You can use the graphical interface for a display change, then open the file to adjust a binding or window rule. Settings preserves comments and custom fields when saving. File changes reload automatically, and invalid edits leave the last working configuration active. This lets the graphical controls remain useful without making the configuration file a secondary or unsupported way to manage the desktop. +Settings and `~/.config/ferese/config.kdl` edit the same configuration, so you can change a display in Settings, then open the file to adjust a binding or window rule. Settings preserves comments and untouched configuration content when saving, and file changes reload automatically. If an edit is invalid, the last working configuration stays active. -The shared settings have a defined scope. Ferese can coordinate the surfaces it draws and the transitions it manages; it does not control every animation an application draws inside its own window. That distinction matters when describing a consistent desktop: consistency between Ferese’s components should not be confused with taking over application behaviour. +Ferese can coordinate the surfaces it draws and the transitions it manages; it does not control every animation an application draws inside its own window. [![Ferese Appearance settings with six paired light and dark themes.](images/screenshots/ferese-appearance.webp)](images/screenshots/ferese-appearance.webp) @@ -20,7 +20,7 @@ A scrolling layout is useful when several windows need more space than can comfo Ferese supports both, with the layout selected per workspace. Floating windows are also available and remember their size and placement. A workspace can therefore use scrolling columns for a sequence of wide windows, while another uses tree tiling for a smaller set that needs to be viewed together. -Even within scrolling, moving focus does not always need to move the desktop. Ferese’s minimal focus strategy leaves a fully visible column where it is and scrolls only far enough to reveal a hidden edge. Other strategies centre the focused column or arrange columns into viewport-sized pages. These choices affect how much the desktop moves while you work, so they belong in the layout’s behaviour rather than being treated as visual decoration. +Even within scrolling, moving focus does not always need to move the desktop. Ferese’s minimal focus strategy leaves a fully visible column where it is and scrolls only far enough to reveal a hidden edge. Other strategies centre the focused column or arrange columns into viewport-sized pages. [![Ferese Overview with open windows and three workspace previews.](images/screenshots/ferese-overview.webp)](images/screenshots/ferese-overview.webp) @@ -28,12 +28,10 @@ Even within scrolling, moving focus does not always need to move the desktop. Fe An animation often starts before you have finished deciding what to do. You might focus another window while the viewport is moving, reverse a fullscreen transition, or reopen a panel before it has finished closing. The desktop needs to handle that next input using the state already on screen. -Continuum, Ferese’s shared motion system, keeps a moving value’s current position, velocity and destination. Changing the destination can preserve the existing movement instead of restarting from rest. Direct manipulation follows a different rule: a dragged window follows the pointer directly, while a released gesture hands its velocity to a spring. The distinction keeps the animation from adding a trailing movement between your hand and the window. +Continuum, Ferese’s shared motion system, keeps a moving value’s current position, velocity and destination. Changing the destination can preserve the existing movement instead of restarting from rest. A dragged window follows the pointer directly so it does not trail your hand; when you release a gesture, its velocity passes to a spring. -This coordination extends beyond position. A window’s content, clipping and border need to follow the same presented bounds. When the application closes, its live surface may disappear before the visual exit finishes, so Ferese retains an image for that transition. When a shell panel closes, its content and compositor-drawn material need to leave together. Otherwise, the text can disappear while its background or blur remains. These are practical reasons for designing the motion system alongside the compositor and shell. +A window’s content, clipping and border need to follow the same presented bounds. When the application closes, its live surface may disappear before the visual exit finishes, so Ferese retains an image for that transition. Shell content and its compositor-drawn material also need to leave together, or the text can disappear while its background or blur remains. -## Taking responsibility for the whole interaction +## Testing the whole interaction -Including more of the desktop also means taking responsibility for more of its behaviour. A common theme does not resolve a late application buffer, and a correct spring equation does not make a missed frame arrive on time. Resize coordination, output timing and interrupted transitions still need testing, including measurements on the hardware where the desktop runs. - -Ferese’s reason for existing is to work on those interactions together while keeping window management configurable. Choosing separate desktop components remains useful when that independence is what you want. Ferese takes responsibility for the components it includes, so changing the layout, adjusting the appearance or using an everyday desktop control does not also require maintaining the connections between them. +A shared theme does not resolve a late application buffer, and a correct spring equation does not make a missed frame arrive on time. Resize coordination, output timing and interrupted transitions still need testing and measurements on the hardware where the desktop runs. diff --git a/packaging/config.kdl b/packaging/config.kdl index 0c00844..af0b1da 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -147,7 +147,7 @@ status { } // Panel composition is explicit; visibility and item options belong to each item. -// Background opacity and corner radius inherit from the shell unless overridden here. +// Opacity follows the shell; corners follow the panel style unless overridden here. panel "main" { geometry { height 30 @@ -156,8 +156,7 @@ panel "main" { window-clearance 4 } edge "top" - surface "solid" - group-surface "none" + style "flat" border #false start { spacing 3 diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 30f3d66..77bddfe 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -1,5 +1,3 @@ -//! ScreenCast portal backend. Only the portal frontend may create sessions; -//! saved monitor consent is restored only through the frontend permission store. use std::collections::HashMap; use std::process::Stdio; use std::sync::Arc; diff --git a/portal/src/capture.rs b/portal/src/capture.rs index 5e11a5c..9ef8a98 100644 --- a/portal/src/capture.rs +++ b/portal/src/capture.rs @@ -1,4 +1,3 @@ -//! Bounded, cancellable Wayland capture. No screenshots or external capture tools. use std::fs::File; use std::os::fd::{AsFd, AsRawFd, FromRawFd}; use std::sync::Arc; diff --git a/portal/src/record_main.rs b/portal/src/record_main.rs index 2c98cfc..0381d78 100644 --- a/portal/src/record_main.rs +++ b/portal/src/record_main.rs @@ -1,5 +1,3 @@ -//! Native recording client. The portal retains capture authorization; encoding -//! and file I/O live in this separate process, never in the shell render loop. mod recorder; mod recording; diff --git a/portal/src/recording_region.rs b/portal/src/recording_region.rs index cb11621..c8cc99b 100644 --- a/portal/src/recording_region.rs +++ b/portal/src/recording_region.rs @@ -1,4 +1,3 @@ -//! Map an approved logical recording region into the upright video stream. use crate::recording::Crop; #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/portal/src/stream.rs b/portal/src/stream.rs index 1711ba9..551ebed 100644 --- a/portal/src/stream.rs +++ b/portal/src/stream.rs @@ -1,5 +1,3 @@ -//! PipeWire runs outside the compositor. Keep one frame, drop old frames under -//! backpressure, and destroy the node when the portal's control pipe closes. use std::io::{Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; diff --git a/protocols/ferese-effects-v1.xml b/protocols/ferese-effects-v1.xml index 2fd073e..7cd55d9 100644 --- a/protocols/ferese-effects-v1.xml +++ b/protocols/ferese-effects-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the Ferese effects capability. Roles describe compositor-owned appearance policy and do not @@ -48,7 +48,7 @@ - + The role affects only compositor rendering. It does not change surface geometry, input, focus, stacking, or the surface's Wayland role. @@ -121,5 +121,24 @@ + + + Panel-only material presentation. Regions are bounded capture/damage + rectangles in the set_regions format. Shapes are native-endian binary32 + tuples (x,y,width,height,top-left,top-right,bottom-right,bottom-left, + operation,blend-zone). Operations: 0 rounded union, 1 rounded cutout, + 2..5 rectangular concave patches whose circle centers are bottom-left, + bottom-right, top-right, top-left. Blend zones 1..3 fuse independently; + zone 0 never fuses. At most 32 primitives and one cutout are accepted. + All coordinates must be finite and bounded by 32768 logical pixels. + Invalid requests leave pending state unchanged. Empty shapes require + empty regions. Input remains controlled by wl_surface.set_input_region. + This presentation becomes visible atomically with the next surface commit. + + + + + + diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index a741a3f..af8cad4 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the shell-control capability. It does not assign a Wayland surface role. @@ -35,7 +35,7 @@ - + Window, workspace, and output identifiers are unsigned 64-bit values encoded as high and low 32-bit words. Each snapshot atomically replaces @@ -72,6 +72,7 @@ + @@ -79,6 +80,7 @@ + @@ -181,5 +183,27 @@ disabled monitors and mirror targets without a logical wl_output. + + + Follows its window event inside a snapshot. Coordinates are compositor + logical coordinates at the layout target, independent of animations. + Width and height are positive. An omitted event means unavailable geometry. + + + + + + + + + + + Both IDs must exist and belong to the same output. A stale ID is rejected. + + + + + + diff --git a/scripts/build-site.py b/scripts/build-site.py index 26bf106..f278607 100644 --- a/scripts/build-site.py +++ b/scripts/build-site.py @@ -18,6 +18,7 @@ PAGES = [ ('installation', 'Installation', ROOT / 'docs/installation.md'), ('packaging', 'Packages and releases', ROOT / 'docs/packaging.md'), ('configuration', 'Configuration', ROOT / 'docs/configuration.md'), + ('panel-configuration', 'Panel configuration', ROOT / 'docs/panel-configuration.md'), ('animation-model', 'Animation model', ROOT / 'docs/animation-model.md'), ('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'), ('locking', 'Lock screen', ROOT / 'docs/locking.md'), diff --git a/scripts/ci/run-native-checks.py b/scripts/ci/run-native-checks.py index e77c19d..1ca5ffd 100644 --- a/scripts/ci/run-native-checks.py +++ b/scripts/ci/run-native-checks.py @@ -58,8 +58,8 @@ def main(): raise RuntimeError((logs / "weston.log").read_text()) time.sleep(.05) cases = [ - ("shell-continuous", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "continuous"}), - ("shell-islands", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "islands"}), + ("shell-flat", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "flat"}), + ("shell-floating", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "floating"}), ] if args.suite == "desktop": cases.insert(0, ("lock", "lock", {"FERESE_TEST_LOCK": "1", "FERESE_TEST_BIN_DIR": "target/debug"})) diff --git a/scripts/preview-panels.py b/scripts/preview-panels.py new file mode 100644 index 0000000..99f6b27 --- /dev/null +++ b/scripts/preview-panels.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Run the current shell and Settings in a private nested desktop.""" +import argparse +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import signal +import socket +import struct +import subprocess +import tempfile +import time + +REPO = Path(__file__).resolve().parents[1] + + +def request(endpoint, command, args=None): + data = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args or {}}).encode() + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(2) + connection.connect(str(endpoint)) + connection.sendall(struct.pack(">I", len(data)) + data) + def receive(count): + result = bytearray() + while len(result) < count: + part = connection.recv(count - len(result)) + if not part: + raise ConnectionError("Preview compositor disconnected") + result.extend(part) + return bytes(result) + length = struct.unpack(">I", receive(4))[0] + if length > 4 * 1024 * 1024: + raise ValueError("Oversized preview response") + result = json.loads(receive(length)) + if "error" in result: + raise RuntimeError(result["error"]) + return result["result"] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--no-build", action="store_true", help="Use existing binaries") + parser.add_argument("--debug", action="store_true", help="Use debug builds instead of optimized release builds") + parser.add_argument("--style", default="floating", help="Initial panel style") + args = parser.parse_args() + profile = "debug" if args.debug else "release" + if not args.no_build: + command = ["cargo", "build", "-p", "ferese", "-p", "ferese-shell", "-p", "ferese-settings"] + if not args.debug: + command.append("--release") + subprocess.run(command, cwd=REPO, check=True) + binaries = {name: REPO / "target" / profile / name for name in ("ferese", "ferese-shell", "ferese-settings")} + if any(not binary.is_file() for binary in binaries.values()): + parser.error("Build the compositor, shell and Settings first") + display = Path(os.environ.get("WAYLAND_DISPLAY", "")) + if not os.environ.get("WAYLAND_DISPLAY"): + parser.error("Run this preview inside a Wayland session") + if not display.is_absolute(): + display = Path(os.environ["XDG_RUNTIME_DIR"]) / display + children = [] + with tempfile.TemporaryDirectory(prefix="ferese-panel-preview-") as temporary: + root = Path(temporary) + # Keep sibling validation and apps opened from the shell on this build, + # even if Cargo replaces target/release while the preview is running. + preview_bin = root / "bin" + preview_bin.mkdir() + for name, binary in binaries.items(): + shutil.copy2(binary, preview_bin / name) + for name in ("ferese-record", "ferese-lock", "ferese-launcher", "feresectl"): + binary = REPO / "target" / profile / name + if binary.is_file(): + shutil.copy2(binary, preview_bin / name) + binaries = {name: preview_bin / name for name in binaries} + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + # Keep the complete composition; an authored panel with no groups is empty. + source = (REPO / "packaging/config.kdl").read_text().replace('style "flat"', 'style ' + json.dumps(args.style), 1) + if args.style == "morph": + source = source.replace('border #false', 'border #true', 1) + source = re.sub(r'keybinding-guide\s+#true', 'keybinding-guide #false', source) + config_path = config / "config.kdl" + config_path.write_text(source) + subprocess.run([str(binaries["ferese"]), "--check-config", str(config_path)], check=True) + env = {key: value for key, value in os.environ.items() if not key.startswith("FERESE_")} + env["PATH"] = str(preview_bin) + os.pathsep + env.get("PATH", os.defpath) + env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display), XDG_CONFIG_HOME=str(root / "config"), + XDG_STATE_HOME=str(root / "state"), XDG_CACHE_HOME=str(root / "cache"), + XDG_DATA_HOME=str(root / "data"), FERESE_ENABLE_SCREENCOPY="1", + FERESE_COMPOSITOR_WALLPAPER="1", WINIT_UNIX_BACKEND="wayland", ICED_BACKEND="wgpu", RUST_LOG="ferese=info") + env.pop("WAYLAND_SOCKET", None) + bus_config = root / "bus.conf" + bus_config.write_text('sessionunix:tmpdir=/tmp' + '' + '') + launcher = root / "launch.sh" + launcher.write_text('#!/bin/bash\nunset DISPLAY\nexport WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY}"\n' + 'env -u WAYLAND_SOCKET -u FERESE_SHELL_CONTROL_SOCKET -u DISPLAY ' + 'WINIT_UNIX_BACKEND=wayland ' + + shlex.quote(str(binaries["ferese-settings"])) + ' --page bar &\nexec ' + + shlex.quote(str(binaries["ferese-shell"])) + '\n') + launcher.chmod(0o700) + log_path = root / "session.log" + def stop(_signal, _frame): + raise KeyboardInterrupt + signal.signal(signal.SIGTERM, stop) + try: + with log_path.open("w") as log: + bus = subprocess.Popen(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"], + env=env, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True) + children.append(bus) + env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() + compositor = subprocess.Popen([str(binaries["ferese"]), "--backend", "nested", "--grant-effects", + "--grant-shell-control", "--", str(launcher)], + env=env, stdout=log, stderr=log, start_new_session=True) + children.append(compositor) + deadline = time.monotonic() + 30 + readiness = "Waiting for the compositor socket" + while True: + endpoints = list(runtime.glob("ferese/instances/*/control.sock")) + trace = log_path.read_text() + child = re.search(r'spawned Wayland client.*pid=(\d+)', trace) + if compositor.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError("Preview did not create its shell and Settings: " + readiness + "\n" + trace[-8000:]) + if endpoints and child: + try: + panel = request(endpoints[0], "has-client-surfaces", {"pid": int(child.group(1))}) + # The private socketpair has the compositor's peer credentials. + # Wallpaper is disabled, so this client can only own shell surfaces. + panel = panel or request(endpoints[0], "has-client-surfaces", {"pid": compositor.pid}) + windows = request(endpoints[0], "get-windows") + settings = any(window["mapped"] and window["app_id"] == "dev.ferese.Settings" + for window in windows) + readiness = f"shell surfaces={panel}; windows={windows}" + if panel and settings: + break + except (OSError, ConnectionError): + pass + time.sleep(.05) + print(f"Panel preview ready ({profile} build). Changes apply only inside this window.\nConfig: {config_path}\nLog: {log_path}", flush=True) + compositor.wait() + except KeyboardInterrupt: + pass + finally: + for child in reversed(children): + try: + os.killpg(child.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait(timeout=5) + if child.stdout is not None: + child.stdout.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_shell_motion_isolated.py b/scripts/tests/test_shell_motion_isolated.py index ddd8ffd..16a7691 100644 --- a/scripts/tests/test_shell_motion_isolated.py +++ b/scripts/tests/test_shell_motion_isolated.py @@ -1,10 +1,11 @@ """Exercise real shell frame-driven motion on a private nested desktop and bus. FERESE_TEST_SHELL_MOTION=1 python3 scripts/tests/test_shell_motion_isolated.py -Set FERESE_TEST_BAR_LAYOUT=islands to also test island masks and live switching. +Set FERESE_TEST_BAR_LAYOUT=floating to also test island masks and live switching. Uses release binaries by default. Requires a Wayland host, dbus-daemon and gdbus. Protocol traces contain only this test's shell traffic, never the host desktop. """ +import json import os from pathlib import Path from session_socket import ipc_environment, ipc_socket @@ -18,10 +19,9 @@ import unittest def panel_config(preset): - surface, group_surface = ("solid", "none") if preset == "continuous" else ("none", "island") + style = preset return f'''panel main {{ - surface "{surface}" - group-surface "{group_surface}" + style "{style}" start {{ group navigation {{ island-padding 12; item overview kind=overview; item spaces kind=workspaces; }}; }} center {{ group title {{ island-padding 12; item title kind=focused-window; }}; }} end {{ group status {{ island-padding 12; item media kind=media; item quick kind=quick-settings; item network kind=network; item audio kind=audio; item recording kind=recording; item notifications kind=notifications; item battery kind=battery; item clock kind=clock; item display kind=display-mode; }}; }} @@ -43,8 +43,8 @@ class ShellMotionTest(unittest.TestCase): runtime.mkdir(mode=0o700) config = root / "config/ferese" config.mkdir(parents=True) - bar_layout = os.environ.get("FERESE_TEST_BAR_LAYOUT", "continuous") - self.assertIn(bar_layout, ("continuous", "islands")) + bar_layout = os.environ.get("FERESE_TEST_BAR_LAYOUT", "flat") + self.assertIn(bar_layout, ("flat", "floating")) config_file = config / "config.kdl" config_file.write_text( 'animations { speed 0.5; }\n' @@ -122,9 +122,18 @@ class ShellMotionTest(unittest.TestCase): return None updates = re.findall( - r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_presentation\(\d+, array\[(\d+)\],', trace) + r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_panel_presentation\(array\[(\d+)\],', trace) return int(updates[-1]) // 20 if updates else None + def bar_size(surface): + identity, start = surface + trace = log_path.read_text()[start:] + match = re.search(r'get_layer_surface\(new id zwlr_layer_surface_v1[@#](\d+), wl_surface[@#]' + identity + r',', trace) + if not match: + return None + updates = re.findall(r'zwlr_layer_surface_v1[@#]' + match.group(1) + r'\.set_size\((\d+), (\d+)\)', trace) + return tuple(map(int, updates[-1])) if updates else None + def bar_input_rectangles(surface): identity, start = surface trace = log_path.read_text()[start:] @@ -165,9 +174,10 @@ class ShellMotionTest(unittest.TestCase): wait_for(lambda: (ipc_socket(runtime)).is_socket()) wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0) bar = wait_for(lambda: layer_surface("ferese-shell-top-bar")) - if bar_layout == "islands": + if bar_layout == "floating": wait_for(lambda: bar_region_count(bar) == 2) original_input = wait_for(lambda: bar_input_rectangles(bar)) + original_start_width = max(x + width for x, y, width, height in original_input if x < 300) - min(x for x, y, width, height in original_input if x < 300) time.sleep(.5) reply = dbus("Notify", "Ferese motion test", "0", "", "Frame cadence", "Synthetic test notification", "[]", "{}", "0").stdout notice = re.search(r'uint32 (\d+)', reply).group(1) @@ -251,26 +261,84 @@ class ShellMotionTest(unittest.TestCase): wait_for(lambda: destroyed(remaining[0])) print("Modal: rapid toggles released all native surfaces", flush=True) - if bar_layout == "islands": - for layout_name, expected_count in [("continuous", 1), ("islands", None)]: + if bar_layout == "floating": + for layout_name, expected_count in [("flat", 1), ("floating", None)]: config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name)) subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) wait_for(lambda: bar_region_count(bar) == (expected_count or 2)) self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) self.assertNotIn("panicked at", log_path.read_text()) - print("Bar: separate material regions; continuous/islands live switches passed", flush=True) + print("Bar: separate material regions; flat/floating live switches passed", flush=True) for padding in [4, 0]: source = config_file.read_text() config_file.write_text(re.sub( r'island-padding \d+', f'island-padding {padding}', source)) subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) - expected_width = original_input[0][2] - 2 * (12 - padding) - wait_for(lambda: len(bar_input_rectangles(bar)) == 2 - and bar_input_rectangles(bar)[0][2] == expected_width) + expected_width = original_start_width - 2 * (12 - padding) + def start_width(): + rectangles = [(x, width) for x, y, width, height in bar_input_rectangles(bar) if x < 300] + return max((x + width for x, width in rectangles), default=0) - min((x for x, width in rectangles), default=0) + wait_for(lambda: start_width() == expected_width) print("Bar: live padding reductions updated the input regions", flush=True) + if settings_binary := os.environ.get("FERESE_TEST_SETTINGS"): + child_display = next(path for path in runtime.glob("wayland-*") if path.is_socket()) + settings_env = dict(ipc_environment(env), WAYLAND_DISPLAY=str(child_display), WINIT_UNIX_BACKEND="wayland", ICED_BACKEND="wgpu") + settings_env.pop("WAYLAND_SOCKET", None) + settings_env.pop("FERESE_SHELL_CONTROL_SOCKET", None) + settings = subprocess.Popen([str(repo / settings_binary), "--page", "bar"], env=settings_env, + start_new_session=True, stdout=log, stderr=log) + children.append(settings) + def windows(): + response = subprocess.run([str(ctl), "--json", "windows"], env=ipc_environment(env), + text=True, capture_output=True, check=True, timeout=5) + return json.loads(response.stdout) + wait_for(lambda: any(window["mapped"] and window["app_id"] == "dev.ferese.Settings" for window in windows())) + config_file.write_text('status { keybinding-guide #false; }\n' + panel_config("floating").replace('style "floating"', 'style "adaptive"')) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + wait_for(lambda: bar_region_count(bar) == 3) + for command, count in [("toggle-maximized", 1), ("toggle-maximized", 3)]: + subprocess.run([str(ctl), command], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + wait_for(lambda: bar_region_count(bar) == count) + subprocess.run([str(ctl), "toggle-fullscreen"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + wait_for(lambda: not bar_input_rectangles(bar)) + subprocess.run([str(ctl), "toggle-fullscreen"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + wait_for(lambda: bar_input_rectangles(bar)) + settings.terminate() + settings.wait(timeout=5) + wait_for(lambda: not windows()) + print("Adaptive: maximization changed the real panel; fullscreen released input and restored it", flush=True) + + if bar_layout == "floating": + for style, regions in [("floating", 2), ("capsules", 2), ("notch", 2), ("flat", 1), ("fused", 2), ("adaptive", 2)]: + config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(style)) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + wait_for(lambda: bar_region_count(bar) == regions) + self.assertTrue(bar_input_rectangles(bar), style + " lost its native input") + config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\nnotifications { timeout-ms 1200; }\n' + panel_config("morph")) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + wait_for(lambda: bar_size(bar) == (150, 34) and bar_region_count(bar) == 1) + reply = dbus("Notify", "Morph test", "0", "", "Hub notification", "Bounded native surface", "[]", "{}", "0").stdout + notice = re.search(r'uint32 (\d+)', reply).group(1) + wait_for(lambda: bar_size(bar) == (320, 48)) + # Removing a notice must collapse its hub immediately; an old timeout cannot reopen it. + dbus("CloseNotification", notice) + wait_for(lambda: bar_size(bar) == (150, 34)) + time.sleep(.25) + before = commits(bar) + trace_start = len(log_path.read_text()) + time.sleep(.5) + # Clock/service updates can cause a short render burst; settled geometry must not animate. + self.assertNotIn(".set_panel_presentation(", log_path.read_text()[trace_start:]) + self.assertLessEqual(commits(bar) - before, 4, "settled Morph keeps scheduling frames") + self.assertEqual(bar_region_count(bar), 1) + config_file.write_text('status { keybinding-guide #false; }\n' + panel_config("floating")) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) + wait_for(lambda: bar_region_count(bar) == 2) + print("Styles: native masks survived every live switch; Morph grew, closed and stopped frames", flush=True) + saved = config_file.read_text() config_file.write_text('panel main { surface "invalid-surface"; }\n') rejected = subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), @@ -293,6 +361,8 @@ class ShellMotionTest(unittest.TestCase): self.assertNotIn("panicked at", log_path.read_text()) print("Reload: invalid config preserved the panel; repaired config reopened and released a modal", flush=True) finally: + if destination := os.environ.get("FERESE_TEST_LOG"): + shutil.copyfile(log_path, destination) for child in reversed(children): if child.poll() is None: os.killpg(child.pid, signal.SIGTERM) diff --git a/shell/ferese-lock/src/auth.rs b/shell/ferese-lock/src/auth.rs index f6fbf76..f587343 100644 --- a/shell/ferese-lock/src/auth.rs +++ b/shell/ferese-lock/src/auth.rs @@ -1,4 +1,3 @@ -//! PAM stays on a worker thread. No password is placed in argv, logs or IPC. use std::ffi::{CStr, CString, c_char, c_int, c_void}; use zeroize::Zeroizing; diff --git a/shell/ferese-lock/src/runtime.rs b/shell/ferese-lock/src/runtime.rs index 8460e57..841a8c2 100644 --- a/shell/ferese-lock/src/runtime.rs +++ b/shell/ferese-lock/src/runtime.rs @@ -1,5 +1,3 @@ -//! Startup handshake: the invoking command exits successfully only after the -//! compositor's confirmation. The UI child remains alive for authentication. use std::io::{BufRead, BufReader}; use std::process::{Child, Command, Stdio}; use std::time::Duration; diff --git a/shell/ferese-settings/Cargo.toml b/shell/ferese-settings/Cargo.toml index 5825e80..e795766 100644 --- a/shell/ferese-settings/Cargo.toml +++ b/shell/ferese-settings/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-panel = { path = "../../crates/ferese-panel" } ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-theme = { path = "../../crates/ferese-theme" } ferese-config = { path = "../../crates/ferese-config" } diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index a2e6ec4..1b88347 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -94,6 +94,8 @@ enum InitialPage { #[derive(Clone, Debug)] enum Message { + ReadRenderScale(cosmic::iced::window::Id), + RenderScale(f32), Connections(connections::Input), ConnectionsReady(Result, String>), ConnectionsRefreshed(Box), @@ -114,9 +116,9 @@ enum Message { Change(Edit), PanelClearSelection, PanelPage(panel_controls::PanelPage), - PanelSelect(ferese_config::panel::ItemId), - PanelGroupSelect(ferese_config::panel::GroupId), - PanelPreviewResolved(ferese_config::panel::layout::Resolution), + PanelSelect(ferese_panel::ItemId), + PanelGroupSelect(ferese_panel::GroupId), + PanelPreviewResolved(ferese_panel::layout::Resolution), PanelPreviewOverflow, PanelEdit(panel_edit::Action), Draft(String, String), @@ -159,6 +161,7 @@ struct App { display_selection: Option, display_error: Option, core: Core, + render_scale: f64, path: PathBuf, current: Snapshot, draft: Snapshot, @@ -187,10 +190,10 @@ struct App { undo_revision: u64, auto_details: bool, advanced_theme: bool, - panel_selection: Option, + panel_selection: Option, panel_page: panel_controls::PanelPage, - panel_group_selection: Option, - panel_preview_resolution: ferese_config::panel::layout::Resolution, + panel_group_selection: Option, + panel_preview_resolution: ferese_panel::layout::Resolution, panel_preview_overflow: bool, theme_file_target: usize, wallpaper: wallpaper_controls::State, @@ -263,6 +266,7 @@ impl cosmic::Application for App { display_selection: None, display_error: None, core, + render_scale: 1., path, draft: current.clone(), current, @@ -326,6 +330,13 @@ impl cosmic::Application for App { fn update(&mut self, message: Message) -> Task { match message { + Message::ReadRenderScale(id) => { + return cosmic::iced::window::scale_factor(id) + .map(|scale| cosmic::Action::App(Message::RenderScale(scale))); + } + Message::RenderScale(scale) if scale.is_finite() && scale > 0. && scale <= 16. => { + self.render_scale = f64::from(scale); + } Message::Connections(input) => return self.connections_input(input), Message::ConnectionsReady(result) => return self.connections_ready(result), Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot), @@ -889,6 +900,21 @@ impl cosmic::Application for App { fn subscription(&self) -> cosmic::iced::Subscription { cosmic::iced::Subscription::batch([ + cosmic::iced::event::listen_with(|event, _, id| match event { + cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => { + Some(Message::ReadRenderScale(id)) + } + cosmic::iced::Event::Window(cosmic::iced::window::Event::Rescaled(scale)) => { + Some(Message::RenderScale(scale)) + } + cosmic::iced::Event::PlatformSpecific(cosmic::iced::event::PlatformSpecific::Wayland( + cosmic::iced::event::wayland::Event::Output( + cosmic::iced::event::wayland::OutputEvent::InfoUpdate(_), + _, + ), + )) => Some(Message::ReadRenderScale(id)), + _ => None, + }), if self.sidebar_open { cosmic::iced::event::listen_with(|event, _, _| match event { cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { @@ -1102,7 +1128,7 @@ mod tests { let _ = app.update(Message::Range(field.clone(), 23.)); assert_eq!(app.draft.source, before); let _ = app.update(Message::Release(field)); - let radii: ferese_config::panel::CornerRadii = + let radii: ferese_panel::CornerRadii = serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone()).unwrap(); assert_eq!(radii.0, [4., 8., 23., 16.]); assert!(app.ranges.is_empty()); @@ -1111,7 +1137,7 @@ mod tests { #[test] fn pending_panel_moves_save_both_groups_together_and_keep_the_selected_instance() { - use ferese_config::panel::{GroupId, ItemId}; + use ferese_panel::{GroupId, ItemId}; let mut app = app(); let mut initial = Snapshot::parse(String::new()).unwrap(); initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap(); @@ -1139,13 +1165,13 @@ mod tests { assert!(app.saving, "the complete queued draft is saved once"); assert!(app.pending.is_empty()); assert_eq!(app.draft.doc.value(), &expected); - let panels: Vec = + let panels: Vec = serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap(); - ferese_config::panel::validate(&panels).unwrap(); + ferese_panel::validate(&panels).unwrap(); assert_eq!(panels[0].start.groups[1].items[1].id, id); assert_eq!( panels[0].start.groups[1].items[1].overflow, - ferese_config::panel::OverflowPolicy::Always + ferese_panel::OverflowPolicy::Always ); assert!(!panels[0].end.groups[0].items.iter().any(|item| item.id == id)); } @@ -1181,7 +1207,7 @@ mod tests { }; app.panel_page = panel_controls::PanelPage::Arrange; let collapsed = nodes(&app); - let id = ferese_config::panel::ItemId("clock".into()); + let id = ferese_panel::ItemId("clock".into()); let _ = app.update(Message::PanelSelect(id.clone())); let expanded = nodes(&app); assert!(expanded > collapsed); @@ -1193,7 +1219,7 @@ mod tests { #[test] fn pending_group_moves_and_style_changes_save_one_coherent_draft() { - use ferese_config::panel::GroupId; + use ferese_panel::GroupId; let mut app = app(); let mut initial = Snapshot::parse(String::new()).unwrap(); initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap(); @@ -1225,7 +1251,7 @@ mod tests { fn selecting_a_generated_preview_item_does_not_save_the_composition() { let mut app = app(); app.saving = true; - let id = ferese_config::panel::ItemId("clock".into()); + let id = ferese_panel::ItemId("clock".into()); let _ = app.update(Message::PanelSelect(id.clone())); assert_eq!(app.panel_selection, Some(id)); assert!(app.draft.item("panels").is_none()); @@ -1244,19 +1270,17 @@ mod tests { assert!(app.draft.item("panels").is_none()); } app.saving = true; - let _ = app.update(Message::PanelSelect(ferese_config::panel::ItemId("clock".into()))); + let _ = app.update(Message::PanelSelect(ferese_panel::ItemId("clock".into()))); assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange); assert!(app.panel_group_selection.is_none()); - let _ = app.update(Message::PanelGroupSelect(ferese_config::panel::GroupId( - "status".into(), - ))); + let _ = app.update(Message::PanelGroupSelect(ferese_panel::GroupId("status".into()))); assert!(app.panel_selection.is_none()); assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange); } #[test] fn dropped_items_follow_queued_group_moves_and_are_saved_once() { - use ferese_config::panel::{GroupId, ItemId}; + use ferese_panel::{GroupId, ItemId}; let mut app = app(); let mut initial = Snapshot::parse(String::new()).unwrap(); initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap(); diff --git a/shell/ferese-settings/src/panel_controls.rs b/shell/ferese-settings/src/panel_controls.rs index 1eec42f..37486cf 100644 --- a/shell/ferese-settings/src/panel_controls.rs +++ b/shell/ferese-settings/src/panel_controls.rs @@ -1,4 +1,4 @@ -use ferese_config::panel::{Edge, ItemKind, Panel}; +use ferese_panel::{Edge, ItemKind, Panel}; use crate::panel_edit::{Action, Destination, Zone}; use crate::{App, Element, Message, schema::Page, store, widget}; @@ -12,7 +12,7 @@ pub(super) enum PanelPage { pub(super) fn initialize(_snapshot: &store::Snapshot) -> Result { let panel = Panel::default(); - ferese_config::panel::validate(std::slice::from_ref(&panel))?; + ferese_panel::validate(std::slice::from_ref(&panel))?; Ok(store::set( "panels", serde_json::to_value(vec![panel]).map_err(|error| error.to_string())?, @@ -26,10 +26,18 @@ impl std::fmt::Display for CatalogItem { formatter.write_str(self.0.label()) } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct StyleChoice(ferese_panel::PanelStyle); +impl std::fmt::Display for StyleChoice { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.0.label()) + } +} + const CATALOG: [CatalogItem; 12] = [ CatalogItem(ItemKind::Overview), CatalogItem(ItemKind::Workspaces { - style: ferese_config::panel::WorkspaceStyle::Dots, + style: ferese_panel::WorkspaceStyle::Dots, }), CatalogItem(ItemKind::FocusedWindow), CatalogItem(ItemKind::Media), @@ -104,11 +112,6 @@ impl App { .class(crate::visuals::surface(palette.card, 14.)), ); let preset = self.preview_panel().ok().and_then(|panel| panel.preset()); - let preset_key = match preset { - Some(ferese_config::PanelPreset::Continuous) => "continuous", - Some(ferese_config::PanelPreset::Islands) => "islands", - None => "custom", - }; let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| { let label = if position == Edge::Top { "Top" } else { "Bottom" }; widget::button::custom(self.label(label, 12.)) @@ -134,53 +137,20 @@ impl App { .padding(16) .class(crate::visuals::surface(palette.card, 14.)), ); - let appearance = widget::column([]) + let mut appearance = widget::column([]) .spacing(16) .push(self.panel_heading("Appearance", 15.)) - .push(self.panel_appearance_row( - "Background", - "Apply a preset, then customize each group.", - self.panel_surface_choices( - preset_key, - &[("continuous", "Continuous"), ("islands", "Islands")], - move |key| { - Message::PanelEdit(Action::ApplyPreset(if key == "islands" { - ferese_config::PanelPreset::Islands - } else { - ferese_config::PanelPreset::Continuous - })) - }, - ), - )) - .push( - self.panel_appearance_row( - "Default group surface", - "Groups can override this in their inspector.", - self.panel_surface_choices( - self.preview_panel() - .map_or(ferese_config::panel::GroupSurface::None, |panel| panel.group_surface) - .key(), - &[("none", "None"), ("inset", "Inset"), ("island", "Island")], - |key| { - Message::PanelEdit(Action::SetDefaultSurface(match key { - "inset" => ferese_config::panel::GroupSurface::Inset, - "island" => ferese_config::panel::GroupSurface::Island, - _ => ferese_config::panel::GroupSurface::None, - })) - }, - ), - ), - ) + .push(self.panel_style_choices(preset.unwrap_or_default())) .push(self.panel_divider()) .push(self.panel_opacity_controls()) .push(self.panel_divider()) .push( self.panel_appearance_row( - "Group borders", - "Outline each group of items.", + "Outline", + "Add a fine line around the panel shape.", widget::container( ferese_theme::controls::switch(self.draft.boolean("panels.0.border", false), palette) - .name("Group borders") + .name("Outline") .on_press(Message::PanelEdit(Action::SetBorder( !self.draft.boolean("panels.0.border", false), ))), @@ -190,7 +160,44 @@ impl App { ), ) .push(self.panel_divider()) - .push(self.panel_radius_controls()); + .push(if preset == Some(ferese_panel::PanelStyle::Flat) { + widget::Space::new().height(0).into() + } else { + self.panel_radius_controls() + }); + if preset == Some(ferese_panel::PanelStyle::Morph) { + let enabled = self.draft.boolean("panels.0.morph.peek_on_hover", true); + let attached = self.draft.boolean("panels.0.morph.attached", false); + appearance = appearance + .push(self.panel_divider()) + .push( + self.panel_appearance_row( + "Attach to screen edge", + "Join the island to the screen edge with curved shoulders.", + widget::container( + ferese_theme::controls::switch(attached, palette) + .name("Attach to screen edge") + .on_press(Message::PanelEdit(Action::SetMorphAttached(!attached))), + ) + .align_right(cosmic::iced::Length::Fill) + .into(), + ), + ) + .push(self.panel_divider()) + .push( + self.panel_appearance_row( + "Preview on hover", + "Expand the island when you hover over it.", + widget::container( + ferese_theme::controls::switch(enabled, palette) + .name("Preview on hover") + .on_press(Message::PanelEdit(Action::SetPeekOnHover(!enabled))), + ) + .align_right(cosmic::iced::Length::Fill) + .into(), + ), + ); + } rows = rows.push( widget::container(appearance) .id("panel-appearance-settings") @@ -198,21 +205,70 @@ impl App { .width(cosmic::iced::Length::Fill) .class(crate::visuals::surface(palette.card, 14.)), ); - let mut settings = widget::column([]) - .spacing(4) - .push(widget::container(self.panel_heading("Size & spacing", 15.)).padding([4, 10])); + let pins = self.preview_panel().is_ok_and(|panel| { + panel.geometry != ferese_panel::GeometryIntent::default() || panel.corner_radius.is_some() + }); + let mut settings = widget::column([]).spacing(4).push( + widget::container( + widget::row![ + self.panel_heading("Size & spacing", 15.) + .width(cosmic::iced::Length::Fill), + self.settings_button( + "Reset to style", + "M3 12a9 9 0 1 0 3-6 M3 3v6h6", + pins.then_some(Message::PanelEdit(Action::ResetShape)), + false + ) + ] + .align_y(cosmic::iced::Alignment::Center), + ) + .padding([4, 10]), + ); for field in crate::schema::fields(Page::Bar) .into_iter() .filter(|field| field.path.starts_with("panels.0.geometry.")) + .filter(|field| { + if preset == Some(ferese_panel::PanelStyle::Morph) + && self.draft.boolean("panels.0.morph.attached", false) + && field.path.ends_with(".edge_margin") + { + return false; + } + !matches!( + preset.unwrap_or_default(), + ferese_panel::PanelStyle::Flat | ferese_panel::PanelStyle::Notch + ) || !matches!(field.path.rsplit('.').next(), Some("edge_margin" | "side_margins")) + }) { let mut field = field; + if preset == Some(ferese_panel::PanelStyle::Morph) { + if matches!(field.path.rsplit('.').next(), Some("inner_padding" | "side_margins")) { + continue; + } + if field.path.ends_with(".height") { + field.label = "Collapsed height".into(); + } + } if let crate::schema::Kind::Range { default, .. } = &mut field.kind { - let geometry = - serde_json::to_value(self.preview_panel().map(|panel| panel.geometry).unwrap_or_default()) - .unwrap(); + let geometry = serde_json::to_value( + self.preview_panel() + .map(|panel| panel.resolved_geometry()) + .unwrap_or_default(), + ) + .unwrap(); *default = geometry[field.path.rsplit('.').next().unwrap()].as_f64().unwrap(); } - settings = settings.push(self.field(field)); + let pinned = self.draft.item(&field.path).is_some(); + let reset = pinned.then_some(Message::PanelEdit(Action::ClearGeometry(field.path.clone()))); + let reset_label = format!("Reset {}", field.label); + settings = settings.push( + widget::row![ + self.field(field), + self.settings_icon_button(&reset_label, "M3 12a9 9 0 1 0 3-6 M3 3v6h6", reset) + ] + .spacing(8) + .align_y(cosmic::iced::Alignment::Center), + ); } rows = rows.push( widget::container(settings) @@ -241,10 +297,11 @@ impl App { return None; } let panel = self.preview_panel().ok()?; - Some( - self.panel_preview_resolution - .side_margin(4096, display.logical_width?, panel.geometry.inner_padding), - ) + Some(self.panel_preview_resolution.side_margin( + 4096, + display.logical_width?, + panel.resolved_geometry().inner_padding, + )) } fn panel_appearance_row( @@ -331,13 +388,16 @@ impl App { fn panel_radius_controls(&self) -> Element<'static, Message> { use cosmic::iced::{Alignment, Length}; let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented); - let shell_radius = self.resolved.presented.tokens.geometry.shell_radius as f32; + let shell_radius = self + .preview_panel() + .map(|panel| panel.resolved_geometry().height * 0.5) + .unwrap_or(18.); let custom = self .draft .item("panels.0.corner_radius") - .and_then(|value| serde_json::from_value::(value.clone()).ok()); - let radii = custom.unwrap_or_else(|| ferese_config::panel::CornerRadii::uniform(shell_radius)); - let modes = widget::row([("Shell default", false), ("Custom", true)].map(|(label, selected)| { + .and_then(|value| serde_json::from_value::(value.clone()).ok()); + let radii = custom.unwrap_or_else(|| ferese_panel::CornerRadii::uniform(shell_radius)); + let modes = widget::row([("Automatic", false), ("Custom", true)].map(|(label, selected)| { widget::button::custom(self.label(label, 12.).align_x(Alignment::Center)) .name(format!("Corner radius: {label}")) .padding([8, 12]) @@ -359,10 +419,10 @@ impl App { let mut corners = Vec::new(); // Match the physical corners: top row first, then bottom-left / bottom-right. for (index, label, icon) in [ - (0, "Top left", "M5 19V9a4 4 0 0 1 4-4h10"), - (1, "Top right", "M5 5h10a4 4 0 0 1 4 4v10"), - (3, "Bottom left", "M5 5v10a4 4 0 0 0 4 4h10"), - (2, "Bottom right", "M5 19h10a4 4 0 0 0 4-4V5"), + (0, "Edge left", "M5 19V9a4 4 0 0 1 4-4h10"), + (1, "Edge right", "M5 5h10a4 4 0 0 1 4 4v10"), + (3, "Outer left", "M5 5v10a4 4 0 0 0 4 4h10"), + (2, "Outer right", "M5 19h10a4 4 0 0 0 4-4V5"), ] { let value = radii.0[index]; let tint = if custom.is_some() { palette.text } else { palette.muted }; @@ -406,10 +466,21 @@ impl App { .into(), ); } + if custom.is_none() { + return self.panel_appearance_row( + "Corner radius", + "Corners follow the style and panel height.", + modes.into(), + ); + } let bottom_corners = corners.split_off(2); - let mut content = widget::column([]) + let content = widget::column([]) .spacing(12) - .push(self.panel_appearance_row("Corner radius", "Set the rounding of each corner.", modes.into())) + .push(self.panel_appearance_row( + "Corner radius", + "Pins stay in place when you change styles. Top and bottom panels mirror them.", + modes.into(), + )) .push( widget::column([]) .spacing(8) @@ -426,14 +497,7 @@ impl App { .width(Length::Fill), ), ); - if self.preview_panel().is_ok_and(|panel| panel.geometry.edge_margin == 0) { - let bottom = self.preview_panel().is_ok_and(|panel| panel.edge == Edge::Bottom); - content = content.push(self.note(if bottom { - "Bottom corners stay square while the panel touches the bottom edge." - } else { - "Top corners stay square while the panel touches the top edge." - })); - } + content.into() } @@ -536,51 +600,45 @@ impl App { .into() } - fn panel_surface_choices( - &self, - selected: &str, - options: &'static [(&'static str, &'static str)], - message: impl Fn(&'static str) -> Message, - ) -> Element<'static, Message> { - use cosmic::iced::{Alignment, Length}; + fn panel_style_choices(&self, selected: ferese_panel::PanelStyle) -> Element<'static, Message> { + use cosmic::iced::Length; + use ferese_panel::PanelStyle; let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented); - let options = widget::row(options.iter().map(|&(key, label)| { - widget::button::custom( - widget::row([]) - .spacing(8) - .align_y(Alignment::Center) - .push(crate::visuals::action_icon( - match key { - "continuous" => "M3 7h18v10H3z", - "none" => "M4 4l16 16 M4 20L20 4", - "inset" => "M3 5h18v14H3z M7 9h10v6H7z", - _ => "M2 7h5v10H2z M10 7h4v10h-4z M17 7h5v10h-5z", - }, - palette.muted, - )) - .push(self.label(label, 12.)), + let description = match selected { + PanelStyle::Floating => "Separate floating groups", + PanelStyle::Capsules => "Connected groups with rounded ends", + PanelStyle::Notch => "Attached to the screen edge", + PanelStyle::Flat => "A continuous edge-to-edge panel", + PanelStyle::Fused => "Groups blend where they meet", + PanelStyle::Adaptive => "Follows the active window state", + PanelStyle::Morph => "A clock hub with expandable controls", + _ => "", + }; + self.panel_appearance_row( + "Style", + description, + crate::visuals::panel_select( + cosmic::iced::widget::pick_list( + PanelStyle::AVAILABLE.map(StyleChoice).to_vec(), + Some(StyleChoice(selected)), + |style| Message::PanelEdit(Action::ApplyPreset(style.0)), + ) + .font(self.font) + .text_size(12) + .padding([6, 10]) + .width(Length::Fill), + palette, ) - .name(format!("Background: {label}")) - .padding([8, 10]) - .width(Length::Fill) - .class(crate::visuals::panel_button(palette, selected == key)) - .on_press(message(key)) - .into() - })) - .spacing(2); - widget::container(options) - .padding(3) - .width(Length::Fill) - .class(crate::visuals::surface(palette.sidebar, 9.)) - .into() + .into(), + ) } fn panel_editor(&self, panel: &Panel) -> Element<'static, Message> { use cosmic::iced::{Alignment, Length}; let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented); let mut zones = widget::column([]).spacing(18); - if panel.group_count() == ferese_config::panel::MAX_GROUPS { - zones = zones.push(self.label(format!("{}-group limit reached", ferese_config::panel::MAX_GROUPS), 12.)); + if panel.group_count() == ferese_panel::MAX_GROUPS { + zones = zones.push(self.label(format!("{}-group limit reached", ferese_panel::MAX_GROUPS), 12.)); } for placement in Zone::ALL { let zone = placement.definition(panel); @@ -592,7 +650,7 @@ impl App { self.settings_button( "Add group", "M12 5v14 M5 12h14", - (panel.group_count() < ferese_config::panel::MAX_GROUPS) + (panel.group_count() < ferese_panel::MAX_GROUPS) .then_some(Message::PanelEdit(Action::AddGroup(placement))), false, ), @@ -787,8 +845,8 @@ mod tests { modes: Vec::new(), }]; app.panel_preview_resolution.items.insert( - ferese_config::panel::ItemId("clock".into()), - ferese_config::panel::layout::Placement::Overflow, + ferese_panel::ItemId("clock".into()), + ferese_panel::layout::Placement::Overflow, ); app.panel_preview_resolution.minimum_width = 600.; assert_eq!(app.panel_side_margin_limit(), Some(408)); @@ -806,8 +864,8 @@ mod tests { snapshot.edit(&initialize(&snapshot).unwrap()).unwrap(); let snapshot = store::Snapshot::parse(snapshot.source).unwrap(); let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); - assert_eq!(panels[0].preset(), Some(ferese_config::PanelPreset::Continuous)); - assert_eq!(panels[0].geometry, ferese_config::panel::PanelGeometry::default()); + assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Flat)); + assert_eq!(panels[0].resolved_geometry(), ferese_panel::PanelGeometry::default()); assert!(panels[0].center.groups[0].items[0].visible); assert_eq!(snapshot.number("animations.speed", 1.), 0.8); assert!(snapshot.source.contains("// keep")); diff --git a/shell/ferese-settings/src/panel_drag.rs b/shell/ferese-settings/src/panel_drag.rs index b8632fd..176f73e 100644 --- a/shell/ferese-settings/src/panel_drag.rs +++ b/shell/ferese-settings/src/panel_drag.rs @@ -1,8 +1,7 @@ -//! Local item dragging. Pointer motion changes only the drop hint; release saves once. use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; use cosmic::iced::{Border, Color, Event, Length, Point, Rectangle, Size, Vector, keyboard}; use cosmic::{Element, Renderer, Theme}; -use ferese_config::panel::{GroupId, ItemId, Panel}; +use ferese_panel::{GroupId, ItemId, Panel}; use crate::Message; use crate::panel_edit::{Action, Destination, Zone}; diff --git a/shell/ferese-settings/src/panel_edit.rs b/shell/ferese-settings/src/panel_edit.rs index b9acfb6..3b98e39 100644 --- a/shell/ferese-settings/src/panel_edit.rs +++ b/shell/ferese-settings/src/panel_edit.rs @@ -1,4 +1,4 @@ -use ferese_config::panel::{GroupId, Item, ItemId, ItemKind, Panel}; +use ferese_panel::{GroupId, Item, ItemId, ItemKind, Panel}; use serde_json::Value; use crate::store::{Edit, Snapshot, set}; @@ -26,14 +26,16 @@ impl std::fmt::Display for Destination { #[derive(Clone, Debug)] pub(super) enum Action { - ApplyPreset(ferese_config::PanelPreset), - SetDefaultSurface(ferese_config::panel::GroupSurface), - ClearGroupSurface(GroupId), - SetEdge(ferese_config::panel::Edge), + ApplyPreset(ferese_panel::PanelStyle), + ResetShape, + ClearGeometry(String), + SetEdge(ferese_panel::Edge), SetGeometry(String, Value), SetRadius(String), SetCorner(usize, f32), SetBorder(bool), + SetPeekOnHover(bool), + SetMorphAttached(bool), SetOpacity(f32), ClearOpacity, ClearRadius, @@ -71,7 +73,7 @@ impl Zone { } } - pub fn definition(self, panel: &Panel) -> &ferese_config::panel::Zone { + pub fn definition(self, panel: &Panel) -> &ferese_panel::Zone { match self { Self::Start => &panel.start, Self::Center => &panel.center, @@ -168,30 +170,29 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result, Str } let panels: Vec = serde_json::from_value(snapshot.item("panels").ok_or("Customize items first.")?.clone()) .map_err(|error| error.to_string())?; - ferese_config::panel::validate(&panels)?; + ferese_panel::validate(&panels)?; let panel = &panels[0]; let earlier = matches!(&action, Action::Earlier(_)); let earlier_group = matches!(&action, Action::EarlierGroup(_)); let edits = match action { - Action::ApplyPreset(preset) => { - let mut edited = panel.clone(); - edited.apply_preset(preset); - let mut edits = vec![ - set("panels.0.surface", serde_json::to_value(edited.surface).unwrap()), - set( - "panels.0.group_surface", - serde_json::to_value(edited.group_surface).unwrap(), - ), - ]; - for group in Zone::ALL.into_iter().flat_map(|zone| &zone.definition(panel).groups) { - if group.surface.is_some() { - edits.push(Edit::Unset(format!("{}.surface", group_record_path(panel, &group.id)?))); - } + Action::ApplyPreset(style) => vec![set("panels.0.style", style.key())], + Action::ResetShape => vec![ + Edit::Unset("panels.0.geometry".into()), + Edit::Unset("panels.0.corner_radius".into()), + ], + Action::ClearGeometry(path) => { + if !matches!( + path.as_str(), + "panels.0.geometry.height" + | "panels.0.geometry.edge_margin" + | "panels.0.geometry.side_margins" + | "panels.0.geometry.window_clearance" + | "panels.0.geometry.inner_padding" + ) { + return Err("Unknown panel geometry setting.".into()); } - edits + vec![Edit::Unset(path)] } - Action::SetDefaultSurface(surface) => vec![set("panels.0.group_surface", surface.key())], - Action::ClearGroupSurface(id) => vec![Edit::Unset(format!("{}.surface", group_record_path(panel, &id)?))], Action::SetEdge(edge) => vec![set("panels.0.edge", edge.key())], Action::SetGeometry(field, value) => { if !matches!( @@ -204,6 +205,8 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result, Str } Action::SetOpacity(value) => vec![set("panels.0.background_opacity", value)], Action::ClearOpacity => vec![Edit::Unset("panels.0.background_opacity".into())], + Action::SetMorphAttached(enabled) => vec![set("panels.0.morph.attached", enabled)], + Action::SetPeekOnHover(enabled) => vec![set("panels.0.morph.peek_on_hover", enabled)], Action::SetBorder(enabled) => vec![set("panels.0.border", enabled)], Action::SetCorner(index, value) => { let mut radii = panel.corner_radius.ok_or("Customize corners first.")?; @@ -212,7 +215,7 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result, Str vec![set("panels.0.corner_radius", radii.to_string())] } Action::SetRadius(value) => { - let radii: ferese_config::panel::CornerRadii = value.parse()?; + let radii: ferese_panel::CornerRadii = value.parse()?; vec![set("panels.0.corner_radius", radii.to_string())] } Action::ClearRadius => vec![Edit::Unset("panels.0.corner_radius".into())], @@ -407,7 +410,7 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result, Str } _ => (field, value), }; - if !matches!(field.as_str(), "spacing" | "padding" | "island_padding" | "surface") { + if !matches!(field.as_str(), "spacing" | "padding" | "island_padding") { return Err("Unknown panel group setting.".into()); } vec![set(&format!("{}.{field}", group_record_path(panel, &id)?), value)] @@ -434,9 +437,13 @@ mod tests { snapshot.edit(&edit).unwrap(); } let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); - assert_eq!(panels[0].geometry.height, 28.); - assert_eq!(panels[0].geometry.edge_margin, 0); - assert_eq!(panels[0].geometry.side_margins, 18); + assert_eq!(panels[0].resolved_geometry().height, 36.); + assert_eq!(panels[0].resolved_geometry().edge_margin, 0); + assert_eq!(panels[0].geometry.side_margins, Some(18)); + assert_eq!(panels[0].resolved_geometry().side_margins, 0); + let mut floating = panels[0].clone(); + floating.apply_preset(ferese_panel::PanelStyle::Floating); + assert_eq!(floating.resolved_geometry().side_margins, 18); assert!(snapshot.source.contains("// keep")); assert!(plan(&snapshot, Action::SetGeometry("height".into(), 12.into())).is_err()); } @@ -444,7 +451,7 @@ mod tests { #[test] fn group_capacity_is_checked_before_saving_and_removal_reopens_a_slot() { let mut snapshot = Snapshot::parse("panel main {}".into()).unwrap(); - for _ in 0..ferese_config::panel::MAX_GROUPS { + for _ in 0..ferese_panel::MAX_GROUPS { apply(&mut snapshot, Action::AddGroup(Zone::End)); } let source = snapshot.source.clone(); @@ -497,6 +504,42 @@ animations { speed 0.8; } } } + #[test] + fn morph_attachment_restores_floating_margin_and_preserves_clearance() { + let mut snapshot = + Snapshot::parse("panel main { style morph; geometry { edge-margin 9; window-clearance 3; }; }".into()) + .unwrap(); + let before = serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(); + apply(&mut snapshot, Action::SetMorphAttached(true)); + let attached = serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(); + assert_eq!(attached[0].resolved_geometry().edge_margin, 0); + assert_eq!(attached[0].resolved_geometry().window_clearance, 3); + assert_eq!(attached[0].exclusive_zone(false), before[0].exclusive_zone(false)); + assert!(attached[0].shape_parameters().attached); + apply(&mut snapshot, Action::SetMorphAttached(false)); + assert_eq!( + serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(), + before + ); + } + + #[test] + fn hover_preference_round_trips_without_changing_shape_or_items() { + let mut snapshot = Snapshot::parse("panel main { style morph; morph { idle-width 180; }; }".into()).unwrap(); + let before = serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(); + apply(&mut snapshot, Action::SetPeekOnHover(false)); + let after = serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(); + assert!(!after[0].morph.peek_on_hover); + let mut expected = before.clone(); + expected[0].morph.peek_on_hover = false; + assert_eq!(after, expected); + apply(&mut snapshot, Action::SetPeekOnHover(true)); + assert_eq!( + serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(), + before + ); + } + #[test] fn shared_group_style_and_opacity_edit_only_the_panel_and_can_restore_inheritance() { let mut snapshot = Snapshot::parse(String::new()).unwrap(); @@ -541,7 +584,7 @@ animations { speed 0.8; } apply(&mut snapshot, Action::SetRadius("4px 8px 12px 16px".into())); apply(&mut snapshot, Action::SetCorner(0, 9.)); apply(&mut snapshot, Action::SetCorner(3, 21.)); - let radii: ferese_config::panel::CornerRadii = + let radii: ferese_panel::CornerRadii = serde_json::from_value(snapshot.item("panels.0.corner_radius").unwrap().clone()).unwrap(); assert_eq!(radii.0, [9., 8., 12., 21.]); let previous = snapshot.source.clone(); @@ -585,11 +628,11 @@ animations { speed 0.8; } #[test] fn changing_edge_materializes_preferences_and_round_trips_without_moving_items() { let mut snapshot = Snapshot::parse("".into()).unwrap(); - apply(&mut snapshot, Action::SetEdge(ferese_config::panel::Edge::Bottom)); + apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Bottom)); let before = snapshot.item("panels.0.start").unwrap().clone(); assert_eq!(snapshot.string("panels.0.edge", ""), "bottom"); assert!(snapshot.boolean("panels.0.end.groups.0.items.6.percentage", false)); - apply(&mut snapshot, Action::SetEdge(ferese_config::panel::Edge::Top)); + apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Top)); assert_eq!(snapshot.string("panels.0.edge", ""), "top"); assert_eq!(snapshot.item("panels.0.start"), Some(&before)); Snapshot::parse(snapshot.source).unwrap(); @@ -809,7 +852,7 @@ animations { speed 0.8; } #[test] fn empty_zones_can_gain_unique_groups_and_nonempty_groups_cannot_be_removed() { - let mut snapshot = Snapshot::parse("panel \"main\" { surface none; group-surface island; }".into()).unwrap(); + let mut snapshot = Snapshot::parse("panel \"main\" { style floating; }".into()).unwrap(); for _ in 0..3 { apply(&mut snapshot, Action::AddGroup(Zone::End)); } @@ -934,7 +977,7 @@ animations { speed 0.8; } &mut snapshot, Action::Set(ItemId("battery".into()), "percentage".into(), false.into()), ); - for style in ferese_config::panel::WorkspaceStyle::ALL { + for style in ferese_panel::WorkspaceStyle::ALL { apply( &mut snapshot, Action::Set(ItemId("workspaces".into()), "style".into(), style.key().into()), @@ -961,7 +1004,7 @@ animations { speed 0.8; } assert_eq!( panel.item(&ItemId("workspaces".into())).unwrap().kind, ItemKind::Workspaces { - style: ferese_config::panel::WorkspaceStyle::AppIcons + style: ferese_panel::WorkspaceStyle::AppIcons } ); } @@ -982,38 +1025,43 @@ animations { speed 0.8; } assert!(panels[0].item(&ItemId("title".into())).unwrap().visible); } #[test] - fn group_surface_edits_and_presets_preserve_composition_and_comments() { - let mut snapshot = Snapshot::parse("// keep\npanel main { surface none; group-surface island; center { group title surface=none { item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }".into()).unwrap(); - let items = snapshot.item("panels.0.center.groups.0.items").unwrap().clone(); - let media = GroupId("media".into()); + fn resetting_one_geometry_pin_preserves_composition_and_other_pins() { + let mut snapshot = Snapshot::parse("// keep\npanel main { style floating; geometry { height 32; side-margins 18; }; corner-radius \"8px 9px 10px 11px\"; end { group status { item clock kind=clock; }; }; }".into()).unwrap(); + let groups = snapshot.item("panels.0.end.groups").unwrap().clone(); apply( &mut snapshot, - Action::SetGroup(media.clone(), "surface".into(), "inset".into()), + Action::ClearGeometry("panels.0.geometry.side_margins".into()), ); - assert_eq!(snapshot.string("panels.0.end.groups.0.surface", ""), "inset"); - apply(&mut snapshot, Action::ClearGroupSurface(media)); - assert!(snapshot.item("panels.0.end.groups.0.surface").is_none()); - apply( - &mut snapshot, - Action::ApplyPreset(ferese_config::PanelPreset::Continuous), - ); - assert_eq!(snapshot.string("panels.0.surface", ""), "solid"); - assert!(snapshot.item("panels.0.center.groups.0.surface").is_none()); - assert_eq!(snapshot.item("panels.0.center.groups.0.items"), Some(&items)); + assert_eq!(snapshot.item("panels.0.geometry.side_margins"), None); + assert_eq!(snapshot.item("panels.0.geometry.height"), Some(&serde_json::json!(32))); + assert!(snapshot.item("panels.0.corner_radius").is_some()); + apply(&mut snapshot, Action::ResetShape); + assert_eq!(snapshot.item("panels.0.geometry"), None); + assert_eq!(snapshot.item("panels.0.corner_radius"), None); + assert_eq!(snapshot.item("panels.0.end.groups"), Some(&groups)); assert!(snapshot.source.contains("// keep")); - apply( - &mut snapshot, - Action::SetGroup(GroupId("title".into()), "surface".into(), "none".into()), - ); - apply(&mut snapshot, Action::ApplyPreset(ferese_config::PanelPreset::Islands)); + } + + #[test] + fn panel_presets_preserve_group_layout_items_and_comments() { + let mut snapshot = Snapshot::parse("// keep\npanel main { style floating; center { group title { padding 2 7; item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }".into()).unwrap(); + let groups = snapshot.item("panels.0.center.groups").unwrap().clone(); + apply(&mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Flat)); + assert_eq!(snapshot.string("panels.0.style", ""), "flat"); + assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups)); + assert!(snapshot.source.contains("// keep")); + apply(&mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Floating)); + assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups)); let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); - assert_eq!(panels[0].preset(), Some(ferese_config::PanelPreset::Islands)); - assert!( - plan( - &snapshot, - Action::SetGroup(GroupId("title".into()), "surface".into(), "unsupported".into()) - ) - .is_err() - ); + assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Floating)); + for surface in ["none", "inset", "island"] { + assert!( + plan( + &snapshot, + Action::SetGroup(GroupId("title".into()), "surface".into(), surface.into()) + ) + .is_err() + ); + } } } diff --git a/shell/ferese-settings/src/panel_inspector.rs b/shell/ferese-settings/src/panel_inspector.rs index e4c4bac..4a3f914 100644 --- a/shell/ferese-settings/src/panel_inspector.rs +++ b/shell/ferese-settings/src/panel_inspector.rs @@ -1,7 +1,7 @@ use crate::panel_edit::{Action, Destination, Zone}; use crate::{App, Element, Field, Kind, Message, widget}; use cosmic::iced::{Alignment, Length}; -use ferese_config::panel::{Group, Item, ItemKind, Panel, Representation}; +use ferese_panel::{Group, Item, ItemKind, Panel, Representation}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Choice(&'static str, &'static str); @@ -146,7 +146,7 @@ impl App { .spacing(6) .push(self.label("Workspace style", 13.)) .push(self.note("A sample of active, occupied and empty workspaces.")); - for style in ferese_config::panel::WorkspaceStyle::ALL { + for style in ferese_panel::WorkspaceStyle::ALL { let sample = ferese_theme::workspaces::sample(style, palette, self.font, 22.); choices = choices.push( widget::button::custom( @@ -192,30 +192,6 @@ impl App { palette, )), ); - let surface_choices = [ - Choice("inherit", "Default"), - Choice("none", "None"), - Choice("inset", "Inset"), - Choice("island", "Island"), - ]; - let selected_surface = group.surface.map_or(surface_choices[0], |surface| { - *surface_choices.iter().find(|choice| choice.0 == surface.key()).unwrap() - }); - let surface_id = group.id.clone(); - rows = rows.push(self.label("Surface", 13.)).push(crate::visuals::panel_select( - cosmic::iced::widget::pick_list(surface_choices.to_vec(), Some(selected_surface), move |choice| { - Message::PanelEdit(if choice.0 == "inherit" { - Action::ClearGroupSurface(surface_id.clone()) - } else { - Action::SetGroup(surface_id.clone(), "surface".into(), choice.0.into()) - }) - }) - .font(self.font) - .text_size(13) - .padding([8, 10]) - .width(Length::Fill), - palette, - )); rows = rows.push(self.panel_divider()); let mut spacing = widget::column([]).spacing(12); for (name, label, value, maximum) in [ @@ -229,7 +205,7 @@ impl App { ), ("island_padding", "Island padding", group.island_padding, 32.), ] { - if panel.resolved_surface(group) != ferese_config::panel::GroupSurface::Island && name == "island_padding" { + if panel.group_surface() != ferese_panel::GroupSurface::Island && name == "island_padding" { continue; } let change = |value: f32| { diff --git a/shell/ferese-settings/src/panel_preview.rs b/shell/ferese-settings/src/panel_preview.rs index 6aa6683..5577e2f 100644 --- a/shell/ferese-settings/src/panel_preview.rs +++ b/shell/ferese-settings/src/panel_preview.rs @@ -1,7 +1,7 @@ use cosmic::iced::{Alignment, Length}; use cosmic::widget::{button, column, container, row}; -use ferese_config::panel::layout::{Placement, Resolution}; -use ferese_config::panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone}; +use ferese_panel::layout::{Placement, Resolution}; +use ferese_panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone}; use ferese_theme::panel::{Sample, Spacing, frame}; use crate::{App, Element, Message, panel_controls, visuals}; @@ -36,12 +36,12 @@ impl App { .push( container(self.panel_preview_frame(panel.clone())) .padding(cosmic::iced::Padding { - top: if panel.edge == ferese_config::panel::Edge::Top { + top: if panel.edge == ferese_panel::Edge::Top { 10. } else { 26. }, - bottom: if panel.edge == ferese_config::panel::Edge::Top { + bottom: if panel.edge == ferese_panel::Edge::Top { 26. } else { 10. @@ -98,6 +98,47 @@ impl App { } fn panel_preview_frame(&self, panel: Panel) -> Element<'_, Message> { + if panel.style == ferese_panel::PanelStyle::Morph { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + let height = panel.resolved_geometry().height; + let hub = container(self.label("● 10:09", 13.)) + .id("morph-preview-body") + .center_x(panel.morph.idle_width as f32) + .center_y(height); + let hub = ferese_theme::panel_surface::frame( + container(hub) + .padding([0, if panel.morph.attached { 12 } else { 0 }]) + .into(), + ferese_theme::panel_surface::Options { + parameters: panel.shape_parameters(), + bounds: Some(cosmic::iced::advanced::widget::Id::new("morph-preview-body")), + groups: Vec::new(), + scale: self.render_scale, + high_contrast: palette.high_contrast, + background: cosmic::iced::Color { + a: if self.resolved.presented.accessibility.reduce_transparency { + 1. + } else { + panel.background_opacity.unwrap_or(palette.card.a) + }, + ..palette.card + }, + border: if panel.border { + visuals::color( + &self.resolved.presented.tokens.colors.border, + cosmic::iced::Color::TRANSPARENT, + ) + } else { + cosmic::iced::Color::TRANSPARENT + }, + divider: cosmic::iced::Color::TRANSPARENT, + native: false, + }, + |_| {}, + None, + ); + return container(hub).center_x(Length::Fill).height(height).into(); + } let mut samples = Vec::new(); for zone in [&panel.center, &panel.start, &panel.end] { for item in zone @@ -128,24 +169,42 @@ impl App { view: self.preview_control(&overflow.items[0], Representation::Icon, None), }); let overflow_width = 2. * f32::from(overflow.padding[1]) - + if panel.resolved_surface(&overflow) == ferese_config::panel::GroupSurface::Island { + + if panel.group_surface() == ferese_panel::GroupSurface::Island { 2. * overflow.island_padding } else { 0. }; - let continuous = panel.surface == ferese_config::panel::PanelSurface::Solid; - let corners = panel - .resolved_radius(self.resolved.presented.tokens.geometry.shell_radius as f32) - .at_edge(panel.edge, panel.geometry.edge_margin == 0) - .0; + let parameters = panel.shape_parameters(); + let groups = [ + (ferese_panel::shape::ZonePosition::Start, &panel.start), + (ferese_panel::shape::ZonePosition::Center, &panel.center), + (ferese_panel::shape::ZonePosition::End, &panel.end), + ] + .into_iter() + .flat_map(|(position, zone)| { + zone.groups.iter().map(move |group| { + ( + cosmic::iced::advanced::widget::Id::new(format!("preview-group:{}", group.id.0)), + position, + ) + }) + }) + .chain(std::iter::once(( + cosmic::iced::advanced::widget::Id::new("preview-group:_overflow"), + ferese_panel::shape::ZonePosition::End, + ))) + .collect(); let opacity = self.resolved.presented.panel_opacity(panel.background_opacity); + let height = panel.resolved_geometry().height; + let padding = panel.resolved_geometry().inner_padding; + let border = panel.border; let measured_panel = panel.clone(); let content = frame( std::borrow::Cow::Owned(measured_panel), samples, Spacing { overflow_decoration: overflow_width, - zone_gap: 32., + zone_gap: 8., }, self.panel_preview_resolution.clone(), move |resolution| { @@ -172,7 +231,7 @@ impl App { let has_end = panel.end.groups.iter().flat_map(|group| &group.items) .any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.)); end = row([]) - .spacing(if has_end { panel.end.spacing } else { 0. }) + .spacing(if has_end { panel.zone_spacing(&panel.end) } else { 0. }) .align_y(Alignment::Center) .push(trigger) .push(end) @@ -193,29 +252,44 @@ impl App { Message::PanelPreviewResolved, ); let palette = visuals::Palette::from_resolved(&self.resolved.presented); - container(content) - .width(Length::Fill) - .height(48) - .clip(true) - .class(cosmic::theme::Container::custom(move |_| { - let mut style = ferese_theme::controls::surface_appearance( - cosmic::iced::Color { - a: opacity, - ..palette.card - }, - 0., - ); - style.border.radius = corners.into(); - if !continuous { - style.background = None; - } - style - })) - .into() + ferese_theme::panel_surface::frame( + container(content) + .width(Length::Fill) + .height(height) + .padding([0., padding]) + .clip(true) + .into(), + ferese_theme::panel_surface::Options { + parameters, + bounds: None, + groups, + scale: self.render_scale, + high_contrast: palette.high_contrast, + background: cosmic::iced::Color { + a: opacity, + ..palette.card + }, + border: if border { + visuals::color( + &self.resolved.presented.tokens.colors.border, + cosmic::iced::Color::TRANSPARENT, + ) + } else { + cosmic::iced::Color::TRANSPARENT + }, + divider: visuals::color( + &self.resolved.presented.tokens.colors.border, + cosmic::iced::Color::TRANSPARENT, + ), + native: false, + }, + |_| {}, + None, + ) } fn preview_zone(&self, panel: &Panel, zone: &Zone, resolution: &Resolution) -> Element<'static, Message> { - let mut groups = row([]).spacing(zone.spacing).align_y(Alignment::Center); + let mut groups = row([]).spacing(panel.zone_spacing(zone)).align_y(Alignment::Center); for group in &zone.groups { let mut controls = row([]).align_y(Alignment::Center); let mut count = 0; @@ -246,44 +320,22 @@ impl App { group: &Group, controls: Element<'static, Message>, ) -> Element<'static, Message> { - let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let surface = panel.resolved_surface(group); - let radius = panel - .resolved_radius(self.resolved.presented.tokens.geometry.shell_radius as f32) - .at_edge( - panel.edge, - surface == ferese_config::panel::GroupSurface::Island && panel.geometry.edge_margin == 0, - ) - .0; - let opacity = self.resolved.presented.panel_opacity(panel.background_opacity); - let border = panel.border; - let content: Element<'static, Message> = container(controls) - .padding(group.padding) - .class(cosmic::theme::Container::custom(move |_| { - ferese_theme::panel::group_border(palette.muted.scale_alpha(0.4), radius, border) - })) - .into(); - if surface == ferese_config::panel::GroupSurface::None { - return content; - } - let parent_solid = panel.surface == ferese_config::panel::PanelSurface::Solid; - container(content) - .padding(if surface == ferese_config::panel::GroupSurface::Island { - [2., group.island_padding] + let horizontal = f32::from(group.padding[1]) + + if panel.group_surface() == ferese_panel::GroupSurface::Island { + group.island_padding } else { - [0., 0.] + 0. + }; + container(controls) + .id(format!("preview-group:{}", group.id.0)) + .padding(cosmic::iced::Padding { + top: group.padding[0].into(), + bottom: group.padding[0].into(), + left: horizontal, + right: horizontal, }) - .class(cosmic::theme::Container::custom(move |_| { - let mut style = ferese_theme::controls::surface_appearance( - cosmic::iced::Color { - a: if parent_solid { opacity * 0.08 } else { opacity }, - ..if parent_solid { palette.text } else { palette.card } - }, - 0., - ); - style.border.radius = radius.into(); - style - })) + .height(panel.resolved_geometry().height) + .align_y(Alignment::Center) .into() } @@ -294,13 +346,14 @@ impl App { width: Option, ) -> Element<'static, Message> { use ferese_theme::icons; + let control_height = (self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.); let palette = visuals::Palette::from_resolved(&self.resolved.presented); if let ItemKind::Workspaces { style } = item.kind { let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.); let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0))) .name("Edit Workspaces") - .padding([4, 2]) - .height(28) + .padding([0, 2]) + .height(control_height) .on_press(Message::PanelSelect(item.id.clone())) .class(preview_button(palette, self.panel_selection.as_ref() == Some(&item.id))); return container(control) @@ -353,8 +406,8 @@ impl App { }; let control: Element<'static, Message> = button::custom(content) .name(format!("Edit {}", item.id.0)) - .padding([4, 7]) - .height(28) + .padding([0, 7]) + .height(control_height) .on_press(action) .class(preview_button( palette, @@ -587,11 +640,8 @@ mod tests { ), ) .0; - for background in [ - ferese_config::PanelPreset::Continuous, - ferese_config::PanelPreset::Islands, - ] { - let mut panel = Panel::from_preset(ferese_config::PanelPreset::Islands); + for background in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating] { + let mut panel = Panel::from_preset(ferese_panel::PanelStyle::Floating); panel.apply_preset(background); // Match a three-group layout with media in navigation and a larger status group. let media = panel.end.groups[0].items.remove(0); @@ -648,11 +698,7 @@ mod tests { } else { side.x - center.x - center.width }; - assert!( - gap >= 31.9, - "{background:?} at {width}: {} crowds title ({gap}px)", - id.0 - ); + assert!(gap >= 7.9, "{background:?} at {width}: {} crowds title ({gap}px)", id.0); } } } @@ -680,10 +726,7 @@ mod tests { ), ) .0; - for background in [ - ferese_config::PanelPreset::Continuous, - ferese_config::PanelPreset::Islands, - ] { + for background in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating] { let panel = Panel::from_preset(background); let ids: Vec<_> = [&panel.start, &panel.center, &panel.end] .into_iter() diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 5c026d3..53a7648 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -526,7 +526,7 @@ pub fn fields(page: Page) -> Vec { "Height", "Keeps icon and text sizes unchanged.", RangeSpec { - default: 28.0, + default: 36.0, min: 24.0, max: 48.0, step: 1.0, diff --git a/shell/ferese-settings/src/scroll_tests.rs b/shell/ferese-settings/src/scroll_tests.rs index 0be3cb0..0d1537e 100644 --- a/shell/ferese-settings/src/scroll_tests.rs +++ b/shell/ferese-settings/src/scroll_tests.rs @@ -348,7 +348,6 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() { | "Shown on all displays" | "Position" | "Background" - | "Default group surface" | "Background opacity" | "Group borders" | "Corner radius" @@ -363,6 +362,8 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() { | "Add item" | "Overview" | "Workspaces" + | "Morph" + | "Collapsed height" | "Battery" | "Placement" | "Show item" @@ -409,8 +410,8 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() { )) .expect("GPU panel rendering requires a hardware or software WGPU adapter"); for width in [740., 1100.] { - for scale in [1., 1.75] { - for selection in ["panels", "items", "battery", "status"] { + for scale in [1., 1.25, 1.5, 1.75] { + for selection in ["panels", "morph", "items", "battery", "status"] { let mut app = App::init( Core::default(), ( @@ -421,14 +422,24 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() { ) .0; app.font = font; - app.panel_page = if selection == "panels" { + app.render_scale = f64::from(scale); + if selection == "morph" { + for edit in crate::panel_edit::plan( + &app.draft, + crate::panel_edit::Action::ApplyPreset(ferese_panel::PanelStyle::Morph), + ) + .unwrap() + { + app.draft.edit(&edit).unwrap(); + } + } + app.panel_page = if matches!(selection, "panels" | "morph") { panel_controls::PanelPage::Panels } else { panel_controls::PanelPage::Arrange }; - app.panel_selection = (selection == "battery").then(|| ferese_config::panel::ItemId("battery".into())); - app.panel_group_selection = - (selection == "status").then(|| ferese_config::panel::GroupId("status".into())); + app.panel_selection = (selection == "battery").then(|| ferese_panel::ItemId("battery".into())); + app.panel_group_selection = (selection == "status").then(|| ferese_panel::GroupId("status".into())); let bounds = Rectangle::with_size(Size::new(width, 650.)); let physical = Size::new((width * scale) as u32, (650. * scale) as u32); let theme = app.native_palette.native_theme(); @@ -565,14 +576,15 @@ fn render_panel_review_states() { app.native_palette = visuals::Palette::from_resolved(&app.resolved.presented); for (name, tab, item, group) in [ ("panels", panel_controls::PanelPage::Panels, None, None), - ("mixed-surfaces", panel_controls::PanelPage::Panels, None, None), + ("floating-groups", panel_controls::PanelPage::Panels, None, None), ( - "surface-inspector", + "group-spacing-inspector", panel_controls::PanelPage::Arrange, None, Some("title"), ), ("corners", panel_controls::PanelPage::Panels, None, None), + ("morph", panel_controls::PanelPage::Panels, None, None), ("preview-hover", panel_controls::PanelPage::Panels, None, None), ("navigation", panel_controls::PanelPage::Arrange, None, None), ("shortcuts", panel_controls::PanelPage::Panels, None, None), @@ -603,15 +615,23 @@ fn render_panel_review_states() { _ => Page::Bar, }; app.draft = Snapshot::parse(String::new()).unwrap(); - if matches!(name, "mixed-surfaces" | "surface-inspector") { - let mut panel = ferese_config::panel::Panel::from_preset(ferese_config::PanelPreset::Islands); - panel.center.groups[0].surface = Some(ferese_config::panel::GroupSurface::None); - panel.end.groups[0].surface = Some(ferese_config::panel::GroupSurface::Inset); - panel.geometry.edge_margin = 6; + if matches!(name, "floating-groups" | "group-spacing-inspector") { + let mut panel = ferese_panel::Panel::from_preset(ferese_panel::PanelStyle::Floating); + panel.geometry.edge_margin = Some(6); app.draft .edit(&crate::store::set("panels", serde_json::to_value(vec![panel]).unwrap())) .unwrap(); } + if name == "morph" { + for edit in crate::panel_edit::plan( + &app.draft, + crate::panel_edit::Action::ApplyPreset(ferese_panel::PanelStyle::Morph), + ) + .unwrap() + { + app.draft.edit(&edit).unwrap(); + } + } if name == "corners" { for edit in crate::panel_edit::plan( &app.draft, @@ -625,8 +645,8 @@ fn render_panel_review_states() { } app.panel_page = tab; app.sidebar_open = name == "navigation"; - app.panel_selection = item.map(|id| ferese_config::panel::ItemId(id.into())); - app.panel_group_selection = group.map(|id| ferese_config::panel::GroupId(id.into())); + app.panel_selection = item.map(|id| ferese_panel::ItemId(id.into())); + app.panel_group_selection = group.map(|id| ferese_panel::GroupId(id.into())); for (width, height) in [(1100, 860), (740, 650)] { let bounds = Rectangle::with_size(Size::new(width as f32, height as f32)); let theme = app.native_palette.native_theme(); @@ -763,10 +783,8 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() { let mut initial = None; for selection in [ None, - Some(Message::PanelSelect(ferese_config::panel::ItemId("battery".into()))), - Some(Message::PanelGroupSelect(ferese_config::panel::GroupId( - "status".into(), - ))), + Some(Message::PanelSelect(ferese_panel::ItemId("battery".into()))), + Some(Message::PanelGroupSelect(ferese_panel::GroupId("status".into()))), Some(Message::PanelClearSelection), ] { if let Some(selection) = selection { diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs index c9372ab..0c765a6 100644 --- a/shell/ferese-settings/src/store.rs +++ b/shell/ferese-settings/src/store.rs @@ -22,9 +22,9 @@ impl Snapshot { } let doc = Document::parse(&source).map_err(|e| e.to_string())?; if let Some(value) = doc.get("panels") { - let panels: Vec = + let panels: Vec = serde_json::from_value(value.clone()).map_err(|error| error.to_string())?; - ferese_config::panel::validate(&panels)?; + ferese_panel::validate(&panels)?; } Ok(Self { source, doc }) } diff --git a/shell/ferese-settings/src/watch.rs b/shell/ferese-settings/src/watch.rs index 9d97a23..390ea51 100644 --- a/shell/ferese-settings/src/watch.rs +++ b/shell/ferese-settings/src/watch.rs @@ -1,4 +1,3 @@ -//! Watch the parent directory so atomic editor saves keep working. use std::path::Path; use std::time::Duration; diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index aafacea..dcd1713 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-layout = { path = "../../crates/ferese-layout" } +ferese-panel = { path = "../../crates/ferese-panel" } ferese-animation = { path = "../../crates/ferese-animation" } ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-ipc = { path = "../../crates/ferese-ipc" } diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index f5393a0..43aacb3 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -1,11 +1,13 @@ +#[cfg(test)] +use super::Background; use super::{ - Background, BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment, - bar_icon, button, color, container, control, motion, row, status_ui, text, theme, window, + BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment, bar_icon, button, + color, container, control, motion, row, status_ui, theme, window, }; use crate::panel::{Availability, Item, ItemKind, Panel, Zone}; #[cfg(test)] -use ferese_config::PanelPreset; -use ferese_config::panel::{GroupSurface, PanelSurface}; +use ferese_panel::PanelStyle; +use ferese_panel::{GroupSurface, PanelSurface}; use ferese_theme::panel as adaptive; pub(super) mod presentation; @@ -37,16 +39,17 @@ impl FereseShell { 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(); + return container(self.render.text("")) + .width(Length::Fill) + .height(Length::Fill) + .into(); + } + let panel = self.panel_for_bar(id); + if panel.style == ferese_panel::PanelStyle::Morph { + return self.view_hub(id); } - let panel = &self.config.panels[0]; let mut shell_theme = self.config.theme.for_bar(); shell_theme.bar_background[3] = (shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8; - let corners = panel - .resolved_radius(shell_theme.bar_radius) - .at_edge(panel.edge, panel.geometry.edge_margin == 0); - let mode = panel.surface; - let islands = mode == PanelSurface::None; let output = self.outputs.iter().find(|output| output.bar == id); let effects = output.and_then(|output| output.effects.as_ref()); let compositor_material = effects.is_some(); @@ -74,7 +77,7 @@ impl FereseShell { id, item, None, - panel.resolved_surface(group) == GroupSurface::Island, + panel.group_surface() == GroupSurface::Island, representation, ) .0; @@ -103,25 +106,34 @@ impl FereseShell { id, &overflow_group.items[0], None, - panel.resolved_surface(&overflow_group) == GroupSurface::Island, + panel.group_surface() == GroupSurface::Island, Representation::Icon, ) .0, }); // Measure the control itself; reserve group decoration separately. let overflow_width = 2.0 * f32::from(overflow_group.padding[1]) - + if panel.resolved_surface(&overflow_group) == GroupSurface::Island { + + if panel.group_surface() == GroupSurface::Island { 2.0 * overflow_group.island_padding } else { 0.0 }; - let materials = [&panel.start, &panel.center, &panel.end] - .into_iter() - .flat_map(|zone| &zone.groups) - .chain(std::iter::once(&overflow_group)) - .filter(|group| islands && panel.resolved_surface(group) != GroupSurface::None) - .map(|group| (group_material_id(panel, group), 1.)) - .collect(); + let groups = [ + (ferese_panel::shape::ZonePosition::Start, &panel.start), + (ferese_panel::shape::ZonePosition::Center, &panel.center), + (ferese_panel::shape::ZonePosition::End, &panel.end), + ] + .into_iter() + .flat_map(|(position, zone)| { + zone.groups + .iter() + .map(move |group| (group_material_id(panel, group), position)) + }) + .chain(std::iter::once(( + group_material_id(panel, &overflow_group), + ferese_panel::shape::ZonePosition::End, + ))) + .collect(); let content = adaptive::frame( std::borrow::Cow::Borrowed(panel), samples, @@ -183,7 +195,7 @@ impl FereseShell { .any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0)); if end_has_items { row![trigger, end] - .spacing(panel.end.spacing) + .spacing(panel.zone_spacing(&panel.end)) .align_y(cosmic::iced::Alignment::Center) .into() } else { @@ -204,18 +216,46 @@ impl FereseShell { let content = container(content) .width(Length::Fill) .height(Length::Fill) - .padding([0, panel.geometry.inner_padding.round() as u16]) - .class(theme::Container::custom(move |_| { - let mut style = bar_style(shell_theme, compositor_material, islands); - style.border.radius = corners.0.into(); - style - })) + .padding([0, panel.resolved_geometry().inner_padding.round() as u16]) .into(); - presentation::frame( + let content = ferese_theme::panel_surface::frame( + content, + ferese_theme::panel_surface::Options { + parameters: panel.shape_parameters(), + bounds: None, + groups, + scale: output.map_or(1., |entry| entry.scale), + high_contrast: shell_theme.high_contrast, + background: color(shell_theme.bar_background), + border: if panel.border { + color(shell_theme.border) + } else { + Color::TRANSPARENT + }, + divider: color(shell_theme.border), + native: compositor_material, + }, + move |scene| { + if let Some(effects) = effects + && let Err(error) = effects.set_panel_presentation(scene) + { + eprintln!("ferese-shell: could not update panel material: {error}"); + } + }, + Some(Box::new(move |scene| { + cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone()))) + })), + ); + self.with_workspace_hover(id, content) + } + + pub(super) fn with_workspace_hover<'a>( + &'a self, + id: window::Id, + content: Element<'a, cosmic::Action>, + ) -> Element<'a, cosmic::Action> { + presentation::hover( content, - mode, - corners.max(), - materials, self.workspace_ui .hover_target(id) .map(|(workspace, target)| presentation::HoverTarget { @@ -224,19 +264,6 @@ impl FereseShell { cosmic::Action::App(Message::HoverWorkspace(id, workspace, target.clone(), false)) }), }), - move |regions, opacities| { - if let Some(effects) = effects - && let Err(error) = effects.set_presentation( - regions, - 1., - opacities.iter().copied(), - super::ferese_surface_effects_v1::Role::Panel, - ) - { - eprintln!("ferese-shell: could not update bar material: {error}"); - } - }, - move |regions| cosmic::Action::App(Message::BarRegionsChanged(id, regions)), ) } @@ -249,7 +276,7 @@ impl FereseShell { representation: Representation, ) -> (Element<'_, cosmic::Action>, bool) { let shell_theme = self.config.theme.for_bar(); - let bar = BarMetrics::from(self.config.panels[0].geometry); + let bar = BarMetrics::from(self.panel_for_bar(id).resolved_geometry()); let control_height = if islands { bar.group_item_height } else { @@ -264,6 +291,7 @@ impl FereseShell { }); let element = match item.kind { ItemKind::Overview => motion::button( + self.render, button::custom(overview_control( BarMetrics { control_height, ..bar }, color(shell_theme.accent), @@ -276,7 +304,7 @@ impl FereseShell { 1.0, ), ItemKind::Workspaces { style } => { - let workspaces = self.view_workspace_item(id, style, &item.id.0); + let workspaces = self.view_workspace_item(id, style, &item.id.0, 1.); if let Some(width) = width { cosmic::iced::widget::scrollable(workspaces) .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( @@ -296,7 +324,9 @@ impl FereseShell { } else { "" }; - let element: Element<'_, cosmic::Action> = text(title) + let element: Element<'_, cosmic::Action> = self + .render + .text(title) .size(bar.text_size) .width(width.map_or(Length::Shrink, Length::Fixed)) .height(control_height) @@ -332,19 +362,22 @@ impl FereseShell { .align_y(cosmic::iced::Alignment::Center); if representation == Representation::Wide && !date.is_empty() { content = content.push( - text(date) + self.render + .text(date) .size(12) .wrapping(cosmic::iced::widget::text::Wrapping::None) .class(theme::Text::Color(foreground)), ); } let content = content.push( - text(time) + self.render + .text(time) .size(bar.text_size) .wrapping(cosmic::iced::widget::text::Wrapping::None) .class(theme::Text::Color(foreground)), ); motion::button( + self.render, button::custom(container(content).center_y(bar.group_item_height)) .padding([0, 4]) .height(bar.group_item_height) @@ -366,6 +399,7 @@ impl FereseShell { ) } ItemKind::DisplayMode => motion::button( + self.render, button::custom(bar_content( bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground), control_height, @@ -414,7 +448,7 @@ impl FereseShell { let Some(output) = self.outputs.iter().find(|output| output.bar == menu.anchor.parent) else { return rows; }; - let panel = &self.config.panels[0]; + let panel = self.panel_for_bar(output.bar); let Some(resolution) = &output.panel_resolution else { return rows; }; @@ -445,20 +479,25 @@ impl FereseShell { ) => cosmic::Action::App(Message::PanelAction(Box::new(message))), other => other, }); - rows = rows.push(cosmic::widget::column![text(item.kind.label()).size(12), element].spacing(4)); + rows = rows.push(cosmic::widget::column![self.render.text(item.kind.label()).size(12), element].spacing(4)); } rows } - fn view_workspace_item( + pub(super) fn view_workspace_item( &self, id: window::Id, - style: ferese_config::panel::WorkspaceStyle, + style: ferese_panel::WorkspaceStyle, instance: &str, + opacity: f32, ) -> Element<'_, cosmic::Action> { let focused_output = self.output_for_bar(id); let shell_theme = self.config.theme.for_bar(); - let bar = BarMetrics::from(self.config.panels[0].geometry); + let mut palette = shell_theme.palette(); + palette.text.a *= opacity; + palette.muted.a *= opacity; + palette.accent.a *= opacity; + let bar = BarMetrics::from(self.panel_for_bar(id).resolved_geometry()); let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len()) .spacing(1) .align_y(cosmic::iced::Alignment::Center); @@ -471,7 +510,7 @@ impl FereseShell { .output .and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id)); let transition = self.workspace_ui.transition(workspace.id, active); - let apps = if style == ferese_config::panel::WorkspaceStyle::AppIcons { + let apps = if style == ferese_panel::WorkspaceStyle::AppIcons { self.workspace_ui.apps(workspace.id, &self.snapshot) } else { Vec::new() @@ -490,12 +529,17 @@ impl FereseShell { ferese_theme::workspaces::indicator( style, details.clone(), - shell_theme.palette(), - super::shell_font(), + palette, + self.render.font, bar.group_item_height, transition.progress(now), apps.iter() - .map(|handle| cosmic::widget::icon::icon(handle.clone()).size(16).into()) + .map(|handle| { + cosmic::widget::icon::icon(handle.clone()) + .size(16) + .opacity(opacity) + .into() + }) .collect(), ) }, @@ -514,6 +558,7 @@ impl FereseShell { .on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id))); let selector = motion::button( + self.render, workspace_button, color(if active { shell_theme.accent @@ -521,7 +566,7 @@ impl FereseShell { shell_theme.text_primary }), false, - 1.0, + opacity, ); let target = cosmic::iced::advanced::widget::Id::new(format!("workspace:{id:?}:{instance}:{}", workspace.id)); @@ -562,20 +607,19 @@ fn view_zone<'a>( panel: &Panel, zone: &Zone, availability: Availability, - shell_theme: ShellTheme, - compositor_material: bool, + _shell_theme: ShellTheme, + _compositor_material: bool, visible: impl Fn(&Item) -> bool, render: impl Fn(&Item, GroupSurface) -> (Element<'a, cosmic::Action>, bool), ) -> Element<'a, cosmic::Action> { - let bar = BarMetrics::from(panel.geometry); + let bar = BarMetrics::from(panel.resolved_geometry()); let mut groups = row::with_capacity(zone.groups.len()) - .spacing(zone.spacing) + .spacing(panel.zone_spacing(zone)) .align_y(cosmic::iced::Alignment::Center); for group in &zone.groups { - let surface = panel.resolved_surface(group); + let surface = panel.group_surface(); let mut controls = row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center); let mut count = 0; - let mut has_content = false; for item in group .items .iter() @@ -585,8 +629,7 @@ fn view_zone<'a>( controls = controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing))); } - let (element, content) = render(item, surface); - has_content |= content; + let (element, _) = render(item, surface); let element: Element<'_, cosmic::Action> = if matches!(item.kind, ItemKind::FocusedWindow) { element } else { @@ -598,57 +641,26 @@ fn view_zone<'a>( if count == 0 { continue; } - let opacity = shell_theme.panel_opacity(panel.background_opacity); - let corners = panel - .resolved_radius(shell_theme.bar_radius) - .at_edge( - panel.edge, - surface == GroupSurface::Island && panel.geometry.edge_margin == 0, - ) - .0; - let border = panel.border; - let content: Element<'_, cosmic::Action> = container(controls) - .padding(group.padding) - .height(bar.group_height) + let horizontal = f32::from(group.padding[1]) + + if panel.group_surface() == GroupSurface::Island { + group.island_padding + } else { + 0. + }; + let content = container(controls) + .padding(cosmic::iced::Padding { + top: f32::from(group.padding[0]), + bottom: f32::from(group.padding[0]), + left: horizontal, + right: horizontal, + }) + .height(bar.height) .align_y(alignment::Vertical::Center) - .class(theme::Container::custom(move |_| { - ferese_theme::panel::group_border(color(shell_theme.border), corners, border && has_content) - })) + .id(format!("panel:{}:group:{}", panel.id.0, group.id.0)); + let content: Element<'_, cosmic::Action> = container(content) + .id(group_material_id(panel, group)) + .height(bar.height) .into(); - let content = container(content) - .id(format!("panel:{}:group:{}", panel.id.0, group.id.0)) - .into(); - let panel_surface = panel.surface; - let material = compositor_material && panel.surface == PanelSurface::None; - let content = match surface { - GroupSurface::None => content, - GroupSurface::Inset => container(content) - .id(group_material_id(panel, group)) - .class(theme::Container::custom(move |_| { - group_surface_style(shell_theme, panel_surface, material, opacity, corners) - })) - .into(), - GroupSurface::Island => { - let content = island( - content, - shell_theme, - panel.geometry, - group.island_padding, - has_content, - material || panel.surface == PanelSurface::Solid, - Some((group_material_id(panel, group), opacity, corners)), - ); - if panel.surface == PanelSurface::Solid { - container(content) - .class(theme::Container::custom(move |_| { - group_surface_style(shell_theme, PanelSurface::Solid, false, opacity, corners) - })) - .into() - } else { - content - } - } - }; groups = groups.push(content); } groups.into() @@ -725,6 +737,7 @@ pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::O output.is_some_and(|output| output.active_workspace == workspace) } +#[cfg(test)] pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: bool) -> container::Style { let mut style = if islands { container::Style::default() @@ -740,6 +753,7 @@ pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: b style } +#[cfg(test)] pub(super) fn island<'a>( content: Element<'a, cosmic::Action>, theme: ShellTheme, @@ -754,7 +768,7 @@ pub(super) fn island<'a>( } let bar = BarMetrics::from(geometry); - let fallback = ferese_config::panel::CornerRadii::uniform(theme.bar_radius) + let fallback = ferese_panel::CornerRadii::uniform(theme.bar_radius) .at_top_edge(geometry.edge_margin == 0) .0; let (id, opacity, corners) = @@ -780,26 +794,6 @@ pub(super) fn island<'a>( .into() } -fn group_surface_style( - theme: ShellTheme, - parent: PanelSurface, - material: bool, - opacity: f32, - corners: [f32; 4], -) -> cosmic::widget::container::Style { - let mut style = bar_style(theme, material, false); - style.border.radius = corners.into(); - if parent == PanelSurface::Solid { - style.background = Some(Background::Color(Color { - a: opacity * 0.08, - ..color(theme.text_primary) - })); - } else if let Some(Background::Color(fill)) = style.background { - style.background = Some(Background::Color(Color { a: opacity, ..fill })); - } - style -} - pub(super) fn input_region( mode: PanelSurface, hidden: bool, @@ -964,7 +958,7 @@ mod island_tests { shell.clock = "10 oct, 4:35 pm".into(); let item = Item::new("clock", ItemKind::Clock); for height in [28., 31., 48.] { - shell.config.panels[0].geometry.height = height; + shell.config.panels[0].geometry.height = Some(height); for representation in [Representation::Wide, Representation::Compact] { let mut view = shell .view_panel_item(shell.outputs[0].bar, &item, None, true, representation) @@ -1015,7 +1009,7 @@ mod island_tests { availability, theme, true, - |_| true, + |item| title_content || !matches!(item.kind, ItemKind::FocusedWindow), |item, _| { let width = match item.kind { ItemKind::Overview => 28., @@ -1036,14 +1030,14 @@ mod island_tests { }, ); let mut view: Element<'_, cosmic::Action> = container(view) - .height(panel.geometry.height) + .height(panel.resolved_geometry().height) .align_y(alignment::Vertical::Center) .into(); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, - &layout::Limits::new(Size::ZERO, Size::new(1200., panel.geometry.height)), + &layout::Limits::new(Size::ZERO, Size::new(1200., panel.resolved_geometry().height)), ); let mut bounds = Bounds { group_ids: zone @@ -1076,19 +1070,19 @@ mod island_tests { #[test] fn islands_cover_the_full_panel_height_independently_of_group_decoration() { - let mut panel = Panel::from_preset(PanelPreset::Islands); + let mut panel = Panel::from_preset(PanelStyle::Floating); for margin in [0, 6] { let theme = ShellTheme::default(); - panel.geometry.height = 36.; - panel.geometry.edge_margin = margin; + panel.geometry.height = Some(36.); + panel.geometry.edge_margin = Some(margin); for border in [false, true] { panel.border = border; let bounds = measure_zone_with_theme(&panel, &panel.start, Availability::default(), true, theme); assert_eq!(bounds.islands[0].y, 0.); assert_eq!(bounds.islands[0].height, 36.); assert_eq!( - bounds.groups[0].height, 32., - "decoration must not give the inner content a second full-height background" + bounds.groups[0].height, 36., + "group layout follows the same baseline as its shared surface" ); } } @@ -1097,7 +1091,7 @@ mod island_tests { #[test] fn composition_preserves_default_group_gaps_and_material_geometry() { for padding in [0., 4., 13.5] { - for background in [PanelPreset::Continuous, PanelPreset::Islands] { + for background in [PanelStyle::Flat, PanelStyle::Floating] { let mut panel = Panel::from_preset(background); for group in [&mut panel.start, &mut panel.center, &mut panel.end] .into_iter() @@ -1110,14 +1104,17 @@ mod island_tests { start.items.iter().map(|(id, _)| id.as_str()).collect::>(), ["overview", "workspaces"] ); - let islands = background == PanelPreset::Islands; + let islands = background == PanelStyle::Floating; assert_eq!(start.items[0].1.x, if islands { padding + 3. } else { 0. }); assert_eq!(start.items[1].1.x - start.items[0].1.x, if islands { 29. } else { 34. }); - assert_eq!(start.islands.len(), usize::from(islands)); + assert_eq!(start.islands.len(), panel.start.groups.len()); if islands { assert_eq!( start.islands[0], - Rectangle::new((0., 0.).into(), (107. + 2. * padding, 28.).into()) + Rectangle::new( + (0., 0.).into(), + (107. + 2. * padding, panel.resolved_geometry().height).into() + ) ); } let end = measure_zone(&panel, &panel.end, Availability::default(), true); @@ -1130,19 +1127,22 @@ mod island_tests { if islands { assert_eq!( end.islands[0], - Rectangle::new((0., 0.).into(), (108. + 2. * padding, 28.).into()) + Rectangle::new( + (0., 0.).into(), + (108. + 2. * padding, panel.resolved_geometry().height).into() + ) ); } let center = measure_zone(&panel, &panel.center, Availability::default(), true); - assert_eq!(center.islands.len(), usize::from(islands)); + assert_eq!(center.islands.len(), panel.center.groups.len()); if islands { assert_eq!(center.islands[0].width, 80. + 2. * padding); } let empty_title = measure_zone(&panel, &panel.center, Availability::default(), false); assert!(empty_title.islands.is_empty()); - assert_eq!( - empty_title.items[0].1.width, 80., - "keep the current empty title reservation" + assert!( + empty_title.items.is_empty(), + "an unavailable title leaves no group behind" ); } } @@ -1150,7 +1150,7 @@ mod island_tests { #[test] fn group_renderer_obeys_order_and_instance_identity_and_skips_unavailable_groups() { - let mut panel = Panel::from_preset(PanelPreset::Islands); + let mut panel = Panel::from_preset(PanelStyle::Floating); let group = &mut panel.end.groups[0]; let mut clock = group .items @@ -1206,7 +1206,7 @@ mod island_tests { #[test] fn default_groups_have_no_fill_or_outline_in_either_arrangement() { let theme = ShellTheme::default(); - for background in [PanelPreset::Continuous, PanelPreset::Islands] { + for background in [PanelStyle::Flat, PanelStyle::Floating] { let panel = Panel::from_preset(background); let group = ferese_theme::panel::group_border(color(theme.border), theme.bar_radius, panel.border); assert!(group.background.is_none()); @@ -1239,27 +1239,25 @@ mod island_tests { assert_eq!(inner.border.width, 1.); } #[test] - fn mixed_group_surfaces_use_independent_heights_and_padding() { - let mut panel = Panel::from_preset(PanelPreset::Islands); - panel.geometry.height = 36.; - panel.center.groups[0].surface = Some(GroupSurface::None); - panel.end.groups[0].surface = Some(GroupSurface::Inset); - let navigation = measure_zone(&panel, &panel.start, Availability::default(), true); - let title = measure_zone(&panel, &panel.center, Availability::default(), true); - let status = measure_zone(&panel, &panel.end, Availability::default(), true); - assert_eq!(navigation.islands.len(), 1); - assert_eq!(navigation.islands[0].height, 36.); - assert!(title.islands.is_empty()); - assert_eq!(title.items.len(), 1); - assert_eq!(status.islands.len(), 1); - assert_eq!(status.islands[0].height, BarMetrics::from(panel.geometry).group_height); - assert_eq!(status.islands[0].width, status.groups[0].width); - assert!(navigation.islands[0].width > navigation.groups[0].width); - // The same media island can sit on a continuous panel. - panel.surface = PanelSurface::Solid; - panel.group_surface = GroupSurface::None; - panel.end.groups[0].surface = Some(GroupSurface::Island); - let status = measure_zone(&panel, &panel.end, Availability::default(), true); - assert_eq!(status.islands[0].height, 36.); + fn panel_surface_policy_applies_to_every_occupied_group() { + let mut panel = Panel::from_preset(PanelStyle::Floating); + panel.geometry.height = Some(36.); + for zone in [&panel.start, &panel.center, &panel.end] { + let measured = measure_zone(&panel, zone, Availability::default(), true); + assert_eq!(measured.islands.len(), measured.groups.len()); + for (island, group) in measured.islands.iter().zip(&measured.groups) { + assert_eq!(island.height, 36.); + assert_eq!(island.width, group.width); + } + } + panel.apply_preset(PanelStyle::Flat); + for zone in [&panel.start, &panel.center, &panel.end] { + let measured = measure_zone(&panel, zone, Availability::default(), true); + assert_eq!( + measured.islands.len(), + measured.groups.len(), + "changing the shared shape preserves layout bounds" + ); + } } } diff --git a/shell/ferese-shell/src/bar/presentation.rs b/shell/ferese-shell/src/bar/presentation.rs index cd2b52d..bfe0d73 100644 --- a/shell/ferese-shell/src/bar/presentation.rs +++ b/shell/ferese-shell/src/bar/presentation.rs @@ -1,7 +1,7 @@ use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; use cosmic::iced::{Event, Length, Rectangle, Size, Vector}; use cosmic::{Element, Theme}; -use ferese_config::panel::PanelSurface; +use ferese_panel::PanelSurface; pub(crate) fn island_id() -> widget::Id { static ID: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -13,6 +13,7 @@ pub(crate) fn input_id() -> widget::Id { ID.get_or_init(widget::Id::unique).clone() } +#[cfg(test)] pub(super) fn frame<'a, M: 'a>( content: Element<'a, M>, mode: PanelSurface, @@ -29,7 +30,19 @@ pub(super) fn frame<'a, M: 'a>( materials, hover, present: Box::new(present), - changed: Box::new(changed), + changed: Some(Box::new(changed)), + }) +} + +pub(super) fn hover<'a, M: 'a>(content: Element<'a, M>, hover: Option>) -> Element<'a, M> { + Element::new(Presentation { + content, + mode: PanelSurface::None, + radius: 0., + materials: Vec::new(), + hover, + present: Box::new(|_, _| {}), + changed: None, }) } @@ -66,6 +79,8 @@ impl widget::Operation for HoverBounds<'_> { } } +type Changed<'a, M> = Box) -> M + 'a>; + type Present<'a> = Box; struct Presentation<'a, M> { @@ -75,7 +90,7 @@ struct Presentation<'a, M> { materials: Vec<(widget::Id, f32)>, hover: Option>, present: Present<'a>, - changed: Box) -> M + 'a>, + changed: Option>, } impl Widget for Presentation<'_, M> { @@ -166,7 +181,7 @@ impl Widget for Presentation<'_, M> { shell, viewport, ); - if !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) { + if self.changed.is_none() || !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) { return; } @@ -203,7 +218,7 @@ impl Widget for Presentation<'_, M> { state.mode = Some(self.mode); state.current.clone_from(&collector.regions); state.input.clone_from(&collector.input); - shell.publish((self.changed)(if self.mode == PanelSurface::Solid { + shell.publish((self.changed.as_ref().unwrap())(if self.mode == PanelSurface::Solid { collector.regions.clone() } else { collector.input.clone() @@ -482,25 +497,20 @@ mod tests { ) }; let islands = row![section(100), section(200),].spacing(60); - let content = container(islands) + let content: Element<'_, cosmic::Action> = container(islands) .padding([0, 12]) .width(600) .height(28) .align_y(cosmic::iced::alignment::Vertical::Center) .into(); let mut view = frame( - content, + content.map(|_| Vec::<[f32; 5]>::new()), mode, 14., vec![], None, |_, _| {}, - |regions| { - cosmic::Action::App(crate::Message::BarRegionsChanged( - cosmic::iced::window::Id::unique(), - regions, - )) - }, + |regions| regions, ); let tree = tree.get_or_insert_with(|| widget::Tree::new(&view)); tree.diff(&mut view); @@ -531,9 +541,7 @@ mod tests { } else { vec![[0., 0., 600., 28., 14.]] }; - let cosmic::Action::App(crate::Message::BarRegionsChanged(_, regions)) = &messages[0] else { - panic!("expected measured bar regions"); - }; + let regions = &messages[0]; assert_eq!(*regions, expected); } } diff --git a/shell/ferese-shell/src/clock.rs b/shell/ferese-shell/src/clock.rs index c184db5..df0f18c 100644 --- a/shell/ferese-shell/src/clock.rs +++ b/shell/ferese-shell/src/clock.rs @@ -1,4 +1,3 @@ -//! Visible clock labels, driven by realtime boundaries and timezone changes. use std::ffi::{CString, OsString}; use std::io; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 73f407c..9bc5750 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -60,6 +60,7 @@ pub(crate) struct ShellTheme { pub(crate) bar_text_primary: [u8; 4], pub(crate) bar_text_muted: [u8; 4], pub(crate) surface_base: [u8; 4], + pub(crate) surface_raised: [u8; 4], pub(crate) surface_popover: [u8; 4], pub(crate) text_primary: [u8; 4], pub(crate) text_muted: [u8; 4], @@ -86,13 +87,15 @@ impl Default for ShellTheme { bar_text_primary: [240, 243, 250, 255], bar_text_muted: [170, 180, 199, 255], surface_base: [17, 24, 33, 255], + surface_raised: parse_color(&ferese_config::theme::default_tokens().colors.surface_raised) + .expect("valid theme surface color"), surface_popover: [17, 24, 33, 255], text_primary: [244, 247, 251, 255], text_muted: [135, 147, 162, 255], accent: [61, 123, 230, 255], accent_gradient: None, on_accent: [244, 247, 251, 255], - border: [255, 255, 255, 24], + border: parse_color(&default_border()).expect("valid theme border color"), shadow: [0, 0, 0, 85], bar_radius: 14.0, material_radius: 14.0, @@ -249,6 +252,8 @@ struct TypographyConfig { struct ThemeColorsConfig { #[serde(default = "default_surface_base")] surface_base: String, + #[serde(default = "default_surface_raised")] + surface_raised: String, #[serde(default = "default_text_primary")] text_primary: String, #[serde(default = "default_text_muted")] @@ -267,6 +272,7 @@ impl Default for ThemeColorsConfig { fn default() -> Self { Self { surface_base: default_surface_base(), + surface_raised: default_surface_raised(), text_primary: default_text_primary(), text_muted: default_text_muted(), accent: default_accent(), @@ -371,7 +377,7 @@ fn parse_document( { Ok(config) => { if let Some(panels) = &config.panels { - ferese_config::panel::validate(panels).map_err(ferese_config::Error::from)?; + ferese_panel::validate(panels).map_err(ferese_config::Error::from)?; } config.animations.validate().map_err(ferese_config::Error::from)?; @@ -447,6 +453,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme { bar_text_primary: parse_color(&theme.surface.bar.text_primary).unwrap_or(defaults.bar_text_primary), bar_text_muted: parse_color(&theme.surface.bar.text_muted).unwrap_or(defaults.bar_text_muted), surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base), + surface_raised: parse_color(&theme.colors.surface_raised).unwrap_or(defaults.surface_raised), surface_popover: { let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base); color[3] = alpha; @@ -501,6 +508,10 @@ fn default_surface_base() -> String { "#111821".to_owned() } +fn default_surface_raised() -> String { + ferese_config::theme::default_tokens().colors.surface_raised +} + fn default_text_primary() -> String { "#F4F7FB".to_owned() } @@ -514,7 +525,7 @@ fn default_accent() -> String { } fn default_border() -> String { - "#FFFFFF18".to_owned() + ferese_config::theme::default_tokens().colors.border } fn default_shadow() -> String { @@ -547,6 +558,25 @@ pub(crate) fn config_path() -> Option { #[cfg(test)] mod tests { + #[test] + fn morph_profile_uses_the_current_strict_schema() { + let config = parse_test_source( + "panel main { style morph; morph { idle-width 180; media-height 184; peek-on-hover #false; }; }\n", + ) + .unwrap(); + assert_eq!(config.panels[0].morph.idle_width, 180.); + assert_eq!(config.panels[0].morph.media_height, 184.); + assert!(!config.panels[0].morph.peek_on_hover); + assert!( + parse_test_source("panel main { style morph; }\n").unwrap().panels[0] + .morph + .peek_on_hover + ); + assert!(parse_test_source("panel main { style morph; morph { peek-on-hover 1; }; }\n").is_err()); + assert!(parse_test_source("panel main { style morph; morph { idle-wdth 180; }; }\n").is_err()); + assert!(parse_test_source("panel main { style morph; morph { idle-width -1; }; }\n").is_err()); + } + #[test] fn removed_geometry_is_rejected_before_a_runtime_snapshot_can_hide_it() { for key in [ @@ -583,11 +613,11 @@ mod tests { #[test] fn reduce_transparency_overrides_panel_opacity_for_both_surfaces() { - for surface in ["solid", "none"] { + for surface in ["flat", "floating"] { for opacity in [0., 0.4, 1.] { for reduce in [false, true] { let source = format!( - "theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ surface {surface}; group-surface island; background-opacity {opacity}; }}" + "theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ style {surface}; background-opacity {opacity}; }}" ); let document = ferese_config::Document::parse(&source).unwrap(); let resolved = ferese_config::theme::resolve( @@ -649,13 +679,13 @@ mod tests { use crate::panel::{GroupSurface, ItemKind, PanelSurface}; let defaults = parse_test_source("").unwrap(); assert_eq!(ShellConfig::default().panels, defaults.panels); - let source = "panel main { surface none; group-surface island; geometry { height 36; edge-margin 6; }; center { group title { surface none; item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }"; + let source = "panel main { style floating; geometry { height 36; edge-margin 6; }; center { group title { item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }"; let current = parse_test_source(source).unwrap(); let panel = ¤t.panels[0]; - assert_eq!(panel.surface, PanelSurface::None); - assert_eq!(panel.group_surface, GroupSurface::Island); - assert_eq!(panel.geometry.height, 36.); - assert_eq!(panel.geometry.edge_margin, 6); + assert_eq!(panel.surface(), PanelSurface::None); + assert_eq!(panel.group_surface(), GroupSurface::Island); + assert_eq!(panel.resolved_geometry().height, 36.); + assert_eq!(panel.resolved_geometry().edge_margin, 6); assert_eq!(panel.end.groups[0].island_padding, 9.5); assert!(!panel.center.groups[0].items[0].visible); assert_eq!( @@ -667,10 +697,10 @@ mod tests { #[test] fn authored_composition_rejects_invalid_reload_data() { - let source = r#"panel "custom" { surface "none"; group-surface "island"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#; + let source = r#"panel "custom" { style "floating"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#; let config = parse_test_source(source).unwrap(); assert_eq!(config.panels[0].id.0, "custom"); - assert_eq!(config.panels[0].surface, crate::panel::PanelSurface::None); + assert_eq!(config.panels[0].surface(), crate::panel::PanelSurface::None); assert_eq!(config.panels[0].end.groups[0].items.len(), 2); assert!(parse_test_source(&source.replace("item \"two\"", "item \"one\"")).is_err()); assert!(parse_test_source("panel one; panel two;").is_err()); @@ -680,7 +710,10 @@ mod tests { #[test] fn group_surfaces_do_not_change_modal_opacity() { let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap(); - let islands = parse_test_source("panel main { surface none; group-surface island; }\ntheme { material { style translucent; opacity 0.7; }; }").unwrap(); + let islands = parse_test_source( + "panel main { style floating; }\ntheme { material { style translucent; opacity 0.7; }; }", + ) + .unwrap(); assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover); assert_eq!(islands.theme.surface_base, normal.theme.surface_base); assert_eq!(islands.theme.bar_background, normal.theme.bar_background); @@ -696,7 +729,7 @@ mod tests { ); let config = parse_test_source(&source).unwrap(); assert_eq!(config.panels[0].end.groups[0].island_padding, padding); - assert_eq!(config.panels[0].geometry.inner_padding, 12.); + assert_eq!(config.panels[0].resolved_geometry().inner_padding, 12.); } for value in ["-1", "32.5", "\"small\""] { assert!( @@ -788,7 +821,7 @@ theme { assert_eq!(config.theme.background.mode, WallpaperMode::Fill); let theme = shell_theme(&config.theme); assert_eq!(crate::panel::PanelGeometry::default().edge_margin, 0); - assert_eq!(crate::panel::PanelGeometry::default().height, 28.0); + assert_eq!(crate::panel::PanelGeometry::default().height, 36.0); assert_eq!(crate::panel::PanelGeometry::default().side_margins, 0); assert_eq!(theme.bar_radius, 14.0); assert_eq!(theme.bar_background[3], 255); @@ -800,6 +833,7 @@ theme { fn bar_palette_can_be_changed_without_changing_popovers() { let config: FereseConfig = ferese_config::from_str( r##"theme { + colors { surface-raised "#314159"; } surface { bar { background "#EAECEEDD" @@ -813,6 +847,7 @@ theme { .unwrap(); let theme = shell_theme(&config.theme); assert_eq!(theme.bar_background, [234, 236, 238, 255]); + assert_eq!(theme.surface_raised, [49, 65, 89, 255]); assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]); assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]); assert_eq!(theme.text_primary, ShellTheme::default().text_primary); diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 5c9df0f..ec39ba9 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -48,15 +48,15 @@ pub(crate) struct WorkspaceSnapshot { #[derive(Clone, Debug)] pub(crate) struct WindowSnapshot { - #[cfg_attr(not(test), expect(dead_code, reason = "Retained for window-targeted shell views."))] pub(crate) id: u64, pub(crate) workspace: u64, + pub(crate) geometry: Option<[f64; 4]>, pub(crate) app_id: String, pub(crate) title: String, pub(crate) focused: bool, - #[cfg_attr(not(test), expect(dead_code, reason = "The current bar does not display urgency."))] pub(crate) urgent: bool, pub(crate) fullscreen: bool, + pub(crate) maximized: bool, #[cfg_attr( not(test), expect(dead_code, reason = "The current bar does not display window placement.") @@ -136,7 +136,7 @@ impl ShellControl { let connection = control_connection()?; let (globals, mut queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 4..=6, ())?; + let manager = globals.bind::(&qh, 7..=8, ())?; let shell = manager.get_shell(&qh, ()); let (sender, updates) = mpsc::channel(); let wake = Wake(Arc::new(tokio::sync::Notify::new())); @@ -194,6 +194,16 @@ impl ShellControl { } } + pub(crate) fn move_window_to_workspace(&self, window: u64, workspace: u64) { + if self.shell.version() < 8 { + return; + } + let (hi, lo) = split_id(window); + let (workspace_hi, workspace_lo) = split_id(workspace); + self.shell.move_window_to_workspace(hi, lo, workspace_hi, workspace_lo); + let _ = self.connection.flush(); + } + pub(crate) fn activate_workspace(&self, id: u64) { let (hi, lo) = split_id(id); @@ -375,14 +385,36 @@ impl Dispatch for ControlState { state.pending.windows.push(WindowSnapshot { id: join_id(window_hi, window_lo), workspace: join_id(workspace_hi, workspace_lo), + geometry: None, app_id, title, focused: flags.contains(ferese_shell_v1::WindowState::Focused), urgent: flags.contains(ferese_shell_v1::WindowState::Urgent), fullscreen: flags.contains(ferese_shell_v1::WindowState::Fullscreen), + maximized: flags.contains(ferese_shell_v1::WindowState::Maximized), floating: flags.contains(ferese_shell_v1::WindowState::Floating), }); } + ferese_shell_v1::Event::WindowGeometry { + window_hi, + window_lo, + x, + y, + width, + height, + } => { + if [x, y, width, height].iter().all(|n| n.is_finite()) + && width > 0. + && height > 0. + && let Some(window) = state + .pending + .windows + .last_mut() + .filter(|window| window.id == join_id(window_hi, window_lo)) + { + window.geometry = Some([x, y, width, height]); + } + } ferese_shell_v1::Event::Capabilities { .. } => { let _ = state.sender.send(ControlUpdate::MonitorsChanged); } @@ -564,6 +596,110 @@ mod tests { assert_ne!(tiled.id, floating.id); } + #[test] + fn geometry_is_attached_to_its_window_and_published_only_with_the_complete_snapshot() { + let (control, mut state, queue, _peer) = control_fixture(); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotBegin { serial: 8 }, + ); + let id = 0x1234_5678_9abc_def0; + dispatch_event(&control, &mut state, &queue, window_event(id, 0)); + let (window_hi, window_lo) = split_id(id); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::WindowGeometry { + window_hi, + window_lo, + x: -640., + y: 20., + width: 620., + height: 480., + }, + ); + // Invalid geometry must not replace a valid rectangle. + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::WindowGeometry { + window_hi, + window_lo, + x: 0., + y: 0., + width: -1., + height: 480., + }, + ); + assert!(control.poll().snapshot.is_none()); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotEnd { serial: 8 }, + ); + assert_eq!( + control.poll().snapshot.unwrap().windows[0].geometry, + Some([-640., 20., 620., 480.]) + ); + } + + #[test] + fn geometry_stream_uses_only_the_preceding_window() { + let (control, mut state, queue, _peer) = control_fixture(); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotBegin { serial: 9 }, + ); + for id in 1..=512 { + dispatch_event(&control, &mut state, &queue, window_event(id, 0)); + let (window_hi, window_lo) = split_id(id); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::WindowGeometry { + window_hi, + window_lo, + x: id as f64, + y: 0., + width: 50., + height: 40., + }, + ); + } + // A malformed late event must not overwrite an earlier window. + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::WindowGeometry { + window_hi: 0, + window_lo: 1, + x: -1., + y: 0., + width: 50., + height: 40., + }, + ); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotEnd { serial: 9 }, + ); + let snapshot = control.poll().snapshot.unwrap(); + for window in snapshot.windows { + assert_eq!(window.geometry, Some([window.id as f64, 0., 50., 40.])); + } + } + #[test] fn known_window_flags_survive_unknown_future_bits() { use ferese_shell_v1::WindowState as Flags; @@ -575,14 +711,15 @@ mod tests { wayland_client::WEnum::Unknown(_) )); let cases = [ - (known.bits(), [true, true, true, true]), - (known.bits() | future, [true, true, true, true]), - (Flags::Focused.bits() | future, [true, false, false, false]), - (Flags::Urgent.bits() | future, [false, true, false, false]), - (Flags::Fullscreen.bits() | future, [false, false, true, false]), - (Flags::Floating.bits() | future, [false, false, false, true]), - (future, [false; 4]), - (0, [false; 4]), + (known.bits(), [true, true, true, true, false]), + (known.bits() | future, [true, true, true, true, false]), + (Flags::Focused.bits() | future, [true, false, false, false, false]), + (Flags::Urgent.bits() | future, [false, true, false, false, false]), + (Flags::Fullscreen.bits() | future, [false, false, true, false, false]), + (Flags::Floating.bits() | future, [false, false, false, true, false]), + (Flags::Maximized.bits() | future, [false, false, false, false, true]), + (future, [false; 5]), + (0, [false; 5]), ]; dispatch_event( &control, @@ -602,7 +739,13 @@ mod tests { let snapshot = control.poll().snapshot.unwrap(); for (window, (_, expected)) in snapshot.windows.iter().zip(cases) { assert_eq!( - [window.focused, window.urgent, window.fullscreen, window.floating], + [ + window.focused, + window.urgent, + window.fullscreen, + window.floating, + window.maximized + ], expected ); } diff --git a/shell/ferese-shell/src/display_mode.rs b/shell/ferese-shell/src/display_mode.rs index 5596e02..ffda606 100644 --- a/shell/ferese-shell/src/display_mode.rs +++ b/shell/ferese-shell/src/display_mode.rs @@ -390,7 +390,7 @@ impl FereseShell { let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible); let pending = model.pending(); let mut header = row![ - text("Display mode").size(17), + self.render.text("Display mode").size(17), cosmic::iced::widget::Space::new().width(Length::Fill) ] .align_y(cosmic::iced::Alignment::Center) @@ -401,7 +401,8 @@ impl FereseShell { .and_then(|inventory| inventory.profile.as_deref()) { header = header.push( - text(format!("{profile} profile")) + self.render + .text(format!("{profile} profile")) .size(12) .class(theme::Text::Color(palette.muted)), ); @@ -417,10 +418,12 @@ impl FereseShell { .is_some_and(|inventory| inventory.available(mode)); let card = cosmic::widget::column![ ferese_theme::icons::tinted(mode.icon(), 40, palette.text), - text(mode.label()) + self.render + .text(mode.label()) .size(13) .wrapping(cosmic::iced::widget::text::Wrapping::None), - text((index + 1).to_string()) + self.render + .text((index + 1).to_string()) .size(11) .class(theme::Text::Color(palette.muted)), ] @@ -449,7 +452,10 @@ impl FereseShell { displays = displays.push( row![ ferese_theme::icons::tinted(ferese_theme::icons::DISPLAY, 16, palette.muted), - text(&display.label).size(12).class(theme::Text::Color(palette.muted)) + self.render + .text(&display.label) + .size(12) + .class(theme::Text::Color(palette.muted)) ] .spacing(8) .align_y(cosmic::iced::Alignment::Center), @@ -460,7 +466,7 @@ impl FereseShell { } if let Some(error) = &model.error { - rows = rows.push(text(error).size(12)); + rows = rows.push(self.render.text(error).size(12)); } let hint = if model.busy { @@ -491,14 +497,14 @@ impl FereseShell { .inventory .as_ref() .is_some_and(|inventory| inventory.available(model.selected))); - rows.push(text(hint).size(12).class(theme::Text::Color(palette.muted))) + rows.push(self.render.text(hint).size(12).class(theme::Text::Color(palette.muted))) .push( container( row![ cosmic::iced::widget::Space::new().width(Length::Fill), ferese_theme::controls::text_button( if pending { "Revert" } else { "Cancel" }, - shell_font(), + self.render.font, palette, false ) @@ -512,7 +518,7 @@ impl FereseShell { .on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))), ferese_theme::controls::text_button( if pending { "Keep" } else { "Apply" }, - shell_font(), + self.render.font, palette, true ) diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 741a557..ddd2f80 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -15,9 +15,12 @@ mod motion; mod notification_ui; mod notifications; mod panel; +mod panel_hub; mod panel_layout; +mod panel_runtime; mod recording; mod renderer; +mod rendering; mod status; mod status_ui; mod system_modal; @@ -57,25 +60,10 @@ const APP_ID: &str = "dev.ferese.Shell"; // that compatibility path gated; current backends use Window::Opened and // window::run to retrieve handles without updates at the display refresh rate. static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true); -static SHELL_FONT: std::sync::OnceLock> = std::sync::OnceLock::new(); - fn configured_font(family: Option<&str>) -> cosmic::font::Font { ferese_theme::font(family) } -fn shell_font() -> cosmic::font::Font { - *SHELL_FONT - .get_or_init(|| std::sync::RwLock::new(cosmic::font::default())) - .read() - .unwrap() -} - -fn text<'a>( - content: impl Into> + 'a, -) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> { - ferese_theme::text(content, shell_font()) -} - #[derive(Clone, Copy)] struct BarMetrics { height: f32, @@ -83,6 +71,7 @@ struct BarMetrics { icon_size: u16, overview_icon_size: u16, control_height: f32, + #[cfg(test)] group_height: f32, group_item_height: f32, } @@ -103,6 +92,7 @@ impl From for BarMetrics { icon_size: 20, overview_icon_size: 20, control_height, + #[cfg(test)] group_height, group_item_height: (group_height - 4.0).max(0.0), } @@ -116,7 +106,6 @@ fn main() -> cosmic::iced::Result { }); renderer::configure_shell(); - motion::configure(config.animations, config.theme.material_radius); let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); if compositor_wallpaper { // The compositor owns one GPU image, shared across output renderers; @@ -124,7 +113,6 @@ fn main() -> cosmic::iced::Result { config.wallpaper.path = None; } let shell_font = configured_font(config.font_family.as_deref()); - let _ = SHELL_FONT.set(std::sync::RwLock::new(shell_font)); // Decode alongside toolkit/GPU initialization, never during a UI draw. let wallpaper = config.wallpaper.path.clone().map(|path| { let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel(); @@ -153,6 +141,7 @@ type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver, control: Option, snapshot: ShellSnapshot, @@ -179,6 +168,8 @@ struct FereseShell { } struct OutputSurfaces { + runtime: panel_runtime::Runtime, + scale: f64, output: wl_output::WlOutput, name: Option, bar: window::Id, @@ -193,7 +184,20 @@ struct OutputSurfaces { #[derive(Clone, Debug)] enum Message { - BarRegionsChanged(window::Id, Vec<[f32; 5]>), + HubOpen(window::Id, status_ui::Menu), + HubClose(window::Id), + HubWorkspaceScroll(window::Id, cosmic::iced::mouse::ScrollDelta), + HubOverview(window::Id), + HubWorkspaceHover(window::Id, Option), + HubWorkspaceSelect(window::Id, u64), + HubWorkspaceMove(window::Id, u64, u64), + HubWorkspaceFocus(window::Id, Option, Option), + HubToastExpired(window::Id, Instant), + HubWorkspaceFeedbackExpired(window::Id, Instant), + HubIgnore, + PanelScale(window::Id, f32), + AdaptiveDeadline(window::Id, u64), + PanelSurfaceChanged(window::Id, Box), PanelResolved(window::Id, panel_layout::Resolution), ThemeChanged(Box), ThemeMode(ferese_config::theme::Mode), @@ -279,7 +283,7 @@ impl cosmic::Application for FereseShell { fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task) { let app = Self { core, - notifications: notifications::Center::new(config.notifications.clone()), + notifications: notifications::Center::new(config.notifications.clone(), config.animations), notification_surface: None, status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), @@ -295,6 +299,7 @@ impl cosmic::Application for FereseShell { guide_load: Default::default(), guide_attempts: 0, pending_power: None, + render: rendering::Rendering::new(&config), config, wallpaper: None, control: ShellControl::connect() @@ -339,8 +344,12 @@ impl cosmic::Application for FereseShell { .load(Ordering::Relaxed) .then_some(Message::Event(event, id)), Event::Keyboard(_) - | Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_) | cosmic::iced::mouse::Event::CursorLeft) - | Event::Window(window::Event::Opened { .. } | window::Event::Closed) + | Event::Mouse( + cosmic::iced::mouse::Event::ButtonPressed(_) + | cosmic::iced::mouse::Event::CursorLeft + | cosmic::iced::mouse::Event::CursorEntered, + ) + | Event::Window(window::Event::Opened { .. } | window::Event::Closed | window::Event::Rescaled(_)) | Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..), )) => Some(Message::Event(event, id)), @@ -358,6 +367,17 @@ impl cosmic::Application for FereseShell { fn update(&mut self, message: Self::Message) -> Task { match message { + Message::HubOpen(id, kind) => self.open_hub(id, kind), + Message::HubClose(id) => self.close_hub(id), + Message::HubWorkspaceScroll(id, delta) => self.scroll_hub_workspaces(id, delta), + Message::HubOverview(id) => self.open_hub_overview(id), + Message::HubWorkspaceHover(id, workspace) => self.hub_workspace_hover(id, workspace), + Message::HubWorkspaceSelect(id, workspace) => self.hub_workspace_select(id, workspace), + Message::HubWorkspaceMove(id, window, workspace) => self.hub_workspace_move(id, window, workspace), + Message::HubWorkspaceFocus(id, focused, key) => self.hub_workspace_focus(id, focused, key), + Message::HubToastExpired(id, revision) => self.hub_toast_expired(id, revision), + Message::HubWorkspaceFeedbackExpired(id, revision) => self.hub_workspace_feedback_expired(id, revision), + Message::HubIgnore => Task::none(), Message::PanelResolved(id, resolution) => { let allocation_changed = self .outputs @@ -390,7 +410,12 @@ impl cosmic::Application for FereseShell { Task::batch([margin, tooltip]) } } - Message::BarRegionsChanged(id, regions) => { + Message::PanelSurfaceChanged(id, scene) => { + let regions = scene + .input_rectangles(1.) + .into_iter() + .map(|r| [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32, 0.]) + .collect(); let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else { return Task::none(); }; @@ -398,7 +423,7 @@ impl cosmic::Application for FereseShell { output.bar_regions = regions; set_input_zone( id, - bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions), + bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions), ) } @@ -515,7 +540,7 @@ impl cosmic::Application for FereseShell { } Message::StopRecording => { self.recorder.stop(); - Task::none() + self.sync_hub_activity() } Message::StatusUpdated(update) => { if update.generation >= self.status_service.generation { @@ -530,9 +555,14 @@ impl cosmic::Application for FereseShell { dnd: self.notifications.dnd, }); } - self.sync_notification_surface() + Task::batch([self.sync_hub_activity(), self.sync_notification_surface()]) } Message::NotificationEvent(event) => { + let notice = if let notifications::Event::Notice(notice) = &event { + Some(notice.id) + } else { + None + }; self.notifications.handle_event(event); if self.notifications.ready { self.status.notifications = Some(status::Notifications { @@ -540,11 +570,12 @@ impl cosmic::Application for FereseShell { dnd: self.notifications.dnd, }); } - self.sync_notification_surface() + let hub = notice.map_or_else(Task::none, |notice| self.hub_notice(notice)); + Task::batch([hub, self.sync_hub_notices(), self.sync_notification_surface()]) } Message::NotificationTick => { self.notifications.tick(); - self.sync_notification_surface() + Task::batch([self.sync_hub_notices(), self.sync_notification_surface()]) } Message::DismissNotification(id) => { self.notifications.close(id, 2); @@ -715,10 +746,22 @@ impl cosmic::Application for FereseShell { } Message::RecorderEvent(event) => { self.recorder.handle(event); - Task::none() + self.sync_hub_activity() } Message::RecorderElapsed => Task::none(), + Message::PanelScale(id, scale) => { + if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) + && scale.is_finite() + && scale > 0. + && scale <= 16. + { + entry.scale = f64::from(scale); + } + self.sync_hub_workspaces() + } + Message::AdaptiveDeadline(id, ticket) => self.adaptive_deadline(id, ticket), Message::ControlReady => { + let overview_was_active = self.overview_active; let mut reload_task = Task::none(); if let Some(control) = &self.control { let poll = control.poll(); @@ -740,9 +783,15 @@ impl cosmic::Application for FereseShell { if let Some(snapshot) = poll.snapshot { self.snapshot = snapshot; - let mut tasks = vec![reload_task, self.update_workspace_ui()]; + let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active); + let mut tasks = vec![ + reload_task, + self.update_workspace_ui(), + feedback, + self.refresh_adaptive_panels(), + ]; let mut visibility_changed = false; - + let mut suspended_hubs = Vec::new(); for entry in &mut self.outputs { let output = self .snapshot @@ -756,7 +805,14 @@ impl cosmic::Application for FereseShell { continue; } + tasks.push(set_exclusive_zone( + entry.bar, + entry.runtime.panel(&self.config.panels[0]).exclusive_zone(hidden), + )); entry.hidden = hidden; + if hidden && entry.runtime.hub.is_some() { + suspended_hubs.push(entry.bar); + } visibility_changed = true; if let Some(effects) = &entry.effects && let Err(error) = effects.set_visible(!hidden) @@ -765,9 +821,12 @@ impl cosmic::Application for FereseShell { } tasks.push(set_input_zone( entry.bar, - bar::input_region(self.config.panels[0].surface, hidden, &entry.bar_regions), + bar::input_region(ferese_panel::PanelSurface::None, hidden, &entry.bar_regions), )); } + for id in suspended_hubs { + tasks.push(self.suspend_hub(id)); + } if self .menu .as_ref() @@ -826,11 +885,11 @@ impl cosmic::Application for FereseShell { } fn view(&self) -> Element<'_, Self::Message> { - text("").into() + self.render.text("").into() } fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> { - text("").into() + self.render.text("").into() } } @@ -854,13 +913,7 @@ impl FereseShell { config.wallpaper.path = None; } - if self.config.font_family != config.font_family { - *SHELL_FONT - .get_or_init(|| std::sync::RwLock::new(cosmic::font::default())) - .write() - .unwrap() = configured_font(config.font_family.as_deref()); - } - motion::configure(config.animations, config.theme.material_radius); + self.render = rendering::Rendering::new(&config); if self.config.animations != config.animations { self.notifications.update_motion_settings(config.animations); @@ -877,14 +930,25 @@ impl FereseShell { let composition_changed = !self.config.panels[0].same_composition(&config.panels[0]); let edge_changed = self.config.panels[0].edge != config.panels[0].edge; let old = self.config.theme; - let old_geometry = self.config.panels[0].geometry; + let old_geometry = self.config.panels[0].resolved_geometry(); let theme = config.theme; - let geometry = config.panels[0].geometry; + let geometry = config.panels[0].resolved_geometry(); let geometry_changed = edge_changed || old_geometry != geometry; self.notifications.configure(config.notifications.clone()); self.config = config; + if composition_changed || geometry_changed { + for entry in &mut self.outputs { + entry + .runtime + .reset(&self.config.panels[0], &self.snapshot, entry.name.as_deref()); + } + } else { + for entry in &mut self.outputs { + entry.runtime.update_appearance(&self.config.panels[0]); + } + } let workspace_task = self.update_workspace_ui(); - let mut tasks = vec![workspace_task]; + let mut tasks = vec![workspace_task, self.sync_hub_activity()]; if composition_changed || geometry_changed { tasks.push(self.destroy_menu()); @@ -893,7 +957,7 @@ impl FereseShell { // publishes its replacement through PanelResolved. tasks.push(set_input_zone( output.bar, - bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions), + bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions), )); } } @@ -909,21 +973,40 @@ impl FereseShell { tasks.push(cosmic::command::set_theme(theme.palette().native_theme())); } - for entry in self.outputs.iter().filter(|_| geometry_changed) { + for entry in self.outputs.iter().filter(|_| geometry_changed || composition_changed) { + let panel = entry.runtime.panel(&self.config.panels[0]); + let geometry = panel.resolved_geometry(); let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0); - tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT)); - tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32))); - tasks.push(set_exclusive_zone( + let hub_size = entry.runtime.hub.as_ref().map(|hub| hub.borrow().allocation); + tasks.push(set_anchor( entry.bar, - (geometry.height.round() as i32).saturating_add(geometry.window_clearance), + if hub_size.is_some() { + anchor + } else { + anchor | Anchor::LEFT | Anchor::RIGHT + }, )); + let (width, height) = hub_size.map_or((None, Some(geometry.height.round() as u32)), |size| { + (Some(size[0]), Some(size[1])) + }); + tasks.push(set_size(entry.bar, width, height)); + tasks.push( + cosmic::iced::platform_specific::shell::commands::layer_surface::set_keyboard_interactivity( + entry.bar, + KeyboardInteractivity::None, + ), + ); + tasks.push(set_exclusive_zone(entry.bar, panel.exclusive_zone(entry.hidden))); } let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect(); for bar in bars { - tasks.push(self.update_panel_margin(bar, edge_changed || old_geometry.edge_margin != geometry.edge_margin)); + tasks.push(self.update_panel_margin( + bar, + composition_changed || edge_changed || old_geometry.edge_margin != geometry.edge_margin, + )); } - if geometry_changed { + if geometry_changed || composition_changed { tasks.push(self.dismiss_workspace_tooltip()); if let Some(surface) = self.notification_surface.take() { tasks.push(destroy_layer_surface(surface.id)); @@ -934,20 +1017,26 @@ impl FereseShell { } fn update_panel_margin(&mut self, bar: window::Id, force: bool) -> Task { - let geometry = self.config.panels[0].geometry; + let panel = self.panel_for_bar(bar); + let is_hub = panel.style == ferese_panel::PanelStyle::Morph; + let geometry = panel.resolved_geometry(); let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == bar) else { return Task::none(); }; - let margin = entry.size.zip(entry.panel_resolution.as_ref()).map_or( - entry.bar_margin_horizontal, - |((width, _), resolution)| { - resolution.side_margin( - geometry.side_margins, - width, - f32::from(geometry.inner_padding.round() as u16), - ) - }, - ); + let margin = if is_hub { + 0 + } else { + entry.size.zip(entry.panel_resolution.as_ref()).map_or( + entry.bar_margin_horizontal, + |((width, _), resolution)| { + resolution.side_margin( + geometry.side_margins, + width, + f32::from(geometry.inner_padding.round() as u16), + ) + }, + ) + }; if entry.bar_margin_horizontal == margin && !force { return Task::none(); } @@ -982,7 +1071,7 @@ mod tests { let output = wl_output::WlOutput::inert(connection.backend().downgrade()); FereseShell { core: Core::default(), - notifications: notifications::Center::new(config.notifications.clone()), + notifications: notifications::Center::new(config.notifications.clone(), config.animations), notification_surface: None, status_service: status::Service::start(None), status: Default::default(), @@ -998,6 +1087,7 @@ mod tests { guide_load: Default::default(), guide_attempts: 0, pending_power: None, + render: rendering::Rendering::new(&config), config, wallpaper: None, control: None, @@ -1006,6 +1096,8 @@ mod tests { overview_active: false, clock: String::new(), outputs: vec![OutputSurfaces { + runtime: panel_runtime::Runtime::new(&panel::Panel::default(), &ShellSnapshot::default(), None), + scale: 1., output, name: Some("test".into()), bar: window::Id::unique(), @@ -1025,7 +1117,7 @@ mod tests { #[test] fn panel_placement_uses_only_the_selected_edge_for_anchor_and_margin() { - use ferese_config::panel::Edge; + use ferese_panel::Edge; for gap in [0, 12] { let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32); assert_eq!(anchor, Anchor::TOP); @@ -1039,7 +1131,8 @@ mod tests { #[test] fn cosmetic_reloads_preserve_measured_margins_and_the_open_popover() { let mut shell = shell_with_measured_panel(); - shell.config.panels[0].geometry.side_margins = 240; + shell.config.panels[0].style = ferese_panel::PanelStyle::Floating; + shell.config.panels[0].geometry.side_margins = Some(240); drop(shell.update_panel_margin(shell.outputs[0].bar, false)); assert_eq!(shell.outputs[0].bar_margin_horizontal, 240); let popup = window::Id::unique(); @@ -1061,7 +1154,6 @@ mod tests { ("background_opacity", serde_json::json!(0.5)), ("corner_radius", serde_json::json!("4px 8px")), ("border", serde_json::json!(true)), - ("group_surface", serde_json::json!("inset")), ] { let edit = field; let mut document = ferese_config::Document::parse("").unwrap(); @@ -1205,7 +1297,8 @@ mod tests { fn an_empty_measured_panel_is_not_an_unmeasured_panel() { let mut shell = shell_with_measured_panel(); let bar = shell.outputs[0].bar; - shell.config.panels[0].geometry.side_margins = 240; + shell.config.panels[0].style = ferese_panel::PanelStyle::Floating; + shell.config.panels[0].geometry.side_margins = Some(240); shell.outputs[0].panel_resolution = None; drop(shell.update_panel_margin(bar, false)); assert_eq!(shell.outputs[0].bar_margin_horizontal, 0); @@ -1365,7 +1458,9 @@ mod tests { focused: true, urgent: false, fullscreen: true, + maximized: false, floating: false, + geometry: None, }], } } diff --git a/shell/ferese-shell/src/media.rs b/shell/ferese-shell/src/media.rs index 9624739..6be71a8 100644 --- a/shell/ferese-shell/src/media.rs +++ b/shell/ferese-shell/src/media.rs @@ -138,10 +138,17 @@ impl FereseShell { } pub fn media_tick_subscription(&self) -> Subscription<()> { - let visible = self - .menu - .as_ref() - .is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()) + let hub_visible = self.outputs.iter().any(|entry| { + !entry.hidden + && entry.runtime.hub.as_ref().is_some_and(|hub| { + hub.borrow().hub.mode() == ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Media) + }) + }); + let visible = (hub_visible + || self + .menu + .as_ref() + .is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing())) && self.outputs.iter().any(|output| !output.hidden); let on_battery = self .status diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index bb6c303..1df5951 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -1,5 +1,5 @@ mod frames; -pub(crate) use frames::frame_driven; +pub(crate) use frames::{ContentMeasurement, frame_driven, geometry_driven}; use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget}; use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector}; @@ -10,29 +10,6 @@ pub type Regions = std::sync::Arc>>; pub(crate) use ferese_animation::MotionSettings as Settings; use ferese_animation::{AnimatedValue, SpringConfig}; -static SETTINGS: std::sync::OnceLock> = std::sync::OnceLock::new(); -static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits()); - -/// All shell shapes share one radius, capped to fit smaller controls. -pub(crate) fn radius(limit: f32) -> f32 { - f32::from_bits(SHELL_RADIUS.load(std::sync::atomic::Ordering::Relaxed)).min(limit) -} - -pub(crate) fn configure(settings: Settings, radius: f32) { - SHELL_RADIUS.store(radius.to_bits(), std::sync::atomic::Ordering::Relaxed); - *SETTINGS - .get_or_init(|| std::sync::RwLock::new(Settings::default())) - .write() - .unwrap() = settings; -} - -pub(crate) fn settings() -> Settings { - *SETTINGS - .get_or_init(|| std::sync::RwLock::new(Settings::default())) - .read() - .unwrap() -} - pub(crate) struct PopupMotion { value: AnimatedValue, started: Option, @@ -129,18 +106,20 @@ impl PopupMotion { /// Animate paint only; the original button retains its layout, hit target, /// keyboard handling, pressed state, and accessibility identity. pub(crate) fn button<'a, M: Clone + 'a>( + render: crate::rendering::Rendering, button: cosmic::widget::button::Button<'a, M>, foreground: cosmic::iced::Color, selected: bool, opacity: f32, ) -> Element<'a, M> { let paint = move |progress: f32, pressed: bool| { - ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed) + ferese_theme::controls::shell_button(foreground, selected, opacity, render.radius(14.), progress, pressed) }; - painted_button(button, paint) + painted_button(render, button, paint) } pub(crate) fn circular_button<'a, M: Clone + 'a>( + render: crate::rendering::Rendering, button: cosmic::widget::button::Button<'a, M>, foreground: cosmic::iced::Color, fill: Option, @@ -163,10 +142,11 @@ pub(crate) fn circular_button<'a, M: Clone + 'a>( style }; - painted_button(button, paint) + painted_button(render, button, paint) } fn painted_button<'a, M: Clone + 'a>( + render: crate::rendering::Rendering, button: cosmic::widget::button::Button<'a, M>, paint: impl Fn(f32, bool) -> cosmic::widget::button::Style + Copy + 'static, ) -> Element<'a, M> { @@ -184,10 +164,7 @@ fn painted_button<'a, M: Clone + 'a>( Element::new(Hover { content, progress, - duration: SETTINGS - .get() - .map_or_else(Settings::default, |settings| *settings.read().unwrap()) - .duration(120.0), + duration: render.motion.duration(120.0), }) } @@ -358,7 +335,7 @@ impl Widget for Hover<'_, M> { } pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> { - animated_from_edge(content, progress, regions, radius, ferese_config::panel::Edge::Top) + animated_from_edge(content, progress, regions, radius, ferese_panel::Edge::Top) } pub fn animated_from_edge<'a, M: 'a>( @@ -366,18 +343,14 @@ pub fn animated_from_edge<'a, M: 'a>( progress: f32, regions: Regions, radius: f32, - edge: ferese_config::panel::Edge, + edge: ferese_panel::Edge, ) -> Element<'a, M> { Element::new(Motion { content, progress, regions, radius, - direction: if edge == ferese_config::panel::Edge::Top { - -1. - } else { - 1. - }, + direction: if edge == ferese_panel::Edge::Top { -1. } else { 1. }, }) } diff --git a/shell/ferese-shell/src/motion/frames.rs b/shell/ferese-shell/src/motion/frames.rs index d3f97ad..017aef9 100644 --- a/shell/ferese-shell/src/motion/frames.rs +++ b/shell/ferese-shell/src/motion/frames.rs @@ -3,18 +3,50 @@ use super::*; use std::time::Instant; +pub(crate) struct ContentMeasurement<'a, M> { + pub content: Element<'a, M>, + pub width: f32, + pub measured: Box, +} + +pub(crate) fn geometry_driven<'a, M: 'a>( + revision: Option, + build: impl Fn(Instant) -> Element<'a, M> + 'a, + active: impl Fn(Instant) -> bool + 'a, + after_frame: impl Fn(Instant) + 'a, + completion: impl Fn(Instant) -> Option + 'a, + measurement: Option>, +) -> Element<'a, M> { + driven(revision, build, active, after_frame, completion, true, measurement) +} + pub(crate) fn frame_driven<'a, M: 'a>( revision: Option, build: impl Fn(Instant) -> Element<'a, M> + 'a, active: impl Fn(Instant) -> bool + 'a, after_frame: impl Fn(Instant) + 'a, completion: impl Fn(Instant) -> Option + 'a, +) -> Element<'a, M> { + driven(revision, build, active, after_frame, completion, false, None) +} + +fn driven<'a, M: 'a>( + revision: Option, + build: impl Fn(Instant) -> Element<'a, M> + 'a, + active: impl Fn(Instant) -> bool + 'a, + after_frame: impl Fn(Instant) + 'a, + completion: impl Fn(Instant) -> Option + 'a, + geometry_changes: bool, + measurement: Option>, ) -> Element<'a, M> { let now = Instant::now(); let content = build(now); let content_active = active(now); Element::new(FrameDriven { content, + measurement, + measured: false, + geometry_changes, content_active, revision, build: Box::new(build), @@ -29,6 +61,8 @@ struct FrameState { revision: Option, last_frame: Option, completed: bool, + pending: bool, + limits: Option, } impl FrameState { @@ -42,6 +76,7 @@ impl FrameState { return false; } self.last_frame = Some(at); + self.pending = true; true } @@ -56,7 +91,10 @@ impl FrameState { struct FrameDriven<'a, M> { content: Element<'a, M>, + measurement: Option>, + measured: bool, content_active: bool, + geometry_changes: bool, revision: Option, build: Box Element<'a, M> + 'a>, active: Box bool + 'a>, @@ -72,10 +110,20 @@ impl<'a, M: 'a> Widget for FrameDriven<'a, M> { widget::tree::State::new(FrameState::default()) } fn children(&self) -> Vec { - vec![widget::Tree::new(&self.content)] + std::iter::once(widget::Tree::new(&self.content)) + .chain(self.measurement.iter().map(|m| widget::Tree::new(&m.content))) + .collect() } fn diff(&mut self, tree: &mut widget::Tree) { - tree.diff_children(std::slice::from_mut(&mut self.content)); + tree.children.truncate(1 + usize::from(self.measurement.is_some())); + tree.children[0].diff(&mut self.content); + if let Some(measurement) = &mut self.measurement { + if tree.children.len() == 1 { + tree.children.push(widget::Tree::new(&measurement.content)); + } else { + tree.children[1].diff(&mut measurement.content); + } + } } fn size(&self) -> Size { self.content.as_widget().size() @@ -86,6 +134,23 @@ impl<'a, M: 'a> Widget for FrameDriven<'a, M> { renderer: &cosmic::Renderer, limits: &layout::Limits, ) -> layout::Node { + tree.state.downcast_mut::().limits = Some(*limits); + if !self.measured { + if let Some(measurement) = &mut self.measurement { + // Measure at the final width once, so spring overshoot never changes wrapping or its target. + let probe_limits = layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY)); + let node = measurement + .content + .as_widget_mut() + .layout(&mut tree.children[1], renderer, &probe_limits); + (measurement.measured)(node.size()); + let now = Instant::now(); + self.content = (self.build)(now); + self.content_active = (self.active)(now); + tree.children[0].diff(&mut self.content); + } + self.measured = true; + } self.content .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) @@ -117,39 +182,57 @@ impl<'a, M: 'a> Widget for FrameDriven<'a, M> { } else { None }; + let mut updated_geometry = false; + let mut fresh_layout = None; if let Some(at) = redraw { - // Only paint parameters depend on time. Preserve the child tree's - // focus/hover/scroll state and its existing layout. + // Preserve focus and scroll state while sampling the current frame. let active = (self.active)(at); // Include the final settled sample, then reuse the tree on hover // and other redraws. Application updates build fresh content. if active || self.content_active { self.content = (self.build)(at); tree.children[0].diff(&mut self.content); + if self.geometry_changes { + self.content_active = (self.active)(at); + let limits = tree + .state + .downcast_ref::() + .limits + .expect("laid-out animation"); + fresh_layout = Some( + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, &limits) + .move_to(layout.position()), + ); + updated_geometry = true; + } } - self.content_active = active; + self.content_active = (self.active)(at); } + // Iced revalidates the base layout without replaying the event. Publish + // the measured shape and run the child against this frame's layout now. self.content.as_widget_mut().update( &mut tree.children[0], event, - layout, + fresh_layout.as_ref().map_or(layout, Layout::new), cursor, renderer, clipboard, shell, viewport, ); + if updated_geometry { + shell.invalidate_layout(); + } if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event { let active = (self.active)(*at); if active { shell.request_redraw(); } - // Iced can replay the same redraw after a child invalidates layout. - // Keep its NextFrame request, but sample/publish only once. - if redraw.is_some() { + // Complete only after a valid layout, including the replay of this frame. + if std::mem::take(&mut tree.state.downcast_mut::().pending) { (self.after_frame)(*at); - } - if redraw.is_some() { let completion = (self.completion)(*at); if tree.state.downcast_mut::().complete(completion.is_none()) && let Some(message) = completion diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index 780211f..f476ceb 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -27,6 +27,7 @@ fn blend(base: Color, foreground: Color, amount: f32) -> Color { } fn icon_well<'a>( + render: crate::rendering::Rendering, content: impl Into>>, foreground: Color, extent: u16, @@ -36,7 +37,7 @@ fn icon_well<'a>( .center_y(extent) .class(ferese_theme::controls::surface( foreground.scale_alpha(0.07), - motion::radius(f32::from(extent) / 2.0), + render.radius(f32::from(extent) / 2.0), )) .into() } @@ -51,6 +52,7 @@ fn age_label(age: Duration) -> String { } fn card_button<'a>( + render: crate::rendering::Rendering, content: impl Into>>, message: Message, foreground: Color, @@ -65,7 +67,7 @@ fn card_button<'a>( .class(ferese_theme::controls::notification_button( foreground, foreground.scale_alpha(0.08), - if round { motion::radius(11.) } else { 0. }, + if round { render.radius(11.) } else { 0. }, round, )) .into() @@ -89,6 +91,7 @@ fn card_surface<'a>( } fn icon_control<'a>( + render: crate::rendering::Rendering, source: &'static [u8], label: &'static str, message: Message, @@ -97,6 +100,7 @@ fn icon_control<'a>( extent: u16, ) -> Element<'a, cosmic::Action> { let control = card_button( + render, container(bar_icon(source, 14, foreground)) .center_x(extent) .center_y(extent), @@ -107,14 +111,14 @@ fn icon_control<'a>( ); cosmic::widget::tooltip( control, - text(label).size(11).class(theme::Text::Color(foreground)), + render.text(label).size(11).class(theme::Text::Color(foreground)), cosmic::widget::tooltip::Position::Left, ) .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(background)), border: Border { shape: BorderShape::Continuous, - radius: motion::radius(12.0).into(), + radius: render.radius(12.0).into(), ..Default::default() }, text_color: Some(foreground), @@ -145,13 +149,30 @@ impl FereseShell { }) .and_then(|output| output.size) .map_or(HISTORY_MAX_HEIGHT, |(_, height)| { - let geometry = self.config.panels[0].geometry; + let geometry = self + .menu + .as_ref() + .map(|menu| self.panel_for_bar(menu.anchor.parent)) + .unwrap_or(&self.config.panels[0]) + .resolved_geometry(); (height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0).max(1.0) }) .min(HISTORY_MAX_HEIGHT) } pub(super) fn toggle_notification_history(&mut self) -> Task { + if let Some(parent) = self.active_bar() + && self.hub_mode(parent).is_some() + { + return if self.hub_mode(parent) + == Some(ferese_panel::morph::Mode::Expanded( + ferese_panel::morph::Content::Notifications, + )) { + self.close_hub(parent) + } else { + self.open_hub(parent, status_ui::Menu::Notifications) + }; + } if self.notifications.history_open { return self.close_menu(); } @@ -174,7 +195,7 @@ impl FereseShell { x: (width - 48).max(0), y: 0, width: 24, - height: self.config.panels[0].geometry.height.round() as i32, + height: self.config.panels[0].resolved_geometry().height.round() as i32, }, }; self.open_menu(status_ui::Menu::Notifications, anchor) @@ -195,7 +216,10 @@ impl FereseShell { }); } let count = self.notifications.popup_groups().len(); - let wanted = self.notifications.ready && !self.notifications.history_open && count > 0; + let wanted = self.config.panels[0].style != ferese_panel::PanelStyle::Morph + && self.notifications.ready + && !self.notifications.history_open + && count > 0; let selected = self .outputs .iter() @@ -236,7 +260,13 @@ impl FereseShell { .find(|entry| entry.output == output) .and_then(|entry| entry.size) .map_or(1080, |size| size.1); - let top = self.config.panels[0].geometry.height + self.config.panels[0].geometry.edge_margin as f32 + 12.0; + let geometry = self + .outputs + .iter() + .find(|entry| entry.output == output) + .map(|entry| entry.runtime.panel(&self.config.panels[0]).resolved_geometry()) + .unwrap_or_else(|| self.config.panels[0].resolved_geometry()); + let top = geometry.height + geometry.edge_margin as f32 + 12.0; let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32); if let Some(surface) = &mut self.notification_surface { if surface.height != height { @@ -306,7 +336,7 @@ impl FereseShell { let divider = tint(palette.border); let well = blend(base, foreground, 0.07); let id = notice.id; - let mut bold = *SHELL_FONT.get().unwrap().read().unwrap(); + let mut bold = self.render.font; bold.weight = cosmic::iced::font::Weight::Bold; let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() { accented_icon(ferese_theme::icons::FERESE, 18, accent, accent) @@ -316,7 +346,7 @@ impl FereseShell { icon::from_name(notice.icon.as_str()).size(18).icon() } .opacity(opacity); - let icon_well = icon_well(app_icon, foreground, 30); + let icon_well = icon_well(self.render, app_icon, foreground, 30); let icon: Element<'_, cosmic::Action> = if count > 1 { let badge_fill = ferese_theme::composite( color(palette.accent), @@ -328,7 +358,8 @@ impl FereseShell { ); let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary)); let badge = container( - text(count.to_string()) + self.render + .text(count.to_string()) .size(10) .font(bold) .class(theme::Text::Color(badge_text.scale_alpha(opacity))), @@ -339,7 +370,7 @@ impl FereseShell { background: Some(Background::Color(badge_fill.scale_alpha(opacity))), border: Border { shape: BorderShape::Continuous, - radius: motion::radius(8.0).into(), + radius: self.render.radius(8.0).into(), ..Default::default() }, ..Default::default() @@ -372,17 +403,25 @@ impl FereseShell { .align_y(alignment::Vertical::Center) .push(icon) .push( - container(text(app).size(11).font(bold).class(theme::Text::Color(muted))) - .width(Length::Fill) - .height(16) - .clip(true), + container( + self.render + .text(app) + .size(11) + .font(bold) + .class(theme::Text::Color(muted)), + ) + .width(Length::Fill) + .height(16) + .clip(true), ) .push( - text(age_label(notice.received_at.elapsed())) + self.render + .text(age_label(notice.received_at.elapsed())) .size(11) .class(theme::Text::Color(muted)), ) .push(icon_control( + self.render, ferese_theme::icons::CLOSE, "Dismiss notification", if history && count > 1 { @@ -398,7 +437,8 @@ impl FereseShell { )); let mut content = column([]).spacing(4).push(header).push( container( - text(notice.title.clone()) + self.render + .text(notice.title.clone()) .size(14) .font(bold) .class(theme::Text::Color(foreground)), @@ -417,7 +457,7 @@ impl FereseShell { }; if !body.is_empty() { content = content.push( - container(text(body).size(13).class(theme::Text::Color(muted))) + container(self.render.text(body).size(13).class(theme::Text::Color(muted))) .height(if history && count == 1 { Length::Shrink } else { @@ -447,8 +487,10 @@ impl FereseShell { )); } actions = actions.push(card_button( + self.render, container( - text(label.clone()) + self.render + .text(label.clone()) .font(bold) .size(13) .class(theme::Text::Color(accent)), @@ -511,7 +553,7 @@ impl FereseShell { })), border: Border { shape: BorderShape::Continuous, - radius: [0.0, 0.0, motion::radius(6.0), motion::radius(6.0)].into(), + radius: [0.0, 0.0, self.render.radius(6.0), self.render.radius(6.0)].into(), ..Default::default() }, ..Default::default() @@ -579,7 +621,7 @@ impl FereseShell { let base = color(palette.surface_base); let compositor_material = surface.is_some_and(|menu| menu.effects.is_some()); let well = blend(base, primary, 0.07); - let mut bold = *SHELL_FONT.get().unwrap().read().unwrap(); + let mut bold = self.render.font; bold.weight = cosmic::iced::font::Weight::Bold; let groups = self.notifications.history_groups(); let count = self.notifications.entries.len(); @@ -596,17 +638,19 @@ impl FereseShell { let heading = column([]) .spacing(3) .push( - text("Notifications") + self.render + .text("Notifications") .size(16) .font(bold) .class(theme::Text::Color(primary)), ) - .push(text(subtitle).size(11).class(theme::Text::Color(muted))); + .push(self.render.text(subtitle).size(11).class(theme::Text::Color(muted))); let header = row([]) .spacing(10) .align_y(alignment::Vertical::Center) .push(container(heading).width(Length::Fill)) .push(icon_control( + self.render, ferese_theme::icons::CLOSE, "Close notification center", Message::ToggleNotificationHistory, @@ -629,27 +673,31 @@ impl FereseShell { column([]) .spacing(2) .push( - text("Do Not Disturb") + self.render + .text("Do Not Disturb") .size(12) .font(bold) .class(theme::Text::Color(primary)), ) .push( - text(if self.notifications.dnd { - "Popups paused" - } else { - "Popups enabled" - }) - .size(11) - .class(theme::Text::Color(muted)), + self.render + .text(if self.notifications.dnd { + "Popups paused" + } else { + "Popups enabled" + }) + .size(11) + .class(theme::Text::Color(muted)), ), ) .width(Length::Fill), ) .push(motion::button( + self.render, button::custom( container( - text(if self.notifications.dnd { "On" } else { "Off" }) + self.render + .text(if self.notifications.dnd { "On" } else { "Off" }) .size(12) .font(bold) .class(theme::Text::Color(dnd_color)), @@ -672,18 +720,21 @@ impl FereseShell { .spacing(10) .align_x(alignment::Horizontal::Center) .push(icon_well( + self.render, accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent), primary, 56, )) .push( - text("You're all caught up") + self.render + .text("You're all caught up") .size(15) .font(bold) .class(theme::Text::Color(primary)), ) .push( - text("New notifications will appear here.") + self.render + .text("New notifications will appear here.") .size(11) .class(theme::Text::Color(muted)), ); @@ -693,13 +744,15 @@ impl FereseShell { row([]) .align_y(alignment::Vertical::Center) .push( - text("Recent") + self.render + .text("Recent") .size(11) .font(bold) .class(theme::Text::Color(muted)) .width(Length::Fill), ) .push(icon_control( + self.render, ferese_theme::icons::TRASH, "Clear all notifications", Message::ClearNotifications, @@ -721,13 +774,15 @@ impl FereseShell { .spacing(6) .align_y(alignment::Vertical::Center) .push( - text(format!("{} · {}", app, group.len())) + self.render + .text(format!("{} · {}", app, group.len())) .size(11) .font(bold) .class(theme::Text::Color(muted)) .width(Length::Fill), ) .push(icon_control( + self.render, ferese_theme::icons::CHEVRON_UP, "Collapse group", Message::ToggleNotificationGroup(latest.app.clone()), @@ -736,6 +791,7 @@ impl FereseShell { 24, )) .push(icon_control( + self.render, ferese_theme::icons::TRASH, "Clear group", Message::RemoveNotificationGroup(latest.app.clone()), @@ -834,7 +890,12 @@ mod tests { .unwrap(); for foreground in [Color::BLACK, Color::WHITE] { for extent in [30, 56] { - let inset = icon_well(cosmic::widget::Space::new(), foreground, extent); + let inset = icon_well( + crate::rendering::Rendering::new(&crate::config::ShellConfig::default()), + cosmic::widget::Space::new(), + foreground, + extent, + ); let controls = ferese_theme::menus::section( cosmic::widget::Space::new().width(100).height(40).into(), foreground, @@ -899,6 +960,7 @@ mod tests { for history in [false, true] { for scale in [1.0, 1.25, 1.5] { let action = card_button( + crate::rendering::Rendering::new(&crate::config::ShellConfig::default()), cosmic::widget::Space::new().height(38), Message::InvokeNotification(1, "action".into()), Color::WHITE, diff --git a/shell/ferese-shell/src/notifications.rs b/shell/ferese-shell/src/notifications.rs index bd6b5b2..cd3ca6a 100644 --- a/shell/ferese-shell/src/notifications.rs +++ b/shell/ferese-shell/src/notifications.rs @@ -279,10 +279,11 @@ pub struct Center { pub entries: VecDeque, toasts: VecDeque, last_tick: Instant, + motion: super::motion::Settings, } impl Center { - pub fn new(config: ferese_config::notifications::NotificationConfig) -> Self { + pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self { Self { service: Service::start(&config), ready: false, @@ -294,6 +295,7 @@ impl Center { entries: VecDeque::new(), toasts: VecDeque::new(), last_tick: Instant::now(), + motion, } } @@ -321,6 +323,7 @@ impl Center { } pub fn update_motion_settings(&mut self, settings: super::motion::Settings) { + self.motion = settings; for toast in &mut self.toasts { toast.motion.update_settings(settings); } @@ -518,7 +521,7 @@ impl Center { self.toasts.push_back(Toast { id: notice.id, motion: { - let mut motion = super::motion::PopupMotion::new(super::motion::settings()); + let mut motion = super::motion::PopupMotion::new(self.motion); motion.begin(Instant::now()); motion }, @@ -641,6 +644,7 @@ mod tests { let (commands, receiver) = mpsc::sync_channel(256); let center = Center { config: NotificationConfig::default(), + motion: Default::default(), service: Service { timeout: Arc::new(std::sync::atomic::AtomicU32::new(6000)), events: Events(Arc::new(tokio::sync::Mutex::new(events))), diff --git a/shell/ferese-shell/src/panel.rs b/shell/ferese-shell/src/panel.rs index d888c59..45bddd9 100644 --- a/shell/ferese-shell/src/panel.rs +++ b/shell/ferese-shell/src/panel.rs @@ -1 +1 @@ -pub(crate) use ferese_config::panel::*; +pub(crate) use ferese_panel::*; diff --git a/shell/ferese-shell/src/panel_hub.rs b/shell/ferese-shell/src/panel_hub.rs new file mode 100644 index 0000000..e0acd28 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub.rs @@ -0,0 +1,2629 @@ +use std::cell::RefCell; +use std::time::Instant; + +use crate::KeyboardInteractivity; +use cosmic::app::Task; +use cosmic::iced::platform_specific::shell::commands::layer_surface::{set_keyboard_interactivity, set_size}; +use cosmic::iced::{Alignment, Length, window}; +use cosmic::widget::{button, column, container, row, scrollable}; + +mod activity; +mod content_frame; +mod equalizer; +mod scroll; +mod transition; +use transition::{Event, Transient}; +mod workspace_grid; +mod workspaces; +use cosmic::{Element, theme}; +use ferese_panel::morph::{Content, Hub, Mode, Sample}; +use ferese_panel::{Edge, ItemKind, Panel, PanelStyle}; +use ferese_theme::icons; + +use crate::{FereseShell, Message, color_with_opacity, motion, status_ui}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Peek { + Recording, + Battery, + Media, + Date, + Workspaces, +} + +struct ControlAction { + icon: &'static [u8], + label: &'static str, + message: Option, + menu: Option, +} + +#[derive(Clone, Copy)] +struct PreviousContent { + menu: Option, + peek: Peek, + toast: Option, + size: [f64; 2], +} + +pub(super) struct Runtime { + pub(super) hub: Hub, + pub(super) menu: Option, + previous: PreviousContent, + transient: Transient, + pub(super) allocation: [u32; 2], + minimum_allocation: [u32; 2], + fillet_space: u32, + pub(super) revision: Instant, + sampled: Instant, + motion: motion::Settings, + last_menu: status_ui::Menu, + priority: Option, + peek: Peek, + observed_workspace: Option, + workspace_hover: Option, + workspace_selected: Option, + workspace_selection_pending: Option, + hovered: bool, + idle_content_width: Option, + available: [f64; 2], + peek_width: f64, + idle_width: f64, + workspace_width: f64, + workspace_scroll: workspaces::Scroll, +} + +impl Runtime { + pub(super) fn new(panel: &Panel) -> Self { + let mut hub = Hub::new(panel.morph, panel.resolved_geometry().height.into()).expect("validated panel hub"); + hub.pin_radii(panel.corner_radius.map(|r| r.0.map(f64::from))) + .expect("validated panel radii"); + hub.advance(std::time::Duration::ZERO, true); + let now = Instant::now(); + // Keep the native surface stable through hover, including spring overshoot. + let fillet_space = if panel.morph.attached { 24 } else { 0 }; + let minimum_allocation = [ + (panel.morph.peek_width.max(panel.morph.idle_width).max(200.) * 1.1).ceil() as u32 + fillet_space, + (panel.morph.peek_height.max(f64::from(panel.resolved_geometry().height)) * 1.1).ceil() as u32, + ]; + Self { + allocation: minimum_allocation, + minimum_allocation, + fillet_space, + hub, + menu: None, + previous: PreviousContent { + menu: None, + peek: Peek::Date, + toast: None, + size: [panel.morph.idle_width, f64::from(panel.resolved_geometry().height)], + }, + transient: Transient::None, + revision: now, + sampled: now, + motion: Default::default(), + last_menu: status_ui::Menu::Workspaces, + priority: None, + peek: Peek::Date, + observed_workspace: None, + workspace_hover: None, + workspace_selected: None, + workspace_selection_pending: None, + hovered: false, + idle_content_width: None, + available: [1280., 720.], + peek_width: 200., + idle_width: panel.morph.idle_width, + workspace_width: 0., + workspace_scroll: Default::default(), + } + } + + fn sample(&mut self, now: Instant) -> Sample { + let policy = self.motion; + self.hub.advance( + now.saturating_duration_since(self.sampled).mul_f64(policy.speed), + !policy.motion_enabled(), + ); + self.sampled = self.sampled.max(now); + self.hub.sample() + } + + fn retarget(&mut self, mode: Mode, available: [f64; 2], menu: Option) { + let now = Instant::now(); + let shape_available = [(available[0] - f64::from(self.fillet_space)).max(24.), available[1]]; + self.available = shape_available; + self.sample(now); + self.menu = menu; + if mode == Mode::Idle { + self.hub.retarget_idle( + self.idle_content_width + .unwrap_or(self.idle_width + self.workspace_width) + .clamp(24., 1024.), + shape_available, + ) + } else if mode == Mode::Peek { + self.hub.retarget_peek(self.peek_width.min(1024.), shape_available) + } else { + self.hub.retarget(mode, shape_available) + } + .expect("bounded hub area"); + let mut target = self.hub.allocation_size().map(|n| n.ceil() as u32); + target[0] = target[0] + .saturating_add(self.fillet_space) + .min(available[0].ceil() as u32); + // Retain the backing surface between opens; resizing after settlement moves its origin. + self.minimum_allocation = std::array::from_fn(|i| self.minimum_allocation[i].min(available[i].ceil() as u32)); + self.allocation = std::array::from_fn(|i| self.allocation[i].max(target[i]).max(self.minimum_allocation[i])); + self.revision = now; + } +} + +pub(super) fn new(panel: &Panel) -> Option> { + (panel.style == PanelStyle::Morph).then(|| RefCell::new(Runtime::new(panel))) +} + +fn content(kind: status_ui::Menu) -> Content { + match kind { + status_ui::Menu::Workspaces => Content::Workspaces, + status_ui::Menu::Media => Content::Media, + status_ui::Menu::Calendar => Content::Calendar, + status_ui::Menu::Notifications => Content::Notifications, + _ => Content::Controls, + } +} + +impl FereseShell { + pub(super) fn hub_mode(&self, id: window::Id) -> Option { + self.outputs + .iter() + .find(|entry| entry.bar == id)? + .runtime + .hub + .as_ref() + .map(|hub| hub.borrow().hub.mode()) + } + + pub(super) fn hub_hover(&mut self, id: window::Id, entered: bool) -> Task { + if self.bar_hidden(id) { + return Task::none(); + } + self.transition_hub( + id, + Event::Hover { + entered, + enabled: self.panel_for_bar(id).morph.peek_on_hover, + peek: self.hub_peek_content(id), + priority: self.hub_priority(), + }, + ) + } + + pub(super) fn open_hub(&mut self, id: window::Id, kind: status_ui::Menu) -> Task { + if self.bar_hidden(id) || self.hub_mode(id).is_none() { + return Task::none(); + } + if kind == status_ui::Menu::Notifications { + for notice in &mut self.notifications.entries { + notice.unread = false; + } + if let Some(status) = &mut self.status.notifications { + status.count = 0; + } + } + if kind == status_ui::Menu::Calendar { + self.calendar_offset = 0; + } + if kind == status_ui::Menu::Media { + self.media.now_us = ferese_ipc::media::now_us(); + self.media.refresh_art(true); + self.media.error = None; + } + let close = self.destroy_menu(); + let open = self.transition_hub(id, Event::Open(kind)); + Task::batch([close, open]) + } + + pub(super) fn suspend_hub(&mut self, id: window::Id) -> Task { + let close = self.close_hub(id); + let Some(hub) = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + else { + return close; + }; + let mut hub = hub.borrow_mut(); + hub.hub.advance(std::time::Duration::ZERO, true); + hub.allocation = hub.minimum_allocation; + Task::batch([close, set_size(id, Some(hub.allocation[0]), Some(hub.allocation[1]))]) + } + + pub(super) fn hub_notice(&mut self, notice: u32) -> Task { + let Some(id) = self.active_bar() else { + return Task::none(); + }; + if self.bar_hidden(id) + || self.hub_priority().is_some() + || !matches!(self.hub_mode(id), Some(Mode::Idle | Mode::Peek | Mode::Toast)) + || !self + .notifications + .popup_groups() + .iter() + .any(|(entry, _, _)| entry.id == notice) + { + return Task::none(); + } + let revision = Instant::now(); + let open = self.transition_hub( + id, + Event::Toast { + notice, + token: revision, + }, + ); + let timeout = std::time::Duration::from_millis(u64::from(self.config.notifications.timeout_ms)); + Task::batch([ + open, + Task::perform( + async move { + tokio::time::sleep(timeout).await; + }, + move |_| cosmic::Action::App(Message::HubToastExpired(id, revision)), + ), + ]) + } + + pub(super) fn sync_hub_notices(&mut self) -> Task { + let closing: Vec<_> = self + .outputs + .iter() + .filter_map(|entry| { + let hub = entry.runtime.hub.as_ref()?.borrow(); + (hub.hub.mode() == Mode::Toast + && hub.transient.toast().is_some_and(|id| { + !self + .notifications + .entries + .iter() + .any(|notice| notice.id == id && notice.live) + })) + .then_some(entry.bar) + }) + .collect(); + Task::batch(closing.into_iter().map(|id| self.close_hub(id))) + } + + pub(super) fn hub_toast_expired(&mut self, id: window::Id, revision: Instant) -> Task { + self.transition_hub(id, Event::ToastExpired(revision)) + } + + pub(super) fn close_hub(&mut self, id: window::Id) -> Task { + self.transition_hub(id, Event::Close) + } + + #[cfg(test)] + fn set_hub_mode(&mut self, id: window::Id, mode: Mode, menu: Option) -> Task { + self.transition_hub(id, Event::Present(mode, menu)) + } + + fn transition_hub(&mut self, id: window::Id, event: Event) -> Task { + let workspace_width = self.hub_workspace_width(id); + let panel = self.panel_for_bar(id); + let geometry = panel.resolved_geometry(); + let Some(entry) = self.outputs.iter().find(|entry| entry.bar == id) else { + return Task::none(); + }; + let Some(hub) = &entry.runtime.hub else { + return Task::none(); + }; + let (width, height) = entry.size.unwrap_or((1280, 720)); + let available = [ + (f64::from(width) - 2. * f64::from(geometry.side_margins)).max(24.), + (f64::from(height) - f64::from(geometry.edge_margin)).max(24.), + ]; + let (resize, mode) = { + let mut hub = hub.borrow_mut(); + hub.motion = self.render.motion; + let before = hub.allocation; + hub.workspace_width = if workspace_width > 0. { + workspace_width + 10. + } else { + 0. + }; + if !hub.transition(event, available) { + return Task::none(); + } + ((before != hub.allocation).then_some(hub.allocation), hub.hub.mode()) + }; + Task::batch([ + resize.map_or_else(Task::none, |size| set_size(id, Some(size[0]), Some(size[1]))), + set_keyboard_interactivity( + id, + if mode.expanded() { + KeyboardInteractivity::OnDemand + } else { + KeyboardInteractivity::None + }, + ), + self.dismiss_workspace_tooltip_for_bar(id), + ]) + } + + fn hub_idle_width_limit(&self, id: window::Id) -> f32 { + let runtime = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + .expect("Morph runtime"); + let runtime = runtime.borrow(); + let artwork_width = if self.hub_priority().is_some() { + 64. + } else if self + .media + .snapshot + .selected + .as_ref() + .is_some_and(|player| player.status == ferese_ipc::media::Playback::Playing) + { + 32. + } else { + 0. + }; + (runtime.idle_width + runtime.workspace_width + artwork_width) + .min(runtime.available[0]) + .min(f64::from(runtime.allocation[0].saturating_sub(runtime.fillet_space)) / 1.1) as f32 + } + + pub(super) fn view_hub(&self, id: window::Id) -> Element<'_, cosmic::Action> { + let entry = self.outputs.iter().find(|entry| entry.bar == id).expect("panel output"); + let runtime = entry.runtime.hub.as_ref().expect("Morph runtime"); + runtime.borrow_mut().motion = self.render.motion; + let revision = runtime.borrow().revision; + let mode = runtime.borrow().hub.mode(); + let workspace_feedback = mode == Mode::Peek && runtime.borrow().peek == Peek::Workspaces; + let measurement = if mode == Mode::Idle || workspace_feedback { + let cap = if workspace_feedback { + let runtime = runtime.borrow(); + 260_f64 + .min(runtime.available[0]) + .min(f64::from(runtime.allocation[0].saturating_sub(runtime.fillet_space)) / 1.1) + as f32 + } else { + self.hub_idle_width_limit(id) + }; + Some(motion::ContentMeasurement { + content: self.hub_content(id, mode, 1., None, false, true), + width: cap, + measured: Box::new(move |size| { + let mut runtime = runtime.borrow_mut(); + let width = f64::from(size.width).ceil().clamp(24., 1024.); + let available = runtime.available; + if workspace_feedback { + runtime.peek_width = width; + runtime + .hub + .retarget_peek(width, available) + .expect("measured workspace feedback"); + } else { + runtime.idle_content_width = Some(width); + runtime + .hub + .retarget_idle(width, available) + .expect("measured idle content"); + } + }), + }) + } else { + (runtime.borrow().hub.mode() == Mode::Expanded(Content::Controls)).then(|| { + let menu = runtime.borrow().menu.unwrap_or(status_ui::Menu::Overflow); + let content = if menu == status_ui::Menu::Overflow { + self.hub_controls(id, 1.) + } else { + self.view_hub_menu(menu, 1.) + }; + let width = runtime.borrow().hub.target_size()[0] as f32; + motion::ContentMeasurement { + content, + width: (width - 28.).max(1.), + measured: Box::new(move |size| { + runtime + .borrow_mut() + .hub + .fit_content_height(f64::from(size.height).ceil() + 56.) + .expect("measured hub content"); + }), + } + }) + }; + motion::geometry_driven( + Some(revision), + move |now| { + let (sample, menu, previous_menu) = { + let mut runtime = runtime.borrow_mut(); + (runtime.sample(now), runtime.menu, runtime.previous.menu) + }; + self.view_hub_at(id, sample, menu, previous_menu) + }, + move |_| runtime.borrow().hub.active(), + |_| {}, + |_| None, + measurement, + ) + } + + fn view_hub_at( + &self, + id: window::Id, + sample: Sample, + menu: Option, + previous_menu: Option, + ) -> Element<'_, cosmic::Action> { + let panel = self.panel_for_bar(id); + let entry = self.outputs.iter().find(|entry| entry.bar == id).expect("panel output"); + let effects = entry.effects.as_ref(); + let bounds = sample.geometry.bounds; + let (target_size, previous_size) = { + let runtime = entry.runtime.hub.as_ref().expect("Morph runtime").borrow(); + (runtime.hub.target_size(), runtime.previous.size) + }; + let mut layers = cosmic::iced::widget::Stack::new(); + if sample.previous_opacity > 0. { + layers = layers.push(content_frame::view( + self.hub_content( + id, + sample.previous, + sample.previous_opacity, + previous_menu, + false, + false, + ), + previous_size, + panel.edge, + )); + } + if sample.opacity > 0. { + layers = layers.push(content_frame::view( + container(self.hub_content(id, sample.current, sample.opacity, menu, true, false)) + .id("ferese-hub-content") + .width(Length::Fill) + .height(Length::Fill) + .into(), + target_size, + panel.edge, + )); + } + let body = container(layers) + .id("ferese-panel-hub") + .width(bounds.width as f32) + .height(bounds.height as f32) + .clip(true); + let root = container(body) + .center_x(Length::Fill) + .height(Length::Fill) + .align_y(if panel.edge == Edge::Bottom { + cosmic::iced::alignment::Vertical::Bottom + } else { + cosmic::iced::alignment::Vertical::Top + }); + let palette = self.config.theme.for_bar(); + let mut background = palette.surface_raised.map(|value| f32::from(value) / 255.); + background[3] = palette.panel_opacity(panel.background_opacity); + let background = cosmic::iced::Color::from_rgba(background[0], background[1], background[2], background[3]); + let border = crate::color(palette.border); + ferese_theme::panel_surface::frame( + self.with_workspace_hover(id, root.into()), + ferese_theme::panel_surface::Options { + parameters: ferese_panel::scene::Parameters { + radius: Some(ferese_panel::shape::AnchoredRadii( + sample.geometry.radii.map(|r| r as f32), + )), + ..panel.shape_parameters() + }, + bounds: Some(cosmic::iced::advanced::widget::Id::new("ferese-panel-hub")), + groups: Vec::new(), + scale: entry.scale, + high_contrast: palette.high_contrast, + background, + border: if panel.border { + border + } else { + cosmic::iced::Color::TRANSPARENT + }, + divider: cosmic::iced::Color::TRANSPARENT, + native: effects.is_some(), + }, + move |scene| { + if let Some(effects) = effects + && let Err(error) = effects.set_panel_presentation(scene) + { + eprintln!("ferese-shell: could not update hub material: {error}"); + } + }, + Some(Box::new(move |scene| { + cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone()))) + })), + ) + } + + fn hub_content( + &self, + id: window::Id, + mode: Mode, + opacity: f32, + menu: Option, + interactive: bool, + intrinsic: bool, + ) -> Element<'_, cosmic::Action> { + let palette = self.config.theme; + let primary = color_with_opacity(palette.text_primary, opacity); + let icon_primary = crate::color(palette.text_primary); + let icon_muted = crate::color(palette.text_muted); + let content: Element<'_, cosmic::Action> = match mode { + Mode::Idle | Mode::Peek | Mode::Toast => { + let playing = self + .media + .snapshot + .selected + .as_ref() + .filter(|player| player.status == ferese_ipc::media::Playback::Playing); + let time = self + .clock + .split_once(", ") + .map_or(self.clock.as_str(), |(_, time)| time); + let peek = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + .map_or(Peek::Date, |runtime| { + let runtime = runtime.borrow(); + if !interactive && !intrinsic { + runtime.previous.peek + } else { + runtime.peek + } + }); + let priority = self.hub_priority(); + let activity = if mode == Mode::Idle { + priority + } else if mode == Mode::Peek && matches!(peek, Peek::Recording | Peek::Battery) { + Some(peek) + } else { + None + }; + let label = if mode == Mode::Toast { + self.outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + .and_then(|hub| { + let hub = hub.borrow(); + if !interactive && !intrinsic { + hub.previous.toast + } else { + hub.transient.toast() + } + }) + .and_then(|id| self.notifications.entries.iter().find(|notice| notice.id == id)) + .map_or_else(|| time.to_owned(), |notice| notice.title.clone()) + } else if mode == Mode::Peek { + match peek { + Peek::Recording | Peek::Battery => String::new(), + Peek::Media => self + .media + .snapshot + .selected + .as_ref() + .map_or_else(|| "Playback ended".to_owned(), |player| player.label().to_owned()), + Peek::Date => self.clock.clone(), + Peek::Workspaces => self + .output_for_bar(id) + .and_then(|output| { + self.snapshot + .workspaces + .iter() + .find(|w| w.id == output.active_workspace) + }) + .map_or_else( + || "Workspaces".to_owned(), + |workspace| { + if workspace.name == workspace.index.to_string() { + format!("Workspace {}", workspace.index) + } else { + format!("{} · {}", workspace.index, workspace.name) + } + }, + ), + } + } else if let Some(player) = playing { + player.label().to_owned() + } else { + let title = crate::bar::focused_bar_title(&self.snapshot, self.output_for_bar(id)); + if title.trim().is_empty() { + self.clock + .split_once(", ") + .map_or(self.clock.as_str(), |(date, _)| date) + } else { + title + } + .to_owned() + }; + let kind = if let Some(activity) = activity { + if activity == Peek::Recording { + status_ui::Menu::Overflow + } else { + status_ui::Menu::Battery + } + } else if mode == Mode::Toast { + status_ui::Menu::Notifications + } else if mode == Mode::Peek { + match peek { + Peek::Recording => status_ui::Menu::Overflow, + Peek::Battery => status_ui::Menu::Battery, + Peek::Media => status_ui::Menu::Media, + Peek::Date => status_ui::Menu::Calendar, + Peek::Workspaces => status_ui::Menu::Workspaces, + } + } else if playing.is_some() { + status_ui::Menu::Media + } else { + self.outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + .map_or(status_ui::Menu::Media, |runtime| runtime.borrow().last_menu) + }; + let label = self + .render + .text(label) + .size(13) + .class(theme::Text::Color(primary)) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), + )); + let body: Element<'_, cosmic::Action> = match mode { + _ if activity.is_some() => { + self.hub_activity_strip(id, activity.unwrap(), mode == Mode::Peek, intrinsic, opacity) + } + Mode::Peek => { + let leading = match peek { + Peek::Recording | Peek::Battery => unreachable!("activity strip handled above"), + Peek::Media => self.hub_music_art(opacity), + Peek::Workspaces => self.hub_workspaces(id, opacity), + Peek::Date => icons::tinted(icons::CALENDAR, 18, icon_primary).opacity(opacity).into(), + }; + let width = if intrinsic { Length::Shrink } else { Length::Fill }; + let label = label.width(width).align_x(if peek == Peek::Date { + Alignment::End + } else { + Alignment::Start + }); + let mut strip = row![leading, label].spacing(10).width(width).align_y(Alignment::Center); + if peek == Peek::Media { + strip = strip.push(self.hub_equalizer(id, opacity)); + } + strip.into() + } + Mode::Toast => row![ + icons::tinted(icons::NOTIFICATIONS, 16, icon_muted).opacity(opacity), + label.width(Length::Fill) + ] + .spacing(10) + .align_y(Alignment::Center) + .into(), + _ if playing.is_some() => { + // Measure the title only after reserving both side widgets, + // outer padding, and gaps. During rendering, flex gives it + // whatever remains as the shell animates. + let workspaces = self.hub_workspace_width(id) as f32; + let title_limit = (self.hub_idle_width_limit(id) + - if workspaces > 0. { workspaces + 10. } else { 0. } + - 28. + - 24. + - 13. + - 16.) + .max(0.); + let title = if intrinsic { + container(label).max_width(title_limit) + } else { + container(label.width(Length::Fill)).width(Length::Fill) + }; + row![self.hub_music_art(opacity), title, self.hub_equalizer(id, opacity)] + .spacing(8) + .width(if intrinsic { Length::Shrink } else { Length::Fill }) + .align_y(Alignment::Center) + .into() + } + _ => label.into(), + }; + let appearance = button::Style { + text_color: Some(primary), + icon_color: Some(primary), + ..Default::default() + }; + let content_width = if intrinsic || (mode == Mode::Idle && (playing.is_none() || activity.is_some())) { + Length::Shrink + } else { + Length::Fill + }; + let content_height = if intrinsic { Length::Fixed(28.) } else { Length::Fill }; + let context = button::custom(container(body).width(content_width).center_y(content_height)) + .name(match activity { + Some(Peek::Recording) => format!( + "{}. Open recording controls", + self.recorder + .elapsed() + .map_or_else(|| "Saving recording".into(), |elapsed| format!("Recording, {elapsed}")) + ), + Some(Peek::Battery) => "Low battery. Open battery controls".into(), + _ => "Expand panel hub".into(), + }) + .padding(0) + .width(content_width) + .height(content_height) + .class(theme::Button::Custom { + active: Box::new(move |_, _| appearance), + hovered: Box::new(move |_, _| appearance), + pressed: Box::new(move |_, _| appearance), + disabled: Box::new(move |_| appearance), + }) + .on_press(cosmic::Action::App(Message::HubOpen(id, kind))); + let mut strip = row::with_capacity(2).spacing(10).align_y(Alignment::Center); + if mode == Mode::Idle && self.hub_workspace_width(id) > 0. { + strip = strip.push(self.hub_workspaces(id, opacity)); + } + let group = container(strip.push(context).width(content_width).height(content_height)) + .padding([0, 14]) + .height(content_height); + if intrinsic { + group.into() + } else { + group.center_x(Length::Fill).into() + } + } + Mode::Expanded(selected) => { + let mut tabs = row::with_capacity(6) + .width(Length::Fill) + .spacing(2) + .align_y(Alignment::Center); + let shortcuts = self.hub_shortcuts(id); + for (kind, label, icon) in [ + (status_ui::Menu::Workspaces, "Workspaces", icons::OVERVIEW), + (status_ui::Menu::Media, "Music", icons::MEDIA), + (status_ui::Menu::Calendar, "Calendar", icons::CALENDAR), + (status_ui::Menu::Notifications, "Notifications", icons::NOTIFICATIONS), + (status_ui::Menu::Overflow, "Controls", icons::CONTROL_CENTER), + ] { + let active = if selected == Content::Controls { + menu.unwrap_or(status_ui::Menu::Overflow) == kind + } else { + selected == content(kind) + }; + let unread = if kind == status_ui::Menu::Notifications { + self.notifications.unread() + } else { + 0 + }; + let foreground = if unread > 0 { + crate::color(palette.accent) + } else { + icon_primary + }; + let accessible_name = if unread > 0 { + format!("{label}, {unread} unread") + } else { + label.to_owned() + }; + tabs = tabs.push(motion::button( + self.render, + button::custom(icons::tinted(icon, 18, foreground).opacity(opacity)) + .name(accessible_name) + .padding([5, 3]) + .width(30) + .height(28) + .on_press(cosmic::Action::App(Message::HubOpen(id, kind))), + primary, + active, + opacity, + )); + } + for shortcut in shortcuts.into_iter().filter(|kind| *kind != ItemKind::Overview) { + tabs = tabs.push(self.hub_shortcut_button(id, shortcut, menu, opacity)); + } + let navigation = scrollable(tabs.width(Length::Shrink)) + .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( + cosmic::iced::widget::scrollable::Scrollbar::default() + .width(0) + .scroller_width(0), + )) + .width(Length::Fill); + let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center); + let mut tabs = tabs.push(navigation); + tabs = tabs.push(motion::button( + self.render, + button::custom(icons::tinted(icons::CLOSE, 18, icon_primary).opacity(opacity)) + .name("Collapse panel hub") + .padding([5, 3]) + .width(30) + .height(28) + .on_press(cosmic::Action::App(Message::HubClose(id))), + primary, + false, + opacity, + )); + let kind = match selected { + Content::Workspaces => status_ui::Menu::Workspaces, + Content::Media => status_ui::Menu::Media, + Content::Calendar => status_ui::Menu::Calendar, + Content::Notifications => status_ui::Menu::Notifications, + Content::Controls => menu.unwrap_or(status_ui::Menu::Overflow), + }; + let body = if kind == status_ui::Menu::Workspaces { + self.hub_workspace_grid(id, opacity) + } else if kind == status_ui::Menu::Overflow { + self.hub_controls(id, opacity) + } else { + self.view_hub_menu(kind, opacity) + }; + column![ + container(tabs).height(32), + scrollable(body) + .direction(cosmic::iced::widget::scrollable::Direction::Vertical( + cosmic::iced::widget::scrollable::Scrollbar::default() + .width(0) + .scroller_width(0) + )) + .height(Length::Fill) + ] + .spacing(8) + .padding([8, 14]) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + }; + let content = if matches!(mode, Mode::Idle | Mode::Peek) { + scroll::area(content, id) + } else { + content + }; + if interactive && opacity >= 0.5 { + content + } else { + content.map(|_| cosmic::Action::App(Message::HubIgnore)) + } + } + + fn hub_music_art(&self, opacity: f32) -> Element<'_, cosmic::Action> { + match self.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) { + Some(handle) => cosmic::widget::image(handle.clone()) + .width(24) + .height(24) + .opacity(opacity) + .content_fit(cosmic::iced::ContentFit::Cover) + .border_radius(5.) + .into(), + None => container( + icons::tinted(icons::MEDIA, 18, crate::color(self.config.theme.text_primary)).opacity(opacity), + ) + .center_x(24) + .center_y(24) + .into(), + } + } + + fn hub_equalizer(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action> { + let playing = self + .media + .snapshot + .selected + .as_ref() + .is_some_and(|player| player.status == ferese_ipc::media::Playback::Playing); + let animate = playing && self.render.motion.motion_enabled(); + let started = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + .map_or_else(Instant::now, |runtime| runtime.borrow().revision); + equalizer::view(started, animate, color_with_opacity(self.config.theme.accent, opacity)) + } + + fn hub_shortcut_button( + &self, + id: window::Id, + kind: ItemKind, + menu: Option, + opacity: f32, + ) -> Element<'_, cosmic::Action> { + let action = self.hub_control_action(id, kind).expect("shortcut action"); + let primary = crate::color(self.config.theme.for_bar().text_primary); + motion::button( + self.render, + button::custom(icons::tinted(action.icon, 18, primary).opacity(opacity)) + .name(action.label) + .padding([5, 3]) + .width(30) + .height(28) + .on_press_maybe(action.message.map(cosmic::Action::App)), + primary.scale_alpha(opacity), + action.menu.is_some() && action.menu == menu, + opacity, + ) + } + + fn hub_control_action(&self, id: window::Id, kind: ItemKind) -> Option { + let (icon, menu) = match kind { + ItemKind::Overview => (icons::OVERVIEW, None), + ItemKind::QuickSettings => (icons::SETTINGS, Some(status_ui::Menu::System)), + ItemKind::Network => (icons::WIFI, Some(status_ui::Menu::Network)), + ItemKind::Recording => ( + match self.recorder.state { + crate::recording::State::Selecting => icons::RECORD_CANCEL, + crate::recording::State::Recording(_) => icons::RECORD_STOP, + crate::recording::State::Saving => icons::RECORD_SAVING, + _ => icons::RECORD, + }, + None, + ), + ItemKind::Audio => (icons::VOLUME, Some(status_ui::Menu::Audio)), + ItemKind::Battery { .. } => (icons::BATTERY, Some(status_ui::Menu::Battery)), + ItemKind::DisplayMode if self.display_mode.external_connected() => (icons::DISPLAY, None), + _ => return None, + }; + let message = match kind { + ItemKind::Overview => Some(Message::ToggleOverview), + ItemKind::Recording => match self.recorder.state { + crate::recording::State::Selecting | crate::recording::State::Recording(_) => { + Some(Message::StopRecording) + } + crate::recording::State::Saving => None, + _ => Some(Message::StartRecording), + }, + ItemKind::DisplayMode => Some(Message::OpenDisplays( + self.outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.name.clone()), + )), + _ => menu.map(|menu| Message::HubOpen(id, menu)), + }; + Some(ControlAction { + icon, + menu, + message, + label: if kind == ItemKind::Recording { + self.recorder.label() + } else { + kind.label() + }, + }) + } + + fn hub_shortcuts(&self, id: window::Id) -> Vec { + let panel = self.panel_for_bar(id); + let available = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.size) + .map_or(panel.morph.expanded_width, |(width, _)| { + (f64::from(width) - 2. * f64::from(panel.resolved_geometry().side_margins)).max(0.) + }); + let width = panel.morph.expanded_width.min(available); + // Five tabs and Close keep their space through the morph. + let capacity = ((width - 28. - 6. * 32. - 8.).max(0.) / 32.).floor() as usize; + let mut shortcuts = Vec::new(); + for item in [&panel.start, &panel.center, &panel.end] + .into_iter() + .flat_map(|zone| &zone.groups) + .flat_map(|group| &group.items) + .filter(|item| item.visible && item.overflow != ferese_panel::OverflowPolicy::Always) + { + if shortcuts.len() == capacity { + break; + } + if item.kind == ItemKind::Overview { + continue; + } + if matches!( + item.kind, + ItemKind::Network | ItemKind::Audio | ItemKind::Battery { .. } + ) { + continue; + } + if self.hub_control_action(id, item.kind).is_some() && !shortcuts.contains(&item.kind) { + shortcuts.push(item.kind); + } + } + shortcuts + } + + fn hub_controls(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action> { + let primary = color_with_opacity(self.config.theme.text_primary, opacity); + let mut rows = column::with_capacity(8).spacing(2).width(Length::Fill); + let mut tiles = Vec::new(); + let panel = self.panel_for_bar(id); + let shortcuts = self.hub_shortcuts(id); + for item in [&panel.start, &panel.center, &panel.end] + .into_iter() + .flat_map(|zone| &zone.groups) + .flat_map(|group| &group.items) + .filter(|item| item.visible) + { + if shortcuts.contains(&item.kind) { + continue; + } + if item.kind == ItemKind::Overview { + continue; + } + let Some(action) = self.hub_control_action(id, item.kind) else { + continue; + }; + let ControlAction { + icon, label, message, .. + } = action; + tiles.push(motion::button( + self.render, + button::custom( + row![ + container( + icons::tinted(icon, 22, crate::color(self.config.theme.text_primary)).opacity(opacity) + ) + .center_x(30) + .center_y(28), + self.render + .text(label) + .size(12) + .class(theme::Text::Color(primary)) + .height(28) + .width(Length::Fill) + .align_y(cosmic::iced::alignment::Vertical::Center) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), + )) + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .name(label) + .padding([4, 8]) + .height(36) + .width(Length::Fill) + .on_press_maybe(message.map(cosmic::Action::App)), + primary, + false, + opacity, + )); + } + let mut tiles = tiles.into_iter(); + while let Some(left) = tiles.next() { + let right = tiles + .next() + .unwrap_or_else(|| cosmic::widget::Space::new().width(Length::Fill).into()); + rows = rows.push(row![left, right].spacing(8)); + } + rows.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::Application; + use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, widget}; + use cosmic::iced::{Event, Rectangle, Size}; + use std::time::Duration; + + fn shell() -> FereseShell { + let mut shell = crate::tests::shell_with_measured_panel(); + shell.config.panels[0].style = PanelStyle::Morph; + shell.config.panels[0].border = true; + shell.outputs[0] + .runtime + .reset(&shell.config.panels[0], &shell.snapshot, Some("test")); + shell.clock = "11 oct, 10:09 am".into(); + shell + } + + #[test] + fn shell_rendering_configuration_is_instance_owned() { + let mut animated = shell(); + let mut snap = shell(); + let original = animated.render; + let mut config = snap.config.clone(); + config.font_family = Some("monospace".into()); + config.theme.material_radius = 3.; + config.animations.reduced_motion = true; + drop(snap.apply_config(config)); + assert_eq!(animated.render.font, original.font); + assert_ne!(snap.render.font, animated.render.font); + assert_eq!(animated.render.radius(20.), original.radius(20.)); + assert_eq!(snap.render.radius(20.), 3.); + for instance in [&mut animated, &mut snap] { + drop(instance.open_hub(instance.outputs[0].bar, status_ui::Menu::Calendar)); + } + let mut a = animated.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); + let mut b = snap.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); + let now = Instant::now() + Duration::from_millis(50); + assert_ne!(a.sample(now).geometry.bounds.height, a.hub.target_size()[1]); + assert_eq!(b.sample(now).geometry.bounds.height, b.hub.target_size()[1]); + assert!(a.motion.motion_enabled()); + assert!(!b.motion.motion_enabled()); + } + + #[test] + fn closing_and_repeated_feedback_preserve_the_outgoing_content() { + use transition::Event as Transition; + let mut runtime = Runtime::new(&Panel::default()); + let area = [1000., 700.]; + runtime.transition( + Transition::Hover { + entered: true, + enabled: true, + peek: (Peek::Media, 240.), + priority: None, + }, + area, + ); + runtime.hub.advance(Duration::from_secs(1), false); + runtime.transition(Transition::Close, area); + assert_eq!( + runtime.previous.peek, + Peek::Media, + "the closing fade keeps its media content" + ); + runtime.hub.advance(Duration::from_secs(1), false); + + let token = Instant::now(); + runtime.transition(Transition::Toast { notice: 7, token }, area); + runtime.hub.advance(Duration::from_secs(1), false); + runtime.transition(Transition::ToastExpired(token), area); + assert_eq!( + runtime.previous.toast, + Some(7), + "expiry keeps the outgoing toast for its fade" + ); + runtime.hub.advance(Duration::from_secs(1), false); + + runtime.transition( + Transition::Hover { + entered: true, + enabled: true, + peek: (Peek::Media, 240.), + priority: None, + }, + area, + ); + runtime.hub.advance(Duration::from_secs(1), false); + runtime.transition(Transition::Workspaces(token), area); + runtime.hub.advance(Duration::from_millis(60), false); + runtime.transition(Transition::Workspaces(token + Duration::from_millis(1)), area); + assert_eq!( + runtime.previous.peek, + Peek::Media, + "repeated feedback must not replace the fading content" + ); + assert_eq!(runtime.peek, Peek::Workspaces); + } + + #[test] + fn stale_transient_expiry_cannot_close_new_content() { + use transition::Event as Transition; + let panel = Panel::default(); + let mut runtime = Runtime::new(&panel); + let area = [1000., 700.]; + let first = Instant::now(); + let second = first + Duration::from_millis(1); + runtime.transition( + Transition::Toast { + notice: 1, + token: first, + }, + area, + ); + runtime.transition( + Transition::Toast { + notice: 2, + token: second, + }, + area, + ); + assert!(!runtime.transition(Transition::ToastExpired(first), area)); + assert_eq!(runtime.transient.toast(), Some(2)); + runtime.transition(Transition::Open(status_ui::Menu::Calendar), area); + assert!(!runtime.transition(Transition::ToastExpired(second), area)); + assert_eq!(runtime.hub.mode(), Mode::Expanded(Content::Calendar)); + runtime.transition(Transition::Close, area); + runtime.transition(Transition::Workspaces(first), area); + runtime.transition( + Transition::Activity { + priority: Some(Peek::Recording), + peek: (Peek::Recording, 240.), + }, + area, + ); + assert!(!runtime.transition(Transition::WorkspacesExpired(first), area)); + assert_eq!(runtime.peek, Peek::Recording); + assert!(matches!(runtime.transient, Transient::None)); + assert!(!runtime.transition( + Transition::Toast { + notice: 3, + token: second + }, + area + )); + assert!(!runtime.transition(Transition::Workspaces(second), area)); + } + + #[test] + fn recording_and_low_battery_take_priority_without_interrupting_open_panels() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = Some(ferese_ipc::media::Player { + title: "Music".into(), + status: ferese_ipc::media::Playback::Playing, + ..Default::default() + }); + drop(shell.hub_hover(id, true)); + assert_eq!(shell.hub_peek_content(id).0, Peek::Media); + shell.status.battery = Some(crate::status::Battery { + percent: 12, + status: "Discharging".into(), + }); + drop(shell.sync_hub_activity()); + assert_eq!(shell.hub_priority(), Some(Peek::Battery)); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, + Peek::Battery + ); + shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now()); + drop(shell.sync_hub_activity()); + assert_eq!(shell.hub_priority(), Some(Peek::Recording)); + assert_eq!( + shell.hub_low_battery(), + Some(12), + "battery warning must remain available beside the timer" + ); + assert_eq!(shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek_width, 280.); + drop(shell.hub_notice(99)); + assert_eq!( + shell.hub_mode(id), + Some(Mode::Peek), + "notifications cannot hide recording" + ); + shell.status.battery.as_mut().unwrap().status = "Charging".into(); + drop(shell.sync_hub_activity()); + assert_eq!(shell.hub_low_battery(), None); + assert_eq!(shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek_width, 240.); + shell.recorder.state = crate::recording::State::Saving; + drop(shell.sync_hub_activity()); + assert_eq!(shell.hub_priority(), Some(Peek::Recording)); + shell.recorder.state = crate::recording::State::Idle; + drop(shell.sync_hub_activity()); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, + Peek::Media + ); + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now()); + drop(shell.sync_hub_activity()); + assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); + drop(shell.close_hub(id)); + assert_eq!(shell.hub_priority(), Some(Peek::Recording)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + } + + #[test] + fn low_battery_respects_threshold_charging_and_config_reload() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + shell.config.status.low_battery_threshold = 20; + for (percent, status, expected) in [ + (20, "Discharging", false), + (19, "Discharging", true), + (0, "Empty", true), + (10, "Pending discharge", true), + (10, "Charging", false), + (10, "Pending charge", false), + (10, "Full", false), + (10, "Unknown", false), + ] { + shell.status.battery = Some(crate::status::Battery { + percent, + status: status.into(), + }); + assert_eq!(shell.hub_low_battery().is_some(), expected, "{percent}%, {status}"); + } + shell.status.battery = Some(crate::status::Battery { + percent: 15, + status: "Discharging".into(), + }); + drop(shell.hub_hover(id, true)); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, + Peek::Battery + ); + let mut config = shell.config.clone(); + config.status.low_battery_threshold = 10; + drop(shell.apply_config(config)); + assert_eq!(shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date); + shell.recorder.state = crate::recording::State::Selecting; + assert_eq!(shell.hub_priority(), None, "selection is not an active recording"); + } + + #[test] + fn priority_status_supersedes_workspace_feedback_and_toasts() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + shell.snapshot.outputs = vec![crate::control::OutputSnapshot { + id: 4, + name: "test".into(), + active_workspace: 1, + focused: true, + }]; + shell.snapshot.workspaces = (1..=3) + .map(|id| crate::control::WorkspaceSnapshot { + id, + name: id.to_string(), + output: Some(4), + index: id as u32, + window_count: 1, + visible: id == 1, + focused: id == 1, + }) + .collect(); + drop(shell.sync_hub_workspace_feedback(false)); + shell.snapshot.outputs[0].active_workspace = 2; + drop(shell.sync_hub_workspace_feedback(false)); + assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); + shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now()); + drop(shell.sync_hub_activity()); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + assert!( + shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow() + .transient + .workspace_token() + .is_none() + ); + shell.snapshot.outputs[0].active_workspace = 3; + drop(shell.sync_hub_workspace_feedback(false)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + shell.recorder.state = crate::recording::State::Idle; + drop(shell.sync_hub_activity()); + drop(shell.set_hub_mode(id, Mode::Toast, None)); + shell.status.battery = Some(crate::status::Battery { + percent: 1, + status: "Discharging".into(), + }); + drop(shell.sync_hub_activity()); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + } + + #[test] + fn workspace_strip_is_output_local_and_does_not_interrupt_open_content() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + shell.snapshot.outputs = vec![crate::control::OutputSnapshot { + id: 4, + name: "test".into(), + active_workspace: 9, + focused: true, + }]; + shell.snapshot.workspaces = [(9, 4, 0), (10, 7, 10), (11, 4, 0)] + .into_iter() + .map(|(workspace, output, count)| crate::control::WorkspaceSnapshot { + id: workspace, + name: workspace.to_string(), + output: Some(output), + index: workspace as u32, + window_count: count, + visible: workspace == 9, + focused: workspace == 9, + }) + .collect(); + // A bar can be created after the initial compositor snapshot arrives. + drop(shell.update(Message::PanelScale(id, 1.25))); + let width = shell.hub_workspace_width(id); + assert!(width > 0., "the active empty workspace remains visible"); + shell.snapshot.workspaces[1].window_count = 20; + drop(shell.update_workspace_ui()); + assert_eq!( + shell.hub_workspace_width(id), + width, + "another output cannot change this strip" + ); + { + let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); + runtime.hub.advance(Duration::ZERO, true); + assert_eq!( + runtime.hub.target_size()[0], + shell.config.panels[0].morph.idle_width + width + 10. + ); + assert_eq!(runtime.hub.sample().opacity, 1.); + } + drop(shell.hub_hover(id, true)); + { + let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow(); + assert_eq!(runtime.hub.target_size()[0], runtime.peek_width); + } + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + let before = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().hub.sample(); + shell.snapshot.workspaces[2].window_count = 1; + drop(shell.update_workspace_ui()); + assert!(shell.hub_workspace_width(id) > width); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().hub.sample(), + before + ); + assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); + drop(shell.open_hub_overview(id)); + assert!(!shell.overview_active); + assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Workspaces))); + } + + #[test] + fn workspace_tile_selection_closes_without_confirmation_and_hover_resets() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + shell.snapshot.outputs = vec![crate::control::OutputSnapshot { + id: 4, + name: "test".into(), + active_workspace: 1, + focused: true, + }]; + shell.snapshot.workspaces = (1..=3) + .map(|id| crate::control::WorkspaceSnapshot { + id, + name: id.to_string(), + output: Some(if id == 3 { 9 } else { 4 }), + index: id as u32, + window_count: 0, + visible: id == 1, + focused: id == 1, + }) + .collect(); + drop(shell.sync_hub_workspaces()); + drop(shell.open_hub_overview(id)); + drop(shell.hub_workspace_hover(id, Some(2))); + drop(shell.hub_workspace_hover(id, None)); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().workspace_hover, + None + ); + drop(shell.hub_workspace_select(id, 3)); + assert_eq!( + shell.hub_mode(id), + Some(Mode::Expanded(Content::Workspaces)), + "another output must be rejected" + ); + drop(shell.hub_workspace_select(id, 2)); + shell.snapshot.outputs[0].active_workspace = 2; + drop(shell.sync_hub_workspace_feedback(false)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + assert!(!shell.overview_active); + assert_eq!( + shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow() + .workspace_selection_pending, + None + ); + shell.snapshot.outputs[0].active_workspace = 1; + drop(shell.sync_hub_workspace_feedback(false)); + assert_eq!( + shell.hub_mode(id), + Some(Mode::Peek), + "later keyboard switches still get feedback" + ); + } + + #[test] + fn workspace_feedback_updates_in_place_and_restores_hover_content() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + shell.snapshot.outputs = vec![crate::control::OutputSnapshot { + id: 4, + name: "test".into(), + active_workspace: 1, + focused: true, + }]; + shell.snapshot.workspaces = (1..=3) + .map(|id| crate::control::WorkspaceSnapshot { + id, + name: id.to_string(), + output: Some(4), + index: id as u32, + window_count: 1, + visible: id == 1, + focused: id == 1, + }) + .collect(); + drop(shell.sync_hub_workspace_feedback(false)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle), "initial snapshot must not flash"); + drop(shell.hub_hover(id, true)); + shell.snapshot.outputs[0].active_workspace = 2; + drop(shell.sync_hub_workspace_feedback(false)); + let first = { + let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); + assert_eq!(runtime.peek, Peek::Workspaces); + runtime.hub.advance(Duration::ZERO, true); + runtime.transient.workspace_token().unwrap() + }; + shell.snapshot.outputs[0].active_workspace = 3; + drop(shell.sync_hub_workspace_feedback(false)); + let latest = { + let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow(); + assert_eq!( + runtime.hub.sample().opacity, + 1., + "repeated switches must not restart the fade" + ); + runtime.transient.workspace_token().unwrap() + }; + assert_ne!(first, latest); + drop(shell.hub_workspace_feedback_expired(id, first)); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, + Peek::Workspaces + ); + drop(shell.hub_workspace_feedback_expired(id, latest)); + assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); + assert_eq!(shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date); + drop(shell.hub_hover(id, false)); + shell.snapshot.outputs[0].active_workspace = 1; + drop(shell.sync_hub_workspace_feedback(false)); + let revision = shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow() + .transient + .workspace_token() + .unwrap(); + drop(shell.hub_workspace_feedback_expired(id, revision)); + assert_eq!( + shell.hub_mode(id), + Some(Mode::Idle), + "keyboard feedback returns to idle" + ); + shell.snapshot.outputs[0].active_workspace = 2; + drop(shell.sync_hub_workspace_feedback(true)); + assert_eq!( + shell.hub_mode(id), + Some(Mode::Idle), + "overview selection must not flash" + ); + drop(shell.hub_hover(id, true)); + shell.snapshot.outputs[0].active_workspace = 3; + drop(shell.sync_hub_workspace_feedback(false)); + drop(shell.hub_hover(id, false)); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, + Peek::Date, + "leaving early must clear temporary workspace content" + ); + } + + #[test] + fn native_pointer_reentry_always_peeks_without_resizing_the_surface() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + let allocation = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation; + for _ in 0..20 { + drop(shell.handle_event(Event::Mouse(mouse::Event::CursorEntered), id)); + assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); + drop(shell.handle_event(Event::Mouse(mouse::Event::CursorLeft), id)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); + runtime.hub.advance(Duration::from_secs(1), false); + assert_eq!(runtime.allocation, allocation); + } + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + drop(shell.handle_event(Event::Mouse(mouse::Event::CursorLeft), id)); + drop(shell.handle_event(Event::Mouse(mouse::Event::CursorEntered), id)); + assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); + } + + #[test] + fn hover_only_peeks_and_respects_preference_and_visibility() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + drop(shell.hub_hover(id, true)); + assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); + drop(shell.hub_hover(id, true)); + assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); + drop(shell.hub_hover(id, false)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + drop(shell.set_hub_mode(id, Mode::Toast, None)); + drop(shell.hub_hover(id, true)); + drop(shell.hub_hover(id, false)); + assert_eq!(shell.hub_mode(id), Some(Mode::Toast)); + drop(shell.close_hub(id)); + let mut config = shell.config.clone(); + config.panels[0].morph.peek_on_hover = false; + drop(shell.apply_config(config)); + drop(shell.hub_hover(id, true)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + let mut config = shell.config.clone(); + config.panels[0].morph.peek_on_hover = true; + drop(shell.apply_config(config)); + shell.snapshot.outputs.push(crate::control::OutputSnapshot { + id: 1, + name: "test".into(), + active_workspace: 7, + focused: true, + }); + shell.snapshot.windows.push(crate::control::WindowSnapshot { + id: 42, + workspace: 7, + app_id: "test".into(), + title: "Test".into(), + focused: true, + urgent: false, + fullscreen: true, + maximized: false, + floating: false, + geometry: None, + }); + drop(shell.hub_hover(id, true)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + } + + #[test] + fn peek_category_and_width_stay_fixed_until_the_next_hover() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + drop(shell.hub_hover(id, true)); + assert_eq!(shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date); + assert_eq!( + shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow() + .hub + .target_size(), + [200., 44.] + ); + std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = Some(ferese_ipc::media::Player { + title: "A long track title that needs the wider preview".into(), + status: ferese_ipc::media::Playback::Paused, + ..Default::default() + }); + drop(shell.hub_hover(id, true)); + assert_eq!(shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date); + drop(shell.hub_hover(id, false)); + drop(shell.hub_hover(id, true)); + let size = shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow() + .hub + .target_size(); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, + Peek::Media + ); + assert!((200. ..=280.).contains(&size[0])); + assert_eq!(size[1], 44.); + std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = None; + drop(shell.hub_hover(id, true)); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, + Peek::Media + ); + assert_eq!( + shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow() + .hub + .target_size(), + size + ); + drop(shell.hub_hover(id, false)); + drop(shell.hub_hover(id, true)); + assert_eq!(shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date); + } + + #[test] + fn collapse_remembers_the_last_selected_panel() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().last_menu, + status_ui::Menu::Workspaces + ); + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + drop(shell.close_hub(id)); + drop(shell.hub_hover(id, true)); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().last_menu, + status_ui::Menu::Calendar + ); + } + + #[test] + fn header_shortcuts_fit_the_settled_width_and_respect_item_visibility() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + for (width, count) in [(240., 0), (340., 2), (400., 2)] { + shell.config.panels[0].morph.expanded_width = width; + let shortcuts = shell.hub_shortcuts(id); + assert_eq!(shortcuts.len(), count); + assert!(!shortcuts.contains(&ItemKind::Network)); + assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 0); + assert!(!shortcuts.contains(&ItemKind::DisplayMode)); + assert!(!shortcuts.contains(&ItemKind::Audio)); + assert!(!shortcuts.iter().any(|kind| matches!(kind, ItemKind::Battery { .. }))); + } + let before = shell.hub_shortcuts(id); + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + assert_eq!( + shell.hub_shortcuts(id), + before, + "shortcuts must not shuffle during the spring" + ); + let panel = &mut shell.config.panels[0]; + for item in [&mut panel.start, &mut panel.center, &mut panel.end] + .into_iter() + .flat_map(|zone| &mut zone.groups) + .flat_map(|group| &mut group.items) + { + if item.kind == ItemKind::Overview { + item.visible = false; + } + if matches!(item.kind, ItemKind::DisplayMode | ItemKind::Recording) { + item.overflow = ferese_panel::OverflowPolicy::Always; + } + } + let shortcuts = shell.hub_shortcuts(id); + assert!(!shortcuts.contains(&ItemKind::Overview)); + assert!(!shortcuts.contains(&ItemKind::DisplayMode)); + assert!(!shortcuts.contains(&ItemKind::Recording)); + shell.outputs[0].size = Some((200, 600)); + assert!( + shell.hub_shortcuts(id).is_empty(), + "small outputs must keep optional controls in the grid" + ); + } + + #[test] + fn opening_and_switching_panels_do_not_resize_again_after_settlement() { + let panel = Panel::from_preset(PanelStyle::Morph); + let mut runtime = Runtime::new(&panel); + for menu in [ + status_ui::Menu::Media, + status_ui::Menu::Calendar, + status_ui::Menu::Notifications, + status_ui::Menu::Overflow, + ] { + runtime.retarget(Mode::Expanded(content(menu)), [1000., 700.], Some(menu)); + let allocation = runtime.allocation; + for _ in 0..200 { + runtime.hub.advance(Duration::from_millis(8), false); + } + assert!(!runtime.hub.active()); + assert_eq!(runtime.allocation, allocation); + } + } + + #[test] + fn attached_shoulders_fit_the_surface_through_morphs_and_reversals() { + let mut panel = Panel::from_preset(PanelStyle::Morph); + panel.morph.attached = true; + for edge in [Edge::Top, Edge::Bottom] { + panel.edge = edge; + let mut runtime = Runtime::new(&panel); + for mode in [ + Mode::Peek, + Mode::Expanded(Content::Calendar), + Mode::Idle, + Mode::Peek, + Mode::Idle, + ] { + runtime.retarget(mode, [800., 600.], None); + let allocation = runtime.allocation; + for _ in 0..18 { + runtime.hub.advance(Duration::from_millis(8), false); + let sample = runtime.hub.sample(); + let bounds = sample.geometry.bounds; + let scene = ferese_panel::scene::derive( + ferese_panel::scene::Parameters { + radius: Some(ferese_panel::shape::AnchoredRadii( + sample.geometry.radii.map(|r| r as f32), + )), + ..panel.shape_parameters() + }, + ferese_layout::Rect::new( + (f64::from(allocation[0]) - bounds.width) / 2., + 0., + bounds.width, + bounds.height, + ), + &[], + ferese_panel::shape::PixelGrid::new(1.5).unwrap(), + ) + .unwrap(); + assert_eq!(scene.paint.len(), 3); + for primitive in scene.paint { + assert!(primitive.bounds.x >= -0.01); + assert!(primitive.bounds.x + primitive.bounds.width <= f64::from(allocation[0]) + 0.01); + } + assert_eq!(runtime.allocation, allocation, "no resize during settlement"); + } + } + } + } + + #[test] + fn opening_closing_and_reopening_retain_the_native_surface() { + let panel = Panel::from_preset(PanelStyle::Morph); + let mut runtime = Runtime::new(&panel); + assert_eq!(runtime.allocation, [286, 49]); + runtime.retarget( + Mode::Expanded(Content::Calendar), + [1000., 700.], + Some(status_ui::Menu::Calendar), + ); + let expanded_allocation = runtime.allocation; + assert!(expanded_allocation[0] > 340 && expanded_allocation[1] > 230); + runtime.hub.advance(Duration::from_millis(50), false); + let before = runtime.hub.sample().geometry; + runtime.retarget(Mode::Idle, [1000., 700.], None); + // A reversal never commits a smaller buffer underneath an unfinished shape. + assert_eq!(runtime.allocation, expanded_allocation); + assert!(runtime.hub.sample().geometry.bounds.width >= before.bounds.width); + runtime.hub.advance(Duration::from_secs(2), false); + runtime.hub.advance(Duration::from_millis(120), false); + assert_eq!(runtime.allocation, expanded_allocation); + assert!(!runtime.hub.active()); + runtime.retarget( + Mode::Expanded(Content::Calendar), + [1000., 700.], + Some(status_ui::Menu::Calendar), + ); + assert_eq!(runtime.allocation, expanded_allocation); + } + + #[test] + fn hover_leave_cannot_close_an_expanded_hub_and_reload_clears_its_owner() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + let reservation = shell.config.panels[0].exclusive_zone(false); + drop(shell.hub_hover(id, true)); + assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); + drop(shell.hub_hover(id, false)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + drop(shell.update(Message::HubOpen(id, status_ui::Menu::Calendar))); + assert!(shell.menu.is_none(), "expanded hub owns no popup surface"); + drop(shell.hub_hover(id, false)); + assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); + assert_eq!(shell.config.panels[0].exclusive_zone(false), reservation); + drop(shell.update(Message::HubClose(id))); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + let mut config = shell.config.clone(); + config.panels[0].style = PanelStyle::Floating; + drop(shell.apply_config(config)); + assert_eq!(shell.hub_mode(id), None); + assert_eq!(shell.config.panels[0].style, PanelStyle::Floating); + } + + #[test] + fn fullscreen_collapses_immediately_and_old_toast_deadlines_do_not_close_new_content() { + let mut shell = shell(); + let id = shell.outputs[0].bar; + drop(shell.set_hub_mode(id, Mode::Toast, None)); + let old = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().revision; + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + drop(shell.hub_toast_expired(id, old)); + assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); + drop(shell.suspend_hub(id)); + let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow(); + assert_eq!(runtime.hub.mode(), Mode::Idle); + assert_eq!(runtime.allocation, [286, 49]); + assert!(!runtime.hub.active()); + } + + #[test] + fn cursor_leave_closes_peek_and_escape_closes_expanded_content() { + use cosmic::iced::keyboard::{Event as KeyEvent, Key, Location, Modifiers, key}; + let mut shell = shell(); + let id = shell.outputs[0].bar; + drop(shell.hub_hover(id, true)); + drop(shell.handle_event(Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), id)); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + drop(shell.handle_event(Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), id)); + assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); + drop(shell.handle_event( + Event::Keyboard(KeyEvent::KeyPressed { + key: Key::Named(key::Named::Escape), + modified_key: Key::Named(key::Named::Escape), + physical_key: key::Physical::Code(key::Code::Escape), + location: Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }), + id, + )); + assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); + } + + #[test] + #[ignore = "writes Morph review captures to /tmp/ferese-hub-review"] + fn render_hub_review_states() { + use cosmic::iced::advanced::renderer::{Headless, Renderer as _}; + let backend = std::env::var("FERESE_REVIEW_BACKEND").unwrap_or_else(|_| "wgpu".into()); + let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + Some(&backend), + )) + .unwrap(); + std::fs::create_dir_all("/tmp/ferese-hub-review").unwrap(); + let mut shell = shell(); + let id = shell.outputs[0].bar; + std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = Some(ferese_ipc::media::Player { + title: "Losing My Mind".into(), + artist: "London Grammar".into(), + status: ferese_ipc::media::Playback::Playing, + length_us: Some(240_000_000), + position_us: Some(80_000_000), + sampled_at_us: ferese_ipc::media::now_us(), + can_control: true, + can_pause: true, + can_next: true, + can_previous: true, + can_seek: true, + track_id: Some("/track/1".into()), + ..Default::default() + }); + shell.snapshot.outputs = vec![crate::control::OutputSnapshot { + id: 4, + name: "test".into(), + active_workspace: 1, + focused: true, + }]; + shell.snapshot.workspaces = (1..=5) + .map(|id| crate::control::WorkspaceSnapshot { + id, + name: format!("Desk {id}"), + output: Some(4), + index: id as u32, + window_count: (id % 3) as u32, + visible: id == 1, + focused: id == 1, + }) + .collect(); + shell.snapshot.windows = shell + .snapshot + .workspaces + .iter() + .flat_map(|workspace| { + (0..workspace.window_count).map(move |n| crate::control::WindowSnapshot { + id: workspace.id * 10 + u64::from(n), + workspace: workspace.id, + app_id: "test".into(), + title: format!("Window {}", n + 1), + geometry: Some([f64::from(n) * 650., 0., if n == 0 { 640. } else { 320. }, 720.]), + focused: false, + urgent: false, + fullscreen: false, + maximized: false, + floating: false, + }) + }) + .collect(); + drop(shell.update_workspace_ui()); + let theme = shell.config.theme.palette().native_theme(); + for scale in [1., 1.25, 1.5] { + shell.outputs[0].scale = scale; + let mut date_pixels = None; + for (name, mode, menu) in [ + ("idle", Mode::Idle, None), + ("idle-attached", Mode::Idle, None), + ("idle-attached-bottom", Mode::Idle, None), + ("peek-attached", Mode::Peek, None), + ("peek-attached-bottom", Mode::Peek, None), + ( + "calendar-attached", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ( + "calendar-attached-bottom", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ("idle-translucent", Mode::Idle, None), + ("idle-long-title", Mode::Idle, None), + ("idle-recording", Mode::Idle, None), + ("idle-low-battery", Mode::Idle, None), + ("idle-recording-low-battery", Mode::Idle, None), + ("idle-saving-low-battery", Mode::Idle, None), + ("peek-recording", Mode::Peek, None), + ("peek-low-battery", Mode::Peek, None), + ("peek-recording-low-battery", Mode::Peek, None), + ("peek-saving-low-battery", Mode::Peek, None), + ("peek-date", Mode::Peek, None), + ("peek-date-hover", Mode::Peek, None), + ("peek-date-pressed", Mode::Peek, None), + ("peek-media", Mode::Peek, None), + ("peek-workspaces", Mode::Peek, None), + ( + "workspaces", + Mode::Expanded(Content::Workspaces), + Some(status_ui::Menu::Workspaces), + ), + ("music", Mode::Expanded(Content::Media), Some(status_ui::Menu::Media)), + ( + "wifi-connected", + Mode::Expanded(Content::Controls), + Some(status_ui::Menu::Network), + ), + ( + "wifi-off", + Mode::Expanded(Content::Controls), + Some(status_ui::Menu::Network), + ), + ( + "battery", + Mode::Expanded(Content::Controls), + Some(status_ui::Menu::Battery), + ), + ( + "calendar", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ( + "alerts", + Mode::Expanded(Content::Notifications), + Some(status_ui::Menu::Notifications), + ), + ( + "controls", + Mode::Expanded(Content::Controls), + Some(status_ui::Menu::Overflow), + ), + ( + "controls-narrow", + Mode::Expanded(Content::Controls), + Some(status_ui::Menu::Overflow), + ), + ( + "controls-wide", + Mode::Expanded(Content::Controls), + Some(status_ui::Menu::Overflow), + ), + ( + "system", + Mode::Expanded(Content::Controls), + Some(status_ui::Menu::System), + ), + ( + "spring-60", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ( + "spring-120", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ( + "spring-190", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ( + "spring-250", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ( + "spring-400", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ( + "spring-900", + Mode::Expanded(Content::Calendar), + Some(status_ui::Menu::Calendar), + ), + ] { + std::sync::Arc::make_mut(&mut shell.media.snapshot) + .selected + .as_mut() + .unwrap() + .title = if name == "idle-long-title" { + "A very long track title that must truncate before the equalizer and never hide its bars".into() + } else { + "Losing My Mind".into() + }; + shell.recorder.state = if name.contains("recording") { + crate::recording::State::Recording(std::time::Instant::now() - Duration::from_secs(65)) + } else if name.contains("saving") { + crate::recording::State::Saving + } else { + crate::recording::State::Idle + }; + shell.status.battery = name.contains("low-battery").then(|| crate::status::Battery { + percent: 12, + status: "Discharging".into(), + }); + shell.config.theme.bar_background[3] = if name.ends_with("translucent") { 128 } else { 255 }; + let panel = &mut shell.config.panels[0]; + panel.morph.attached = name.contains("attached"); + panel.edge = if name.ends_with("bottom") { + Edge::Bottom + } else { + Edge::Top + }; + panel.morph.expanded_width = match name { + "controls-narrow" => 240., + "controls-wide" => 400., + _ => 340., + }; + if name.starts_with("wifi-") { + shell.status.network = Some(crate::status::Network { + enabled: name == "wifi-connected", + connection: (name == "wifi-connected").then(|| "Home network".into()), + signal: 80, + }); + } + let workspace_width = shell.hub_workspace_width(id); + let hub = shell.outputs[0].runtime.hub.as_ref().unwrap(); + let (mut sample, allocation) = { + let mut hub = hub.borrow_mut(); + let animation_time = name.strip_prefix("spring-").map(|ms| ms.parse::().unwrap()); + *hub = Runtime::new(&shell.config.panels[0]); + hub.workspace_width = if workspace_width > 0. { + workspace_width + 10. + } else { + 0. + }; + hub.peek = match name { + _ if shell.hub_priority().is_some() => shell.hub_priority().unwrap(), + "peek-media" => Peek::Media, + "peek-workspaces" => Peek::Workspaces, + _ => Peek::Date, + }; + hub.peek_width = if shell.hub_priority().is_some() { + shell.hub_peek_content(id).1 + } else if name == "peek-media" { + 260. + } else { + 200. + }; + hub.retarget(mode, [1200., 800.], menu); + hub.hub.advance( + Duration::from_millis(animation_time.unwrap_or(0)), + animation_time.is_none(), + ); + (hub.hub.sample(), hub.allocation) + }; + if mode == Mode::Idle || mode == Mode::Expanded(Content::Controls) || name == "peek-workspaces" { + let mut measured = shell.view_hub(id); + let mut measured_tree = widget::Tree::new(&measured); + measured.as_widget_mut().layout( + &mut measured_tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(allocation[0] as f32, allocation[1] as f32)), + ); + let idle_width_limit = f64::from(shell.hub_idle_width_limit(id)); + let mut runtime = hub.borrow_mut(); + runtime.hub.advance(Duration::ZERO, true); + sample = runtime.hub.sample(); + if name == "peek-workspaces" { + assert!( + sample.geometry.bounds.width < 200., + "short workspace feedback should fit its dots and name" + ); + } + if mode == Mode::Idle { + assert!( + sample.geometry.bounds.width <= idle_width_limit, + "idle should fit its content instead of retaining a fixed text allowance" + ); + } + if name == "controls" || name.starts_with("wifi-") { + assert!( + sample.geometry.bounds.height < shell.config.panels[0].morph.controls_height, + "{name} should shrink to its content" + ); + } + } + let mut view = shell.view_hub_at(id, sample, menu, None); + let mut tree = widget::Tree::new(&view); + let viewport = Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32)); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, viewport.size()), + ); + if shell.hub_priority().is_some() { + #[derive(Default)] + struct TextBounds(Vec<(String, Rectangle)>); + impl widget::Operation for TextBounds { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { + self.0.push((text.to_owned(), bounds)); + } + } + let mut bounds = TextBounds::default(); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut bounds); + let left = (viewport.width - sample.geometry.bounds.width as f32) / 2.; + let right = left + sample.geometry.bounds.width as f32; + for (label, rect) in &bounds.0 { + assert!( + rect.x >= left && rect.x + rect.width <= right + 0.1, + "{name}: {label} must fit inside shell: {rect:?}, {left}..{right}" + ); + assert!(rect.height <= 28., "{name}: {label} must stay on one line: {rect:?}"); + } + assert!( + !bounds.0.iter().any(|(text, _)| text == "Losing My Mind"), + "media cannot displace priority status" + ); + // Exercise the nested battery button as well as its recording parent. + if name == "peek-recording-low-battery" { + for (label, expected) in [ + ("12%", status_ui::Menu::Battery), + ("Recording", status_ui::Menu::Overflow), + ] { + let rect = bounds.0.iter().find(|(text, _)| text == label).unwrap().1; + let cursor = mouse::Cursor::Available(rect.center()); + let mut messages = Vec::new(); + for event in [ + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + view.as_widget_mut().update( + &mut tree, + &Event::Mouse(event), + Layout::new(&node), + cursor, + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &viewport, + ); + } + let opened: Vec<_> = messages + .iter() + .filter_map(|message| match message { + cosmic::Action::App(Message::HubOpen(_, menu)) => Some(*menu), + _ => None, + }) + .collect(); + assert_eq!(opened, vec![expected], "{label} must open exactly its own controls"); + } + } + } + if name == "peek-date" || name == "peek-workspaces" { + let mut messages = Vec::new(); + view.as_widget_mut().update( + &mut tree, + &Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: -1. }, + }), + Layout::new(&node), + mouse::Cursor::Available(cosmic::iced::Point::new(viewport.width / 2., 22.)), + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &viewport, + ); + assert_eq!( + messages + .iter() + .filter(|message| matches!(message, cosmic::Action::App(Message::HubWorkspaceScroll(_, _)))) + .count(), + 1, + "peek must handle a wheel event exactly once" + ); + } + let cursor = if name == "peek-date-hover" || name == "peek-date-pressed" { + let position = cosmic::iced::Point::new(viewport.width / 2., viewport.height / 2.); + let cursor = mouse::Cursor::Available(position); + let mut messages = Vec::new(); + let event = if name == "peek-date-pressed" { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + } else { + Event::Mouse(mouse::Event::CursorMoved { position }) + }; + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + cursor, + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &viewport, + ); + cursor + } else { + mouse::Cursor::Unavailable + }; + renderer.reset(viewport); + view.as_widget().draw( + &tree, + &mut renderer, + &theme, + &cosmic::iced::advanced::renderer::Style { + text_color: crate::color(shell.config.theme.text_primary), + icon_color: crate::color(shell.config.theme.text_primary), + scale_factor: scale, + }, + Layout::new(&node), + cursor, + &viewport, + ); + let physical = Size::new( + (allocation[0] as f64 * scale).ceil() as u32, + (allocation[1] as f64 * scale).ceil() as u32, + ); + let pixels = renderer.screenshot(physical, scale as f32, cosmic::iced::Color::TRANSPARENT); + assert!(pixels.chunks_exact(4).any(|pixel| pixel[3] > 0)); + if name == "idle-translucent" { + let x = + (((viewport.width + sample.geometry.bounds.width as f32) / 2. - 8.) * scale as f32) as usize; + let y = (sample.geometry.bounds.height * scale / 2.) as usize; + let alpha = pixels[(y * physical.width as usize + x) * 4 + 3]; + assert!(alpha.abs_diff(128) <= 2, "Morph must inherit theme alpha, got {alpha}"); + } + if name == "idle-long-title" { + let right = (viewport.width + sample.geometry.bounds.width as f32) / 2.; + let left_x = ((right - 28.) * scale as f32).floor() as usize; + let right_x = ((right - 14.) * scale as f32).ceil() as usize; + let accent = shell.config.theme.accent; + assert!( + pixels.chunks_exact(4).enumerate().any(|(index, pixel)| { + let x = index % physical.width as usize; + (left_x..right_x).contains(&x) + && pixel[3] > 200 + && pixel[..3] + .iter() + .zip(accent[..3].iter()) + .all(|(a, b)| a.abs_diff(*b) <= 2) + }), + "long titles must leave the equalizer visible at the right edge" + ); + } + + if name == "peek-date" { + date_pixels = Some(pixels.clone()); + } else if name == "peek-date-hover" || name == "peek-date-pressed" { + assert_eq!( + Some(&pixels), + date_pixels.as_ref(), + "Peek must not paint a hover or pressed overlay" + ); + } + image_codec::save_buffer( + format!("/tmp/ferese-hub-review/{backend}-{name}-{scale}.png"), + &pixels, + physical.width, + physical.height, + image_codec::ColorType::Rgba8, + ) + .unwrap(); + } + } + } + + #[test] + fn content_layout_stays_fixed_through_spring_settlement_and_rebuilds() { + use cosmic::iced::advanced::renderer::Headless; + #[derive(Default)] + struct Bounds { + content: Option, + text: Vec<(String, Rectangle)>, + } + impl widget::Operation for Bounds { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if id == Some(&widget::Id::new("ferese-hub-content")) { + self.content = Some(bounds); + } + } + fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { + self.text.push((text.to_owned(), bounds)); + } + } + fn close(a: Rectangle, b: Rectangle) { + for (a, b) in [(a.x, b.x), (a.y, b.y), (a.width, b.width), (a.height, b.height)] { + assert!((a - b).abs() < 0.001, "content moved during settlement: {a} vs {b}"); + } + } + let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let mut shell = shell(); + let id = shell.outputs[0].bar; + for edge in [Edge::Top, Edge::Bottom] { + shell.config.panels[0].edge = edge; + for scale in [1., 1.25, 1.5] { + shell.outputs[0].scale = scale; + for mode in [Mode::Peek, Mode::Expanded(Content::Calendar), Mode::Idle] { + let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap(); + runtime.borrow_mut().retarget(mode, [1200., 800.], None); + let allocation = runtime.borrow().allocation; + let mut baseline: Option = None; + let mut elapsed = 0; + for at in [180, 250, 400, 650, 1000, 1600, 1600] { + let sample = { + let mut runtime = runtime.borrow_mut(); + runtime.hub.advance(Duration::from_millis(at - elapsed), false); + elapsed = at; + runtime.hub.sample() + }; + let mut view = shell.view_hub_at(id, sample, None, None); + let mut tree = widget::Tree::new(&view); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(allocation[0] as f32, allocation[1] as f32)), + ); + let mut bounds = Bounds::default(); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut bounds); + assert!(bounds.content.is_some()); + assert!(!bounds.text.is_empty()); + if let Some(baseline) = &baseline { + close(bounds.content.unwrap(), baseline.content.unwrap()); + assert_eq!(bounds.text.len(), baseline.text.len()); + for ((text, rect), (expected_text, expected_rect)) in bounds.text.iter().zip(&baseline.text) + { + assert_eq!(text, expected_text); + close(*rect, *expected_rect); + } + } else { + baseline = Some(bounds); + } + } + } + } + } + } + + #[test] + fn native_input_follows_the_animated_hub_and_bottom_anchor_mirrors_it() { + use cosmic::iced::advanced::renderer::Headless; + let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let mut shell = shell(); + let id = shell.outputs[0].bar; + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + // Render the initial sample while the backing surface is already expanded. + let hub = shell.outputs[0].runtime.hub.as_ref().unwrap(); + let sample = hub.borrow().hub.sample(); + for edge in [Edge::Top, Edge::Bottom] { + shell.config.panels[0].edge = edge; + let mut view = shell.view_hub_at(id, sample, Some(status_ui::Menu::Calendar), None); + let mut tree = widget::Tree::new(&view); + let viewport = Rectangle::with_size(Size::new(340., 230.)); + let mut node = + view.as_widget_mut() + .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size())); + let mut messages = Vec::new(); + let event = Event::Window(window::Event::RedrawRequested(Instant::now())); + for _ in 0..2 { + let mut update = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + mouse::Cursor::Unavailable, + &renderer, + &mut clipboard::Null, + &mut update, + &viewport, + ); + if update.is_layout_invalid() { + node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, viewport.size()), + ); + } + } + let scene = messages + .iter() + .find_map(|message| match message { + cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene), + _ => None, + }) + .expect("native material and input scene"); + assert!(scene.contains([170., if edge == Edge::Top { 17. } else { 213. }])); + assert!(!scene.contains([20., 17.]), "unused backing space is click-through"); + assert!( + !scene.contains([170., 100.]), + "target geometry must not steal input before it is visible" + ); + assert_eq!(scene.damage.len(), 1); + assert!(scene.damage[0].width <= 151.); + } + } + + #[test] + fn each_animation_frame_publishes_its_measured_shape_without_event_replay() { + use cosmic::iced::advanced::renderer::Headless; + use cosmic::iced::runtime::UserInterface; + let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let mut shell = shell(); + let id = shell.outputs[0].bar; + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + let start = Instant::now() + Duration::from_secs(1); + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut().sampled = start; + let mut ui = UserInterface::build( + shell.view_layer(id), + Size::new(340., 248.), + Default::default(), + &mut renderer, + ); + let mut last_width = 0.; + // Sample before the spring tail rounds to the same physical pixel. + for frame in 1..=8 { + let mut messages = Vec::new(); + ui.update( + &[Event::Window(window::Event::RedrawRequested( + start + Duration::from_millis(frame * 8), + ))], + mouse::Cursor::Unavailable, + &mut renderer, + &mut clipboard::Null, + &mut messages, + ); + let scene = messages + .iter() + .find_map(|message| match message { + cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene), + _ => None, + }) + .unwrap_or_else(|| panic!("frame {frame} must publish its freshly laid-out native shape")); + let width = scene.paint[0].bounds.width; + assert!(width > last_width, "frame {frame}: {width} must exceed {last_width}"); + last_width = width; + } + } +} diff --git a/shell/ferese-shell/src/panel_hub/activity.rs b/shell/ferese-shell/src/panel_hub/activity.rs new file mode 100644 index 0000000..3f04081 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/activity.rs @@ -0,0 +1,199 @@ +use cosmic::app::Task; +use cosmic::iced::{Alignment, Length, window}; +use cosmic::widget::{button, container, row}; +use cosmic::{Element, theme}; +use ferese_theme::icons; + +use super::{Event, Peek}; +use crate::{FereseShell, Message, recording, status_ui}; + +impl FereseShell { + pub(super) fn hub_low_battery(&self) -> Option { + self.status + .battery + .as_ref() + .filter(|battery| { + battery.percent < self.config.status.low_battery_threshold + && matches!(battery.status.as_str(), "Discharging" | "Pending discharge" | "Empty") + }) + .map(|battery| battery.percent) + } + + pub(super) fn hub_priority(&self) -> Option { + if matches!( + self.recorder.state, + recording::State::Recording(_) | recording::State::Saving + ) { + Some(Peek::Recording) + } else { + self.hub_low_battery().map(|_| Peek::Battery) + } + } + + pub(super) fn hub_peek_content(&self, id: window::Id) -> (Peek, f64) { + if let Some(priority) = self.hub_priority() { + return ( + priority, + if priority == Peek::Recording && self.hub_low_battery().is_some() { + 280. + } else { + 240. + }, + ); + } + let player = self.media.snapshot.selected.as_ref().filter(|player| { + matches!( + player.status, + ferese_ipc::media::Playback::Playing | ferese_ipc::media::Playback::Paused + ) + }); + let maximum = self.panel_for_bar(id).morph.peek_width.clamp(200., 280.); + player.map_or_else( + || { + ( + Peek::Date, + (self.clock.chars().count() as f64 * 7. + 54.).clamp(200., maximum), + ) + }, + |player| { + ( + Peek::Media, + (player.label().chars().count() as f64 * 7. + 77.).clamp(200., maximum), + ) + }, + ) + } + + pub(crate) fn sync_hub_activity(&mut self) -> Task { + let priority = self.hub_priority(); + let changes: Vec<_> = self + .outputs + .iter() + .filter(|entry| entry.runtime.hub.is_some()) + .map(|entry| (entry.bar, self.hub_peek_content(entry.bar))) + .collect(); + Task::batch( + changes + .into_iter() + .map(|(id, peek)| self.transition_hub(id, Event::Activity { priority, peek })), + ) + } + + pub(super) fn hub_activity_strip( + &self, + id: window::Id, + priority: Peek, + expanded: bool, + intrinsic: bool, + opacity: f32, + ) -> Element<'_, cosmic::Action> { + let palette = self.config.theme.palette(); + let foreground = palette.text.scale_alpha(opacity); + let warning = palette.error.scale_alpha(opacity); + let width = if intrinsic || !expanded { + Length::Shrink + } else { + Length::Fill + }; + if priority == Peek::Battery { + let percent = self + .hub_low_battery() + .or_else(|| self.status.battery.as_ref().map(|b| b.percent)); + let label = percent.map_or_else( + || "Battery".into(), + |percent| { + if expanded { + format!("Low battery · {percent}%") + } else { + format!("{percent}% battery") + } + }, + ); + return row![ + icons::tinted(icons::BATTERY_25, 18, warning), + self.render + .text(label) + .size(13) + .class(theme::Text::Color(foreground)) + .width(width) + .align_x(if expanded { Alignment::End } else { Alignment::Start }) + ] + .spacing(8) + .align_y(Alignment::Center) + .width(width) + .into(); + } + let elapsed = self.recorder.elapsed(); + let saving = matches!(self.recorder.state, recording::State::Saving); + let mut strip = row::with_capacity(4) + .spacing(8) + .align_y(Alignment::Center) + .width(width) + .push(icons::tinted( + if saving { icons::RECORD_SAVING } else { icons::RECORD }, + 18, + warning, + )); + if let Some(elapsed) = elapsed { + if expanded { + strip = strip.push( + self.render + .text("Recording") + .size(13) + .class(theme::Text::Color(foreground)) + .width(Length::Fill), + ); + } + // Reserve the same width for every second, regardless of digit advances. + let timer_width = elapsed.len().max(5) as f32 * 8.; + strip = strip.push( + container(self.render.text(elapsed).size(13).class(theme::Text::Color(foreground))) + .center_x(timer_width), + ); + } else { + strip = strip.push( + self.render + .text(if saving && !expanded { + "Saving…" + } else if saving { + "Saving recording…" + } else { + "Recording ended" + }) + .size(13) + .class(theme::Text::Color(foreground)) + .width(width), + ); + } + if let Some(percent) = self.hub_low_battery() { + let style = button::Style { + text_color: Some(warning), + ..Default::default() + }; + strip = strip.push( + button::custom( + row![ + icons::tinted(icons::BATTERY_25, 16, warning), + self.render + .text(format!("{percent}%")) + .size(12) + .class(theme::Text::Color(warning)) + ] + .spacing(4) + .align_y(Alignment::Center), + ) + .name(format!("Low battery, {percent} percent. Open battery controls")) + .padding(0) + .height(28) + .class(theme::Button::Custom { + active: Box::new(move |_, _| style), + hovered: Box::new(move |_, _| style), + pressed: Box::new(move |_, _| style), + disabled: Box::new(move |_| style), + }) + .on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Battery))), + ); + } + strip.into() + } +} diff --git a/shell/ferese-shell/src/panel_hub/content_frame.rs b/shell/ferese-shell/src/panel_hub/content_frame.rs new file mode 100644 index 0000000..6f7ac09 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/content_frame.rs @@ -0,0 +1,170 @@ +use crate::Message; +use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget}; +use cosmic::iced::{Event, Length, Rectangle, Size, Vector}; +use cosmic::{Element, Theme}; + +type Action = cosmic::Action; + +// Content keeps its destination layout while the shell springs around it. +// Centering against the animated width cancels the shell's movement; anchoring +// to the screen edge keeps its vertical position stable too. +pub(super) fn view(content: Element<'_, Action>, size: [f64; 2], edge: ferese_panel::Edge) -> Element<'_, Action> { + Element::new(ContentFrame { + content, + target: Size::new(size[0] as f32, size[1] as f32), + edge, + }) +} + +struct ContentFrame<'a> { + content: Element<'a, Action>, + target: Size, + edge: ferese_panel::Edge, +} + +impl Widget for ContentFrame<'_> { + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.content)] + } + fn diff(&mut self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &cosmic::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let size = limits.resolve(Length::Fill, Length::Fill, Size::ZERO); + let child = self + .content + .as_widget_mut() + .layout( + &mut tree.children[0], + renderer, + &layout::Limits::new(self.target, self.target), + ) + .move_to(cosmic::iced::Point::new( + (size.width - self.target.width) / 2., + if self.edge == ferese_panel::Edge::Bottom { + size.height - self.target.height + } else { + 0. + }, + )); + layout::Node::with_children(size, vec![child]) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &cosmic::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Action>, + viewport: &Rectangle, + ) { + let viewport = viewport.intersection(&layout.bounds()).unwrap_or_default(); + let cursor = if cursor.is_over(viewport) { + cursor + } else { + mouse::Cursor::Unavailable + }; + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout.children().next().expect("content layout"), + cursor, + renderer, + clipboard, + shell, + &viewport, + ); + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut cosmic::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if let Some(clip) = viewport.intersection(&layout.bounds()) { + renderer.with_layer(clip, |renderer| { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout.children().next().expect("content layout"), + cursor, + &clip, + ); + }); + } + } + fn a11y_nodes( + &self, + layout: Layout<'_>, + tree: &widget::Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + self.content.as_widget().a11y_nodes( + layout.children().next().expect("content layout"), + &tree.children[0], + cursor, + ) + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &cosmic::Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout.children().next().expect("content layout"), + cursor, + viewport, + renderer, + ) + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &cosmic::Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout.children().next().expect("content layout"), + renderer, + operation, + ); + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &cosmic::Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout.children().next().expect("content layout"), + renderer, + viewport, + translation, + ) + } +} diff --git a/shell/ferese-shell/src/panel_hub/equalizer.rs b/shell/ferese-shell/src/panel_hub/equalizer.rs new file mode 100644 index 0000000..580eab8 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/equalizer.rs @@ -0,0 +1,152 @@ +use std::time::Instant; + +use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget}; +use cosmic::iced::{Color, Event, Length, Rectangle, Size, window}; +use cosmic::{Element, Theme}; + +use crate::Message; + +pub(super) fn view<'a>(started: Instant, animate: bool, color: Color) -> Element<'a, cosmic::Action> { + Element::new(Equalizer { + started, + animate, + color, + }) +} + +struct Equalizer { + started: Instant, + animate: bool, + color: Color, +} + +impl Widget, Theme, cosmic::Renderer> for Equalizer { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(self.started) + } + + fn size(&self) -> Size { + Size::new(Length::Fixed(13.), Length::Fixed(14.)) + } + + fn layout(&mut self, _: &mut widget::Tree, _: &cosmic::Renderer, limits: &layout::Limits) -> layout::Node { + layout::Node::new(limits.resolve(13., 14., Size::ZERO)) + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + _: Layout<'_>, + _: mouse::Cursor, + _: &cosmic::Renderer, + _: &mut dyn Clipboard, + shell: &mut Shell<'_, cosmic::Action>, + _: &Rectangle, + ) { + if let Event::Window(window::Event::RedrawRequested(at)) = event { + *tree.state.downcast_mut::() = *at; + if self.animate { + shell.request_redraw(); + } + } + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut cosmic::Renderer, + _: &Theme, + _: &renderer::Style, + layout: Layout<'_>, + _: mouse::Cursor, + viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let Some(clip) = bounds.intersection(viewport) else { + return; + }; + let elapsed = tree + .state + .downcast_ref::() + .saturating_duration_since(self.started) + .as_secs_f32(); + renderer.with_layer(clip, |renderer| { + for index in 0..3 { + let phase = (elapsed - index as f32 * 0.2) * std::f32::consts::TAU / 0.9; + let fraction = if self.animate { 0.65 + 0.35 * phase.sin() } else { 0.45 }; + let height = bounds.height * fraction; + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + (bounds.x + index as f32 * 5., bounds.y + bounds.height - height).into(), + (3., height).into(), + ), + border: cosmic::iced::Border { + radius: 2.0.into(), + ..Default::default() + }, + ..Default::default() + }, + self.color, + ); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::advanced::{clipboard, renderer::Headless}; + + #[test] + fn bars_redraw_without_rebuilding_layout_or_publishing_messages() { + let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + for animate in [true, false] { + let started = Instant::now(); + let mut element = view(started, animate, Color::WHITE); + let mut tree = widget::Tree::new(&element); + let node = element.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(100., 100.)), + ); + let mut messages = Vec::new(); + for frame in 0..120 { + let mut shell = Shell::new(&mut messages); + element.as_widget_mut().update( + &mut tree, + &Event::Window(window::Event::RedrawRequested( + started + std::time::Duration::from_millis(frame * 8), + )), + Layout::new(&node), + mouse::Cursor::Unavailable, + &renderer, + &mut clipboard::Null, + &mut shell, + &Rectangle::with_size(Size::new(100., 100.)), + ); + assert!(!shell.is_layout_invalid()); + assert_eq!( + shell.redraw_request(), + if animate { + window::RedrawRequest::NextFrame + } else { + window::RedrawRequest::Wait + } + ); + } + assert!(messages.is_empty()); + } + } +} diff --git a/shell/ferese-shell/src/panel_hub/scroll.rs b/shell/ferese-shell/src/panel_hub/scroll.rs new file mode 100644 index 0000000..1642cb3 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/scroll.rs @@ -0,0 +1,126 @@ +use crate::Message; +use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; +use cosmic::iced::{Event, Length, Rectangle, Size, Vector, window}; +use cosmic::{Element, Theme}; + +type Action = cosmic::Action; + +pub(super) fn area(content: Element<'_, Action>, id: window::Id) -> Element<'_, Action> { + Element::new(WheelArea { content, id }) +} + +// Handle the event directly: the pinned toolkit's MouseArea emits its scroll +// callback twice per wheel event, which doubles trackpad accumulation. +struct WheelArea<'a> { + content: Element<'a, Action>, + id: window::Id, +} + +impl Widget for WheelArea<'_> { + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.content)] + } + fn diff(&mut self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &cosmic::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &cosmic::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Action>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + if !shell.is_event_captured() + && cursor.is_over(layout.bounds()) + && cursor.is_over(*viewport) + && let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event + { + shell.publish(cosmic::Action::App(Message::HubWorkspaceScroll(self.id, *delta))); + shell.capture_event(); + } + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut cosmic::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content + .as_widget() + .draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport); + } + fn a11y_nodes( + &self, + layout: Layout<'_>, + tree: &widget::Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor) + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &cosmic::Renderer, + ) -> mouse::Interaction { + self.content + .as_widget() + .mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer) + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &cosmic::Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &cosmic::Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(&mut tree.children[0], layout, renderer, viewport, translation) + } +} diff --git a/shell/ferese-shell/src/panel_hub/transition.rs b/shell/ferese-shell/src/panel_hub/transition.rs new file mode 100644 index 0000000..eca14c1 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/transition.rs @@ -0,0 +1,196 @@ +use std::time::Instant; + +use ferese_panel::morph::Mode; + +use super::{Peek, PreviousContent, Runtime, content}; +use crate::status_ui::Menu; + +/// A transient owns its expiry token and the content it interrupted. +#[derive(Clone, Copy, Default)] +pub(super) enum Transient { + #[default] + None, + Toast { + notice: u32, + token: Instant, + }, + Workspaces { + token: Instant, + resume: (Peek, f64), + }, +} + +impl Transient { + pub(super) fn toast(self) -> Option { + match self { + Self::Toast { notice, .. } => Some(notice), + _ => None, + } + } + + pub(super) fn workspace_token(self) -> Option { + match self { + Self::Workspaces { token, .. } => Some(token), + _ => None, + } + } +} + +pub(super) enum Event { + Hover { + entered: bool, + enabled: bool, + peek: (Peek, f64), + priority: Option, + }, + Open(Menu), + Close, + Activity { + priority: Option, + peek: (Peek, f64), + }, + Toast { + notice: u32, + token: Instant, + }, + ToastExpired(Instant), + Workspaces(Instant), + WorkspacesExpired(Instant), + /// Recompute the allocation after a size change without changing content. + Resize, + #[cfg(test)] + Present(Mode, Option), +} + +impl Runtime { + fn restore_transient(&mut self) { + if let Transient::Workspaces { resume, .. } = self.transient { + (self.peek, self.peek_width) = resume; + } + self.transient = Transient::None; + } + + /// All semantic state changes enter here. Retargeting below handles only geometry. + pub(super) fn transition(&mut self, event: Event, available: [f64; 2]) -> bool { + let before = self.sample(Instant::now()); + let outgoing = PreviousContent { + menu: self.menu, + peek: self.peek, + toast: self.transient.toast(), + size: self.hub.target_size(), + }; + let mut replace_content = false; + let current = self.hub.mode(); + let (mode, menu) = match event { + Event::Hover { + entered, + enabled, + peek, + priority, + } => { + self.hovered = entered; + match current { + Mode::Idle if entered && enabled => { + (self.peek, self.peek_width) = peek; + self.priority = priority; + (Mode::Peek, None) + } + Mode::Peek if !entered => { + self.restore_transient(); + (Mode::Idle, None) + } + _ => return false, + } + } + Event::Open(menu) => { + self.restore_transient(); + self.last_menu = menu; + self.workspace_hover = None; + self.workspace_selected = None; + (Mode::Expanded(content(menu)), Some(menu)) + } + Event::Close => { + self.restore_transient(); + (Mode::Idle, None) + } + Event::Activity { priority, peek } => { + let changed = self.priority != priority; + let resized = priority.is_some() && current == Mode::Peek && self.peek_width != peek.1; + self.priority = priority; + if (!changed && !resized) || current.expanded() { + return false; + } + if current != Mode::Peek && !(current == Mode::Toast && priority.is_some()) { + return false; + } + // Preserve the outgoing content for its fade, but discard its expiry. + self.transient = Transient::None; + (self.peek, self.peek_width) = peek; + if changed && current == Mode::Peek { + replace_content = true; + } + (if self.hovered { Mode::Peek } else { Mode::Idle }, None) + } + Event::Toast { notice, token } => { + if current.expanded() || self.priority.is_some() { + return false; + } + self.restore_transient(); + self.transient = Transient::Toast { notice, token }; + (Mode::Toast, None) + } + Event::ToastExpired(token) => { + if !matches!(self.transient, Transient::Toast { token: live, .. } if live == token) { + return false; + } + self.restore_transient(); + (Mode::Idle, None) + } + Event::Workspaces(token) => { + if !matches!(current, Mode::Idle | Mode::Peek) || self.priority.is_some() { + return false; + } + let resume = match self.transient { + Transient::Workspaces { resume, .. } => resume, + _ => { + let resume = (self.peek, self.peek_width); + self.peek = Peek::Workspaces; + self.peek_width = 260.; + if current == Mode::Peek { + replace_content = true; + } + resume + } + }; + self.transient = Transient::Workspaces { token, resume }; + (Mode::Peek, None) + } + Event::WorkspacesExpired(token) => { + if self.transient.workspace_token() != Some(token) { + return false; + } + self.restore_transient(); + if self.hovered { + replace_content = true; + } + (if self.hovered { Mode::Peek } else { Mode::Idle }, None) + } + Event::Resize => (current, self.menu), + #[cfg(test)] + Event::Present(mode, menu) => { + self.restore_transient(); + (mode, menu) + } + }; + replace_content |= current == mode && self.menu != menu; + let changing_content = current != mode || replace_content; + if changing_content && (replace_content || before.opacity >= before.previous_opacity) { + self.previous = outgoing; + } + self.retarget(mode, available, menu); + if replace_content { + self.hub.replace_content(); + } + true + } +} diff --git a/shell/ferese-shell/src/panel_hub/workspace_grid.rs b/shell/ferese-shell/src/panel_hub/workspace_grid.rs new file mode 100644 index 0000000..62b2715 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/workspace_grid.rs @@ -0,0 +1,602 @@ +mod drag; +mod thumbnail; + +use cosmic::app::Task; +use cosmic::iced::advanced::widget::{Id, operation}; +use cosmic::iced::{Alignment, Length, keyboard::key::Named, window}; +use cosmic::widget::{button, column, container, mouse_area, row, scrollable}; +use cosmic::{Element, theme}; + +use crate::control::{WindowSnapshot, WorkspaceSnapshot}; +use crate::{FereseShell, Message, motion}; + +const TILE_WIDTH: f32 = 56.; +const TILE_HEIGHT: f32 = 40.; +const GAP: f32 = 8.; + +fn tiles(mut workspaces: Vec<&WorkspaceSnapshot>, active: u64) -> Vec<&WorkspaceSnapshot> { + workspaces.sort_by_key(|workspace| (workspace.index, workspace.id)); + let empty = workspaces + .iter() + .find(|w| w.window_count == 0 && w.id != active) + .map(|w| w.id); + workspaces.retain(|w| w.window_count > 0 || w.id == active || Some(w.id) == empty); + workspaces +} + +fn columns(width: f32) -> usize { + (((width + GAP) / (TILE_WIDTH + GAP)).floor() as usize).clamp(1, 5) +} + +fn caption(workspace: &WorkspaceSnapshot) -> String { + let name = if workspace.name.is_empty() || workspace.name == workspace.index.to_string() { + workspace.index.to_string() + } else { + format!("{} · {}", workspace.index, workspace.name) + }; + format!( + "{name} — {} {}", + workspace.window_count, + if workspace.window_count == 1 { + "window" + } else { + "windows" + } + ) +} + +fn tile_id(bar: window::Id, workspace: u64) -> Id { + Id::new(format!("morph-workspace-{bar:?}-{workspace}")) +} + +struct FocusedWorkspace { + tiles: Vec<(Id, u64)>, + focused: Option, +} + +impl cosmic::iced::advanced::widget::Operation> for FocusedWorkspace { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation>)) { + operate(self); + } + + fn focusable(&mut self, id: Option<&Id>, _: cosmic::iced::Rectangle, state: &mut dyn operation::Focusable) { + if state.is_focused() { + self.focused = self + .tiles + .iter() + .find(|(tile, _)| Some(tile) == id) + .map(|(_, workspace)| *workspace) + .or(self.focused); + } + } + + fn finish(&self) -> operation::Outcome> { + operation::Outcome::Some(self.focused) + } +} + +// Fit the actual rectangles together, preserving aspect and relative positions. +fn mini_rects(windows: &[&WindowSnapshot]) -> Vec<(u64, cosmic::iced::Rectangle)> { + let rectangles: Vec<_> = windows.iter().filter_map(|w| w.geometry.map(|r| (w.id, r))).collect(); + if rectangles.is_empty() { + return Vec::new(); + } + let x = rectangles.iter().map(|(_, r)| r[0]).fold(f64::INFINITY, f64::min); + let y = rectangles.iter().map(|(_, r)| r[1]).fold(f64::INFINITY, f64::min); + let right = rectangles + .iter() + .map(|(_, r)| r[0] + r[2]) + .fold(f64::NEG_INFINITY, f64::max); + let bottom = rectangles + .iter() + .map(|(_, r)| r[1] + r[3]) + .fold(f64::NEG_INFINITY, f64::max); + let scale = (44. / (right - x)).min(17. / (bottom - y)); + let dx = (44. - (right - x) * scale) / 2.; + let dy = (17. - (bottom - y) * scale) / 2.; + rectangles + .into_iter() + .map(|(id, r)| { + ( + id, + cosmic::iced::Rectangle { + x: (dx + (r[0] - x) * scale + 0.3) as f32, + y: (dy + (r[1] - y) * scale + 0.3) as f32, + width: (r[2] * scale - 0.6).max(0.2) as f32, + height: (r[3] * scale - 0.6).max(0.2) as f32, + }, + ) + }) + .collect() +} + +impl FereseShell { + pub(crate) fn hub_workspace_move(&mut self, id: window::Id, window: u64, destination: u64) -> Task { + let output = self.output_for_bar(id).map(|o| o.id); + let source = self + .snapshot + .windows + .iter() + .find(|w| w.id == window) + .map(|w| w.workspace); + let valid = |workspace| self.snapshot.workspaces_for_output(output).any(|w| w.id == workspace); + if source.is_some_and(valid) + && valid(destination) + && let Some(control) = &self.control + { + control.move_window_to_workspace(window, destination); + } + Task::none() + } + + pub(crate) fn hub_workspace_hover(&mut self, id: window::Id, workspace: Option) -> Task { + if let Some(runtime) = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + { + let mut runtime = runtime.borrow_mut(); + runtime.workspace_hover = workspace; + runtime.workspace_selected = None; + } + Task::none() + } + + pub(crate) fn hub_workspace_select(&mut self, id: window::Id, workspace: u64) -> Task { + let valid = self + .snapshot + .workspaces_for_output(self.output_for_bar(id).map(|o| o.id)) + .any(|w| w.id == workspace); + if !valid { + return Task::none(); + } + if let Some(control) = &self.control { + control.activate_workspace(workspace); + } + if let Some(runtime) = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + { + runtime.borrow_mut().workspace_selection_pending = Some(workspace); + } + self.close_hub(id) + } + + pub(crate) fn hub_workspace_key(&self, id: window::Id, key: Named) -> Task { + self.query_hub_workspace_focus(id, Some(key)) + } + + pub(crate) fn query_hub_workspace_focus(&self, id: window::Id, key: Option) -> Task { + let output = self.output_for_bar(id); + let tiles = tiles( + self.snapshot.workspaces_for_output(output.map(|o| o.id)).collect(), + output.map_or(0, |o| o.active_workspace), + ) + .into_iter() + .map(|workspace| (tile_id(id, workspace.id), workspace.id)) + .collect(); + cosmic::iced::advanced::widget::operate(FocusedWorkspace { tiles, focused: None }) + .map(move |focused| cosmic::Action::App(Message::HubWorkspaceFocus(id, focused, key))) + } + + pub(crate) fn hub_workspace_focus( + &mut self, + id: window::Id, + focused: Option, + key: Option, + ) -> Task { + if self.hub_mode(id) + != Some(ferese_panel::morph::Mode::Expanded( + ferese_panel::morph::Content::Workspaces, + )) + { + return Task::none(); + } + let Some(output) = self.output_for_bar(id) else { + return Task::none(); + }; + let workspaces = tiles( + self.snapshot.workspaces_for_output(Some(output.id)).collect(), + output.active_workspace, + ); + let Some(runtime) = self + .outputs + .iter() + .find(|e| e.bar == id) + .and_then(|e| e.runtime.hub.as_ref()) + else { + return Task::none(); + }; + let mut runtime = runtime.borrow_mut(); + runtime.workspace_selected = focused; + let Some(key) = key else { + return Task::none(); + }; + let selected = focused.unwrap_or(output.active_workspace); + let current = workspaces.iter().position(|w| w.id == selected).unwrap_or(0); + let cols = columns((runtime.hub.target_size()[0] - 28.) as f32); + let next = match key { + Named::ArrowLeft => current.saturating_sub(1), + Named::ArrowRight => (current + 1).min(workspaces.len().saturating_sub(1)), + Named::ArrowUp => current.saturating_sub(cols), + Named::ArrowDown => (current + cols).min(workspaces.len().saturating_sub(1)), + _ => return Task::none(), + }; + let Some(workspace) = workspaces.get(next) else { + return Task::none(); + }; + runtime.workspace_selected = Some(workspace.id); + runtime.workspace_hover = None; + let rows = workspaces.len().div_ceil(cols); + Task::batch([ + cosmic::iced::advanced::widget::operate(operation::focusable::focus(tile_id(id, workspace.id))), + cosmic::iced::widget::scrollable::snap_to( + Id::new(format!("morph-workspace-grid-{id:?}")), + cosmic::iced::widget::scrollable::RelativeOffset { + x: None, + y: Some(if rows > 2 { + (next / cols).saturating_sub(1) as f32 / (rows - 2) as f32 + } else { + 0. + }), + }, + ), + ]) + } + + pub(crate) fn hub_workspace_grid(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action> { + let output = self.output_for_bar(id); + let active = output.map_or(0, |o| o.active_workspace); + let workspaces = tiles( + self.snapshot.workspaces_for_output(output.map(|o| o.id)).collect(), + active, + ); + let runtime = self + .outputs + .iter() + .find(|e| e.bar == id) + .and_then(|e| e.runtime.hub.as_ref()) + .map(|r| r.borrow()); + let width = runtime + .as_ref() + .map_or(312., |r| (r.hub.target_size()[0] - 28.) as f32) + .max(1.); + let cols = columns(width); + let hovered = runtime + .as_ref() + .and_then(|r| r.workspace_hover.or(r.workspace_selected)) + .unwrap_or(active); + let current = workspaces + .iter() + .find(|w| w.id == hovered) + .or_else(|| workspaces.iter().find(|w| w.id == active)); + let mut label = current.map_or_else(|| "No workspaces".to_owned(), |w| caption(w)); + if let Some(workspace) = current.filter(|_| runtime.as_ref().is_some_and(|r| r.workspace_hover.is_some())) { + let titles = self + .snapshot + .windows + .iter() + .filter(|w| w.workspace == workspace.id && !w.title.is_empty()) + .map(|w| w.title.as_str()) + .collect::>() + .join(", "); + if !titles.is_empty() { + label.push_str(&format!(" · {titles}")); + } + } + let palette = self.config.theme.palette(); + let mut targets = Vec::new(); + let mut grid = column::with_capacity(workspaces.len().div_ceil(cols)).spacing(GAP); + for group in workspaces.chunks(cols) { + let mut line = row::with_capacity(cols).spacing(GAP); + for workspace in group { + let workspace_id = workspace.id; + let index = workspace.index; + let windows: Vec<_> = self + .snapshot + .windows + .iter() + .filter(|w| w.workspace == workspace_id) + .collect(); + let miniatures: thumbnail::Geometry = mini_rects(&windows).into(); + let index_in_grid = targets.len(); + let origin = cosmic::iced::Vector::new( + (index_in_grid % cols) as f32 * (TILE_WIDTH + GAP), + (index_in_grid / cols) as f32 * (TILE_HEIGHT + GAP), + ); + targets.push(drag::Target { + workspace: workspace_id, + bounds: cosmic::iced::Rectangle::new( + cosmic::iced::Point::ORIGIN + origin, + cosmic::iced::Size::new(TILE_WIDTH.min(width), TILE_HEIGHT), + ), + windows: miniatures + .iter() + .copied() + .map(|(id, rect)| (id, rect + origin + cosmic::iced::Vector::new(6., 19.))) + .collect(), + }); + let urgent = workspace_id != active && windows.iter().any(|w| w.urgent); + let empty = workspace.window_count == 0 && workspace_id != active; + let label = format!( + "{}{}{}", + caption(workspace), + if workspace_id == active { ", active" } else { "" }, + windows.iter().map(|w| format!(", {}", w.title)).collect::() + ); + let transition = self.workspace_ui.transition(workspace_id, workspace_id == active); + line = line.push(motion::geometry_driven( + transition.revision(), + move |now| { + let progress = transition.progress(now).clamp(0., 1.); + let alpha = opacity * if empty { 0.6 } else { 1. }; + let foreground = ferese_theme::mix(palette.muted, palette.accent, progress).scale_alpha(alpha); + let fill = cosmic::iced::Color { + a: (0.055 + 0.105 * progress) * alpha, + ..ferese_theme::mix(palette.text, palette.accent, progress) + }; + let appearance = button::Style { + background: Some(fill.into()), + border_radius: 10.0.into(), + border_width: 0.5, + border_color: palette.accent.scale_alpha(progress * alpha * 0.5), + text_color: Some(foreground), + ..Default::default() + }; + let mut heading = row![ + self.render + .text(index.to_string()) + .size(11) + .class(theme::Text::Color(foreground)) + .width(Length::Fill) + ] + .align_y(Alignment::Center); + if urgent { + let pulse = if self.render.motion.motion_enabled() { + 0.65 + 0.35 + * (now + .saturating_duration_since(transition.revision().unwrap_or(now)) + .as_secs_f32() + * 4.) + .cos() + } else { + 1. + }; + heading = heading.push(container(cosmic::widget::Space::new()).width(4).height(4).class( + theme::Container::custom(move |_| container::Style { + background: Some(palette.error.scale_alpha(alpha * pulse).into()), + border: cosmic::iced::Border { + radius: 2.0.into(), + ..Default::default() + }, + ..Default::default() + }), + )); + } + let content = column![ + container(heading).height(14), + thumbnail::view(miniatures.clone(), foreground) + ] + .spacing(1); + mouse_area( + button::custom(content) + .id(tile_id(id, workspace_id)) + .name(label.clone()) + .width(TILE_WIDTH.min(width)) + .height(TILE_HEIGHT) + .padding([4, 6]) + .class(theme::Button::Custom { + active: Box::new(move |focused, _| button::Style { + outline_width: if focused { 1. } else { 0. }, + outline_color: palette.accent.scale_alpha(alpha), + ..appearance + }), + hovered: Box::new(move |_, _| button::Style { + background: Some(palette.accent.scale_alpha(0.12 * alpha).into()), + ..appearance + }), + pressed: Box::new(move |_, _| appearance), + disabled: Box::new(move |_| appearance), + }) + .on_press(cosmic::Action::App(Message::HubWorkspaceSelect(id, workspace_id))), + ) + .on_enter(cosmic::Action::App(Message::HubWorkspaceHover(id, Some(workspace_id)))) + .on_exit(cosmic::Action::App(Message::HubWorkspaceHover(id, None))) + .into() + }, + move |now| transition.active(now) || (urgent && self.render.motion.motion_enabled()), + |_| {}, + |_| None, + None, + )); + } + grid = grid.push(line); + } + column![ + scrollable(drag::area(grid.into(), id, targets)) + .id(Id::new(format!("morph-workspace-grid-{id:?}"))) + .height(88) + .direction(cosmic::iced::widget::scrollable::Direction::Vertical( + cosmic::iced::widget::scrollable::Scrollbar::default() + .width(0) + .scroller_width(0) + )), + self.render + .text(label) + .size(12) + .class(theme::Text::Color(palette.muted.scale_alpha(opacity))) + .width(Length::Fill) + .wrapping(cosmic::iced::widget::text::Wrapping::None), + ] + .spacing(8) + .width(Length::Fill) + .into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workspace(id: u64, count: u32) -> WorkspaceSnapshot { + WorkspaceSnapshot { + id, + index: id as u32, + name: id.to_string(), + output: Some(1), + window_count: count, + visible: false, + focused: false, + } + } + + #[test] + fn grid_keeps_occupied_active_and_one_empty_destination_without_a_ten_tile_cap() { + let mut all: Vec<_> = (1..=14).map(|id| workspace(id, u32::from(id < 12))).collect(); + all.reverse(); + let shown = tiles(all.iter().collect(), 13); + assert_eq!( + shown.iter().map(|w| w.id).collect::>(), + (1..=13).collect::>() + ); + assert_eq!(columns(312.), 5); + assert_eq!(columns(212.), 3); + assert_eq!(caption(&workspace(3, 1)), "3 — 1 window"); + let mut named = workspace(3, 2); + named.name = "Chat".into(); + assert_eq!(caption(&named), "3 · Chat — 2 windows"); + } + + #[test] + fn arrows_follow_the_tile_focused_by_tab_in_both_directions_and_narrow_grids() { + use cosmic::iced::Size; + use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget}; + let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + for (width, start, reverse, key, expected) in [ + (340., 4, false, Named::ArrowDown, 10), + (340., 8, true, Named::ArrowLeft, 6), + (240., 4, false, Named::ArrowDown, 8), + ] { + let mut shell = crate::tests::shell_with_measured_panel(); + shell.config.panels[0].style = ferese_panel::PanelStyle::Morph; + shell.config.panels[0].morph.expanded_width = width; + shell.outputs[0] + .runtime + .reset(&shell.config.panels[0], &shell.snapshot, Some("test")); + let bar = shell.outputs[0].bar; + shell.snapshot.outputs = vec![crate::control::OutputSnapshot { + id: 1, + name: "test".into(), + active_workspace: 1, + focused: true, + }]; + shell.snapshot.workspaces = (1..=12).map(|id| workspace(id, 1)).collect(); + drop(shell.open_hub_overview(bar)); + shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow_mut() + .workspace_selected = Some(1); + let focused = { + let mut view = shell.hub_workspace_grid(bar, 1.); + let mut tree = widget::Tree::new(&view); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(width as f32 - 28., 128.)), + ); + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &renderer, + &mut operation::focusable::focus::<()>(tile_id(bar, start)), + ); + let mut tab: Box = if reverse { + Box::new(operation::focusable::focus_previous()) + } else { + Box::new(operation::focusable::focus_next()) + }; + loop { + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut tab); + match tab.finish() { + operation::Outcome::Chain(next) => tab = next, + _ => break, + } + } + let mut query = FocusedWorkspace { + tiles: (1..=12).map(|id| (tile_id(bar, id), id)).collect(), + focused: None, + }; + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &renderer, + &mut operation::black_box(&mut query), + ); + query.focused + }; + assert_eq!(focused, Some(if reverse { start - 1 } else { start + 1 })); + drop(shell.hub_workspace_focus(bar, focused, None)); + assert_eq!( + shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow() + .workspace_selected, + focused + ); + drop(shell.hub_workspace_focus(bar, focused, Some(key))); + assert_eq!( + shell.outputs[0] + .runtime + .hub + .as_ref() + .unwrap() + .borrow() + .workspace_selected, + Some(expected) + ); + } + } + + #[test] + fn miniatures_preserve_real_layout_and_do_not_invent_missing_windows() { + let window = |id, rect| WindowSnapshot { + id, + workspace: 1, + app_id: "test".into(), + title: "test".into(), + geometry: rect, + focused: false, + urgent: false, + fullscreen: false, + maximized: false, + floating: false, + }; + let a = window(1, Some([-400., 20., 300., 200.])); + let b = window(2, Some([-100., 20., 100., 200.])); + let c = window(3, None); + let rectangles = mini_rects(&[&a, &b, &c]); + assert_eq!(rectangles.len(), 2); + assert_eq!(rectangles[0].0, 1); + let left = rectangles[0].1; + let right = rectangles[1].1; + assert!((left.height - right.height).abs() < 0.001); + assert!(left.width > right.width * 2.9); + assert!(left.x + left.width < right.x); + assert!(right.x + right.width <= 44. && left.y + left.height <= 17.); + } +} diff --git a/shell/ferese-shell/src/panel_hub/workspace_grid/drag.rs b/shell/ferese-shell/src/panel_hub/workspace_grid/drag.rs new file mode 100644 index 0000000..bc8d8a3 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/workspace_grid/drag.rs @@ -0,0 +1,265 @@ +use crate::Message; +use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; +use cosmic::iced::{Event, Length, Rectangle, Size, Vector, window}; +use cosmic::{Element, Theme}; + +type Action = cosmic::Action; + +pub(super) struct Target { + pub workspace: u64, + pub bounds: Rectangle, + pub windows: Vec<(u64, Rectangle)>, +} + +pub(super) fn area(content: Element<'_, Action>, id: window::Id, targets: Vec) -> Element<'_, Action> { + Element::new(DragArea { content, id, targets }) +} + +struct DragArea<'a> { + content: Element<'a, Action>, + id: window::Id, + targets: Vec, +} +#[derive(Default)] +struct State { + pressed: Option<(u64, u64, cosmic::iced::Point)>, + moved: bool, +} + +impl Widget for DragArea<'_> { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::default()) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.content)] + } + fn diff(&mut self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &cosmic::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &cosmic::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Action>, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_mut::(); + let point = cursor + .position() + .map(|p| p - Vector::new(layout.bounds().x, layout.bounds().y)); + let target = point + .filter(|_| cursor.is_over(*viewport)) + .and_then(|p| self.targets.iter().find(|t| t.bounds.contains(p))); + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let (Some(target), Some(point)) = (target, point) + && let Some((window, _)) = target.windows.iter().rev().find(|(_, rect)| rect.contains(point)) + { + state.pressed = Some((*window, target.workspace, point)); + state.moved = false; + shell.capture_event(); + return; + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if let (Some((_, _, start)), Some(point)) = (state.pressed, point) { + state.moved |= start.distance(point) > 4.; + shell.request_redraw(); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if let Some((window, source, _)) = state.pressed.take() { + if let Some(target) = target { + if state.moved && target.workspace != source { + shell.publish(Action::App(Message::HubWorkspaceMove( + self.id, + window, + target.workspace, + ))); + } else if !state.moved && target.workspace == source { + shell.publish(Action::App(Message::HubWorkspaceSelect(self.id, source))); + } + } + state.moved = false; + shell.capture_event(); + return; + } + } + Event::Window(window::Event::Unfocused) => { + state.pressed = None; + state.moved = false; + } + _ => {} + } + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut cosmic::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content + .as_widget() + .draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport); + } + fn a11y_nodes( + &self, + layout: Layout<'_>, + tree: &widget::Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor) + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &cosmic::Renderer, + ) -> mouse::Interaction { + if tree.state.downcast_ref::().pressed.is_some() { + return mouse::Interaction::Grabbing; + } + self.content + .as_widget() + .mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer) + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &cosmic::Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &cosmic::Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(&mut tree.children[0], layout, renderer, viewport, translation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::advanced::{clipboard, renderer::Headless}; + + #[test] + fn drag_moves_the_pressed_window_once_and_click_still_selects_workspace() { + let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + None, + )) + .unwrap(); + let bar = window::Id::unique(); + let build = || { + area( + cosmic::widget::Space::new().width(120).height(40).into(), + bar, + vec![ + Target { + workspace: 100, + bounds: Rectangle::new((0., 0.).into(), (56., 40.).into()), + windows: vec![(999, Rectangle::new((6., 19.).into(), (44., 17.).into()))], + }, + Target { + workspace: 101, + bounds: Rectangle::new((64., 0.).into(), (56., 40.).into()), + windows: vec![], + }, + ], + ) + }; + for (end, expected) in [((80., 25.), Some((999, 101))), ((15., 25.), None)] { + let mut view = build(); + let mut tree = widget::Tree::new(&view); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(120., 40.)), + ); + let mut messages = Vec::new(); + for (event, cursor) in [ + ( + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + (15., 25.), + ), + (Event::Mouse(mouse::Event::CursorMoved { position: end.into() }), end), + (Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), end), + (Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), end), + ] { + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + mouse::Cursor::Available(cursor.into()), + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &Rectangle::new((0., 0.).into(), (120., 40.).into()), + ); + } + assert_eq!( + messages.len(), + 1, + "one action per gesture, even with a repeated release" + ); + match (&messages[0], expected) { + (Action::App(Message::HubWorkspaceMove(id, window, destination)), Some((w, d))) => { + assert_eq!((*id, *window, *destination), (bar, w, d)) + } + (Action::App(Message::HubWorkspaceSelect(id, workspace)), None) => { + assert_eq!((*id, *workspace), (bar, 100)) + } + _ => panic!("unexpected gesture action"), + } + } + } +} diff --git a/shell/ferese-shell/src/panel_hub/workspace_grid/thumbnail.rs b/shell/ferese-shell/src/panel_hub/workspace_grid/thumbnail.rs new file mode 100644 index 0000000..c0ada53 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/workspace_grid/thumbnail.rs @@ -0,0 +1,55 @@ +use cosmic::iced::advanced::{Layout, Renderer as _, Widget, layout, mouse, renderer, widget}; +use cosmic::iced::{Color, Length, Rectangle, Size}; +use cosmic::{Element, Theme}; +use std::sync::Arc; + +pub(super) type Geometry = Arc<[(u64, Rectangle)]>; + +pub(super) fn view<'a, M: 'a>(rectangles: Geometry, color: Color) -> Element<'a, M> { + Element::new(Thumbnail { rectangles, color }) +} + +struct Thumbnail { + rectangles: Geometry, + color: Color, +} + +impl Widget for Thumbnail { + fn size(&self) -> Size { + Size::new(Length::Fixed(44.), Length::Fixed(17.)) + } + + fn layout(&mut self, _: &mut widget::Tree, _: &cosmic::Renderer, limits: &layout::Limits) -> layout::Node { + layout::Node::new(limits.resolve(44., 17., Size::ZERO)) + } + + fn draw( + &self, + _: &widget::Tree, + renderer: &mut cosmic::Renderer, + _: &Theme, + _: &renderer::Style, + layout: Layout<'_>, + _: mouse::Cursor, + viewport: &Rectangle, + ) { + let Some(clip) = layout.bounds().intersection(viewport) else { + return; + }; + renderer.with_layer(clip, |renderer| { + for (_, rect) in self.rectangles.iter() { + renderer.fill_quad( + renderer::Quad { + bounds: *rect + (layout.bounds().position() - cosmic::iced::Point::ORIGIN), + border: cosmic::iced::Border { + radius: 0.8.into(), + ..Default::default() + }, + ..Default::default() + }, + self.color, + ); + } + }); + } +} diff --git a/shell/ferese-shell/src/panel_hub/workspaces.rs b/shell/ferese-shell/src/panel_hub/workspaces.rs new file mode 100644 index 0000000..4e312b8 --- /dev/null +++ b/shell/ferese-shell/src/panel_hub/workspaces.rs @@ -0,0 +1,427 @@ +use std::time::{Duration, Instant}; + +use cosmic::app::Task; +use cosmic::iced::{Alignment, mouse::ScrollDelta, window}; +use cosmic::widget::{button, container, row}; +use cosmic::{Element, theme}; +use ferese_panel::{ItemKind, morph::Mode}; + +use super::Event; +use crate::control::WorkspaceSnapshot; +use crate::{FereseShell, Message, color_with_opacity, motion}; + +const SLOT: f32 = 12.; +const ACTIVE_EXTRA: f32 = 12.; +const DOT_INSET: f32 = 3.; +const LIMIT: usize = 4; + +struct Selection<'a> { + workspaces: Vec<&'a WorkspaceSnapshot>, + hidden: usize, +} + +impl Selection<'_> { + fn width(&self) -> f32 { + if self.workspaces.is_empty() { + return 0.; + } + // Six-pixel gaps, room for the active pill, and an optional overflow count. + self.workspaces.len() as f32 * SLOT + ACTIVE_EXTRA - DOT_INSET + + if self.hidden > 0 { + 6. + (self.hidden.to_string().len() + 1) as f32 * 7. + } else { + 0. + } + } +} + +fn select(mut workspaces: Vec<&WorkspaceSnapshot>, active: u64, budget: f32) -> Selection<'_> { + workspaces.retain(|workspace| workspace.window_count > 0 || workspace.id == active); + workspaces.sort_by_key(|workspace| (workspace.index, workspace.id)); + let count = workspaces.len(); + if count == 0 || (count <= LIMIT && count as f32 * SLOT + ACTIVE_EXTRA - DOT_INSET <= budget) { + return Selection { workspaces, hidden: 0 }; + } + let overflow_width = 6. + (count.to_string().len() + 1) as f32 * 7.; + let visible = (((budget - overflow_width - ACTIVE_EXTRA + DOT_INSET) / SLOT) + .floor() + .max(1.) as usize) + .min(LIMIT) + .min(count); + let position = workspaces + .iter() + .position(|workspace| workspace.id == active) + .unwrap_or(0); + let start = position.saturating_sub(visible / 2).min(count - visible); + Selection { + workspaces: workspaces[start..start + visible].to_vec(), + hidden: count - visible, + } +} + +#[derive(Default)] +pub(super) struct Scroll { + amount: f32, + pixels: bool, + last_input: Option, + last_switch: Option, +} + +impl Scroll { + fn step(&mut self, delta: ScrollDelta, now: Instant) -> Option { + let (x, y, pixels) = match delta { + ScrollDelta::Lines { x, y } => (x, y, false), + ScrollDelta::Pixels { x, y } => (x, y, true), + }; + let amount = if y.abs() >= x.abs() { y } else { -x }; + if !amount.is_finite() || amount == 0. { + return None; + } + if pixels != self.pixels + || self + .last_input + .is_none_or(|last| now.saturating_duration_since(last) > Duration::from_millis(250)) + || self.amount.signum() != amount.signum() + { + self.amount = 0.; + } + self.pixels = pixels; + self.last_input = Some(now); + if self + .last_switch + .is_some_and(|last| now.saturating_duration_since(last) < Duration::from_millis(120)) + { + return None; + } + self.amount += amount; + let threshold = if pixels { 40. } else { 1. }; + if self.amount.abs() < threshold { + return None; + } + let direction = if self.amount > 0. { -1 } else { 1 }; + self.amount = 0.; + self.last_switch = Some(now); + Some(direction) + } +} + +impl FereseShell { + fn hub_workspace_selection(&self, id: window::Id) -> Selection<'_> { + let panel = self.panel_for_bar(id); + let enabled = [&panel.start, &panel.center, &panel.end] + .into_iter() + .flat_map(|zone| &zone.groups) + .flat_map(|group| &group.items) + .any(|item| item.visible && matches!(item.kind, ItemKind::Workspaces { .. })); + let Some(output) = self.output_for_bar(id).filter(|_| enabled) else { + return Selection { + workspaces: Vec::new(), + hidden: 0, + }; + }; + let available = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.size) + .map_or(panel.morph.expanded_width, |(width, _)| { + (f64::from(width) - 2. * f64::from(panel.resolved_geometry().side_margins)).max(0.) + }); + select( + self.snapshot.workspaces_for_output(Some(output.id)).collect(), + output.active_workspace, + // Keep the compact strip within its existing width budget. + (panel.morph.expanded_width.min(available) - 28. - 5. * 32. - 4.).max(0.) as f32, + ) + } + + pub(super) fn hub_workspace_width(&self, id: window::Id) -> f64 { + f64::from(self.hub_workspace_selection(id).width()) + } + + pub(crate) fn sync_hub_workspaces(&mut self) -> Task { + let changes: Vec<_> = self + .outputs + .iter() + .filter_map(|entry| { + let runtime = entry.runtime.hub.as_ref()?; + let width = self.hub_workspace_width(entry.bar); + let width = if width > 0. { width + 10. } else { 0. }; + let mut runtime = runtime.borrow_mut(); + if runtime.observed_workspace.is_none() { + runtime.observed_workspace = self.output_for_bar(entry.bar).map(|output| output.active_workspace); + } + if runtime.workspace_width == width { + return None; + } + runtime.workspace_width = width; + matches!(runtime.hub.mode(), Mode::Idle).then_some(entry.bar) + }) + .collect(); + Task::batch(changes.into_iter().map(|id| self.transition_hub(id, Event::Resize))) + } + + pub(crate) fn open_hub_overview(&mut self, id: window::Id) -> Task { + self.open_hub(id, crate::status_ui::Menu::Workspaces) + } + + pub(crate) fn sync_hub_workspace_feedback(&mut self, suppressed: bool) -> Task { + let changed: Vec<_> = self + .outputs + .iter() + .filter_map(|entry| { + let active = self.output_for_bar(entry.bar)?.active_workspace; + let mut runtime = entry.runtime.hub.as_ref()?.borrow_mut(); + let previous = runtime.observed_workspace.replace(active); + let selected = runtime.workspace_selection_pending == Some(active); + if selected { + runtime.workspace_selection_pending = None; + } + (!suppressed + && !selected + && previous.is_some_and(|previous| previous != active) + && matches!(runtime.hub.mode(), Mode::Idle | Mode::Peek)) + .then_some(entry.bar) + }) + .collect(); + Task::batch(changed.into_iter().map(|id| self.show_hub_workspace_feedback(id))) + } + + fn show_hub_workspace_feedback(&mut self, id: window::Id) -> Task { + if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. || self.hub_priority().is_some() { + return Task::none(); + } + let Some(runtime) = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + else { + return Task::none(); + }; + let revision = Instant::now(); + let first = runtime.borrow().transient.workspace_token().is_none(); + let policy = self.render.motion; + let fade = if first && policy.motion_enabled() { + 0.25 / policy.speed + } else { + 0. + }; + let timeout = Duration::from_secs_f64(1.2 + fade); + Task::batch([ + self.transition_hub(id, Event::Workspaces(revision)), + Task::perform( + async move { + tokio::time::sleep(timeout).await; + }, + move |_| cosmic::Action::App(Message::HubWorkspaceFeedbackExpired(id, revision)), + ), + ]) + } + + pub(crate) fn hub_workspace_feedback_expired(&mut self, id: window::Id, revision: Instant) -> Task { + self.transition_hub(id, Event::WorkspacesExpired(revision)) + } + + pub(crate) fn scroll_hub_workspaces(&mut self, id: window::Id, delta: ScrollDelta) -> Task { + if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. { + return Task::none(); + } + let Some(runtime) = self + .outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.runtime.hub.as_ref()) + else { + return Task::none(); + }; + let Some(direction) = runtime.borrow_mut().workspace_scroll.step(delta, Instant::now()) else { + return Task::none(); + }; + let Some(output) = self.output_for_bar(id) else { + return Task::none(); + }; + // Include the trailing empty workspace so scrolling can create a new working space. + let mut workspaces: Vec<_> = self.snapshot.workspaces_for_output(Some(output.id)).collect(); + workspaces.sort_by_key(|workspace| (workspace.index, workspace.id)); + if let Some(position) = workspaces + .iter() + .position(|workspace| workspace.id == output.active_workspace) + && let Some(next) = position + .checked_add_signed(direction as isize) + .and_then(|next| workspaces.get(next)) + && let Some(control) = &self.control + { + control.activate_workspace(next.id); + } + self.dismiss_workspace_tooltip_for_bar(id) + } + + pub(super) fn hub_workspaces(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action> { + let selection = self.hub_workspace_selection(id); + let width = selection.width(); + let output = self.output_for_bar(id); + let active = output.map_or(0, |output| output.active_workspace); + let palette = self.config.theme.palette(); + let foreground = color_with_opacity(self.config.theme.text_primary, opacity); + let hidden_urgent = self.snapshot.windows.iter().any(|window| { + window.urgent + && !selection + .workspaces + .iter() + .any(|workspace| workspace.id == window.workspace) + && self + .snapshot + .workspaces_for_output(output.map(|output| output.id)) + .any(|workspace| workspace.id == window.workspace) + }); + let mut dots = row::with_capacity(LIMIT).align_y(Alignment::Center); + let mut label = selection + .workspaces + .iter() + .find(|workspace| workspace.id == active) + .map_or_else( + || "Workspace overview".to_owned(), + |workspace| { + format!( + "Workspace {} · {} · {} windows · Active · Open overview", + workspace.index, workspace.name, workspace.window_count + ) + }, + ); + for (index, workspace) in selection.workspaces.iter().enumerate() { + let urgent = self + .snapshot + .windows + .iter() + .any(|window| window.workspace == workspace.id && window.urgent); + if urgent { + label.push_str(&format!(" · Workspace {} needs attention", workspace.index)); + } + let transition = self.workspace_ui.transition(workspace.id, workspace.id == active); + dots = dots.push(motion::geometry_driven( + transition.revision(), + move |now| { + let progress = transition.progress(now).clamp(0., 1.); + let color = if urgent { + palette.error + } else { + ferese_theme::mix(palette.muted, palette.accent, progress) + }; + container( + container(cosmic::widget::Space::new()) + .width(6. + 12. * progress) + .height(6.) + .class(theme::Container::custom(move |_| container::Style { + background: Some(color.scale_alpha(opacity).into()), + border: cosmic::iced::Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + })), + ) + // The first dot starts at the group's edge; only the gaps + // between dots need the two half-slot insets. + .padding(cosmic::iced::Padding { + left: if index == 0 { 0. } else { DOT_INSET }, + right: DOT_INSET, + ..Default::default() + }) + .center_y(28) + .into() + }, + move |now| transition.active(now), + |_| {}, + |_| None, + None, + )); + } + if selection.hidden > 0 { + label.push_str(&format!(" · {} other workspaces", selection.hidden)); + if hidden_urgent { + label.push_str(" · Another workspace needs attention"); + } + dots = dots.push( + container( + self.render + .text(format!("+{}", selection.hidden)) + .size(11) + .class(theme::Text::Color(if hidden_urgent { + palette.error.scale_alpha(opacity) + } else { + foreground + })), + ) + .padding([0, 3]), + ); + } + let appearance = button::Style { + text_color: Some(foreground), + icon_color: Some(foreground), + ..Default::default() + }; + let control = button::custom(dots) + .name(label) + .padding(0) + .width(width) + .height(28) + .class(theme::Button::Custom { + active: Box::new(move |_, _| appearance), + hovered: Box::new(move |_, _| appearance), + pressed: Box::new(move |_, _| appearance), + disabled: Box::new(move |_| appearance), + }) + .on_press(cosmic::Action::App(Message::HubOverview(id))); + super::scroll::area(control.into(), id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overflow_keeps_the_active_empty_workspace_and_order() { + let workspaces: Vec<_> = (1..=20) + .map(|id| WorkspaceSnapshot { + id, + index: id as u32, + name: id.to_string(), + output: Some(1), + window_count: u32::from(id != 20), + visible: id == 20, + focused: id == 20, + }) + .collect(); + for active in [1, 8, 20] { + let selection = select(workspaces.iter().collect(), active, 200.); + assert!(selection.workspaces.iter().any(|workspace| workspace.id == active)); + assert!( + selection + .workspaces + .windows(2) + .all(|pair| pair[0].index < pair[1].index) + ); + assert_eq!(selection.workspaces.len(), 4); + assert_eq!(selection.hidden, if active == 20 { 16 } else { 15 }); + let narrow = select(workspaces.iter().collect(), active, 148.); + assert!(narrow.width() <= 148.); + assert!(narrow.workspaces.iter().any(|workspace| workspace.id == active)); + } + } + + #[test] + fn trackpad_accumulates_and_resets_between_gestures() { + let mut scroll = Scroll::default(); + let start = Instant::now(); + let pixels = |y| ScrollDelta::Pixels { x: 0., y }; + assert_eq!(scroll.step(pixels(-20.), start), None); + assert_eq!(scroll.step(pixels(-20.), start + Duration::from_millis(10)), Some(1)); + assert_eq!(scroll.step(pixels(-100.), start + Duration::from_millis(20)), None); + assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(200)), None); + assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(500)), None); + assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(510)), Some(-1)); + assert_eq!(scroll.step(pixels(f32::NAN), start + Duration::from_secs(1)), None); + } +} diff --git a/shell/ferese-shell/src/panel_layout.rs b/shell/ferese-shell/src/panel_layout.rs index e3103d7..439b1f0 100644 --- a/shell/ferese-shell/src/panel_layout.rs +++ b/shell/ferese-shell/src/panel_layout.rs @@ -1 +1 @@ -pub(crate) use ferese_config::panel::layout::*; +pub(crate) use ferese_panel::layout::*; diff --git a/shell/ferese-shell/src/panel_runtime.rs b/shell/ferese-shell/src/panel_runtime.rs new file mode 100644 index 0000000..3a1b7a9 --- /dev/null +++ b/shell/ferese-shell/src/panel_runtime.rs @@ -0,0 +1,257 @@ +use std::time::{Duration, Instant}; + +use cosmic::app::Task; +use cosmic::iced::platform_specific::shell::commands::layer_surface::{set_exclusive_zone, set_input_zone, set_size}; +use cosmic::iced::window; +use ferese_panel::adaptive::{Adaptive, WindowState}; +use ferese_panel::{Panel, PanelStyle}; + +use crate::{FereseShell, Message, control::ShellSnapshot, output_bar_hidden}; + +pub(super) struct Runtime { + pub(super) hub: Option>, + projection: Option, + adaptive: Adaptive, + epoch: Instant, + ticket: u64, + scheduled: Option, +} + +impl Runtime { + pub(super) fn new(panel: &Panel, snapshot: &ShellSnapshot, name: Option<&str>) -> Self { + let adaptive = Adaptive::new(window_state(snapshot, name)); + let projection = project(panel, &adaptive); + Self { + hub: crate::panel_hub::new(panel), + projection, + adaptive, + epoch: Instant::now(), + ticket: 0, + scheduled: None, + } + } + + pub(super) fn panel<'a>(&'a self, authored: &'a Panel) -> &'a Panel { + self.projection.as_ref().unwrap_or(authored) + } + + pub(super) fn reset(&mut self, panel: &Panel, snapshot: &ShellSnapshot, name: Option<&str>) { + let ticket = self.ticket.wrapping_add(1); + *self = Self::new(panel, snapshot, name); + self.ticket = ticket; + } + + pub(super) fn update_appearance(&mut self, panel: &Panel) { + if let Some(hub) = &self.hub { + hub.borrow_mut() + .hub + .pin_radii(panel.corner_radius.map(|r| r.0.map(f64::from))) + .expect("validated panel radii"); + } + if let Some(projection) = &mut self.projection { + projection.border = panel.border; + projection.background_opacity = panel.background_opacity; + projection.corner_radius = panel.corner_radius; + } + } + + fn observe(&mut self, panel: &Panel, snapshot: &ShellSnapshot, name: Option<&str>) -> bool { + if panel.style != PanelStyle::Adaptive { + return false; + } + if self + .adaptive + .observe(window_state(snapshot, name), self.epoch.elapsed()) + { + self.projection = project(panel, &self.adaptive); + true + } else { + false + } + } + + fn schedule(&mut self) -> Option<(u64, Duration)> { + let deadline = self.adaptive.deadline(); + if self.scheduled == deadline { + return None; + } + self.scheduled = deadline; + self.ticket = self.ticket.wrapping_add(1); + deadline.map(|deadline| (self.ticket, deadline.saturating_sub(self.epoch.elapsed()))) + } +} + +fn project(panel: &Panel, adaptive: &Adaptive) -> Option { + (panel.style == PanelStyle::Adaptive).then(|| { + let mut projection = panel.clone(); + projection.style = adaptive.current().style().unwrap_or(PanelStyle::Floating); + projection + }) +} + +fn window_state(snapshot: &ShellSnapshot, name: Option<&str>) -> WindowState { + let Some(output) = snapshot + .outputs + .iter() + .find(|output| Some(output.name.as_str()) == name) + else { + return WindowState::default(); + }; + let mut state = WindowState::default(); + for window in snapshot + .windows + .iter() + .filter(|window| window.workspace == output.active_workspace) + { + state.maximized |= window.maximized; + state.fullscreen |= window.fullscreen; + } + state +} + +impl FereseShell { + pub(super) fn panel_for_bar(&self, id: window::Id) -> &Panel { + self.outputs + .iter() + .find(|entry| entry.bar == id) + .map_or(&self.config.panels[0], |entry| { + entry.runtime.panel(&self.config.panels[0]) + }) + } + + pub(super) fn adaptive_deadline(&mut self, id: window::Id, ticket: u64) -> Task { + if self + .outputs + .iter() + .any(|entry| entry.bar == id && entry.runtime.ticket == ticket) + { + self.refresh_adaptive_panels() + } else { + Task::none() + } + } + + pub(super) fn refresh_adaptive_panels(&mut self) -> Task { + let mut tasks = Vec::new(); + let mut changed = Vec::new(); + for entry in &mut self.outputs { + if entry + .runtime + .observe(&self.config.panels[0], &self.snapshot, entry.name.as_deref()) + { + let panel = entry.runtime.panel(&self.config.panels[0]); + let geometry = panel.resolved_geometry(); + let output = self + .snapshot + .outputs + .iter() + .find(|output| Some(output.name.as_str()) == entry.name.as_deref()) + .map(|output| output.id); + entry.bar_regions.clear(); + entry.panel_resolution = None; + tasks.push(set_input_zone(entry.bar, Some(Vec::new()))); + tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32))); + tasks.push(set_exclusive_zone( + entry.bar, + panel.exclusive_zone(output_bar_hidden(&self.snapshot, output)), + )); + changed.push(entry.bar); + } + if let Some((ticket, delay)) = entry.runtime.schedule() { + let bar = entry.bar; + tasks.push(Task::perform( + async move { + tokio::time::sleep(delay).await; + }, + move |_| cosmic::Action::App(Message::AdaptiveDeadline(bar, ticket)), + )); + } + } + let geometry_changed = !changed.is_empty(); + for bar in changed { + tasks.push(self.update_panel_margin(bar, true)); + tasks.push(self.dismiss_workspace_tooltip_for_bar(bar)); + if self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == bar) { + tasks.push(self.destroy_menu()); + } + } + if geometry_changed { + if let Some(surface) = self.notification_surface.take() { + tasks.push(crate::destroy_layer_surface(surface.id)); + } + tasks.push(self.sync_notification_surface()); + } + Task::batch(tasks) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projection_preserves_the_authored_composition_and_geometry_pins() { + let mut panel = Panel::from_preset(PanelStyle::Adaptive); + panel.geometry.side_margins = Some(24); + let mut runtime = Runtime::new(&panel, &ShellSnapshot::default(), None); + assert_eq!(runtime.panel(&panel).resolved_geometry().side_margins, 24); + runtime.adaptive.observe( + WindowState { + maximized: true, + fullscreen: false, + }, + Duration::ZERO, + ); + runtime.adaptive.observe( + WindowState { + maximized: true, + fullscreen: false, + }, + Duration::from_millis(150), + ); + runtime.projection = project(&panel, &runtime.adaptive); + assert_eq!(runtime.panel(&panel).resolved_geometry().side_margins, 0); + assert_eq!(runtime.panel(&panel).start, panel.start); + assert_eq!(panel.style, PanelStyle::Adaptive); + assert_eq!(panel.geometry.side_margins, Some(24)); + } + + #[test] + fn appearance_reload_updates_the_projection_without_resetting_adaptive_state() { + let mut panel = Panel::from_preset(PanelStyle::Adaptive); + let mut runtime = Runtime::new(&panel, &ShellSnapshot::default(), None); + runtime.adaptive.observe( + WindowState { + maximized: true, + fullscreen: false, + }, + Duration::ZERO, + ); + let pending = runtime.adaptive.deadline(); + panel.background_opacity = Some(0.4); + panel.border = true; + panel.corner_radius = Some(ferese_panel::CornerRadii::uniform(12.)); + runtime.update_appearance(&panel); + assert_eq!(runtime.panel(&panel).background_opacity, Some(0.4)); + assert_eq!(runtime.panel(&panel).corner_radius, panel.corner_radius); + assert!(runtime.panel(&panel).border); + assert_eq!(runtime.adaptive.deadline(), pending); + } + + #[test] + fn reset_invalidates_a_pending_deadline() { + let panel = Panel::from_preset(PanelStyle::Adaptive); + let mut runtime = Runtime::new(&panel, &ShellSnapshot::default(), None); + runtime.adaptive.observe( + WindowState { + maximized: true, + fullscreen: false, + }, + Duration::ZERO, + ); + let (ticket, _) = runtime.schedule().unwrap(); + runtime.reset(&panel, &ShellSnapshot::default(), None); + assert_ne!(runtime.ticket, ticket); + assert_eq!(runtime.scheduled, None); + } +} diff --git a/shell/ferese-shell/src/recording.rs b/shell/ferese-shell/src/recording.rs index 4073462..5363c1b 100644 --- a/shell/ferese-shell/src/recording.rs +++ b/shell/ferese-shell/src/recording.rs @@ -1,5 +1,3 @@ -//! The bar only controls the recorder and consumes bounded status messages. -//! Encoding and portal negotiation run in the separate ferese-record process. use std::io::{BufRead, BufReader, Read}; use std::path::PathBuf; use std::process::{Child, ChildStdin, Command, Stdio}; diff --git a/shell/ferese-shell/src/rendering.rs b/shell/ferese-shell/src/rendering.rs new file mode 100644 index 0000000..6b9a7ab --- /dev/null +++ b/shell/ferese-shell/src/rendering.rs @@ -0,0 +1,29 @@ +use cosmic::font::Font; + +#[derive(Clone, Copy)] +pub(crate) struct Rendering { + pub font: Font, + pub motion: crate::motion::Settings, + radius: f32, +} + +impl Rendering { + pub fn new(config: &crate::config::ShellConfig) -> Self { + Self { + font: crate::configured_font(config.font_family.as_deref()), + motion: config.animations, + radius: config.theme.material_radius, + } + } + + pub fn radius(self, limit: f32) -> f32 { + self.radius.min(limit) + } + + pub fn text<'a>( + self, + content: impl Into> + 'a, + ) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> { + ferese_theme::text(content, self.font) + } +} diff --git a/shell/ferese-shell/src/status_ui/audio.rs b/shell/ferese-shell/src/status_ui/audio.rs index a56640c..986f4f2 100644 --- a/shell/ferese-shell/src/status_ui/audio.rs +++ b/shell/ferese-shell/src/status_ui/audio.rs @@ -6,7 +6,7 @@ use super::bar::audio_icon; use super::controls::{control_card, shell_switch, slider_row}; use super::{MenuRows, MenuStyle}; use crate::status::Action; -use crate::{FereseShell, Message, color_with_opacity, text}; +use crate::{FereseShell, Message, color_with_opacity}; pub(super) fn view<'a>( shell: &'a FereseShell, @@ -21,14 +21,23 @@ pub(super) fn view<'a>( opacity: p, } = style; if let Some(a) = &shell.status.audio { - let mut volume_heading = row![text("Volume").size(13).width(Length::Fill)].align_y(Alignment::Center); + let mut volume_heading = + row![shell.render.text("Volume").size(13).width(Length::Fill)].align_y(Alignment::Center); if combined { - volume_heading = volume_heading.push(shell_switch("Mute", a.muted, Action::Mute(!a.muted), theme, p)); + volume_heading = volume_heading.push(shell_switch( + shell.render, + "Mute", + a.muted, + Action::Mute(!a.muted), + theme, + p, + )); } let audio = column![ volume_heading, - text(&a.output).size(12).class(theme::Text::Color(muted)), + shell.render.text(&a.output).size(12).class(theme::Text::Color(muted)), slider_row( + shell.render, audio_icon(a.volume, a.muted), a.volume, false, @@ -39,7 +48,7 @@ pub(super) fn view<'a>( ] .spacing(4); let audio: Element<'_, cosmic::Action> = if combined { - control_card(audio.into(), primary, p) + control_card(shell.render, audio.into(), primary, p) } else { audio.into() }; @@ -55,7 +64,14 @@ pub(super) fn heading<'a>( ) -> super::MenuHeading<'a> { let MenuStyle { theme, opacity: p, .. } = style; if let Some(audio) = &shell.status.audio { - heading = heading.push(shell_switch("Mute", audio.muted, Action::Mute(!audio.muted), theme, p)); + heading = heading.push(shell_switch( + shell.render, + "Mute", + audio.muted, + Action::Mute(!audio.muted), + theme, + p, + )); } heading } diff --git a/shell/ferese-shell/src/status_ui/bar.rs b/shell/ferese-shell/src/status_ui/bar.rs index 6bb0d58..0f0a386 100644 --- a/shell/ferese-shell/src/status_ui/bar.rs +++ b/shell/ferese-shell/src/status_ui/bar.rs @@ -5,7 +5,7 @@ use cosmic::{Element, theme}; use super::Menu; use crate::status::Snapshot; -use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording, text}; +use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording}; impl FereseShell { pub(crate) fn view_media_item( @@ -21,7 +21,7 @@ impl FereseShell { super::media::bar( self, theme, - BarMetrics::from(self.config.panels[0].geometry), + BarMetrics::from(self.config.panels[0].resolved_geometry()), representation, selected, width, @@ -36,7 +36,7 @@ impl FereseShell { selected: bool, ) -> Element<'_, cosmic::Action> { let theme = self.config.theme.for_bar(); - let metrics = BarMetrics::from(self.config.panels[0].geometry); + let metrics = BarMetrics::from(self.config.panels[0].resolved_geometry()); let (source, enabled) = if kind == Menu::Recording { let source: &'static [u8] = match self.recorder.state { recording::State::Selecting => ferese_theme::icons::RECORD_CANCEL, @@ -70,7 +70,8 @@ impl FereseShell { && let Some(elapsed) = self.recorder.elapsed() { content = content.push( - text(elapsed) + self.render + .text(elapsed) .size(metrics.text_size) .class(theme::Text::Color(foreground)), ); @@ -81,7 +82,8 @@ impl FereseShell { && let Some(battery) = &self.status.battery { content = content.push( - text(format!("{}%", battery.percent)) + self.render + .text(format!("{}%", battery.percent)) .size(metrics.text_size) .class(theme::Text::Color(foreground)), ); @@ -94,14 +96,14 @@ impl FereseShell { .is_some_and(|n| n.count > 0 && !n.dnd) { content = content.push( - container(text("")) + container(self.render.text("")) .width(4) .height(4) .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(foreground)), border: Border { shape: BorderShape::Continuous, - radius: motion::radius(2.0).into(), + radius: self.render.radius(2.0).into(), ..Default::default() }, ..Default::default() @@ -138,7 +140,7 @@ impl FereseShell { }, )) }); - motion::button(control, foreground, selected, 1.0) + motion::button(self.render, control, foreground, selected, 1.0) } } @@ -161,6 +163,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String { Some(n) => format!("Notifications: {} unread", n.count), _ => "Notifications: unavailable".into(), }, + Menu::Workspaces => "Workspaces".into(), Menu::Calendar => "Calendar".into(), Menu::Overflow => "More panel items".into(), Menu::Recording => "Screen recording".into(), @@ -239,6 +242,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { (ferese_theme::icons::NOTIFICATIONS, true) } } + Menu::Workspaces => (ferese_theme::icons::OVERVIEW, true), Menu::System => (ferese_theme::icons::CONTROL_CENTER, true), } } diff --git a/shell/ferese-shell/src/status_ui/battery.rs b/shell/ferese-shell/src/status_ui/battery.rs index 604dec3..4fb4f26 100644 --- a/shell/ferese-shell/src/status_ui/battery.rs +++ b/shell/ferese-shell/src/status_ui/battery.rs @@ -6,7 +6,7 @@ use super::bar::status_icon; use super::controls::{control_card, level_meter, slider_row}; use super::{Menu, MenuRows, MenuStyle}; use crate::status::Action; -use crate::{FereseShell, Message, ShellTheme, accented_icon, bar_icon, color_with_opacity, motion, shell_font, text}; +use crate::{FereseShell, Message, ShellTheme, accented_icon, bar_icon, color_with_opacity, motion}; pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { let MenuStyle { @@ -27,7 +27,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me rows = rows.push( container( row![ - text(format!("{}%", b.percent)).size(24).width(Length::Fill), + shell + .render + .text(format!("{}%", b.percent)) + .size(24) + .width(Length::Fill), accented_icon( status_icon(Menu::Battery, &shell.status).0, 24, @@ -44,19 +48,25 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me ..Default::default() })), ); - rows = rows.push(level_meter(b.percent, battery_color, p)); + rows = rows.push(level_meter(shell.render, b.percent, battery_color, p)); rows = rows.push( - text(match b.status.as_str() { - "Charging" => "Connected to power", - "Discharging" => "Running on battery power", - "Full" => "Battery fully charged", - _ => "Battery status reported by the system", - }) - .size(12) - .class(theme::Text::Color(muted)), + shell + .render + .text(match b.status.as_str() { + "Charging" => "Connected to power", + "Discharging" => "Running on battery power", + "Full" => "Battery fully charged", + _ => "Battery status reported by the system", + }) + .size(12) + .class(theme::Text::Color(muted)), ); } - rows = rows.push(ferese_theme::menus::section_label("Power mode", shell_font(), muted)); + rows = rows.push(ferese_theme::menus::section_label( + "Power mode", + shell.render.font, + muted, + )); if let Some(profiles) = &shell.status.power_profiles { let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill); for (index, (label, icon, profile)) in [ @@ -68,13 +78,23 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me .enumerate() { if profiles.available[index] { - modes = modes.push(power_tile(label, icon, profile, profiles.active == profile, theme, p)); + modes = modes.push(power_tile( + shell.render, + label, + icon, + profile, + profiles.active == profile, + theme, + p, + )); } } rows = rows.push(modes); } else { rows = rows.push( - text("Power Profiles service unavailable") + shell + .render + .text("Power Profiles service unavailable") .size(12) .class(theme::Text::Color(muted)), ); @@ -102,12 +122,12 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me ), ] { if enabled { - power = power.push(power_tile_action(label, icon, action, theme, p)); + power = power.push(power_tile_action(shell.render, label, icon, action, theme, p)); } } if shell.status.reboot || shell.status.poweroff || shell.status.suspend { - rows = rows.push(ferese_theme::menus::section_label("Power", shell_font(), muted)); + rows = rows.push(ferese_theme::menus::section_label("Power", shell.render.font, muted)); rows = rows.push(power); } rows @@ -122,8 +142,9 @@ pub(super) fn brightness<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, sty } = style; if let Some(value) = shell.status.brightness { let brightness = column![ - text("Brightness").size(13), + shell.render.text("Brightness").size(13), slider_row( + shell.render, ferese_theme::icons::BRIGHTNESS, value, true, @@ -133,12 +154,13 @@ pub(super) fn brightness<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, sty ) ] .spacing(6); - rows = rows.push(control_card(brightness.into(), primary, p)); + rows = rows.push(control_card(shell.render, brightness.into(), primary, p)); } rows } fn power_tile<'a>( + render: crate::rendering::Rendering, label: &'static str, icon: &'static [u8], profile: &'static str, @@ -148,7 +170,7 @@ fn power_tile<'a>( ) -> Element<'a, cosmic::Action> { let foreground = color_with_opacity(if selected { theme.accent } else { theme.text_primary }, opacity); let tile = container( - column![bar_icon(icon, 19, foreground), text(label).size(11)] + column![bar_icon(icon, 19, foreground), render.text(label).size(11)] .spacing(3) .align_x(Alignment::Center), ) @@ -159,10 +181,11 @@ fn power_tile<'a>( .padding([8, 2]) .on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(profile)))); - motion::button(btn, foreground, selected, opacity) + motion::button(render, btn, foreground, selected, opacity) } fn power_tile_action<'a>( + render: crate::rendering::Rendering, label: &'static str, icon: &'static [u8], action: Action, @@ -171,7 +194,7 @@ fn power_tile_action<'a>( ) -> Element<'a, cosmic::Action> { let foreground = color_with_opacity(theme.text_primary, opacity); let tile = container( - column![bar_icon(icon, 19, foreground), text(label).size(11)] + column![bar_icon(icon, 19, foreground), render.text(label).size(11)] .spacing(3) .align_x(Alignment::Center), ) @@ -182,5 +205,5 @@ fn power_tile_action<'a>( .padding([8, 2]) .on_press(cosmic::Action::App(Message::ConfirmPower(action))); - motion::button(btn, foreground, false, opacity) + motion::button(render, btn, foreground, false, opacity) } diff --git a/shell/ferese-shell/src/status_ui/bluetooth.rs b/shell/ferese-shell/src/status_ui/bluetooth.rs index befd444..b5b2d79 100644 --- a/shell/ferese-shell/src/status_ui/bluetooth.rs +++ b/shell/ferese-shell/src/status_ui/bluetooth.rs @@ -6,7 +6,7 @@ use super::bar::status_icon; use super::controls::status_summary; use super::{Menu, MenuRows, MenuStyle}; use crate::status::Action; -use crate::{FereseShell, Message, text}; +use crate::{FereseShell, Message}; const DEVICE_LIST_HEIGHT: f32 = 180.0; @@ -14,10 +14,11 @@ fn device_list_height(count: usize) -> Option { (count > 4).then_some(DEVICE_LIST_HEIGHT) } -pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { +pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle, in_hub: bool) -> MenuRows<'a> { let MenuStyle { muted, .. } = style; if let Some(b) = &shell.status.bluetooth { rows = rows.push(status_summary( + shell.render, status_icon(Menu::Bluetooth, &shell.status).0, if b.enabled { "Bluetooth is on" @@ -41,21 +42,23 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me for device in &b.devices { devices = devices.push( column![ - text(device).size(14), - text("Connected").size(12).class(theme::Text::Color(muted)), + shell.render.text(device).size(14), + shell.render.text("Connected").size(12).class(theme::Text::Color(muted)), ] .spacing(3), ); } - let list: Element<'_, cosmic::Action> = if let Some(height) = device_list_height(b.devices.len()) { - cosmic::iced::widget::scrollable(devices) - .height(height) - .width(Length::Fill) - .into() - } else { - devices.into() - }; + // Morph scrolls the whole view, so its device list needs no nested viewport. + let list: Element<'_, cosmic::Action> = + if let Some(height) = device_list_height(b.devices.len()).filter(|_| !in_hub) { + cosmic::iced::widget::scrollable(devices) + .height(height) + .width(Length::Fill) + .into() + } else { + devices.into() + }; rows = rows.push(list); } } diff --git a/shell/ferese-shell/src/status_ui/calendar.rs b/shell/ferese-shell/src/status_ui/calendar.rs index c47dc9c..b56235b 100644 --- a/shell/ferese-shell/src/status_ui/calendar.rs +++ b/shell/ferese-shell/src/status_ui/calendar.rs @@ -2,20 +2,59 @@ use cosmic::Element; use jiff::Zoned; use super::controls::menu_button; -use crate::{Message, ShellTheme, color_with_opacity, shell_font}; +use crate::{Message, ShellTheme, color_with_opacity}; -pub(super) fn view<'a>(offset: i32, theme: ShellTheme, opacity: f32) -> Element<'a, cosmic::Action> { +pub(super) fn view<'a>( + render: crate::rendering::Rendering, + offset: i32, + theme: ShellTheme, + opacity: f32, +) -> Element<'a, cosmic::Action> { let primary = color_with_opacity(theme.text_primary, opacity); ferese_theme::calendar::grid( Zoned::now().date(), offset, theme.palette(), - shell_font(), + render.font, opacity, [ - menu_button("‹", Message::CalendarMonth(-1), primary, opacity), - menu_button("Today", Message::CalendarToday, primary, opacity), - menu_button("›", Message::CalendarMonth(1), primary, opacity), + menu_button(render, "‹", Message::CalendarMonth(-1), primary, opacity), + menu_button(render, "Today", Message::CalendarToday, primary, opacity), + menu_button(render, "›", Message::CalendarMonth(1), primary, opacity), + ], + ) +} + +pub(super) fn hub<'a>( + render: crate::rendering::Rendering, + offset: i32, + theme: ShellTheme, + opacity: f32, +) -> Element<'a, cosmic::Action> { + let primary = color_with_opacity(theme.text_primary, opacity); + let navigation = |label: &'static str, message: Message| { + crate::motion::button( + render, + cosmic::widget::button::custom(render.text(label).size(11).class(cosmic::theme::Text::Color(primary))) + .name(label) + .padding([2, 4]) + .height(22) + .on_press(cosmic::Action::App(message)), + primary, + false, + opacity, + ) + }; + ferese_theme::calendar::compact_grid( + Zoned::now().date(), + offset, + theme.palette(), + render.font, + opacity, + [ + navigation("‹", Message::CalendarMonth(-1)), + navigation("Today", Message::CalendarToday), + navigation("›", Message::CalendarMonth(1)), ], ) } diff --git a/shell/ferese-shell/src/status_ui/controls.rs b/shell/ferese-shell/src/status_ui/controls.rs index fdb4e3f..9c55466 100644 --- a/shell/ferese-shell/src/status_ui/controls.rs +++ b/shell/ferese-shell/src/status_ui/controls.rs @@ -4,9 +4,10 @@ use cosmic::widget::{button, column, container, row, slider}; use cosmic::{Element, theme}; use crate::status::Action; -use crate::{Message, ShellTheme, accented_icon, color, color_with_opacity, motion, shell_font, text}; +use crate::{Message, ShellTheme, accented_icon, color, color_with_opacity, motion}; pub(super) fn status_summary<'a>( + render: crate::rendering::Rendering, source: &'static [u8], title: &'a str, subtitle: &'a str, @@ -34,13 +35,14 @@ pub(super) fn status_summary<'a>( })), border: Border { shape: BorderShape::Continuous, - radius: motion::radius(16.0).into(), + radius: render.radius(16.0).into(), ..Default::default() }, ..Default::default() })); let badge: Element<'_, cosmic::Action> = if let Some((_, action)) = toggle { motion::button( + render, button::custom(badge) .padding(0) .name(if enabled { "Turn off" } else { "Turn on" }) @@ -55,8 +57,8 @@ pub(super) fn status_summary<'a>( let summary = row![ badge, column![ - text(title).size(14), - text(subtitle).size(12).class(theme::Text::Color(muted)), + render.text(title).size(14), + render.text(subtitle).size(12).class(theme::Text::Color(muted)), ] .spacing(4) .width(Length::Fill) @@ -66,9 +68,14 @@ pub(super) fn status_summary<'a>( summary.into() } -pub(super) fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element<'static, cosmic::Action> { +pub(super) fn level_meter( + render: crate::rendering::Rendering, + value: u8, + foreground: Color, + opacity: f32, +) -> Element<'static, cosmic::Action> { let segment = move |amount: u16, filled: bool| { - container(text("")) + container(render.text("")) .width(Length::FillPortion(amount)) .height(5) .class(theme::Container::custom(move |_| container::Style { @@ -82,7 +89,7 @@ pub(super) fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element })), border: Border { shape: BorderShape::Continuous, - radius: motion::radius(2.5).into(), + radius: render.radius(2.5).into(), ..Default::default() }, ..Default::default() @@ -100,21 +107,24 @@ pub(super) fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element } pub(super) fn control_card<'a>( + render: crate::rendering::Rendering, content: Element<'a, cosmic::Action>, foreground: Color, opacity: f32, ) -> Element<'a, cosmic::Action> { - ferese_theme::menus::section(content, foreground, opacity, motion::radius(22.)) + ferese_theme::menus::section(content, foreground, opacity, render.radius(22.)) } pub(super) fn menu_button<'a>( + render: crate::rendering::Rendering, label: &'a str, message: Message, foreground: Color, opacity: f32, ) -> Element<'a, cosmic::Action> { motion::button( - ferese_theme::menus::button(label, cosmic::Action::App(message), shell_font()), + render, + ferese_theme::menus::button(label, cosmic::Action::App(message), render.font), foreground, false, opacity, @@ -122,6 +132,7 @@ pub(super) fn menu_button<'a>( } pub(super) fn shell_switch<'a>( + render: crate::rendering::Rendering, label: &str, enabled: bool, action: Action, @@ -130,6 +141,7 @@ pub(super) fn shell_switch<'a>( ) -> Element<'a, cosmic::Action> { let toggle = ferese_theme::menus::switch(enabled, palette.palette(), opacity); motion::button( + render, button::custom(toggle) .padding(4) .name(format!("{label}: {}", if enabled { "on" } else { "off" })) @@ -141,16 +153,22 @@ pub(super) fn shell_switch<'a>( } pub(super) fn toggle_row<'a>( + render: crate::rendering::Rendering, label: &'a str, on: bool, action: Action, palette: ShellTheme, opacity: f32, ) -> Element<'a, cosmic::Action> { - ferese_theme::menus::row(label, shell_switch(label, on, action, palette, opacity), shell_font()) + ferese_theme::menus::row( + label, + shell_switch(render, label, on, action, palette, opacity), + render.font, + ) } pub(super) fn slider_row( + render: crate::rendering::Rendering, source: &'static [u8], value: u8, brightness: bool, @@ -171,7 +189,7 @@ pub(super) fn slider_row( row![ accented_icon(source, 18, foreground, icon_accent), control, - text(format!("{value}%")).size(12).width(36) + render.text(format!("{value}%")).size(12).width(36) ] .spacing(8) .align_y(Alignment::Center) diff --git a/shell/ferese-shell/src/status_ui/lifecycle.rs b/shell/ferese-shell/src/status_ui/lifecycle.rs index a8291bb..63cf862 100644 --- a/shell/ferese-shell/src/status_ui/lifecycle.rs +++ b/shell/ferese-shell/src/status_ui/lifecycle.rs @@ -10,11 +10,11 @@ use super::{Menu, OpenMenu, PopoverAnchor}; use crate::status::Action; use crate::{FereseShell, Message}; -fn menu_positioner(rectangle: Rectangle, height_limit: f32, edge: ferese_config::panel::Edge) -> SctkPositioner { +fn menu_positioner(rectangle: Rectangle, height_limit: f32, edge: ferese_panel::Edge) -> SctkPositioner { // xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars. let (anchor, gravity, gap) = match edge { - ferese_config::panel::Edge::Top => (2u32, 6u32, 8), - ferese_config::panel::Edge::Bottom => (1u32, 5u32, -8), + ferese_panel::Edge::Top => (2u32, 6u32, 8), + ferese_panel::Edge::Bottom => (1u32, 5u32, -8), }; SctkPositioner { anchor_rect: rectangle, @@ -46,6 +46,9 @@ impl FereseShell { { return Task::none(); } + if self.panel_for_bar(anchor.parent).style == ferese_panel::PanelStyle::Morph { + return self.open_hub(anchor.parent, kind); + } if self .menu .as_ref() @@ -274,7 +277,7 @@ mod tests { #[test] fn menus_open_toward_the_desktop_from_either_panel_edge() { - use ferese_config::panel::Edge; + use ferese_panel::Edge; let rectangle = Rectangle { x: 400, y: 0, diff --git a/shell/ferese-shell/src/status_ui/media.rs b/shell/ferese-shell/src/status_ui/media.rs index bcc6315..c4dd8c9 100644 --- a/shell/ferese-shell/src/status_ui/media.rs +++ b/shell/ferese-shell/src/status_ui/media.rs @@ -8,7 +8,7 @@ use serde_json::json; use super::{Menu, MenuRows, MenuStyle}; use crate::{ - BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion, text, + BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion, }; const SEEK_THUMB_DIAMETER: f32 = 10.0; @@ -78,7 +78,7 @@ pub(super) fn bar<'a>( width: Option, ) -> Element<'a, cosmic::Action> { let Some(player) = &shell.media.snapshot.selected else { - return text("").into(); + return shell.render.text("").into(); }; let foreground = color(if selected { palette.accent } else { palette.text_primary }); let height = (metrics.group_item_height - 4.0).max(0.0); @@ -89,7 +89,7 @@ pub(super) fn bar<'a>( .width(side) .height(side) .content_fit(cosmic::iced::ContentFit::Cover) - .border_radius(motion::radius(6.0)) + .border_radius(shell.render.radius(6.0)) .shape(BorderShape::Continuous) .into(), None => container(accented_icon( @@ -103,7 +103,9 @@ pub(super) fn bar<'a>( .width(side) .into(), }; - let label = text(player.label()) + let label = shell + .render + .text(player.label()) .size(metrics.text_size) .wrapping(cosmic::iced::widget::text::Wrapping::None) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( @@ -170,8 +172,8 @@ pub(super) fn bar<'a>( let controls = container( row![ - motion::button(details, foreground, false, 1.0), - motion::button(toggle, foreground, false, 1.0) + motion::button(shell.render, details, foreground, false, 1.0), + motion::button(shell.render, toggle, foreground, false, 1.0) ] .spacing(2) .align_y(Alignment::Center), @@ -211,6 +213,18 @@ fn transport<'a>( action: &str, enabled: bool, style: MenuStyle, +) -> Element<'a, cosmic::Action> { + transport_sized(shell, source, label, action, enabled, style, 44.) +} + +fn transport_sized<'a>( + shell: &'a FereseShell, + source: &'static [u8], + label: &'static str, + action: &str, + enabled: bool, + style: MenuStyle, + size: f32, ) -> Element<'a, cosmic::Action> { let fill = (action == "play-pause").then_some(if enabled { style.primary } else { style.muted }); let foreground = match fill { @@ -227,19 +241,142 @@ fn transport<'a>( } }; let mut control = button::custom( - container(accented_icon(source, 24, foreground, color(style.theme.accent))) - .center_x(24) - .center_y(24), + container( + accented_icon( + source, + (size - 20.) as u16, + cosmic::iced::Color { a: 1., ..foreground }, + color(style.theme.accent), + ) + .opacity(foreground.a), + ) + .center_x(size - 20.) + .center_y(size - 20.), ) .padding(10) - .width(44) - .height(44) + .width(size) + .height(size) .name(label); if enabled { control = control.on_press(cosmic::Action::App(Message::MediaAction(shell.media.action(action)))); } - motion::circular_button(control, foreground, fill, style.opacity, 44.0) + motion::circular_button(shell.render, control, foreground, fill, style.opacity, size) +} + +pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { + let Some(player) = &shell.media.snapshot.selected else { + return rows.push( + shell + .render + .text("No active player") + .size(14) + .class(theme::Text::Color(style.muted)), + ); + }; + let cover: Element<'_, cosmic::Action> = + match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) { + Some(handle) => image(handle.clone()) + .width(64) + .height(64) + .opacity(style.opacity) + .content_fit(cosmic::iced::ContentFit::Cover) + .border_radius(10.) + .into(), + None => container( + accented_icon( + icons::MEDIA, + 26, + color(style.theme.text_primary), + color(style.theme.accent), + ) + .opacity(style.opacity), + ) + .center_x(64) + .center_y(64) + .class(theme::Container::custom(move |_| { + ferese_theme::controls::surface_appearance(style.primary.scale_alpha(0.06), 10.) + })) + .into(), + }; + let mut info = column![ + shell + .render + .text(player.label()) + .size(14) + .class(theme::Text::Color(style.primary)) + .width(Length::Fill) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1) + )), + shell + .render + .text(&player.artist) + .size(12) + .class(theme::Text::Color(style.muted)) + .width(Length::Fill) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1) + )) + ] + .spacing(4) + .width(Length::Fill); + if let (Some(length), Some(position)) = ( + player.length_us.filter(|length| *length > 0), + player.position_at(shell.media.now_us), + ) { + let value = + (shell.media.seeking.unwrap_or(position).min(length) as f64 / length as f64 * 10_000.).round() as u32; + if player.can_control && player.can_seek && player.track_id.is_some() { + info = info.push( + slider(0..=10_000_u32, value, move |value| { + cosmic::Action::App(Message::MediaSeek(seek_position(length, value))) + }) + .on_release(cosmic::Action::App(Message::MediaSeekCommit)) + .height(16) + .handle_width(SEEK_THUMB_DIAMETER) + .class(seek_slider_style(style)) + .width(Length::Fill), + ); + } + } + let rows = rows.push(row![cover, info].spacing(12).align_y(Alignment::Center)); + let playing = player.status == Playback::Playing; + rows.push( + container( + row![ + transport_sized( + shell, + icons::MEDIA_PREVIOUS, + "Previous track", + "previous", + player.can_control && player.can_previous, + style, + 36. + ), + transport_sized( + shell, + if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY }, + if playing { "Pause" } else { "Play" }, + "play-pause", + player.can_toggle(), + style, + 36. + ), + transport_sized( + shell, + icons::MEDIA_NEXT, + "Next track", + "next", + player.can_control && player.can_next, + style, + 36. + ), + ] + .spacing(8), + ) + .center_x(Length::Fill), + ) } pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { @@ -250,7 +387,7 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me .width(64) .height(64) .content_fit(cosmic::iced::ContentFit::Contain) - .border_radius(motion::radius(12.0)) + .border_radius(shell.render.radius(12.0)) .shape(BorderShape::Continuous) .into(), None => container(accented_icon(icons::MEDIA, 30, style.muted, color(style.theme.accent))) @@ -259,14 +396,23 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me .into(), }; let text_size = if player.label().len() >= 24 { 13 } else { 15 }; - let mut info = column![text(player.label()).size(text_size).width(Length::Fill).ellipsize( - cosmic::iced::widget::text::Ellipsize::End(cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2)) - )] + let mut info = column![ + shell + .render + .text(player.label()) + .size(text_size) + .width(Length::Fill) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2) + )) + ] .spacing(4) .width(Length::Fill); if !player.artist.is_empty() { info = info.push( - text(&player.artist) + shell + .render + .text(&player.artist) .size(12) .width(Length::Fill) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( @@ -277,7 +423,9 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me } if !player.album.is_empty() { info = info.push( - text(&player.album) + shell + .render + .text(&player.album) .size(11) .width(Length::Fill) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( @@ -342,12 +490,16 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me progress = progress.push( row![ - text(time_label(position)) + shell + .render + .text(time_label(position)) .font(cosmic::iced::Font::MONOSPACE) .size(11) .class(theme::Text::Color(style.muted)) .width(Length::Fill), - text(time_label(length)) + shell + .render + .text(time_label(length)) .font(cosmic::iced::Font::MONOSPACE) .size(11) .class(theme::Text::Color(style.muted)) @@ -398,7 +550,8 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me player.identity.clone() }; rows = rows.push(motion::button( - button::custom(text(label).size(12)) + shell.render, + button::custom(shell.render.text(label).size(12)) .padding([6, 8]) .on_press(cosmic::Action::App(Message::MediaChoose)), style.primary, @@ -406,12 +559,19 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me style.opacity, )); } else { - rows = rows.push(text("No active player").size(13).class(theme::Text::Color(style.muted))); + rows = rows.push( + shell + .render + .text("No active player") + .size(13) + .class(theme::Text::Color(style.muted)), + ); } if shell.media.choosing || shell.media.snapshot.selected.is_none() { let mut choices = column::with_capacity(shell.media.snapshot.players.len() + 1).spacing(4); choices = choices.push(super::controls::menu_button( + shell.render, "Choose automatically", Message::MediaAction(json!({"action":"auto"})), style.primary, @@ -432,12 +592,14 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me choices = choices.push( row![ super::controls::menu_button( + shell.render, label, Message::MediaAction(pin), if player.ignored { style.muted } else { style.primary }, style.opacity ), super::controls::menu_button( + shell.render, if player.ignored { "Show" } else { "Ignore" }, Message::MediaAction(ignore), style.muted, @@ -452,7 +614,7 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me } if let Some(error) = &shell.media.error { - rows = rows.push(text(error).size(12).class(theme::Text::Color(style.muted))); + rows = rows.push(shell.render.text(error).size(12).class(theme::Text::Color(style.muted))); } rows diff --git a/shell/ferese-shell/src/status_ui/mod.rs b/shell/ferese-shell/src/status_ui/mod.rs index bcc6b8f..fcb3f09 100644 --- a/shell/ferese-shell/src/status_ui/mod.rs +++ b/shell/ferese-shell/src/status_ui/mod.rs @@ -32,6 +32,7 @@ struct MenuStyle { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Menu { + Workspaces, Network, Bluetooth, Audio, @@ -74,6 +75,7 @@ impl Menu { fn title(self) -> &'static str { match self { + Self::Workspaces => "Workspaces", Self::System => "Control Center", Self::Overflow => "Panel items", Self::Network => "Wi-Fi", @@ -95,7 +97,7 @@ impl Menu { Self::Battery => status.battery.is_some(), Self::Calendar | Self::Recording | Self::Media => true, Self::Notifications => status.notifications.is_some(), - Self::System | Self::Overflow => true, + Self::System | Self::Overflow | Self::Workspaces => true, } } } @@ -161,8 +163,151 @@ impl OpenMenu { } impl FereseShell { + pub(crate) fn view_hub_menu(&self, kind: Menu, opacity: f32) -> Element<'_, cosmic::Action> { + let theme = self.config.theme; + let style = MenuStyle { + theme, + primary: color_with_opacity(theme.text_primary, opacity), + muted: color_with_opacity(theme.text_muted, opacity), + opacity, + }; + let mut rows = column::with_capacity(8).spacing(8).width(Length::Fill); + if kind == Menu::Network + && let Some(network) = &self.status.network + { + rows = rows.push(toggle_row( + self.render, + "Wi-Fi", + network.enabled, + Action::Wifi(!network.enabled), + theme, + opacity, + )); + } + if kind == Menu::Audio + && let Some(audio) = &self.status.audio + { + rows = rows.push(toggle_row( + self.render, + "Mute", + audio.muted, + Action::Mute(!audio.muted), + theme, + opacity, + )); + } + let mut rows = match kind { + Menu::System => system::view(self, rows, style, true), + Menu::Network => network::view(self, rows, style), + Menu::Bluetooth => bluetooth::view(self, rows, style, true), + Menu::Audio => audio::view(self, rows, style, false), + Menu::Battery => battery::view(self, rows, style), + Menu::Calendar => rows.push(calendar::hub(self.render, self.calendar_offset, theme, opacity)), + Menu::Media => media::hub(self, rows, style), + Menu::Notifications => { + let mut rows = notification_controls(self, rows, style, true); + if self.notifications.entries.is_empty() { + rows = rows.push( + self.render + .text("No notifications") + .size(13) + .class(theme::Text::Color(style.muted)), + ); + } else { + rows = rows.push(menu_button( + self.render, + "Clear notifications", + Message::ClearNotifications, + style.muted, + opacity, + )); + for notice in &self.notifications.entries { + rows = rows.push(control_card( + self.render, + column![ + row![ + self.render + .text(notice.app.clone()) + .size(11) + .class(theme::Text::Color(style.muted)) + .width(Length::Fill), + menu_button( + self.render, + "×", + Message::RemoveNotification(notice.id), + style.muted, + opacity + ) + ], + self.render + .text(notice.title.clone()) + .size(13) + .class(theme::Text::Color(style.primary)), + self.render + .text(notice.body.clone()) + .size(12) + .class(theme::Text::Color(style.muted)), + ] + .spacing(4) + .into(), + style.primary, + opacity, + )); + for (action, label) in ¬ice.actions { + rows = rows.push(menu_button( + self.render, + label, + Message::InvokeNotification(notice.id, action.clone()), + style.primary, + opacity, + )); + } + } + } + rows + } + Menu::Recording => rows.push(menu_button( + self.render, + if self.recorder.busy() { + "Stop recording" + } else { + "Record screen" + }, + if self.recorder.busy() { + Message::StopRecording + } else { + Message::StartRecording + }, + style.primary, + opacity, + )), + Menu::Overflow | Menu::Workspaces => rows, + }; + if !kind.available(&self.status) { + rows = rows.push( + self.render + .text("Service unavailable") + .size(13) + .class(theme::Text::Color(style.muted)), + ); + } + if let Some(error) = self.status_error.as_ref().and_then(|error| kind.action_error(error)) { + rows = rows.push(self.render.text(error).size(12).class(theme::Text::Color(style.muted))); + } + container(rows) + .width(Length::Fill) + .class(theme::Container::custom(move |_| container::Style { + text_color: Some(style.primary), + icon_color: Some(style.primary), + ..Default::default() + })) + .into() + } + fn view_status_menu(&self) -> Element<'_, cosmic::Action> { - let Some(menu) = &self.menu else { return text("").into() }; + let Some(menu) = &self.menu else { + return self.render.text("").into(); + }; motion::frame_driven( menu.motion.revision(), |now| self.view_status_menu_at(now), @@ -186,7 +331,7 @@ impl FereseShell { fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action> { let Some(menu) = &self.menu else { - return text("").into(); + return self.render.text("").into(); }; if menu.kind == Menu::Notifications && self.notifications.ready { return cosmic::widget::autosize::autosize( @@ -217,9 +362,9 @@ impl FereseShell { .into(), color(theme.accent), p, - motion::radius(20.), + self.render.radius(20.), ); - let mut heading = ferese_theme::menus::heading(badge, kind.title(), shell_font()); + let mut heading = ferese_theme::menus::heading(badge, kind.title(), self.render.font); let style = MenuStyle { theme, primary, @@ -237,19 +382,24 @@ impl FereseShell { rows = rows.push(heading); } rows = match kind { - Menu::System => system::view(self, rows, style), + Menu::System => system::view(self, rows, style, false), Menu::Network => network::view(self, rows, style), - Menu::Bluetooth => bluetooth::view(self, rows, style), + Menu::Bluetooth => bluetooth::view(self, rows, style, false), Menu::Audio => audio::view(self, rows, style, false), Menu::Battery => battery::view(self, rows, style), - Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)), + Menu::Calendar => rows.push(calendar::view(self.render, self.calendar_offset, theme, p)), Menu::Notifications => notification_controls(self, rows, style, false), - Menu::Recording => rows, + Menu::Recording | Menu::Workspaces => rows, Menu::Overflow => self.view_panel_overflow(rows), Menu::Media => media::view(self, rows, style), }; if kind != Menu::Calendar && !kind.available(&self.status) { - rows = rows.push(text("Service unavailable").size(13).class(theme::Text::Color(muted))); + rows = rows.push( + self.render + .text("Service unavailable") + .size(13) + .class(theme::Text::Color(muted)), + ); } if let Some(error) = self @@ -257,7 +407,7 @@ impl FereseShell { .as_ref() .and_then(|error| menu.kind.action_error(error)) { - rows = rows.push(text(error).size(12).class(theme::Text::Color(Color { + rows = rows.push(self.render.text(error).size(12).class(theme::Text::Color(Color { a: p, ..Color::from_rgb8(230, 172, 90) }))); @@ -322,9 +472,9 @@ fn notification_controls<'a>( opacity: p, } = style; if let Some(n) = &shell.status.notifications { - let toggle = toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p); + let toggle = toggle_row(shell.render, "Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p); rows = rows.push(if combined { - control_card(toggle, primary, p) + control_card(shell.render, toggle, primary, p) } else { toggle }); @@ -332,17 +482,20 @@ fn notification_controls<'a>( if !combined { rows = rows .push( - text(if n.count == 0 { - "No notifications".to_owned() - } else if n.count == 1 { - "1 notification".to_owned() - } else { - format!("{} notifications", n.count) - }) - .size(13) - .class(theme::Text::Color(muted)), + shell + .render + .text(if n.count == 0 { + "No notifications".to_owned() + } else if n.count == 1 { + "1 notification".to_owned() + } else { + format!("{} notifications", n.count) + }) + .size(13) + .class(theme::Text::Color(muted)), ) .push(menu_button( + shell.render, "Open notification center", Message::Control(Action::Notifications), primary, diff --git a/shell/ferese-shell/src/status_ui/network.rs b/shell/ferese-shell/src/status_ui/network.rs index 3bdff81..9ac7dbd 100644 --- a/shell/ferese-shell/src/status_ui/network.rs +++ b/shell/ferese-shell/src/status_ui/network.rs @@ -4,8 +4,8 @@ use cosmic::widget::{column, row}; use super::bar::status_icon; use super::controls::{level_meter, shell_switch, status_summary}; use super::{Menu, MenuRows, MenuStyle}; +use crate::FereseShell; use crate::status::Action; -use crate::{FereseShell, text}; pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { let MenuStyle { @@ -14,6 +14,7 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me if let Some(n) = &shell.status.network { let connected = n.enabled && n.connection.is_some(); rows = rows.push(status_summary( + shell.render, status_icon(Menu::Network, &shell.status).0, if !n.enabled { "Wi-Fi is off" @@ -41,10 +42,10 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me rows = rows.push( column![ row![ - text("Signal strength").size(12).width(Length::Fill), - text(strength).size(12) + shell.render.text("Signal strength").size(12).width(Length::Fill), + shell.render.text(strength).size(12) ], - level_meter(n.signal, primary, p), + level_meter(shell.render, n.signal, primary, p), ] .spacing(8), ); @@ -61,6 +62,7 @@ pub(super) fn heading<'a>( let MenuStyle { theme, opacity: p, .. } = style; if let Some(network) = &shell.status.network { heading = heading.push(shell_switch( + shell.render, "Wi-Fi", network.enabled, Action::Wifi(!network.enabled), diff --git a/shell/ferese-shell/src/status_ui/system.rs b/shell/ferese-shell/src/status_ui/system.rs index daa1cbf..0a627f9 100644 --- a/shell/ferese-shell/src/status_ui/system.rs +++ b/shell/ferese-shell/src/status_ui/system.rs @@ -6,9 +6,14 @@ use super::bar::status_icon; use super::controls::control_card; use super::{Menu, MenuRows, MenuStyle, audio, battery, notification_controls}; use crate::status::{self, Action}; -use crate::{FereseShell, Message, ShellTheme, accented_icon, color, color_with_opacity, motion, shell_font, text}; +use crate::{FereseShell, Message, accented_icon, color, color_with_opacity, motion}; -pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { +pub(super) fn view<'a>( + shell: &'a FereseShell, + mut rows: MenuRows<'a>, + style: MenuStyle, + show_settings_link: bool, +) -> MenuRows<'a> { let MenuStyle { theme, primary, @@ -19,7 +24,8 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me let mut modes = row([]).spacing(6); for (mode, label) in [(Mode::Light, "☀ Light"), (Mode::Dark, "☾ Dark"), (Mode::Auto, "◐ Auto")] { modes = modes.push(motion::button( - button::custom(text(label).font(shell_font()).size(13)) + shell.render, + button::custom(shell.render.text(label).font(shell.render.font).size(13)) .width(Length::Fill) .padding(9) .on_press(cosmic::Action::App(Message::ThemeMode(mode))), @@ -28,23 +34,54 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me p, )); } - rows = rows.push(modes); + if show_settings_link + && shell + .config + .status + .settings_command + .as_ref() + .is_some_and(|command| !command.is_empty()) + { + let settings = motion::button( + shell.render, + button::custom( + ferese_theme::icons::tinted(ferese_theme::icons::SETTINGS, 18, color(theme.text_primary)).opacity(p), + ) + .name("Open Ferese Settings") + .width(30) + .height(28) + .padding([5, 3]) + .on_press(cosmic::Action::App(Message::Control(Action::Settings))), + primary, + false, + p, + ); + modes = modes.push(cosmic::widget::tooltip( + settings, + shell.render.text("Open Ferese Settings").size(12), + cosmic::widget::tooltip::Position::Left, + )); + } + rows = rows.push(modes.align_y(Alignment::Center)); if shell.config.theme_mode == Mode::Auto { rows = rows.push( - text(if theme.appearance == Appearance::Light { - "Auto · currently light" - } else { - "Auto · currently dark" - }) - .font(shell_font()) - .size(12) - .class(cosmic::theme::Text::Color(muted)), + shell + .render + .text(if theme.appearance == Appearance::Light { + "Auto · currently light" + } else { + "Auto · currently dark" + }) + .font(shell.render.font) + .size(12) + .class(cosmic::theme::Text::Color(muted)), ); } let mut connections = row::with_capacity(3).spacing(8).width(Length::Fill); if let Some(n) = &shell.status.network { connections = connections.push(connection_control( + shell.render, "Wi-Fi", if n.enabled { n.connection.as_deref().unwrap_or("Not connected") @@ -54,13 +91,13 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me status_icon(Menu::Network, &shell.status).0, n.enabled, Some(Action::Wifi(!n.enabled)), - theme, - p, + style, )); } if let Some(b) = &shell.status.bluetooth { connections = connections.push(connection_control( + shell.render, "Bluetooth", if b.enabled { b.devices.first().map(String::as_str).unwrap_or("On") @@ -70,25 +107,24 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me status_icon(Menu::Bluetooth, &shell.status).0, b.enabled, Some(Action::Bluetooth(!b.enabled)), - theme, - p, + style, )); } if let Some(b) = &shell.status.battery { connections = connections.push(connection_control( + shell.render, "Battery", &format!("{}%", b.percent), status_icon(Menu::Battery, &shell.status).0, b.status == "Charging", None, - theme, - p, + style, )); } if shell.status.network.is_some() || shell.status.bluetooth.is_some() || shell.status.battery.is_some() { - rows = rows.push(control_card(connections.into(), primary, p)); + rows = rows.push(control_card(shell.render, connections.into(), primary, p)); } rows = audio::view(shell, rows, style, true); rows = battery::brightness(shell, rows, style); @@ -109,14 +145,19 @@ fn connection_caption(detail: &str) -> String { } fn connection_control<'a>( + render: crate::rendering::Rendering, label: &'static str, detail: &str, source: &'static [u8], enabled: bool, action: Option, - palette: ShellTheme, - opacity: f32, + style: MenuStyle, ) -> Element<'a, cosmic::Action> { + let MenuStyle { + theme: palette, + opacity, + .. + } = style; let selected = enabled && action.is_some(); let (fill, on_accent) = ferese_theme::accent_pair( ferese_theme::composite(color(palette.accent), color(palette.surface_base)), @@ -143,7 +184,8 @@ fn connection_control<'a>( .align_y(alignment::Vertical::Center); let content = column![icon].spacing(2).width(Length::Fill).align_x(Alignment::Center); let content = content.push( - text(connection_caption(detail)) + render + .text(connection_caption(detail)) .size(11) .width(Length::Fill) .height(16) @@ -167,12 +209,12 @@ fn connection_control<'a>( .class(ferese_theme::controls::filled_button( fill, foreground, - motion::radius(14.), + render.radius(14.), opacity, )) .into() } else { - motion::button(button, foreground, false, opacity) + motion::button(render, button, foreground, false, opacity) }; container(tile).width(Length::FillPortion(1)).into() } @@ -197,6 +239,7 @@ pub(super) fn heading<'a>( .is_some_and(|program| status::available(program)) { heading = heading.push(motion::button( + shell.render, button::custom(accented_icon( ferese_theme::icons::SETTINGS, 20, diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 0d314d7..f3437c7 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -3,14 +3,14 @@ use super::{ EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin, IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific, Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop, - destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, text, - theme, wayland, window, wl_output, wl_registry, wl_surface, + destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, theme, + wayland, window, wl_output, wl_registry, wl_surface, }; -pub(super) fn panel_placement(edge: ferese_config::panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) { +pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) { let (anchor, top, bottom) = match edge { - ferese_config::panel::Edge::Top => (Anchor::TOP, margin, 0), - ferese_config::panel::Edge::Bottom => (Anchor::BOTTOM, 0, margin), + ferese_panel::Edge::Top => (Anchor::TOP, margin, 0), + ferese_panel::Edge::Bottom => (Anchor::BOTTOM, 0, margin), }; ( anchor, @@ -81,15 +81,36 @@ impl FereseShell { } else { Task::none() }; - return Task::batch([margin, popup, tooltip]); + return Task::batch([ + margin, + popup, + tooltip, + window::scale_factor(bar).map(move |scale| cosmic::Action::App(Message::PanelScale(bar, scale))), + ]); } let bar_surface_id = window::Id::unique(); let wallpaper_surface_id = window::Id::unique(); - let geometry = self.config.panels[0].geometry; - let panel_surface = self.config.panels[0].surface; + let runtime = super::panel_runtime::Runtime::new(&self.config.panels[0], &self.snapshot, name.as_deref()); + let panel = runtime.panel(&self.config.panels[0]); + let geometry = panel.resolved_geometry(); + let panel_surface = panel.surface(); + let exclusive_zone = panel.exclusive_zone(output_bar_hidden( + &self.snapshot, + self.snapshot + .outputs + .iter() + .find(|output| Some(output.name.as_str()) == name.as_deref()) + .map(|output| output.id), + )); let (edge_anchor, margin) = panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0); let bar = BarMetrics::from(geometry); + let hub_size = runtime.hub.as_ref().map(|hub| hub.borrow().allocation); + let bar_anchor = if hub_size.is_some() { + edge_anchor + } else { + edge_anchor | Anchor::LEFT | Anchor::RIGHT + }; let wallpaper_output = output.clone(); let bar_output = output.clone(); let hidden = output_bar_hidden( @@ -101,6 +122,8 @@ impl FereseShell { .map(|output| output.id), ); self.outputs.push(OutputSurfaces { + runtime, + scale: 1., output, name, bar: bar_surface_id, @@ -141,15 +164,17 @@ impl FereseShell { input_zone: super::bar::input_region(panel_surface, hidden, &[]), layer: Layer::Top, keyboard_interactivity: KeyboardInteractivity::None, - anchor: edge_anchor | Anchor::LEFT | Anchor::RIGHT, + anchor: bar_anchor, output: IcedOutput::Output(bar_output.clone()), namespace: "ferese-shell-top-bar".to_owned(), // Measure at full width before applying the requested side inset. margin, - size: Some((None, Some(bar.height.round() as u32))), + size: Some(hub_size.map_or((None, Some(bar.height.round() as u32)), |size| { + (Some(size[0]), Some(size[1])) + })), size_limits: Limits::NONE, // Reserve space toward the desktop; layer-shell accounts for the edge margin. - exclusive_zone: (bar.height.round() as i32).saturating_add(geometry.window_clearance), + exclusive_zone, }, Some(Box::new(move |app| app.view_layer(bar_surface_id))), ); @@ -186,8 +211,11 @@ impl FereseShell { } pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task { + if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) { + return self.hub_hover(id, true); + } if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorLeft)) { - return self.dismiss_workspace_tooltip_for_bar(id); + return Task::batch([self.dismiss_workspace_tooltip_for_bar(id), self.hub_hover(id, false)]); } if let Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) = &event && let Some(modal) = &mut self.system_modal @@ -230,6 +258,53 @@ impl FereseShell { { return self.display_mode_key(key); } + if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(key), + modifiers, + .. + }) = &event + && !modifiers.control() + && !modifiers.alt() + && self.hub_mode(id) + == Some(ferese_panel::morph::Mode::Expanded( + ferese_panel::morph::Content::Workspaces, + )) + && matches!( + key, + cosmic::iced::keyboard::key::Named::ArrowLeft + | cosmic::iced::keyboard::key::Named::ArrowRight + | cosmic::iced::keyboard::key::Named::ArrowUp + | cosmic::iced::keyboard::key::Named::ArrowDown + ) + { + return self.hub_workspace_key(id, *key); + } + 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.hub_mode(id).is_some_and(|mode| mode.expanded()) + { + use cosmic::iced::advanced::widget::{Id, operation}; + let target = Id::new("ferese-panel-hub"); + let focus: Box>> = + if modifiers.shift() { + Box::new(operation::scoped(target, operation::focusable::focus_previous())) + } else { + Box::new(operation::scoped(target, operation::focusable::focus_next())) + }; + let task = cosmic::iced::advanced::widget::operate(focus); + return if self.hub_mode(id) + == Some(ferese_panel::morph::Mode::Expanded( + ferese_panel::morph::Content::Workspaces, + )) { + task.chain(self.query_hub_workspace_focus(id, None)) + } else { + task + }; + } if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab), modifiers, @@ -247,6 +322,9 @@ impl FereseShell { .. }) ) { + if self.hub_mode(id).is_some() { + return self.close_hub(id); + } if self.system_modal.is_some() { return self.close_system_modal(); } @@ -255,10 +333,22 @@ impl FereseShell { } } + if let Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer( + wayland::LayerEvent::Unfocused, + _, + layer, + ))) = &event + && self.hub_mode(*layer).is_some_and(|mode| mode.expanded()) + { + return self.close_hub(*layer); + } match event { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output))) => { self.output_event(event, output) } + Event::Window(window::Event::Rescaled(scale)) => { + cosmic::task::message(cosmic::Action::App(Message::PanelScale(id, scale))) + } Event::Window(window::Event::Opened { .. }) if !self.owns_surface(id) => { // Creation is asynchronous. A close can arrive before the // surface exists, so dispose of any late opening once its @@ -278,6 +368,7 @@ impl FereseShell { .is_some_and(|surface| surface.id == id) => { Task::batch([ + window::scale_factor(id).map(move |scale| cosmic::Action::App(Message::PanelScale(id, scale))), self.focus_system_modal(id), window::run(id, native_wayland_surface) .map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))), @@ -367,7 +458,8 @@ impl FereseShell { { self.attach_effects(layer_id, &surface); self.attach_power_material(layer_id, &surface); - Task::none() + window::scale_factor(layer_id) + .map(move |scale| cosmic::Action::App(Message::PanelScale(layer_id, scale))) } _ => Task::none(), } @@ -375,7 +467,7 @@ impl FereseShell { pub(super) fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) { let hidden = self.bar_hidden(id); - let islands = self.config.panels[0].surface == ferese_config::panel::PanelSurface::None; + let islands = self.panel_for_bar(id).surface() == ferese_panel::PanelSurface::None; let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else { return; }; @@ -401,7 +493,7 @@ impl FereseShell { pub(super) fn view_wallpaper(&self) -> Element<'_, cosmic::Action> { let Some(handle) = self.wallpaper.as_ref() else { - return container(text("")) + return container(self.render.text("")) .width(Length::Fill) .height(Length::Fill) .class(theme::Container::custom(wallpaper_fallback_style)) @@ -488,6 +580,7 @@ pub(super) struct EffectsBinding { regions: std::cell::RefCell>>, opacity: std::cell::Cell>, presentation: std::cell::RefCell>, + panel_presentation: std::cell::RefCell>, } impl EffectsBinding { @@ -511,7 +604,7 @@ impl EffectsBinding { let connection = Connection::from_backend(backend); let (globals, queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 5..=5, ())?; + let manager = globals.bind::(&qh, 6..=6, ())?; let effects = manager.get_surface_effects(surface, &qh, ()); let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; @@ -531,9 +624,39 @@ impl EffectsBinding { regions: Default::default(), opacity: std::cell::Cell::new(Some(opacity)), presentation: Default::default(), + panel_presentation: Default::default(), }) } + pub(super) fn set_panel_presentation( + &self, + scene: &ferese_panel::scene::Scene, + ) -> Result<(), Box> { + if self.panel_presentation.borrow().as_ref() == Some(scene) { + return Ok(()); + } + let regions: Vec<_> = scene + .damage + .iter() + .map(|r| [r.x as f32, r.y as f32, r.width as f32, r.height as f32, 0.]) + .collect(); + validate_region_counts(regions.len(), 0)?; + if scene.paint.len() > ferese_panel::scene::MAX_PRIMITIVES { + return Err("too many panel primitives".into()); + } + let shapes: Vec<_> = scene + .packets() + .into_iter() + .flatten() + .flat_map(f32::to_ne_bytes) + .collect(); + self.surface + .set_panel_presentation(encode_regions(®ions), shapes, scene.fusion, 1000); + self.connection.flush()?; + *self.panel_presentation.borrow_mut() = Some(scene.clone()); + Ok(()) + } + pub(super) fn set_presentation( &self, regions: &[[f32; 5]], diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index ab9a50f..6de5f6d 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -468,10 +468,10 @@ impl FereseShell { fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action> { let Some(modal) = &self.system_modal else { - return text("").into(); + return self.render.text("").into(); }; let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else { - return text("").into(); + return self.render.text("").into(); }; motion::frame_driven( modal.motion.revision(), @@ -496,10 +496,10 @@ impl FereseShell { fn view_system_modal_at(&self, id: window::Id, now: Instant) -> Element<'_, cosmic::Action> { let Some(modal) = &self.system_modal else { - return text("").into(); + return self.render.text("").into(); }; let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else { - return text("").into(); + return self.render.text("").into(); }; let progress = modal.motion.progress_at(now); let content: Element<'_, cosmic::Action> = if surface.primary { @@ -510,8 +510,9 @@ impl FereseShell { let rows = match &modal.content { Content::Power(action) => { let mut rows = column![ - text(action.title()).size(17), - text(action.description()) + self.render.text(action.title()).size(17), + self.render + .text(action.description()) .size(13) .class(theme::Text::Color(palette.muted)), ] @@ -520,13 +521,14 @@ impl FereseShell { && !inhibitors.reasons.is_empty() { rows = rows.push( - text("Applications requested that this action be prevented:") + self.render + .text("Applications requested that this action be prevented:") .size(13) .class(theme::Text::Color(palette.muted)), ); let items = inhibitors.reasons.iter().fold( column::with_capacity(inhibitors.reasons.len()).spacing(6), - |rows, reason| rows.push(text(reason).size(13)), + |rows, reason| rows.push(self.render.text(reason).size(13)), ); rows = rows .push(container(cosmic::widget::scrollable(items).height(Length::Shrink)).max_height(160)); @@ -537,13 +539,13 @@ impl FereseShell { action.label().to_owned() }; if let Some(error) = &modal.error { - rows = rows.push(text(error).size(13)); + rows = rows.push(self.render.text(error).size(13)); } rows.push( container( row![ Space::new().width(Length::Fill), - ferese_theme::controls::text_button("Cancel", shell_font(), palette, false) + ferese_theme::controls::text_button("Cancel", self.render.font, palette, false) .class(ferese_theme::controls::material_button_style_with_focus( palette, false, @@ -552,7 +554,7 @@ impl FereseShell { )) .id("ferese-modal-cancel".into()) .on_press(cosmic::Action::App(Message::CancelPower)), - ferese_theme::controls::text_button(label, shell_font(), palette, true) + ferese_theme::controls::text_button(label, self.render.font, palette, true) .class(ferese_theme::controls::button_style_with_focus( palette, true, @@ -576,8 +578,11 @@ impl FereseShell { for entry in entries { bindings = bindings.push( row![ - text(&entry.keys).size(13).width(Length::FillPortion(1)), - text(&entry.description).size(13).width(Length::FillPortion(1)), + self.render.text(&entry.keys).size(13).width(Length::FillPortion(1)), + self.render + .text(&entry.description) + .size(13) + .width(Length::FillPortion(1)), ] .spacing(16), ); @@ -592,16 +597,19 @@ impl FereseShell { .saturating_sub(360) .clamp(40, 400) as f32; column![ - text("Welcome to Ferese").size(23), - ferese_theme::menus::section_label("Your active shortcuts", shell_font(), palette.muted,) + self.render.text("Welcome to Ferese").size(23), + ferese_theme::menus::section_label("Your active shortcuts", self.render.font, palette.muted,) .size(14), container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height), - text("Disable this guide in Settings → Shortcuts. It appears at each login until disabled.") + self.render + .text( + "Disable this guide in Settings → Shortcuts. It appears at each login until disabled." + ) .size(12) .class(theme::Text::Color(palette.muted)), container(row![ Space::new().width(Length::Fill), - ferese_theme::controls::text_button("Got it", shell_font(), palette, true,) + ferese_theme::controls::text_button("Got it", self.render.font, palette, true,) .class(ferese_theme::controls::button_style_with_focus( palette, true, @@ -641,7 +649,7 @@ impl FereseShell { })) .into() } else { - text("").into() + self.render.text("").into() }; let content = if surface.primary { motion::animated( diff --git a/shell/ferese-shell/src/workspace_ui.rs b/shell/ferese-shell/src/workspace_ui.rs index 4ee0da4..ebb7092 100644 --- a/shell/ferese-shell/src/workspace_ui.rs +++ b/shell/ferese-shell/src/workspace_ui.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::{Element, Message, container, text}; +use crate::{Element, Message, container}; use cosmic::app::Task; use cosmic::iced::advanced::widget::{Id, Operation, operation}; use cosmic::iced::{Rectangle, window}; @@ -26,7 +26,7 @@ impl FereseShell { && matches!( item.kind, crate::panel::ItemKind::Workspaces { - style: ferese_config::panel::WorkspaceStyle::AppIcons + style: ferese_panel::WorkspaceStyle::AppIcons } ) }); @@ -43,7 +43,7 @@ impl FereseShell { } else { Task::none() }; - Task::batch([icons, tooltip]) + Task::batch([icons, tooltip, self.sync_hub_workspaces()]) } pub(super) fn workspace_icons_loaded(&mut self, result: Result) -> Task { @@ -259,8 +259,8 @@ impl FereseShell { let id = window::Id::unique(); let parent = hover.bar; let (anchor, gravity, gap) = match self.config.panels[0].edge { - ferese_config::panel::Edge::Top => (2u32, 2u32, 6), - ferese_config::panel::Edge::Bottom => (1u32, 1u32, -6), + ferese_panel::Edge::Top => (2u32, 2u32, 6), + ferese_panel::Edge::Bottom => (1u32, 1u32, -6), }; self.workspace_ui.tooltip = Some(id); let action = cosmic::surface::action::app_popup::( @@ -299,7 +299,7 @@ impl FereseShell { .iter() .find(|workspace| workspace.id == hover.workspace) }) else { - return text("").into(); + return self.render.text("").into(); }; let active = self .snapshot @@ -315,7 +315,8 @@ impl FereseShell { }); cosmic::widget::autosize::autosize( container( - text(label) + self.render + .text(label) .size(11) .wrapping(cosmic::iced::widget::text::Wrapping::None) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( @@ -502,7 +503,9 @@ mod tests { focused: false, urgent: false, fullscreen: false, + maximized: false, floating: false, + geometry: None, }); } @@ -553,7 +556,7 @@ mod tests { for item in &mut group.items { if matches!(item.kind, crate::panel::ItemKind::Workspaces { .. }) { item.kind = crate::panel::ItemKind::Workspaces { - style: ferese_config::panel::WorkspaceStyle::AppIcons, + style: ferese_panel::WorkspaceStyle::AppIcons, }; } } @@ -657,7 +660,9 @@ mod tests { focused: false, urgent: false, fullscreen: false, + maximized: false, floating: false, + geometry: None, }) .collect(); let fingerprint = |handles: Vec| { @@ -705,7 +710,7 @@ mod tests { use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, widget}; use cosmic::iced::{Event, Font, Pixels, Point, Size}; - use ferese_config::panel::WorkspaceStyle; + use ferese_panel::WorkspaceStyle; #[derive(Clone, Copy, Debug)] enum Pointer { diff --git a/site/README.md b/site/README.md index 569abfa..e395853 100644 --- a/site/README.md +++ b/site/README.md @@ -27,9 +27,10 @@ handbook. Rebuild and refresh the browser after changing the site sources or gui ## GitHub Pages -Choose **Settings → Pages → Source → GitHub Actions** to publish through the Pages -workflow. It runs when site or documentation changes reach `main`; you can also -start it manually. The expected address is https://ferese-wm.github.io/ferese/. +Choose **Settings → Pages → Source → GitHub Actions** to enable publishing, then +open **Actions → Deploy website to GitHub Pages → Run workflow** and choose the +branch to publish. Deployment runs manually, and the expected address is +https://ferese-wm.github.io/ferese/. The site uses relative links, including for handbook search, so it also works under the `/ferese/` project path. Each build copies screenshots from `docs/images` into diff --git a/site/content/index.md b/site/content/index.md index d5f1fd3..8d75977 100644 --- a/site/content/index.md +++ b/site/content/index.md @@ -39,13 +39,15 @@ group windows, resize them and move between workspaces. ## Desktop guides +- [Panel configuration](panel-configuration.md): choose a style, arrange controls, and configure Morph. - [Lock screen](locking.md): appearance, automatic locking, and display sleep. - [Screen sharing and recording](screen-sharing.md): share an app or display, or save a video. - [Desktop portals](portals.md): integration with applications. - [Animation model](animation-model.md): spring motion, geometry ownership, and resize presentation. -Configure the desktop in Settings or edit `~/.config/ferese/config.kdl` directly. -Changes apply live. Invalid edits leave the last working configuration in place. +Configure the desktop in Settings or edit `~/.config/ferese/config.kdl` directly; +changes apply live, and an invalid edit leaves the last working configuration in +place. ## Troubleshooting