diff --git a/compositor/src/display_presentation.rs b/compositor/src/display_presentation.rs index e4b8d3d..6b461a1 100644 --- a/compositor/src/display_presentation.rs +++ b/compositor/src/display_presentation.rs @@ -25,7 +25,8 @@ fn visible(states: &RenderElementStates) -> HashSet { states .states .iter() - .filter_map(|(id, state)| is_visible(*state).then(|| id.clone())) + .filter(|&(_id, state)| is_visible(*state)) + .map(|(id, _state)| id.clone()) .collect() } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 4b3ba32..ba37797 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -176,6 +176,20 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result Result<(), Box> { let display = Display::new()?; let mut state = Ferese::new(&mut event_loop, display, runtime)?; state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024); + state.config_sections = state + .config_source + .as_deref() + .and_then(|source| ferese_config::Document::parse(source).ok()) + .map(|document| { + let mut value = document.value().clone(); + if let Some(object) = value.as_object_mut() { + object.remove("theme"); + } + value + }); theme::init(&mut event_loop, &mut state, candidate)?; resume::init(&mut event_loop)?; overview::init_font_loader(&mut event_loop, &mut state)?; diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 13b01fd..6448940 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -758,13 +758,10 @@ impl Ferese { if !self.overview.is_active() { return None; } - self.space - .outputs() - .find(|output| { - self.output_bounds_for(output) - .is_some_and(|bounds| contains(workspace_strip(bounds), point)) - }) - .cloned() + let output = self.space.output_under(point).next()?; + self.output_bounds_for(output) + .is_some_and(|bounds| contains(workspace_strip(bounds), point)) + .then(|| output.clone()) } pub(crate) fn click_overview_workspace(&mut self, point: Point) -> bool { diff --git a/compositor/src/portal_session.rs b/compositor/src/portal_session.rs index 2fb84fa..db97e1e 100644 --- a/compositor/src/portal_session.rs +++ b/compositor/src/portal_session.rs @@ -155,7 +155,7 @@ impl PortalSession { } pub(crate) fn validate_end(&self, token: u32, inhibitor_revision: u32, force: bool) -> Result<(), String> { - if !self.query_ready() || !self.query.as_ref().is_some_and(|query| query.token == token) { + if !self.query_ready() || self.query.as_ref().is_none_or(|query| query.token != token) { return Err("Session-ending confirmation expired or applications are still responding".into()); } if self.inhibitor_revision != inhibitor_revision diff --git a/compositor/src/presentation_policy.rs b/compositor/src/presentation_policy.rs index 6fa75f5..7460c0a 100644 --- a/compositor/src/presentation_policy.rs +++ b/compositor/src/presentation_policy.rs @@ -49,7 +49,7 @@ impl Ferese { surfaces .into_iter() - .filter(|surface| mapped_tree(surface)) + .filter(mapped_tree) .filter_map(|surface| { let id = Id::from(&surface); let output = self diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index ee16e25..af6beff 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -117,38 +117,225 @@ impl ConfigMonitor { } } -impl crate::Ferese { - pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> { +#[derive(Clone)] +struct Prepared { + source: String, + sections: serde_json::Value, + runtime: crate::RuntimeConfig, + candidate: ferese_config::theme::Candidate, +} + +impl Prepared { + fn new(source: String, directory: &std::path::Path) -> Result { + Self::with_watch(source, directory, &mut Vec::new()) + } + + fn with_watch(source: String, directory: &std::path::Path, watched: &mut Vec) -> Result { if source.len() > 60 * 1024 { return Err("configuration exceeds the 60 KiB live-reload limit".into()); } - // Validate every runtime field before mutating any scene/input state. - let directory = crate::config::config_path() - .and_then(|path| path.parent().map(ToOwned::to_owned)) - .unwrap_or_else(|| PathBuf::from(".")); - let (_, mut runtime, candidate) = match crate::theme::prepare(&source, &directory) { - Ok(prepared) => prepared, - Err(error) => { - self.theme_engine.reject(error.clone()); - return Err(error); + + let document = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?; + if let Some(value) = document.get("theme") + && let Ok(policy) = serde_json::from_value::(value.clone()) + { + watched.extend( + [&policy.file, &policy.light.file, &policy.dark.file] + .into_iter() + .flatten() + .chain(policy.custom_themes.values().map(|theme| &theme.file)) + .filter(|path| !path.as_os_str().is_empty()) + .map(|path| ferese_config::theme::theme_path(directory, path)), + ); + } + let (_, runtime, candidate) = crate::theme::prepare_document(&document, directory)?; + let mut sections = document.value().clone(); + if let Some(object) = sections.as_object_mut() { + object.remove("theme"); + } + + Ok(Self { + source, + sections, + runtime, + candidate, + }) + } +} + +fn file_stamps(files: &[PathBuf]) -> Vec<(PathBuf, Option<(std::time::SystemTime, u64)>)> { + files + .iter() + .map(|path| { + let stamp = path + .metadata() + .ok() + .and_then(|m| m.modified().ok().map(|time| (time, m.len()))); + (path.clone(), stamp) + }) + .collect() +} + +type Reply = (u64, std::sync::mpsc::SyncSender); +struct Request { + force: bool, + fallback: Option, + reply: Option, +} + +pub(crate) struct Worker { + requests: std::sync::mpsc::SyncSender, +} + +impl Worker { + pub(crate) fn new( + event_loop: &mut calloop::EventLoop<'static, crate::Ferese>, + ) -> Result> { + let (requests, receiver) = std::sync::mpsc::sync_channel::(8); + let (results, events) = calloop::channel::channel::<(Result, Vec, Option)>(); + event_loop.handle().insert_source(events, |event, _, state| { + if let calloop::channel::Event::Msg((result, watched, reply)) = event { + state.theme_engine.watch_files(&watched); + let result = result.and_then(|prepared| state.apply_prepared_config(prepared)); + if let Err(error) = &result { + state.theme_engine.reject(error.clone()); + } + + if let Some((id, reply)) = reply { + let response = match result { + Ok(()) => ferese_ipc::Response::success(id, serde_json::json!({})), + Err(error) => ferese_ipc::Response::error(id, "invalid_config", error), + }; + let _ = reply.try_send(response); + } } + })?; + std::thread::Builder::new() + .name("ferese-config".into()) + .spawn(move || { + let mut cached: Option = None; + let mut stamps = Vec::new(); + for request in receiver { + let started = std::time::Instant::now(); + let path = crate::config::config_path(); + let directory = path + .as_ref() + .and_then(|p| p.parent()) + .unwrap_or(std::path::Path::new(".")); + let mut watched = Vec::new(); + let result = path + .as_ref() + .map_or_else( + || request.fallback.ok_or_else(|| "No configuration available".into()), + |path| { + if path.exists() || !request.force { + crate::theme::read_source(path) + } else { + Ok(String::new()) + } + }, + ) + .and_then(|source| { + // Watcher and explicit reloads share the same cache. + // Auto mode still resolves time and system appearance. + if let Some(previous) = &cached + && previous.source == source + && previous.candidate.policy.mode != ferese_config::theme::Mode::Auto + && file_stamps(&previous.candidate.files) == stamps + { + return Ok(previous.clone()); + } + + let prepared = Prepared::with_watch(source, directory, &mut watched)?; + stamps = file_stamps(&prepared.candidate.files); + cached = Some(prepared.clone()); + Ok(prepared) + }); + tracing::debug!( + elapsed_us = started.elapsed().as_micros() as u64, + "configuration prepared on worker" + ); + if results.send((result, watched, request.reply)).is_err() { + break; + } + } + })?; + Ok(Self { requests }) + } +} + +impl crate::Ferese { + pub(crate) fn queue_config_reload(&self, force: bool, reply: Option) -> Result<(), String> { + let request = Request { + force, + fallback: self.config_source.clone(), + reply, }; + let worker = self.config_worker.as_ref().ok_or("configuration worker unavailable")?; + if let Err(error) = worker.requests.try_send(request) { + let (std::sync::mpsc::TrySendError::Full(request) | std::sync::mpsc::TrySendError::Disconnected(request)) = + error; + if let Some((id, reply)) = request.reply { + let _ = reply.try_send(ferese_ipc::Response::error( + id, + "reload_busy", + "Configuration reload queue is full", + )); + } + return Err("Configuration reload queue is full".into()); + } + + Ok(()) + } + + fn apply_prepared_config(&mut self, prepared: Prepared) -> Result<(), String> { + let Prepared { + source, + sections, + mut runtime, + candidate, + } = prepared; + // Identical watcher/IPC reloads do not restart a theme transition. + if self.config_source.as_ref() == Some(&source) + && candidate.theme == self.theme_engine.snapshot.theme + && candidate.families == self.theme_engine.snapshot.families + && candidate.policy.mode == self.theme_engine.snapshot.mode + && candidate.warnings == self.theme_engine.snapshot.warnings + && candidate.fallback_note == self.theme_engine.snapshot.fallback_note + && self.theme_engine.matches_files(&candidate.files) + && self.theme_engine.snapshot.error.is_none() + { + if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) { + tracing::warn!(%error, "cannot arm appearance schedule"); + } + return Ok(()); + } + let wallpaper = runtime.wallpaper.clone(); runtime.wallpaper = self.wallpaper.configuration().clone(); let previous_theme = self.theme_settings; - let non_theme = |source: &str| -> Option { - let document = ferese_config::Document::parse(source).ok()?; - let mut value = document.value().clone(); - value.as_object_mut()?.remove("theme"); - Some(value) - }; - if self.config_source.as_deref().and_then(non_theme) != non_theme(&source) { - self.apply_runtime_config(runtime)?; + if self.config_sections.is_none() { + self.config_sections = self + .config_source + .as_deref() + .and_then(|s| ferese_config::Document::parse(s).ok()) + .map(|document| { + let mut value = document.value().clone(); + if let Some(object) = value.as_object_mut() { + object.remove("theme"); + } + value + }); } + if self.config_sections.as_ref() != Some(§ions) { + self.apply_runtime_config(runtime, §ions)?; + } + self.overview .set_font_family(candidate.theme.tokens.typography.font_family.clone()); self.theme_settings = previous_theme; self.accept_theme(candidate, wallpaper); + self.config_sections = Some(sections); self.config_source = Some(source.clone()); self.shell_resources.retain(|resource| { if let Ok(shell) = resource.upgrade() { @@ -164,10 +351,17 @@ impl crate::Ferese { Ok(()) } + pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> { + let directory = crate::config::config_path() + .and_then(|p| p.parent().map(ToOwned::to_owned)) + .unwrap_or_else(|| PathBuf::from(".")); + let prepared = Prepared::new(source, &directory)?; + self.apply_prepared_config(prepared) + } + pub(crate) fn reload_config(&mut self) -> Result<(), String> { let path = crate::config::config_path().ok_or("config directory is unavailable")?; - let source = crate::theme::read_source(&path)?; - self.reload_config_source(source) + self.reload_config_source(crate::theme::read_source(&path)?) } } @@ -194,6 +388,75 @@ mod tests { } } + #[test] + fn worker_reload_applies_once_and_rejects_invalid_configuration() { + const CHILD: &str = "FERESE_WORKER_RELOAD_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + use std::os::unix::fs::PermissionsExt; + let directory = Directory::new(); + std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "reload::tests::worker_reload_applies_once_and_rejects_invalid_configuration", + "--nocapture", + ]) + .env(CHILD, "1") + .env("XDG_RUNTIME_DIR", &directory.0) + .env("XDG_CONFIG_HOME", &directory.0) + .env_remove("FERESE_SOCKET") + .output() + .unwrap(); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + return; + } + + let (_, runtime, _) = crate::theme::prepare("", std::path::Path::new(".")).unwrap(); + let mut event_loop = calloop::EventLoop::try_new().unwrap(); + let display = smithay::reexports::wayland_server::Display::new().unwrap(); + let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap(); + let path = crate::config::config_path().unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let valid = "animations { speed 0.5; }"; + std::fs::write(&path, valid).unwrap(); + for (index, source) in [Some(valid), Some(valid), Some("animations { speed -1.0; }"), None] + .into_iter() + .enumerate() + { + if let Some(source) = source { + std::fs::write(&path, source).unwrap(); + } else { + std::fs::remove_file(&path).unwrap(); + } + let before = state.theme_engine.snapshot.revision; + let (reply, received) = std::sync::mpsc::sync_channel(1); + state.queue_config_reload(index == 1, Some((42, reply))).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let response = loop { + event_loop.dispatch(Duration::from_millis(10), &mut state).unwrap(); + if let Ok(response) = received.try_recv() { + break response; + } + assert!(Instant::now() < deadline, "worker did not reply"); + }; + assert_eq!(response.id, 42); + assert_eq!(response.error.is_some(), source != Some(valid)); + assert_eq!( + state.animation_duration(Duration::from_millis(100)), + Duration::from_millis(200) + ); + assert_eq!(state.config_source.as_deref(), Some(valid)); + if source == Some(valid) && before > 0 { + assert_eq!(state.theme_engine.snapshot.revision, before); + } + } + } + #[test] fn live_reload_keeps_dimming_policy_independent_of_theme_transitions() { if std::env::var_os("FERESE_DIM_RELOAD_TEST_CHILD").is_none() { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 0accbb0..6e72678 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -317,6 +317,8 @@ pub struct Ferese { pub(crate) session_lock: Lock, pub(crate) lock_idle: IdleSettings, pub(crate) config_source: Option, + pub(crate) config_sections: Option, + pub(crate) config_worker: Option, pub(crate) theme_engine: crate::theme::Engine, pub start_time: Instant, pub socket_name: OsString, @@ -434,6 +436,7 @@ pub struct Ferese { pub xdg_toplevel_icon_manager: XdgToplevelIconManager, } +#[derive(Clone)] pub struct RuntimeConfig { pub(crate) lock_idle: IdleSettings, pub(crate) autostart: Vec, @@ -542,6 +545,8 @@ impl Ferese { session_lock: crate::session_lock::Lock::default(), lock_idle: config.lock_idle, config_source: None, + config_sections: None, + config_worker: None, theme_engine: Default::default(), start_time, socket_name, @@ -659,6 +664,7 @@ impl Ferese { xdg_shell_state, xdg_toplevel_icon_manager, }; + state.config_worker = Some(crate::reload::Worker::new(event_loop)?); let screenshot = crate::ipc::init(event_loop)?; state.screenshot_parts = Some(screenshot.parts); state.screenshot_worker = Some(screenshot.worker); @@ -667,7 +673,23 @@ impl Ferese { Ok(state) } - pub(crate) fn apply_runtime_config(&mut self, config: RuntimeConfig) -> Result<(), String> { + pub(crate) fn apply_runtime_config( + &mut self, + config: RuntimeConfig, + sections: &serde_json::Value, + ) -> Result<(), String> { + let changed = |keys: &[&str]| { + self.config_sections + .as_ref() + .is_none_or(|previous| keys.iter().any(|key| previous.get(*key) != sections.get(*key))) + }; + let layout_changed = changed(&["layout", "scrolling", "workspaces"]); + let bindings_changed = changed(&["bindings", "commands"]); + let input_changed = changed(&["input"]); + let daemons_changed = changed(&["autostart"]); + let lock_changed = changed(&["lock_screen"]); + let scene_changed = layout_changed || changed(&["animations", "appearance", "window_rules", "output_profiles"]); + if self.output_profiles != config.output_profiles { crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?; } @@ -676,7 +698,7 @@ impl Ferese { || self.input_settings.xkb_variant != config.input_settings.xkb_variant || self.input_settings.xkb_options != config.input_settings.xkb_options; - if let Some(keyboard) = self.seat.get_keyboard() { + if input_changed && let Some(keyboard) = self.seat.get_keyboard() { if keyboard_changed { self.input_capture.close_all(); if self.input_capture.restore_focus { @@ -700,41 +722,51 @@ impl Ferese { keyboard.change_repeat_info(config.input_settings.repeat_rate, config.input_settings.repeat_delay_ms); } - self.lock_idle = config.lock_idle; - self.refresh_lock_idle_policy(); - self.advance_animations(Instant::now()); + if lock_changed { + self.lock_idle = config.lock_idle; + self.refresh_lock_idle_policy(); + } + if scene_changed { + self.advance_animations(Instant::now()); + } let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad; let outputs_changed = self.output_profiles != config.output_profiles; - let bounds = self - .workspaces - .iter() - .map(|workspace| { - let output = self.output_workspaces.output_for_workspace(workspace.id); - let rect = self - .output_ids - .iter() - .find(|(_, id)| Some(**id) == output) - .and_then(|(output, _)| self.output_bounds_for(output)) - .or_else(|| self.output_bounds()) - .unwrap_or(Rect::new(0., 0., 1920., 1080.)); - (workspace.id, rect) - }) - .collect(); + if layout_changed { + let bounds = self + .workspaces + .iter() + .map(|workspace| { + let output = self.output_workspaces.output_for_workspace(workspace.id); + let rect = self + .output_ids + .iter() + .find(|(_, id)| Some(**id) == output) + .and_then(|(output, _)| self.output_bounds_for(output)) + .or_else(|| self.output_bounds()) + .unwrap_or(Rect::new(0., 0., 1920., 1080.)); + (workspace.id, rect) + }) + .collect(); - self.workspaces - .reconfigure_live( - config.layout_mode, - config.default_column_width, - config.scrolling_focus_strategy, - &bounds, - ) - .map_err(|error| error.to_string())?; + self.workspaces + .reconfigure_live( + config.layout_mode, + config.default_column_width, + config.scrolling_focus_strategy, + &bounds, + ) + .map_err(|error| error.to_string())?; + } self.gap_config = config.gap_config; self.input_settings = config.input_settings; - self.bindings = config.bindings.into(); + if bindings_changed { + self.bindings = config.bindings.into(); + } self.workspace_auto_back_and_forth = config.workspace_auto_back_and_forth; - self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings); + if bindings_changed || input_changed { + self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings); + } let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules); self.theme_settings = config.theme_settings; self.inactive_dim = config.inactive_dim; @@ -749,7 +781,7 @@ impl Ferese { self.viewport_spring_config = config.viewport_spring_config; self.output_profiles = config.output_profiles; self.autostart = config.autostart; - if let Some(runner) = self.daemons.clone() { + if daemons_changed && let Some(runner) = self.daemons.clone() { crate::daemon::Runner::refresh(&runner, self); } @@ -766,10 +798,14 @@ impl Ferese { } self.overview.set_font_family(config.overview_font_family); - self.wallpaper.reload(config.wallpaper); + if self.wallpaper.configuration() != &config.wallpaper { + self.wallpaper.reload(config.wallpaper); + } - self.relayout(); - crate::backends::direct::render_all(self); + if scene_changed { + self.relayout(); + crate::backends::direct::render_all(self); + } Ok(()) } diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index 3363bd4..3a2eea3 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -309,10 +309,10 @@ impl Ferese { // Keep scheduling frames while waiting, so the deadline cannot stall. active_animation |= !blocked_workspaces.is_empty() || !resume_pauses.is_empty(); for (workspace, viewport) in &mut self.viewport_animations { - if !self + if self .output_workspaces .output_for_workspace(*workspace) - .is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(*workspace)) + .is_none_or(|output| self.output_workspaces.active_workspace(output) != Some(*workspace)) { continue; } diff --git a/compositor/src/state/focus.rs b/compositor/src/state/focus.rs index bd0beb4..3766d9c 100644 --- a/compositor/src/state/focus.rs +++ b/compositor/src/state/focus.rs @@ -11,7 +11,7 @@ impl Ferese { && self.window_content_ready(window) && self.windows.geometry(id).is_some() && self.output_workspaces.focused_output().is_some() - && !self.windows.record(*id).is_some_and(|record| record.closing.is_some())) + && self.windows.record(*id).is_none_or(|record| record.closing.is_none())) .then_some(*id) }) .collect() diff --git a/compositor/src/state/hit_testing.rs b/compositor/src/state/hit_testing.rs index 60bd23e..1c6c887 100644 --- a/compositor/src/state/hit_testing.rs +++ b/compositor/src/state/hit_testing.rs @@ -17,6 +17,10 @@ impl Ferese { .map(|(_, surface, origin)| (surface, origin)) .or_else(|| self.window_surface_under(position)) .or_else(|| { + if self.overview.is_active() { + return None; + } + self.layer_surface_under(position, &[Layer::Bottom, Layer::Background]) .map(|(_, surface, origin)| (surface, origin)) }) @@ -32,7 +36,7 @@ impl Ferese { if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) { return Some(layer); } - if self.window_surface_under(position).is_some() { + if self.overview.is_active() || self.window_surface_under(position).is_some() { return None; } diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index 1299ac7..7865f6e 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -235,15 +235,7 @@ impl Ferese { } pub(crate) fn focus_output_at(&mut self, position: Point) { - let output = self.space.outputs().find_map(|output| { - let geometry = self.space.output_geometry(output)?; - (position.x >= f64::from(geometry.loc.x) - && position.y >= f64::from(geometry.loc.y) - && position.x < f64::from(geometry.loc.x + geometry.size.w) - && position.y < f64::from(geometry.loc.y + geometry.size.h)) - .then(|| output.clone()) - }); - let Some(output) = output else { + let Some(output) = self.space.output_under(position).next().cloned() else { return; }; let Some(output_id) = self.output_ids.get(&output).copied() else { @@ -260,3 +252,182 @@ impl Ferese { self.relayout(); } } + +#[cfg(test)] +mod tests { + use super::*; + + use smithay::backend::input::{ButtonState, Event, InputBackend, InputEvent, PointerButtonEvent, UnusedEvent}; + use smithay::backend::winit::WinitVirtualDevice; + use smithay::input::pointer::MotionEvent; + use smithay::utils::SERIAL_COUNTER; + + #[derive(Debug)] + struct TestInput; + + impl InputBackend for TestInput { + type Device = WinitVirtualDevice; + type PointerButtonEvent = TestButton; + type KeyboardKeyEvent = UnusedEvent; + type PointerAxisEvent = UnusedEvent; + type PointerMotionEvent = UnusedEvent; + type PointerMotionAbsoluteEvent = UnusedEvent; + type GestureSwipeBeginEvent = UnusedEvent; + type GestureSwipeUpdateEvent = UnusedEvent; + type GestureSwipeEndEvent = UnusedEvent; + type GesturePinchBeginEvent = UnusedEvent; + type GesturePinchUpdateEvent = UnusedEvent; + type GesturePinchEndEvent = UnusedEvent; + type GestureHoldBeginEvent = UnusedEvent; + type GestureHoldEndEvent = UnusedEvent; + type TouchDownEvent = UnusedEvent; + type TouchUpEvent = UnusedEvent; + type TouchMotionEvent = UnusedEvent; + type TouchCancelEvent = UnusedEvent; + type TouchFrameEvent = UnusedEvent; + type TabletToolAxisEvent = UnusedEvent; + type TabletToolProximityEvent = UnusedEvent; + type TabletToolTipEvent = UnusedEvent; + type TabletToolButtonEvent = UnusedEvent; + type SwitchToggleEvent = UnusedEvent; + type SpecialEvent = UnusedEvent; + } + + #[derive(Debug)] + struct TestButton(ButtonState); + + impl Event for TestButton { + fn time(&self) -> u64 { + 1_000 + } + fn device(&self) -> WinitVirtualDevice { + WinitVirtualDevice + } + } + + impl PointerButtonEvent for TestButton { + fn button_code(&self) -> u32 { + 0x110 + } + fn state(&self) -> ButtonState { + self.0 + } + } + + fn click(state: &mut Ferese, position: Point) { + let pointer = state.seat.get_pointer().unwrap(); + pointer.motion( + state, + state.surface_under(position), + &MotionEvent { + location: position, + serial: SERIAL_COUNTER.next_serial(), + time: 1, + }, + ); + pointer.frame(state); + + for button in [ButtonState::Pressed, ButtonState::Released] { + state.process_input_event::(InputEvent::PointerButton { + event: TestButton(button), + }); + } + } + + #[test] + #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] + fn overlapping_outputs_agree_on_pointer_focus_and_overview_selection() { + let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap()); + assert!(runtime.starts_with(std::env::temp_dir())); + + let mut event_loop = EventLoop::try_new().unwrap(); + let config = crate::config::Config::default().runtime_config().unwrap(); + let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap(); + let mut outputs = Vec::new(); + + for name in ["first", "second"] { + let output = Output::new( + name.into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (1920, 1080).into(), + refresh: 60_000, + }), + Some(smithay::utils::Transform::Normal), + Some(smithay::output::Scale::Fractional(1.75)), + Some((0, 0).into()), + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, name.into()); + outputs.push(output); + } + + let position = Point::from((300.0, 300.0)); + let expected_output = state.space.output_under(position).next().unwrap().clone(); + assert_eq!(expected_output, outputs[1]); + let expected_workspace = state.workspace_under_pointer(position).unwrap(); + + state.focus_output_at(position); + assert_eq!(state.workspaces.active_id(), expected_workspace); + assert_eq!( + state.output_workspaces.focused_output(), + state.output_id(&expected_output) + ); + + state + .workspaces + .insert_window(WindowId(1), Axis::Horizontal, 0.5) + .unwrap(); + state.relayout(); + state.set_overview_active(true); + let card = state + .overview_workspace_cards(&expected_output) + .into_iter() + .find(|card| card.workspace != expected_workspace) + .expect("occupied workspace has an empty successor"); + let position = Point::from(( + card.rect.x + card.rect.width / 2.0, + card.rect.y + card.rect.height / 2.0, + )); + assert_eq!(state.overview_strip_at(position), Some(expected_output.clone())); + click(&mut state, position); + assert_eq!(state.workspaces.active_id(), card.workspace); + state.windows.records.insert( + WindowId(1), + super::super::window_registry::WindowRecord { + geometry: Some(WindowGeometry::new(Rect::new(0.0, 0.0, 800.0, 600.0), None)), + ..Default::default() + }, + ); + let occupied = state + .overview_workspace_cards(&expected_output) + .into_iter() + .find(|card| card.workspace == expected_workspace) + .unwrap(); + let preview = occupied.windows[0].1; + let image_point = Point::from((preview.x + preview.width / 2.0, preview.y + preview.height / 2.0)); + let label_point = Point::from(( + occupied.rect.x + occupied.rect.width / 2.0, + occupied.rect.y + occupied.rect.height - 8.0, + )); + + for position in [image_point, label_point] { + state.activate_managed_workspace(card.workspace); + click(&mut state, position); + assert_eq!(state.workspaces.active_id(), expected_workspace); + assert!(state.overview.is_active()); + } + + assert_eq!( + state.output_workspaces.focused_output(), + state.output_id(&expected_output) + ); + } +} diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index 7585b3a..0712370 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -664,7 +664,7 @@ impl Ferese { } pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool { - if !self.windows.window(id).is_some() { + if self.windows.window(id).is_none() { return false; } diff --git a/compositor/src/theme.rs b/compositor/src/theme.rs index 5ff5791..8990742 100644 --- a/compositor/src/theme.rs +++ b/compositor/src/theme.rs @@ -47,7 +47,14 @@ pub(crate) fn read_source(path: &Path) -> Result { pub(crate) fn prepare(source: &str, directory: &Path) -> Result<(Config, RuntimeConfig, Candidate), String> { let document = Document::parse(source).map_err(|e| e.to_string())?; - let candidate = resolve_with_context(&document, directory, Timestamp::now(), &auto_context(), read_source)?; + prepare_document(&document, directory) +} + +pub(crate) fn prepare_document( + document: &Document, + directory: &Path, +) -> Result<(Config, RuntimeConfig, Candidate), String> { + let candidate = resolve_with_context(document, directory, Timestamp::now(), &auto_context(), read_source)?; let config: Config = serde_json::from_value(document.with_theme(&candidate.theme).value().clone()).map_err(|e| e.to_string())?; let runtime = config.runtime_config().map_err(|e| e.to_string())?; @@ -131,6 +138,10 @@ pub(crate) struct Engine { } impl Engine { + pub(crate) fn matches_files(&self, files: &[PathBuf]) -> bool { + self.active_files == files + } + pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender) { if since != self.snapshot.revision { let _ = response.try_send(Response::success(id, self.value())); @@ -163,7 +174,7 @@ impl Engine { } } - fn watch_files(&mut self, files: &[PathBuf]) { + pub(crate) fn watch_files(&mut self, files: &[PathBuf]) { let mut targets = HashSet::from([PathBuf::from("/etc/localtime")]); if let Some(path) = crate::config::config_path() { targets.insert(path); @@ -213,7 +224,7 @@ impl Engine { self.watched_directories = directories; } - fn arm_clock(&self, next: Option) -> io::Result<()> { + pub(crate) fn arm_clock(&self, next: Option) -> io::Result<()> { let Some(fd) = &self.clock else { return Ok(()) }; let mut timer: libc::itimerspec = unsafe { std::mem::zeroed() }; if let Some(next) = next { @@ -342,47 +353,7 @@ pub(crate) fn init( impl Ferese { pub(crate) fn refresh_theme(&mut self) { - let result = crate::config::config_path() - .map_or_else( - || { - self.config_source - .clone() - .ok_or_else(|| "No configuration available".into()) - }, - |path| { - if path.exists() { - read_source(&path) - } else { - Ok(String::new()) - } - }, - ) - .and_then(|source| { - if let Ok(document) = Document::parse(&source) - && let Some(value) = document.get("theme") - && let Ok(policy) = serde_json::from_value::(value.clone()) - { - let directory = crate::config::config_path() - .and_then(|path| path.parent().map(ToOwned::to_owned)) - .unwrap_or_else(|| PathBuf::from(".")); - let files: Vec<_> = [&policy.file, &policy.light.file, &policy.dark.file] - .into_iter() - .flatten() - .filter(|path| !path.as_os_str().is_empty()) - .map(|path| ferese_config::theme::theme_path(&directory, path)) - .collect(); - let mut files = files; - files.extend( - policy - .custom_themes - .values() - .map(|theme| ferese_config::theme::theme_path(&directory, &theme.file)), - ); - self.theme_engine.watch_files(&files); - } - self.reload_config_source(source) - }); - if let Err(error) = result { + if let Err(error) = self.queue_config_reload(true, None) { self.theme_engine.reject(error); } } diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index 368aff1..6166f5f 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -392,6 +392,7 @@ impl Default for Snapshot { } } +#[derive(Clone)] pub struct Candidate { pub policy: Policy, pub theme: ResolvedTheme, diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 060e079..cd4a6f8 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -1025,6 +1025,16 @@ fn finite_or_zero(value: f64) -> f64 { if value.is_finite() { value } else { 0.0 } } +impl Workspace { + pub fn window_count(&self) -> usize { + self.layout.window_ids().count() + self.floating.len() + } + + pub fn is_empty(&self) -> bool { + self.layout.window_ids().next().is_none() && self.floating.is_empty() + } +} + #[cfg(test)] mod tests { #[test] @@ -1582,13 +1592,3 @@ mod tests { ) } } - -impl Workspace { - pub fn window_count(&self) -> usize { - self.layout.window_ids().count() + self.floating.len() - } - - pub fn is_empty(&self) -> bool { - self.layout.window_ids().next().is_none() && self.floating.is_empty() - } -} diff --git a/crates/ferese-theme/src/gallery.rs b/crates/ferese-theme/src/gallery.rs index 903449e..3a5d983 100644 --- a/crates/ferese-theme/src/gallery.rs +++ b/crates/ferese-theme/src/gallery.rs @@ -90,15 +90,16 @@ pub fn preview( selected: bool, ) -> cosmic::widget::icon::Icon { use cosmic::widget::icon; - let key = format!("{family:?}/{variant:?}/{active:?}/{selected}"); - static CACHE: std::sync::OnceLock>> = + let key = format!("{}/{variant:?}/{active:?}/{selected}", family.id); + static CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); let cache = CACHE.get_or_init(Default::default); let mut cache = cache.lock().unwrap(); - let handle = cache - .entry(key) - .or_insert_with(|| icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false)) - .clone(); + if cache.get(&key).is_none_or(|(cached, _)| cached != family) { + let handle = icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false); + cache.insert(key.clone(), (family.clone(), handle)); + } + let handle = cache[&key].1.clone(); // Bound cached previews when users repeatedly edit imported palettes. if cache.len() > 128 { cache.clear(); diff --git a/portal/src/eis.rs b/portal/src/eis.rs index eb04052..986647a 100644 --- a/portal/src/eis.rs +++ b/portal/src/eis.rs @@ -257,11 +257,9 @@ impl State { device.start_emulating(activation); } } - Some("deactivated" | "disabled" | "closed") => { - if self.activation.take().is_some() { - for device in [&self.keyboard, &self.pointer].into_iter().flatten() { - device.stop_emulating(); - } + Some("deactivated" | "disabled" | "closed") if self.activation.take().is_some() => { + for device in [&self.keyboard, &self.pointer].into_iter().flatten() { + device.stop_emulating(); } } Some("keys") if self.activation.is_some() => { diff --git a/shell/ferese-settings/src/connections/controller.rs b/shell/ferese-settings/src/connections/controller.rs index 5112c03..d38ba99 100644 --- a/shell/ferese-settings/src/connections/controller.rs +++ b/shell/ferese-settings/src/connections/controller.rs @@ -142,7 +142,7 @@ impl App { } } Input::Answer(id, accepted) => { - if !self.connections.prompt.as_ref().is_some_and(|p| p.id == id) { + if self.connections.prompt.as_ref().is_none_or(|p| p.id != id) { return Task::none(); } if accepted diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 0fbaf4a..40283a4 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -1,5 +1,6 @@ mod connections; mod form_controls; +mod navigation; mod pages; mod theme_controls; @@ -83,6 +84,8 @@ enum Message { ThemeChanged(Box), DragWindow, Page(Page), + PagePresented(Page), + RowVisibility(&'static str, usize, bool), Search(String), Change(Edit), SelectFont(String, String), @@ -136,12 +139,18 @@ struct App { error: Option, status: String, page: Page, + profile_pages: bool, + page_transition: Option, + visible_rows: std::collections::HashSet<(&'static str, usize)>, + hidden_binding_rows: std::collections::HashSet, search: String, inputs: HashMap, ranges: HashMap, new_command: String, font: cosmic::font::Font, native_palette: visuals::Palette, + family_ids: std::sync::Arc>, + gallery_focus: Option<(String, Option)>, resolved: ferese_config::theme::Snapshot, undo_revision: u64, auto_details: bool, @@ -153,6 +162,19 @@ struct App { thumbnail_error: Option, } +fn initial_visible_rows() -> std::collections::HashSet<(&'static str, usize)> { + // Populate the first viewport before sensors report precise visibility. + // This avoids a blank first frame when a page opens. + (0..4) + .map(|index| ("bindings", index)) + .chain( + ["theme", "theme-light", "theme-dark"] + .into_iter() + .flat_map(|list| (0..3).map(move |index| (list, index))), + ) + .collect() +} + impl Drop for App { fn drop(&mut self) { if let Some(client) = &self.connections.client { @@ -219,12 +241,18 @@ impl cosmic::Application for App { } else { Page::Appearance }, + profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(), + page_transition: None, search: String::new(), + visible_rows: initial_visible_rows(), + hidden_binding_rows: Default::default(), inputs: HashMap::new(), ranges: HashMap::new(), new_command: String::new(), font, native_palette, + gallery_focus: None, + family_ids: std::sync::Arc::new(resolved.families.iter().map(|family| family.id.clone()).collect()), resolved, undo_revision: 0, auto_details: false, @@ -285,8 +313,17 @@ impl cosmic::Application for App { return self.edit_many(displays::edits(&prefix, mode, automatic)); } Message::ThemeChanged(snapshot) => { + let font_changed = self.resolved.presented.tokens.typography.font_family + != snapshot.presented.tokens.typography.font_family; + if self.resolved.families != snapshot.families { + self.family_ids = + std::sync::Arc::new(snapshot.families.iter().map(|family| family.id.clone()).collect()); + } self.resolved = *snapshot; - self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); + if font_changed { + self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); + } + return self.update_theme(); } Message::NoteAction(id, action) => { @@ -396,9 +433,21 @@ impl cosmic::Application for App { self.error = Some(error); } Message::Page(page) => { + if self.page == page && self.search.is_empty() { + return Task::none(); + } + + self.page_transition = self.profile_pages.then(std::time::Instant::now); let leaving = self.page == Page::Connections && page != Page::Connections; self.page = page; self.search.clear(); + self.visible_rows = initial_visible_rows(); + self.hidden_binding_rows.clear(); + self.gallery_focus = None; + let reset = cosmic::iced::widget::scrollable::snap_to( + widget::Id::new("settings-content"), + cosmic::iced::widget::scrollable::RelativeOffset::START.into(), + ); if leaving { let cleanup = self.leave_connections(); let load = match page { @@ -406,16 +455,62 @@ impl cosmic::Application for App { Page::Wallpaper => self.load_thumbnail(), _ => Task::none(), }; - return Task::batch([cleanup, load]); + return Task::batch([reset, cleanup, load]); } if page == Page::Connections { - return self.refresh_connections(); + return Task::batch([reset, self.refresh_connections()]); } if page == Page::Displays { - return self.load_displays(); + return Task::batch([reset, self.load_displays()]); } if page == Page::Wallpaper { - return self.load_thumbnail(); + return Task::batch([reset, self.load_thumbnail()]); + } + + return reset; + } + Message::PagePresented(page) => { + if self.page == page + && let Some(started) = self.page_transition.take() + { + eprintln!( + "settings page={page:?} first_redraw_us={}", + started.elapsed().as_micros() + ); + } + } + Message::RowVisibility(list, index, visible) => { + if visible { + self.visible_rows.insert((list, index)); + if list == "bindings" { + self.hidden_binding_rows.remove(&index); + } + if let Some((id, appearance)) = &self.gallery_focus + && navigation::gallery_list(*appearance) == list + && self + .family_ids + .iter() + .position(|family| family == id) + .is_some_and(|position| position / 3 == index) + { + let (id, appearance) = self.gallery_focus.take().unwrap(); + return cosmic::iced::advanced::widget::operate( + cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)), + ) + .map(cosmic::Action::App); + } + } else { + let dirty = list == "bindings" + && self + .inputs + .keys() + .any(|path| path.starts_with(&format!("bindings.{index}."))); + if dirty { + // Retain the input widget until its pending edit commits. + self.hidden_binding_rows.insert(index); + } else { + self.visible_rows.remove(&(list, index)); + } } } Message::Search(query) => self.search = query, @@ -437,6 +532,20 @@ impl cosmic::Application for App { } Message::Commit(field) => { if let Some(value) = self.inputs.remove(&field.path) { + if field.path.starts_with("bindings.") + && let Some(index) = field + .path + .split('.') + .nth(1) + .and_then(|index| index.parse::().ok()) + && !self + .inputs + .keys() + .any(|path| path.starts_with(&format!("bindings.{index}."))) + && self.hidden_binding_rows.remove(&index) + { + self.visible_rows.remove(&("bindings", index)); + } let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) { match shlex::split(&value).filter(|v| !v.is_empty()) { Some(args) => set( @@ -520,11 +629,29 @@ impl cosmic::Application for App { } } Message::Family(id, appearance) => { + let reveal = if let Some(index) = self.family_ids.iter().position(|family| family == &id) { + let list = navigation::gallery_list(appearance); + if !self.visible_rows.insert((list, index / 3)) { + self.gallery_focus = None; + } else { + // Focus after the sensor observes the newly mounted row. + self.gallery_focus = Some((id.clone(), appearance)); + } + cosmic::iced::advanced::widget::operate(navigation::RevealRow::new(list, index / 3)) + .map(cosmic::Action::App) + } else { + Task::none() + }; let edits = visuals::choose_family(&id, appearance); - let focus = cosmic::iced::advanced::widget::operate( - cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)), - ); - return Task::batch([self.edit_many(edits), focus.map(cosmic::Action::App)]); + let focus = if self.gallery_focus.is_none() { + cosmic::iced::advanced::widget::operate( + cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)), + ) + .map(cosmic::Action::App) + } else { + Task::none() + }; + return Task::batch([self.edit_many(edits), reveal, focus]); } Message::AutoDetails(enabled) => self.auto_details = enabled, Message::AdvancedTheme(enabled) => self.advanced_theme = enabled, @@ -716,7 +843,9 @@ impl cosmic::Application for App { impl App { fn update_theme(&mut self) -> Task { - let palette = visuals::Palette::from_resolved(&self.resolved.presented); + // Widgets use presented colors directly; COSMIC's structural theme only + // changes when the resolved destination changes, not on every fade tick. + let palette = visuals::Palette::from_resolved(&self.resolved.theme); if palette == self.native_palette { return Task::none(); } @@ -901,6 +1030,106 @@ mod tests { .0 } + fn shortcuts(count: usize) -> App { + let mut app = app(); + app.page = Page::Shortcuts; + let source = (0..count) + .map(|index| format!("binding \"Super+F{index}\" \"none\"\n")) + .collect::(); + app.draft = Snapshot::parse(source).unwrap(); + assert_eq!(app.draft.records("bindings"), count); + app + } + + fn tree_nodes(tree: &cosmic::iced::advanced::widget::Tree) -> usize { + 1 + tree.children.iter().map(tree_nodes).sum::() + } + + #[test] + fn shortcuts_build_controls_only_for_mounted_rows() { + let mut app = shortcuts(100); + app.visible_rows.extend((0..4).map(|index| ("bindings", index))); + let visible = tree_nodes(&cosmic::iced::advanced::widget::Tree::new(app.page_view().as_widget())); + app.visible_rows.extend((0..100).map(|index| ("bindings", index))); + let eager = tree_nodes(&cosmic::iced::advanced::widget::Tree::new(app.page_view().as_widget())); + assert!(eager > visible + 96 * 10, "visible={visible}, eager={eager}"); + } + + #[test] + fn virtual_rows_keep_unsaved_text_mounted_until_unfocus() { + let mut app = shortcuts(10); + let _ = app.update(Message::RowVisibility("bindings", 5, true)); + let _ = app.update(Message::Draft("bindings.5.argument".into(), "my command".into())); + let _ = app.update(Message::RowVisibility("bindings", 5, false)); + assert!(app.visible_rows.contains(&("bindings", 5))); + assert_eq!(app.inputs["bindings.5.argument"], "my command"); + let _ = app.update(Message::Commit(schema::text("bindings.5.argument", "Argument", "", ""))); + assert!(!app.visible_rows.contains(&("bindings", 5))); + assert_eq!(app.draft.string("bindings.5.argument", ""), "my command"); + } + + #[test] + fn navigation_resets_scroll_but_reselecting_the_same_page_does_not() { + let mut app = app(); + app.visible_rows.insert(("theme", 2)); + let task = app.update(Message::Page(Page::Shortcuts)); + assert!(task.units() > 0); + assert_eq!(app.visible_rows, initial_visible_rows()); + assert_eq!(app.update(Message::Page(Page::Shortcuts)).units(), 0); + } + + #[test] + fn offscreen_theme_selection_defers_focus_until_the_row_is_mounted() { + let mut app = app(); + let id = app.family_ids.last().unwrap().clone(); + let row = (app.family_ids.len() - 1) / 3; + app.visible_rows.remove(&("theme", row)); + let _ = app.update(Message::Family(id.clone(), None)); + assert_eq!(app.gallery_focus, Some((id, None))); + assert!(app.visible_rows.contains(&("theme", row))); + let task = app.update(Message::RowVisibility("theme", row, true)); + assert!(task.units() > 0); + assert!(app.gallery_focus.is_none()); + } + + #[test] + fn animated_palette_does_not_rebuild_native_theme_or_family_navigation() { + let mut app = app(); + app.native_palette = visuals::Palette::from_resolved(&app.resolved.theme); + let palette = app.native_palette; + let ids = app.family_ids.clone(); + let mut snapshot = app.resolved.clone(); + snapshot.presented.tokens.colors.surface_base = "#123456".into(); + let task = app.update(Message::ThemeChanged(Box::new(snapshot))); + assert_eq!(task.units(), 0); + assert_eq!(app.native_palette, palette); + assert!(std::sync::Arc::ptr_eq(&ids, &app.family_ids)); + } + + #[test] + #[ignore = "release timing sample; run with --ignored --nocapture"] + fn profile_shortcuts_page_construction() { + for count in [50, 200, 500] { + let mut app = shortcuts(count); + for mounted in [4, count] { + app.visible_rows = (0..mounted).map(|index| ("bindings", index)).collect(); + let mut samples = Vec::new(); + for _ in 0..100 { + let started = std::time::Instant::now(); + let view = app.page_view(); + std::hint::black_box(cosmic::iced::advanced::widget::Tree::new(view.as_widget())); + samples.push(started.elapsed()); + } + samples.sort_unstable(); + eprintln!( + "bindings={count} mounted={mounted} construction_median_us={} p95_us={}", + samples[50].as_micros(), + samples[95].as_micros() + ); + } + } + } + #[test] fn disabling_auto_keeps_the_effective_appearance() { for appearance in [ diff --git a/shell/ferese-settings/src/navigation.rs b/shell/ferese-settings/src/navigation.rs new file mode 100644 index 0000000..cc0dcdc --- /dev/null +++ b/shell/ferese-settings/src/navigation.rs @@ -0,0 +1,127 @@ +use cosmic::iced::advanced::widget::Id; +use cosmic::iced::advanced::widget::operation::{Operation, Scrollable}; +use cosmic::iced::{Rectangle, Vector}; + +pub(super) fn gallery_list(appearance: Option) -> &'static str { + match appearance { + Some(ferese_config::theme::Appearance::Light) => "theme-light", + Some(ferese_config::theme::Appearance::Dark) => "theme-dark", + None => "theme", + } +} + +pub(super) fn gallery_row_id(list: &str, row: usize) -> Id { + Id::new(format!("{list}-row-{row}")) +} + +pub(super) struct RevealRow { + target: Id, + bounds: Option, +} + +impl RevealRow { + pub(super) fn new(list: &str, row: usize) -> Self { + Self { + target: gallery_row_id(list, row), + bounds: None, + } + } +} + +impl Operation for RevealRow { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + if id == Some(&self.target) { + self.bounds = Some(bounds); + } + } + + fn scrollable( + &mut self, + id: Option<&Id>, + viewport: Rectangle, + _: Rectangle, + translation: Vector, + state: &mut dyn Scrollable, + ) { + if id != Some(&Id::new("settings-content")) { + return; + } + + if let Some(target) = self.bounds { + let visible = target.y - translation.y; + let delta = if visible < viewport.y { + visible - viewport.y + } else if visible + target.height > viewport.y + viewport.height { + visible + target.height - viewport.y - viewport.height + } else { + return; + }; + state.scroll_to(cosmic::iced::widget::scrollable::AbsoluteOffset { + x: None, + y: Some((translation.y + delta).max(0.)), + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::advanced::widget::operation::scrollable::{AbsoluteOffset, RelativeOffset}; + + #[derive(Default)] + struct Scroll(Option>>); + + impl Scrollable for Scroll { + fn snap_to(&mut self, _: RelativeOffset>) { + panic!("not a snap"); + } + fn scroll_to(&mut self, offset: AbsoluteOffset>) { + self.0 = Some(offset); + } + fn scroll_by(&mut self, _: AbsoluteOffset, _: Rectangle, _: Rectangle) { + panic!("not a relative scroll"); + } + } + + #[test] + fn wrapping_and_home_end_reveal_only_as_much_as_needed() { + let viewport = Rectangle { + x: 0., + y: 40., + width: 600., + height: 500., + }; + for (top, height, offset, expected) in [ + (1000., 200., 0., Some(660.)), + (40., 200., 500., Some(0.)), + (100., 200., 0., None), + ] { + let mut operation = RevealRow::new("theme", 10); + let target = operation.target.clone(); + Operation::<()>::container( + &mut operation, + Some(&target), + Rectangle { + y: top, + height, + ..viewport + }, + ); + let mut state = Scroll::default(); + Operation::<()>::scrollable( + &mut operation, + Some(&Id::new("settings-content")), + viewport, + viewport, + Vector::new(0., offset), + &mut state, + ); + assert_eq!(state.0.and_then(|offset| offset.y), expected); + } + } +} diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index be48bc1..1e95b5c 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -396,28 +396,44 @@ impl App { body = body.push(gestures); for index in 0..self.draft.records("bindings") { - let prefix = format!("bindings.{index}"); - let group = column([]) - .spacing(1) - .push(self.field(schema::text( - format!("{prefix}.keys"), - "Shortcut", - "For example: Super+Return or Swipe3Up", - "", - ))) - .push(self.field(schema::text( - format!("{prefix}.action"), - "Action", - "Action such as toggle-overview, spawn, focus, move, or none.", - "", - ))) - .push(self.field(schema::text( - format!("{prefix}.argument"), - "Argument", - "Command name, direction, or action argument.", - "", - ))); - body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.))); + let content: Element<'static, Message> = if self.visible_rows.contains(&("bindings", index)) { + let prefix = format!("bindings.{index}"); + let group = column([]) + .spacing(1) + .push(self.field(schema::text( + format!("{prefix}.keys"), + "Shortcut", + "Keys or swipe, such as Super+Return", + "", + ))) + .push(self.field(schema::text( + format!("{prefix}.action"), + "Action", + "Action name, such as spawn or focus", + "", + ))) + .push(self.field(schema::text( + format!("{prefix}.argument"), + "Argument", + "Command, direction, or action argument", + "", + ))); + container(group) + .padding(4) + .height(164) + .class(visuals::surface(palette.card, 14.)) + .into() + } else { + widget::Space::new().height(164).width(Length::Fill).into() + }; + + body = body.push( + cosmic::iced::widget::sensor(content) + .key(("bindings", index)) + .anticipate(200) + .on_show(move |_| Message::RowVisibility("bindings", index, true)) + .on_hide(Message::RowVisibility("bindings", index, false)), + ); } if self.draft.records("bindings") == 0 { body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")); @@ -445,7 +461,7 @@ impl App { content = content.push( scrollable( - container(body) + container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())])) .padding(cosmic::iced::Padding { top: 0., right: 12., @@ -507,7 +523,7 @@ impl App { } else { footer.into() }; - row([]) + let view: Element<'_, Message> = row([]) .push(sidebar) .push( container( @@ -527,6 +543,15 @@ impl App { .height(Length::Fill) .class(visuals::surface(palette.background, 0.)), ) - .into() + .into(); + if self.profile_pages { + let page = self.page; + cosmic::iced::widget::sensor(view) + .key((page as usize, self.search.is_empty())) + .on_show(move |_| Message::PagePresented(page)) + .into() + } else { + view + } } } diff --git a/shell/ferese-settings/src/theme_controls.rs b/shell/ferese-settings/src/theme_controls.rs index fd9cf69..ece97af 100644 --- a/shell/ferese-settings/src/theme_controls.rs +++ b/shell/ferese-settings/src/theme_controls.rs @@ -156,15 +156,9 @@ impl App { .into() } - pub(super) fn theme_gallery( - &self, - appearance: Option, - ) -> Element<'static, Message> { + pub(super) fn theme_gallery(&self, appearance: Option) -> Element<'_, Message> { use ferese_config::theme::Appearance::{Dark, Light}; - let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let selected_id = visuals::family_selection(&self.draft, appearance); - let choices = self.resolved.families.clone(); - let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect(); + let choices = &self.resolved.families; let mut heading = row([]).align_y(Alignment::Center).spacing(6).push( self.label( match appearance { @@ -192,38 +186,30 @@ impl App { )); } let mut gallery = column([]).spacing(6).push(heading); - for (chunk, families) in choices.chunks(3).enumerate() { - let mut tiles = row([]).spacing(6); - for (offset, family) in families.iter().enumerate() { - let selected = family.id == selected_id; - let index = chunk * 3 + offset; - let id = family.id.clone(); - let active = selected - .then_some(self.resolved.theme.appearance) - .filter(|a| appearance.is_none_or(|variant| variant == *a)); - let ids = ids.clone(); - let tile_id = gallery_id(&id, appearance); - tiles = tiles.push(gallery::tile( - family, - gallery::TileOptions { - variant: appearance, - active, - selected, - palette, - font: self.font, - id: tile_id, - }, - Message::Family(id, appearance), - move |key| { - gallery::neighbor(index, ids.len(), key) - .map(|index| Message::Family(ids[index].clone(), appearance)) - }, - )); - } - for _ in families.len()..3 { - tiles = tiles.push(widget::Space::new().width(Length::Fill)); - } - gallery = gallery.push(tiles); + let list = crate::navigation::gallery_list(appearance); + for chunk in 0..choices.len().div_ceil(3) { + let content = widget::responsive(move |size| { + // Three previews: 200:126 image, caption, and tile padding. + let height = ((size.width - 12.) / 3. - 16.).max(0.) * 126. / 200. + 34.; + let content: Element<'_, Message> = if self.visible_rows.contains(&(list, chunk)) { + // Rebuild only mounted rows; responsive owns their actual layout width. + self.theme_gallery_row(chunk, appearance) + } else { + widget::Space::new().height(height).width(Length::Fill).into() + }; + cosmic::iced::widget::sensor( + container(content) + .height(height) + .id(crate::navigation::gallery_row_id(list, chunk)), + ) + .key((list, chunk)) + .anticipate(200) + .on_show(move |_| Message::RowVisibility(list, chunk, true)) + .on_hide(Message::RowVisibility(list, chunk, false)) + .into() + }) + .height(Length::Shrink); + gallery = gallery.push(content); } gallery::group( container(gallery).width(Length::Fill), @@ -235,6 +221,49 @@ impl App { }, ) } + + fn theme_gallery_row( + &self, + chunk: usize, + appearance: Option, + ) -> Element<'static, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + let selected_id = visuals::family_selection(&self.draft, appearance); + let families = &self.resolved.families[chunk * 3..self.resolved.families.len().min(chunk * 3 + 3)]; + let ids = &self.family_ids; + let mut tiles = row([]).spacing(6); + for (offset, family) in families.iter().enumerate() { + let selected = family.id == selected_id; + let index = chunk * 3 + offset; + let id = family.id.clone(); + let active = selected + .then_some(self.resolved.theme.appearance) + .filter(|a| appearance.is_none_or(|variant| variant == *a)); + let tile_id = gallery_id(&id, appearance); + let ids = ids.clone(); + tiles = tiles.push(gallery::tile( + family, + gallery::TileOptions { + variant: appearance, + active, + selected, + palette, + font: self.font, + id: tile_id, + }, + Message::Family(id, appearance), + move |key| { + gallery::neighbor(index, ids.len(), key) + .map(|index| Message::Family(ids[index].clone(), appearance)) + }, + )); + } + for _ in families.len()..3 { + tiles = tiles.push(widget::Space::new().width(Length::Fill)); + } + + tiles.into() + } } pub(super) fn gallery_id(