diff --git a/configurator/src/app/daemon_setup/hyprland.rs b/configurator/src/app/daemon_setup/hyprland.rs index 9eeba407..6756eb3f 100644 --- a/configurator/src/app/daemon_setup/hyprland.rs +++ b/configurator/src/app/daemon_setup/hyprland.rs @@ -4,6 +4,7 @@ use std::path::{Component, Path, PathBuf}; use super::command::{command_available, run_command}; use super::service::resolve_wayscriber_binary_path; +use wayscriber::durable_io::AtomicWriteOptions; const HYPRLAND_DIR: &str = "hypr"; const MAIN_CONFIG: &str = "hyprland.conf"; @@ -145,7 +146,12 @@ fn write_light_controls( })?; let include_path = light_controls_include_path(config_root); - fs::write(&include_path, render_light_controls(binary_path)).map_err(|err| { + wayscriber::durable_io::write_text_atomic( + &include_path, + &render_light_controls(binary_path), + AtomicWriteOptions::user_config_file(), + ) + .map_err(|err| { format!( "Failed to write Hyprland light controls {}: {}", include_path.display(), @@ -164,7 +170,12 @@ fn write_light_controls( source_configured = true; source_updated = changed; if changed { - fs::write(&main_config_path, updated_content).map_err(|err| { + wayscriber::durable_io::write_text_atomic( + &main_config_path, + &updated_content, + AtomicWriteOptions::user_config_file(), + ) + .map_err(|err| { format!( "Failed to update Hyprland config {}: {}", main_config_path.display(), @@ -363,163 +374,4 @@ fn shell_quote(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use std::env; - use std::sync::Mutex; - use wayscriber::env_vars::HOME_ENV; - - static ENV_MUTEX: Mutex<()> = Mutex::new(()); - - #[test] - fn render_light_controls_quotes_binary_with_spaces() { - let rendered = render_light_controls(Path::new("/tmp/My Apps/wayscriber")); - assert!(rendered.contains("'/tmp/My Apps/wayscriber' --light-toggle")); - assert!(rendered.contains("'/tmp/My Apps/wayscriber' --light-draw-toggle")); - assert!(rendered.contains("'/tmp/My Apps/wayscriber' --light-draw-on")); - assert!(rendered.contains("'/tmp/My Apps/wayscriber' --light-draw-off")); - } - - #[test] - fn render_light_controls_escapes_single_quotes() { - let rendered = render_light_controls(Path::new("/tmp/O'Brien/wayscriber")); - assert!(rendered.contains("'/tmp/O'\\''Brien/wayscriber' --light-toggle")); - } - - #[test] - fn render_light_controls_unbinds_default_keys_before_binding() { - let rendered = render_light_controls(Path::new("/tmp/wayscriber")); - - let unbind_l = rendered.find("unbind = SUPER ALT, L").unwrap(); - let bind_l = rendered.find("\nbind = SUPER ALT, L").unwrap(); - assert!(unbind_l < bind_l); - - let unbind_d = rendered.find("unbind = SUPER ALT, D").unwrap(); - let bind_d = rendered.find("\nbind = SUPER ALT, D").unwrap(); - assert!(unbind_d < bind_d); - - let unbind_f = rendered.find("unbind = SUPER ALT, F").unwrap(); - let bind_f = rendered.find("\nbind = SUPER ALT, F").unwrap(); - let bindr_f = rendered.find("bindr = SUPER ALT, F").unwrap(); - assert!(unbind_f < bind_f); - assert!(unbind_f < bindr_f); - } - - #[test] - fn ensure_source_line_appends_once() { - let source_line = "source = /tmp/hypr/wayscriber-light.conf"; - let (updated, changed) = - ensure_source_line("source = ~/.config/hypr/base.conf\n", source_line); - assert!(changed); - assert!(updated.contains(LIGHT_CONTROLS_COMMENT)); - assert!(updated.contains(source_line)); - - let (again, changed_again) = ensure_source_line(&updated, source_line); - assert!(!changed_again); - assert_eq!(again.matches(source_line).count(), 1); - } - - #[test] - fn has_source_line_ignores_comments_and_spacing() { - let source_line = "source = /tmp/hypr/wayscriber-light.conf"; - assert!(!has_source_line( - "# source = /tmp/hypr/wayscriber-light.conf\n", - source_line - )); - assert!(has_source_line( - " source = /tmp/hypr/wayscriber-light.conf \n", - source_line - )); - } - - #[test] - fn has_source_line_matches_quoted_and_inline_commented_targets() { - let source_line = "source = /tmp/hypr/wayscriber-light.conf"; - assert!(has_source_line( - "source = '/tmp/hypr/wayscriber-light.conf' # installed by wayscriber\n", - source_line - )); - assert!(has_source_line( - "source = \"/tmp/hypr/wayscriber-light.conf\" # installed by wayscriber\n", - source_line - )); - } - - #[test] - fn has_source_line_matches_tilde_target() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let home = tmp.path(); - let prev_home = env::var_os(HOME_ENV); - unsafe { - env::set_var(HOME_ENV, home); - } - - let absolute = home - .join(".config") - .join("hypr") - .join(LIGHT_CONTROLS_INCLUDE); - let source_line = format!("source = {}", absolute.display()); - assert!(has_source_line( - "source = ~/.config/hypr/wayscriber-light.conf # already sourced\n", - &source_line - )); - - match prev_home { - Some(value) => unsafe { env::set_var(HOME_ENV, value) }, - None => unsafe { env::remove_var(HOME_ENV) }, - } - } - - #[test] - fn write_light_controls_writes_include_and_sources_existing_main() { - let tmp = crate::test_temp::tempdir().unwrap(); - let hypr_dir = tmp.path().join(HYPRLAND_DIR); - fs::create_dir_all(&hypr_dir).unwrap(); - let main = hypr_dir.join(MAIN_CONFIG); - fs::write(&main, "source = ~/.config/hypr/base.conf\n").unwrap(); - - let result = - write_light_controls(tmp.path(), Path::new("/tmp/My Apps/wayscriber")).unwrap(); - - assert!(result.source_configured); - assert!(result.source_updated); - assert!(result.include_path.exists()); - let include = fs::read_to_string(&result.include_path).unwrap(); - assert!(include.contains("'/tmp/My Apps/wayscriber' --light-toggle")); - assert!(include.contains("unbind = SUPER ALT, L")); - let main_content = fs::read_to_string(&main).unwrap(); - assert!(main_content.contains(&result.source_line)); - } - - #[test] - fn write_light_controls_is_idempotent_for_existing_source() { - let tmp = crate::test_temp::tempdir().unwrap(); - let hypr_dir = tmp.path().join(HYPRLAND_DIR); - fs::create_dir_all(&hypr_dir).unwrap(); - let main = hypr_dir.join(MAIN_CONFIG); - let include = light_controls_include_path(tmp.path()); - let source_line = source_line_for_include(&include); - fs::write(&main, format!("{source_line}\n")).unwrap(); - - let result = write_light_controls(tmp.path(), Path::new("/tmp/wayscriber")).unwrap(); - - assert!(result.source_configured); - assert!(!result.source_updated); - let main_content = fs::read_to_string(&main).unwrap(); - assert_eq!(main_content.matches(&source_line).count(), 1); - } - - #[test] - fn write_light_controls_handles_missing_main_config() { - let tmp = crate::test_temp::tempdir().unwrap(); - - let result = write_light_controls(tmp.path(), Path::new("/tmp/wayscriber")).unwrap(); - - assert!(result.include_path.exists()); - assert!(!result.source_configured); - assert!(!result.source_updated); - } -} +mod tests; diff --git a/configurator/src/app/daemon_setup/hyprland/tests.rs b/configurator/src/app/daemon_setup/hyprland/tests.rs new file mode 100644 index 00000000..df94a604 --- /dev/null +++ b/configurator/src/app/daemon_setup/hyprland/tests.rs @@ -0,0 +1,156 @@ +use super::*; +use std::env; +use std::sync::Mutex; +use wayscriber::env_vars::HOME_ENV; + +static ENV_MUTEX: Mutex<()> = Mutex::new(()); + +#[test] +fn render_light_controls_quotes_binary_with_spaces() { + let rendered = render_light_controls(Path::new("/tmp/My Apps/wayscriber")); + assert!(rendered.contains("'/tmp/My Apps/wayscriber' --light-toggle")); + assert!(rendered.contains("'/tmp/My Apps/wayscriber' --light-draw-toggle")); + assert!(rendered.contains("'/tmp/My Apps/wayscriber' --light-draw-on")); + assert!(rendered.contains("'/tmp/My Apps/wayscriber' --light-draw-off")); +} + +#[test] +fn render_light_controls_escapes_single_quotes() { + let rendered = render_light_controls(Path::new("/tmp/O'Brien/wayscriber")); + assert!(rendered.contains("'/tmp/O'\\''Brien/wayscriber' --light-toggle")); +} + +#[test] +fn render_light_controls_unbinds_default_keys_before_binding() { + let rendered = render_light_controls(Path::new("/tmp/wayscriber")); + + let unbind_l = rendered.find("unbind = SUPER ALT, L").unwrap(); + let bind_l = rendered.find("\nbind = SUPER ALT, L").unwrap(); + assert!(unbind_l < bind_l); + + let unbind_d = rendered.find("unbind = SUPER ALT, D").unwrap(); + let bind_d = rendered.find("\nbind = SUPER ALT, D").unwrap(); + assert!(unbind_d < bind_d); + + let unbind_f = rendered.find("unbind = SUPER ALT, F").unwrap(); + let bind_f = rendered.find("\nbind = SUPER ALT, F").unwrap(); + let bindr_f = rendered.find("bindr = SUPER ALT, F").unwrap(); + assert!(unbind_f < bind_f); + assert!(unbind_f < bindr_f); +} + +#[test] +fn ensure_source_line_appends_once() { + let source_line = "source = /tmp/hypr/wayscriber-light.conf"; + let (updated, changed) = ensure_source_line("source = ~/.config/hypr/base.conf\n", source_line); + assert!(changed); + assert!(updated.contains(LIGHT_CONTROLS_COMMENT)); + assert!(updated.contains(source_line)); + + let (again, changed_again) = ensure_source_line(&updated, source_line); + assert!(!changed_again); + assert_eq!(again.matches(source_line).count(), 1); +} + +#[test] +fn has_source_line_ignores_comments_and_spacing() { + let source_line = "source = /tmp/hypr/wayscriber-light.conf"; + assert!(!has_source_line( + "# source = /tmp/hypr/wayscriber-light.conf\n", + source_line + )); + assert!(has_source_line( + " source = /tmp/hypr/wayscriber-light.conf \n", + source_line + )); +} + +#[test] +fn has_source_line_matches_quoted_and_inline_commented_targets() { + let source_line = "source = /tmp/hypr/wayscriber-light.conf"; + assert!(has_source_line( + "source = '/tmp/hypr/wayscriber-light.conf' # installed by wayscriber\n", + source_line + )); + assert!(has_source_line( + "source = \"/tmp/hypr/wayscriber-light.conf\" # installed by wayscriber\n", + source_line + )); +} + +#[test] +fn has_source_line_matches_tilde_target() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let home = tmp.path(); + let prev_home = env::var_os(HOME_ENV); + unsafe { + env::set_var(HOME_ENV, home); + } + + let absolute = home + .join(".config") + .join("hypr") + .join(LIGHT_CONTROLS_INCLUDE); + let source_line = format!("source = {}", absolute.display()); + assert!(has_source_line( + "source = ~/.config/hypr/wayscriber-light.conf # already sourced\n", + &source_line + )); + + match prev_home { + Some(value) => unsafe { env::set_var(HOME_ENV, value) }, + None => unsafe { env::remove_var(HOME_ENV) }, + } +} + +#[test] +fn write_light_controls_writes_include_and_sources_existing_main() { + let tmp = crate::test_temp::tempdir().unwrap(); + let hypr_dir = tmp.path().join(HYPRLAND_DIR); + fs::create_dir_all(&hypr_dir).unwrap(); + let main = hypr_dir.join(MAIN_CONFIG); + fs::write(&main, "source = ~/.config/hypr/base.conf\n").unwrap(); + + let result = write_light_controls(tmp.path(), Path::new("/tmp/My Apps/wayscriber")).unwrap(); + + assert!(result.source_configured); + assert!(result.source_updated); + assert!(result.include_path.exists()); + let include = fs::read_to_string(&result.include_path).unwrap(); + assert!(include.contains("'/tmp/My Apps/wayscriber' --light-toggle")); + assert!(include.contains("unbind = SUPER ALT, L")); + let main_content = fs::read_to_string(&main).unwrap(); + assert!(main_content.contains(&result.source_line)); +} + +#[test] +fn write_light_controls_is_idempotent_for_existing_source() { + let tmp = crate::test_temp::tempdir().unwrap(); + let hypr_dir = tmp.path().join(HYPRLAND_DIR); + fs::create_dir_all(&hypr_dir).unwrap(); + let main = hypr_dir.join(MAIN_CONFIG); + let include = light_controls_include_path(tmp.path()); + let source_line = source_line_for_include(&include); + fs::write(&main, format!("{source_line}\n")).unwrap(); + + let result = write_light_controls(tmp.path(), Path::new("/tmp/wayscriber")).unwrap(); + + assert!(result.source_configured); + assert!(!result.source_updated); + let main_content = fs::read_to_string(&main).unwrap(); + assert_eq!(main_content.matches(&source_line).count(), 1); +} + +#[test] +fn write_light_controls_handles_missing_main_config() { + let tmp = crate::test_temp::tempdir().unwrap(); + + let result = write_light_controls(tmp.path(), Path::new("/tmp/wayscriber")).unwrap(); + + assert!(result.include_path.exists()); + assert!(!result.source_configured); + assert!(!result.source_updated); +} diff --git a/configurator/src/app/daemon_setup/service.rs b/configurator/src/app/daemon_setup/service.rs index 68789d23..4ba0dab8 100644 --- a/configurator/src/app/daemon_setup/service.rs +++ b/configurator/src/app/daemon_setup/service.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::models::DesktopEnvironment; +use wayscriber::durable_io::AtomicWriteOptions; use wayscriber::env_vars::{BIN_ENV, PATH_ENV}; use wayscriber::runtime_capabilities::{ RUNTIME_CAPABILITIES_FLAG, RuntimeCapabilities, parse_runtime_capabilities, @@ -112,7 +113,12 @@ pub(super) fn install_or_update_user_service() -> Result { })?; let contents = render_user_service_file(&binary_path); - fs::write(&service_path, contents).map_err(|err| { + wayscriber::durable_io::write_text_atomic( + &service_path, + &contents, + AtomicWriteOptions::user_config_file(), + ) + .map_err(|err| { format!( "Failed to write user service file {}: {}", service_path.display(), diff --git a/configurator/src/app/daemon_setup/shortcut.rs b/configurator/src/app/daemon_setup/shortcut.rs index 6520ba2d..6a8ffeea 100644 --- a/configurator/src/app/daemon_setup/shortcut.rs +++ b/configurator/src/app/daemon_setup/shortcut.rs @@ -1,6 +1,7 @@ use std::fs; use crate::models::{DesktopEnvironment, ShortcutApplyCapability, ShortcutBackend}; +use wayscriber::durable_io::AtomicWriteOptions; use wayscriber::env_vars::PATH_ENV; use wayscriber::shortcut_hint::{ GNOME_MEDIA_KEYS_KEY, GNOME_MEDIA_KEYS_SCHEMA, GNOME_WAYSCRIBER_KEYBINDING_PATH, @@ -191,7 +192,12 @@ fn write_portal_shortcut_dropin(shortcut: &str) -> Result bool { let is_clipboard_only = matches!(destination, CaptureDestination::ClipboardOnly); @@ -227,220 +230,6 @@ impl WaylandState { } } - pub(in crate::backend::wayland) fn handle_board_pdf_export_action(&mut self, action: Action) { - if self.capture.is_in_progress() { - log::warn!( - "Board PDF export action {:?} requested while another image operation is running; ignoring", - action - ); - return; - } - - if !matches!( - action, - Action::ExportBoardPdfFile | Action::ExportAllBoardsPdfFile - ) { - log::error!( - "Non-board-PDF-export action passed to handle_board_pdf_export_action: {:?}", - action - ); - return; - } - - let operation = if matches!(action, Action::ExportAllBoardsPdfFile) { - ImageOperationKind::AllBoardsPdfExport - } else { - ImageOperationKind::BoardPdfExport - }; - - let destination = CaptureDestination::FileOnly; - let exit_on_success = self.should_exit_after_capture(destination); - let save_config = self.board_pdf_save_config(action); - - if self.should_capture_desktop_for_pdf_export(action) { - let request = self.desktop_backdrop_capture_request(operation); - if !self.enter_overlay_suppression(OverlaySuppression::DesktopBackdrop) { - log::warn!( - "Board PDF export action {:?} requested while overlay is suppressed; ignoring", - action - ); - self.input_state.set_ui_toast( - crate::input::state::UiToastKind::Warning, - "Board PDF export is already preparing another overlay operation.", - ); - return; - } - self.capture.set_exit_on_success(exit_on_success); - self.capture.mark_in_progress(); - self.capture.set_pending_pdf_export(PendingPdfExport { - action, - operation, - save_config, - }); - log::info!( - "Queued {:?} desktop backdrop capture for PDF export; waiting for suppression frame", - operation - ); - self.capture - .queue_preflight(CapturePreflightRequest::DesktopBackdrop(request)); - return; - } - - let snapshot = match self.board_pdf_export_snapshot(action) { - Ok(snapshot) => snapshot, - Err(err) => { - let message = operation.format_error(&err); - log::error!("Board PDF export failed: {}", message); - self.input_state - .set_ui_toast(crate::input::state::UiToastKind::Error, message); - return; - } - }; - - self.queue_board_pdf_document_delivery(snapshot, save_config, operation, exit_on_success); - } - - pub(in crate::backend::wayland) fn finish_pending_board_pdf_export_with_backdrop( - &mut self, - backdrop: DesktopBackdropCaptureResult, - exit_on_success: bool, - ) { - let Some(pending) = self.capture.take_pending_pdf_export() else { - let message = - "Board PDF export failed: desktop backdrop completed without pending PDF export" - .to_string(); - log::error!("{message}"); - self.input_state - .set_ui_toast(crate::input::state::UiToastKind::Error, message); - return; - }; - - let snapshot = match self.board_pdf_export_snapshot_with_desktop_backdrop( - pending.action, - CanvasExportBackdropSnapshot::PersistedImage { - data: backdrop.data, - width: backdrop.width, - height: backdrop.height, - stride: backdrop.stride, - logical_to_image_scale_x: backdrop.logical_to_image_scale_x, - logical_to_image_scale_y: backdrop.logical_to_image_scale_y, - }, - ) { - Ok(snapshot) => snapshot, - Err(err) => { - let message = pending.operation.format_error(&err); - log::error!("Board PDF export failed after desktop capture: {}", message); - self.input_state - .set_ui_toast(crate::input::state::UiToastKind::Error, message); - return; - } - }; - - self.queue_board_pdf_document_delivery( - snapshot, - pending.save_config, - pending.operation, - exit_on_success, - ); - } - - fn queue_board_pdf_document_delivery( - &mut self, - snapshot: BoardPdfExportSnapshot, - save_config: FileSaveConfig, - operation: ImageOperationKind, - exit_on_success: bool, - ) { - let bytes = match render_board_pdf(&snapshot) { - Ok(bytes) => bytes, - Err(err) => { - let message = operation.format_error(&err); - log::error!("Board PDF export failed: {}", message); - self.input_state - .set_ui_toast(crate::input::state::UiToastKind::Error, message); - return; - } - }; - - self.capture.set_exit_on_success(exit_on_success); - self.capture.mark_in_progress(); - - let request = DocumentDeliveryRequest { - document: RenderedDocument { - bytes, - extension: "pdf".to_string(), - mime_type: "application/pdf".to_string(), - }, - destination: CaptureDestination::FileOnly, - save_config: Some(save_config), - operation, - }; - - if let Err(err) = self - .capture - .manager_mut() - .request_document_delivery(request) - { - log::error!("Failed to request board PDF export delivery: {}", err); - self.capture.clear_in_progress(); - self.capture.clear_exit_on_success(); - self.input_state.set_ui_toast( - crate::input::state::UiToastKind::Error, - format!("Board PDF export failed: {err}"), - ); - } - } - - fn board_pdf_save_config(&self, action: Action) -> FileSaveConfig { - FileSaveConfig { - save_directory: expand_tilde(&self.config.capture.save_directory), - filename_template: if matches!(action, Action::ExportAllBoardsPdfFile) { - self.config - .export - .pdf - .resolved_all_boards_filename_template(&self.config.capture) - } else { - self.config - .export - .pdf - .resolved_filename_template(&self.config.capture) - }, - format: "pdf".to_string(), - } - } - - fn should_capture_desktop_for_pdf_export(&self, action: Action) -> bool { - self.config.export.pdf.transparent_background - == crate::config::PdfTransparentBackground::Desktop - && self.board_pdf_export_scope_has_transparent_pages(action) - } - - fn desktop_backdrop_capture_request( - &self, - operation: ImageOperationKind, - ) -> DesktopBackdropCaptureRequest { - DesktopBackdropCaptureRequest { - logical_width: self.surface.width(), - logical_height: self.surface.height(), - scale: self.surface.scale(), - geometry: self.desktop_backdrop_geometry(), - operation, - } - } - - fn desktop_backdrop_geometry(&self) -> Option { - let output = self.surface.current_output()?; - let active_info = self.output_state.info(&output)?; - let active = desktop_backdrop_output_geometry_from_info(&active_info)?; - let mut outputs = Vec::new(); - for output in self.output_state.outputs() { - let info = self.output_state.info(&output)?; - outputs.push(desktop_backdrop_output_geometry_from_info(&info)?); - } - - DesktopBackdropGeometry::from_outputs(active, &outputs, active_info.scale_factor.max(1)) - } - fn canvas_export_snapshot(&self) -> CanvasExportSnapshot { let (origin_x, origin_y) = self.board_view_offset(); CanvasExportSnapshot { @@ -504,101 +293,3 @@ impl WaylandState { } } } - -fn desktop_backdrop_output_geometry_from_info( - info: &smithay_client_toolkit::output::OutputInfo, -) -> Option { - let (logical_x, logical_y) = info.logical_position?; - let (logical_width, logical_height) = info.logical_size?; - if logical_width <= 0 || logical_height <= 0 { - return None; - } - let (physical_width, physical_height) = current_or_preferred_mode_size(info) - .map(|(width, height)| transformed_output_size(width, height, info.transform)) - .or_else(|| { - let scale = u32::try_from(info.scale_factor.max(1)).ok()?; - Some(( - u32::try_from(logical_width).ok()?.checked_mul(scale)?, - u32::try_from(logical_height).ok()?.checked_mul(scale)?, - )) - })?; - if physical_width == 0 || physical_height == 0 { - return None; - } - - Some(DesktopBackdropOutputGeometry { - logical_x, - logical_y, - logical_width: logical_width as u32, - logical_height: logical_height as u32, - physical_width, - physical_height, - }) -} - -fn current_or_preferred_mode_size( - info: &smithay_client_toolkit::output::OutputInfo, -) -> Option<(u32, u32)> { - info.modes - .iter() - .find(|mode| mode.current) - .or_else(|| info.modes.iter().find(|mode| mode.preferred)) - .and_then(|mode| { - Some(( - u32::try_from(mode.dimensions.0).ok()?, - u32::try_from(mode.dimensions.1).ok()?, - )) - }) - .filter(|(width, height)| *width > 0 && *height > 0) -} - -fn transformed_output_size(width: u32, height: u32, transform: wl_output::Transform) -> (u32, u32) { - if matches!( - transform, - wl_output::Transform::_90 - | wl_output::Transform::_270 - | wl_output::Transform::Flipped90 - | wl_output::Transform::Flipped270 - ) { - (height, width) - } else { - (width, height) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn transformed_output_size_keeps_unrotated_transforms() { - assert_eq!( - transformed_output_size(3840, 2160, wl_output::Transform::Normal), - (3840, 2160) - ); - assert_eq!( - transformed_output_size(3840, 2160, wl_output::Transform::_180), - (3840, 2160) - ); - assert_eq!( - transformed_output_size(3840, 2160, wl_output::Transform::Flipped), - (3840, 2160) - ); - assert_eq!( - transformed_output_size(3840, 2160, wl_output::Transform::Flipped180), - (3840, 2160) - ); - } - - #[test] - fn transformed_output_size_swaps_rotated_transforms() { - for transform in [ - wl_output::Transform::_90, - wl_output::Transform::_270, - wl_output::Transform::Flipped90, - wl_output::Transform::Flipped270, - ] { - assert_eq!(transformed_output_size(3840, 2160, transform), (2160, 3840)); - } - } -} diff --git a/src/backend/wayland/state/capture/backdrop.rs b/src/backend/wayland/state/capture/backdrop.rs new file mode 100644 index 00000000..480c4095 --- /dev/null +++ b/src/backend/wayland/state/capture/backdrop.rs @@ -0,0 +1,99 @@ +use super::super::*; + +pub(super) fn desktop_backdrop_output_geometry_from_info( + info: &smithay_client_toolkit::output::OutputInfo, +) -> Option { + let (logical_x, logical_y) = info.logical_position?; + let (logical_width, logical_height) = info.logical_size?; + if logical_width <= 0 || logical_height <= 0 { + return None; + } + let (physical_width, physical_height) = current_or_preferred_mode_size(info) + .map(|(width, height)| transformed_output_size(width, height, info.transform)) + .or_else(|| { + let scale = u32::try_from(info.scale_factor.max(1)).ok()?; + Some(( + u32::try_from(logical_width).ok()?.checked_mul(scale)?, + u32::try_from(logical_height).ok()?.checked_mul(scale)?, + )) + })?; + if physical_width == 0 || physical_height == 0 { + return None; + } + + Some(DesktopBackdropOutputGeometry { + logical_x, + logical_y, + logical_width: logical_width as u32, + logical_height: logical_height as u32, + physical_width, + physical_height, + }) +} + +fn current_or_preferred_mode_size( + info: &smithay_client_toolkit::output::OutputInfo, +) -> Option<(u32, u32)> { + info.modes + .iter() + .find(|mode| mode.current) + .or_else(|| info.modes.iter().find(|mode| mode.preferred)) + .and_then(|mode| { + Some(( + u32::try_from(mode.dimensions.0).ok()?, + u32::try_from(mode.dimensions.1).ok()?, + )) + }) + .filter(|(width, height)| *width > 0 && *height > 0) +} + +fn transformed_output_size(width: u32, height: u32, transform: wl_output::Transform) -> (u32, u32) { + if matches!( + transform, + wl_output::Transform::_90 + | wl_output::Transform::_270 + | wl_output::Transform::Flipped90 + | wl_output::Transform::Flipped270 + ) { + (height, width) + } else { + (width, height) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transformed_output_size_keeps_unrotated_transforms() { + assert_eq!( + transformed_output_size(3840, 2160, wl_output::Transform::Normal), + (3840, 2160) + ); + assert_eq!( + transformed_output_size(3840, 2160, wl_output::Transform::_180), + (3840, 2160) + ); + assert_eq!( + transformed_output_size(3840, 2160, wl_output::Transform::Flipped), + (3840, 2160) + ); + assert_eq!( + transformed_output_size(3840, 2160, wl_output::Transform::Flipped180), + (3840, 2160) + ); + } + + #[test] + fn transformed_output_size_swaps_rotated_transforms() { + for transform in [ + wl_output::Transform::_90, + wl_output::Transform::_270, + wl_output::Transform::Flipped90, + wl_output::Transform::Flipped270, + ] { + assert_eq!(transformed_output_size(3840, 2160, transform), (2160, 3840)); + } + } +} diff --git a/src/backend/wayland/state/capture/pdf.rs b/src/backend/wayland/state/capture/pdf.rs new file mode 100644 index 00000000..2e5092b9 --- /dev/null +++ b/src/backend/wayland/state/capture/pdf.rs @@ -0,0 +1,218 @@ +use super::super::*; +use super::backdrop::desktop_backdrop_output_geometry_from_info; + +impl WaylandState { + pub(in crate::backend::wayland) fn handle_board_pdf_export_action(&mut self, action: Action) { + if self.capture.is_in_progress() { + log::warn!( + "Board PDF export action {:?} requested while another image operation is running; ignoring", + action + ); + return; + } + + if !matches!( + action, + Action::ExportBoardPdfFile | Action::ExportAllBoardsPdfFile + ) { + log::error!( + "Non-board-PDF-export action passed to handle_board_pdf_export_action: {:?}", + action + ); + return; + } + + let operation = if matches!(action, Action::ExportAllBoardsPdfFile) { + ImageOperationKind::AllBoardsPdfExport + } else { + ImageOperationKind::BoardPdfExport + }; + + let destination = CaptureDestination::FileOnly; + let exit_on_success = self.should_exit_after_capture(destination); + let save_config = self.board_pdf_save_config(action); + + if self.should_capture_desktop_for_pdf_export(action) { + let request = self.desktop_backdrop_capture_request(operation); + if !self.enter_overlay_suppression(OverlaySuppression::DesktopBackdrop) { + log::warn!( + "Board PDF export action {:?} requested while overlay is suppressed; ignoring", + action + ); + self.input_state.set_ui_toast( + crate::input::state::UiToastKind::Warning, + "Board PDF export is already preparing another overlay operation.", + ); + return; + } + self.capture.set_exit_on_success(exit_on_success); + self.capture.mark_in_progress(); + self.capture.set_pending_pdf_export(PendingPdfExport { + action, + operation, + save_config, + }); + log::info!( + "Queued {:?} desktop backdrop capture for PDF export; waiting for suppression frame", + operation + ); + self.capture + .queue_preflight(CapturePreflightRequest::DesktopBackdrop(request)); + return; + } + + let snapshot = match self.board_pdf_export_snapshot(action) { + Ok(snapshot) => snapshot, + Err(err) => { + let message = operation.format_error(&err); + log::error!("Board PDF export failed: {}", message); + self.input_state + .set_ui_toast(crate::input::state::UiToastKind::Error, message); + return; + } + }; + + self.queue_board_pdf_document_delivery(snapshot, save_config, operation, exit_on_success); + } + + pub(in crate::backend::wayland) fn finish_pending_board_pdf_export_with_backdrop( + &mut self, + backdrop: DesktopBackdropCaptureResult, + exit_on_success: bool, + ) { + let Some(pending) = self.capture.take_pending_pdf_export() else { + let message = + "Board PDF export failed: desktop backdrop completed without pending PDF export" + .to_string(); + log::error!("{message}"); + self.input_state + .set_ui_toast(crate::input::state::UiToastKind::Error, message); + return; + }; + + let snapshot = match self.board_pdf_export_snapshot_with_desktop_backdrop( + pending.action, + CanvasExportBackdropSnapshot::PersistedImage { + data: backdrop.data, + width: backdrop.width, + height: backdrop.height, + stride: backdrop.stride, + logical_to_image_scale_x: backdrop.logical_to_image_scale_x, + logical_to_image_scale_y: backdrop.logical_to_image_scale_y, + }, + ) { + Ok(snapshot) => snapshot, + Err(err) => { + let message = pending.operation.format_error(&err); + log::error!("Board PDF export failed after desktop capture: {}", message); + self.input_state + .set_ui_toast(crate::input::state::UiToastKind::Error, message); + return; + } + }; + + self.queue_board_pdf_document_delivery( + snapshot, + pending.save_config, + pending.operation, + exit_on_success, + ); + } + + fn queue_board_pdf_document_delivery( + &mut self, + snapshot: BoardPdfExportSnapshot, + save_config: FileSaveConfig, + operation: ImageOperationKind, + exit_on_success: bool, + ) { + let bytes = match render_board_pdf(&snapshot) { + Ok(bytes) => bytes, + Err(err) => { + let message = operation.format_error(&err); + log::error!("Board PDF export failed: {}", message); + self.input_state + .set_ui_toast(crate::input::state::UiToastKind::Error, message); + return; + } + }; + + self.capture.set_exit_on_success(exit_on_success); + self.capture.mark_in_progress(); + + let request = DocumentDeliveryRequest { + document: RenderedDocument { + bytes, + extension: "pdf".to_string(), + mime_type: "application/pdf".to_string(), + }, + destination: CaptureDestination::FileOnly, + save_config: Some(save_config), + operation, + }; + + if let Err(err) = self + .capture + .manager_mut() + .request_document_delivery(request) + { + log::error!("Failed to request board PDF export delivery: {}", err); + self.capture.clear_in_progress(); + self.capture.clear_exit_on_success(); + self.input_state.set_ui_toast( + crate::input::state::UiToastKind::Error, + format!("Board PDF export failed: {err}"), + ); + } + } + + fn board_pdf_save_config(&self, action: Action) -> FileSaveConfig { + FileSaveConfig { + save_directory: expand_tilde(&self.config.capture.save_directory), + filename_template: if matches!(action, Action::ExportAllBoardsPdfFile) { + self.config + .export + .pdf + .resolved_all_boards_filename_template(&self.config.capture) + } else { + self.config + .export + .pdf + .resolved_filename_template(&self.config.capture) + }, + format: "pdf".to_string(), + } + } + + fn should_capture_desktop_for_pdf_export(&self, action: Action) -> bool { + self.config.export.pdf.transparent_background + == crate::config::PdfTransparentBackground::Desktop + && self.board_pdf_export_scope_has_transparent_pages(action) + } + + fn desktop_backdrop_capture_request( + &self, + operation: ImageOperationKind, + ) -> DesktopBackdropCaptureRequest { + DesktopBackdropCaptureRequest { + logical_width: self.surface.width(), + logical_height: self.surface.height(), + scale: self.surface.scale(), + geometry: self.desktop_backdrop_geometry(), + operation, + } + } + + fn desktop_backdrop_geometry(&self) -> Option { + let output = self.surface.current_output()?; + let active_info = self.output_state.info(&output)?; + let active = desktop_backdrop_output_geometry_from_info(&active_info)?; + let mut outputs = Vec::new(); + for output in self.output_state.outputs() { + let info = self.output_state.info(&output)?; + outputs.push(desktop_backdrop_output_geometry_from_info(&info)?); + } + + DesktopBackdropGeometry::from_outputs(active, &outputs, active_info.scale_factor.max(1)) + } +} diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 1275a821..32e5d19a 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -1,10 +1,11 @@ -use crate::config::{RadialMenuMouseBinding, keybindings::Action}; -use crate::input::{Key, state::UiToastKind}; -use crate::onboarding::{DEFERRED_HINT_REPEAT_MAX, FirstRunStep, OnboardingState}; -use crate::ui::{OnboardingCard, OnboardingChecklistItem}; +use crate::config::keybindings::Action; +use crate::input::state::UiToastKind; +use crate::onboarding::DEFERRED_HINT_REPEAT_MAX; use super::*; +mod first_run; + impl WaylandState { pub(in crate::backend::wayland) fn apply_onboarding_hints(&mut self) { // Show capability warning toast first if applicable. @@ -14,301 +15,6 @@ impl WaylandState { self.apply_toolbar_visibility_hint(); } - pub(in crate::backend::wayland) fn try_handle_first_run_background_mode_choice( - &mut self, - key: Key, - ) -> bool { - if !background_mode_prompt_active( - self.onboarding.state(), - self.first_run_onboarding_card_visible(), - ) { - return false; - } - - let Some(enable_background_mode) = background_mode_prompt_choice(key) else { - return false; - }; - - if enable_background_mode { - match crate::daemon::setup::setup_background_mode() { - Ok(summary) => { - mark_background_mode_prompt(self.onboarding.state_mut(), true); - self.onboarding.save(); - self.input_state.set_ui_toast( - UiToastKind::Info, - format!( - "Background mode enabled. Service file: {}", - summary.service_path.display() - ), - ); - } - Err(err) => { - mark_background_mode_prompt(self.onboarding.state_mut(), false); - self.onboarding.save(); - self.input_state.set_ui_toast( - UiToastKind::Error, - format!( - "Background mode setup failed: {err}. You can set this up later in Background Mode settings." - ), - ); - } - } - } else { - mark_background_mode_prompt(self.onboarding.state_mut(), false); - self.onboarding.save(); - self.input_state - .set_ui_toast(UiToastKind::Info, "Skipped background mode setup for now."); - } - - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; - true - } - - pub(in crate::backend::wayland) fn try_skip_first_run_onboarding(&mut self) -> bool { - if !first_run_skip_allowed( - self.onboarding.state().first_run_active(), - self.first_run_onboarding_card_visible(), - ) { - return false; - } - let state = self.onboarding.state_mut(); - state.first_run_skipped = true; - state.first_run_completed = true; - state.active_step = None; - state.quick_access_requires_toolbar = false; - self.onboarding.save(); - self.input_state - .set_ui_toast(UiToastKind::Info, "Onboarding skipped."); - true - } - - pub(in crate::backend::wayland) fn first_run_onboarding_card(&self) -> Option { - if !self.first_run_onboarding_card_visible() { - return None; - } - - let state = self.onboarding.state(); - if !state.first_run_active() { - return None; - } - let step = state.active_step?; - let eyebrow = first_run_step_eyebrow(step); - let footer = "Shift+Escape to skip".to_string(); - - let card = match step { - FirstRunStep::BackgroundModeSetup => OnboardingCard { - eyebrow: eyebrow.to_string(), - title: "Enable background mode?".to_string(), - body: "Keeps Wayscriber ready in the background so you can toggle overlay quickly." - .to_string(), - items: Vec::new(), - footer: "Y = set up now • N = skip • Shift+Escape = skip onboarding" - .to_string(), - }, - FirstRunStep::WaitDraw => OnboardingCard { - eyebrow: eyebrow.to_string(), - title: "Draw one mark".to_string(), - body: "Draw one quick stroke anywhere on the canvas.".to_string(), - items: vec![OnboardingChecklistItem { - label: "Draw a stroke".to_string(), - done: state.first_stroke_done, - }], - footer, - }, - FirstRunStep::DrawUndo => OnboardingCard { - eyebrow: eyebrow.to_string(), - title: "Try Undo".to_string(), - body: "You can always revert mistakes. Draw, then undo once.".to_string(), - items: vec![ - OnboardingChecklistItem { - label: "Draw a stroke".to_string(), - done: state.first_stroke_done, - }, - OnboardingChecklistItem { - label: format!("Undo once ({})", self.shortcut_label(Action::Undo, "Undo")), - done: state.first_undo_done, - }, - ], - footer, - }, - FirstRunStep::QuickAccess => { - let items = self.quick_access_checklist_items(state); - OnboardingCard { - eyebrow: eyebrow.to_string(), - title: "Quick access at cursor".to_string(), - body: "Open quick actions near the pointer.".to_string(), - items, - footer, - } - } - FirstRunStep::Reference => OnboardingCard { - eyebrow: eyebrow.to_string(), - title: "Find anything fast".to_string(), - body: "Use help for full shortcuts and command palette for searchable actions." - .to_string(), - items: vec![ - OnboardingChecklistItem { - label: format!( - "Open Help ({})", - self.shortcut_label(Action::ToggleHelp, "Help") - ), - done: state.used_help_overlay, - }, - OnboardingChecklistItem { - label: format!( - "Open Command Palette ({})", - self.shortcut_label(Action::ToggleCommandPalette, "Command Palette") - ), - done: state.used_command_palette, - }, - ], - footer, - }, - }; - - Some(card) - } - - fn first_run_onboarding_card_visible(&self) -> bool { - if !self.surface.is_configured() || self.overlay_suppressed() { - return false; - } - !first_run_card_hidden_by_ui_state( - self.input_state.presenter_mode, - self.input_state.command_palette_open, - self.input_state.show_help, - self.input_state.is_radial_menu_open(), - self.input_state.is_context_menu_open(), - self.input_state.tour_active, - self.zoom.is_engaged(), - ) - } - - fn apply_first_run_progress(&mut self) { - let usage = std::mem::take(&mut self.input_state.pending_onboarding_usage); - let context_enabled = self.input_state.context_menu_enabled(); - let radial_binding = self.input_state.radial_menu_mouse_binding; - let radial_available = self.shortcut_label_opt(Action::ToggleRadialMenu).is_some(); - let context_keyboard_available = self.shortcut_label_opt(Action::OpenContextMenu).is_some(); - let toolbar_visible = self.input_state.toolbar_visible(); - - let mut changed = false; - let mut completed_now = false; - - { - let state = self.onboarding.state_mut(); - - if usage.first_stroke_done && !state.first_stroke_done { - state.first_stroke_done = true; - changed = true; - } - if usage.first_undo_done && !state.first_undo_done { - state.first_undo_done = true; - changed = true; - } - if usage.used_toolbar_toggle && !state.used_toolbar_toggle { - state.used_toolbar_toggle = true; - changed = true; - } - if usage.used_radial_menu && !state.used_radial_menu { - state.used_radial_menu = true; - changed = true; - } - if usage.used_context_menu_right_click && !state.used_context_menu_right_click { - state.used_context_menu_right_click = true; - changed = true; - } - if usage.used_context_menu_keyboard && !state.used_context_menu_keyboard { - state.used_context_menu_keyboard = true; - changed = true; - } - if usage.used_help_overlay && !state.used_help_overlay { - state.used_help_overlay = true; - changed = true; - } - if usage.used_command_palette && !state.used_command_palette { - state.used_command_palette = true; - changed = true; - } - - if !state.first_run_active() { - if state.active_step.is_some() || state.quick_access_requires_toolbar { - state.active_step = None; - state.quick_access_requires_toolbar = false; - changed = true; - } - } else if state.active_step.is_none() { - state.active_step = Some(FirstRunStep::BackgroundModeSetup); - changed = true; - } - - while let Some(step) = state.active_step { - match step { - FirstRunStep::BackgroundModeSetup => { - if !state.first_run_background_mode_prompted { - break; - } - state.active_step = Some(FirstRunStep::WaitDraw); - changed = true; - } - FirstRunStep::WaitDraw => { - if !state.first_stroke_done { - break; - } - state.active_step = Some(FirstRunStep::DrawUndo); - changed = true; - } - FirstRunStep::DrawUndo => { - if !state.first_undo_done { - break; - } - state.active_step = Some(FirstRunStep::QuickAccess); - state.quick_access_requires_toolbar = !toolbar_visible; - changed = true; - } - FirstRunStep::QuickAccess => { - if !quick_access_completed( - state, - context_enabled, - radial_binding, - radial_available, - context_keyboard_available, - toolbar_visible, - ) { - break; - } - state.active_step = Some(FirstRunStep::Reference); - state.quick_access_requires_toolbar = false; - changed = true; - } - FirstRunStep::Reference => { - if !(state.used_help_overlay && state.used_command_palette) { - break; - } - state.first_run_completed = true; - state.first_run_skipped = false; - state.active_step = None; - state.quick_access_requires_toolbar = false; - changed = true; - completed_now = true; - break; - } - } - } - } - - if changed { - self.onboarding.save(); - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; - } - if completed_now && self.input_state.ui_toast.is_none() { - self.input_state - .set_ui_toast(UiToastKind::Info, "Nice work. Onboarding complete."); - } - } - fn apply_contextual_feature_hints(&mut self) { if !self.surface.is_configured() || self.overlay_suppressed() { return; @@ -421,73 +127,6 @@ impl WaylandState { self.onboarding.save(); } - fn quick_access_checklist_items( - &self, - state: &OnboardingState, - ) -> Vec { - let context_enabled = self.input_state.context_menu_enabled(); - let radial_binding = self.input_state.radial_menu_mouse_binding; - let radial_label = self.shortcut_label_opt(Action::ToggleRadialMenu); - let radial_available = radial_label.is_some(); - let context_keyboard = self.shortcut_label_opt(Action::OpenContextMenu); - let mut items = Vec::new(); - - if context_enabled { - if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { - if let Some(label) = radial_label { - items.push(OnboardingChecklistItem { - label: format!("Open radial menu ({label})"), - done: state.used_radial_menu, - }); - } - if let Some(label) = context_keyboard { - items.push(OnboardingChecklistItem { - label: format!("Open context menu ({label})"), - done: state.used_context_menu_keyboard, - }); - } else { - items.push(OnboardingChecklistItem { - label: "Context menu keyboard shortcut not configured".to_string(), - done: true, - }); - } - } else { - items.push(OnboardingChecklistItem { - label: "Open context menu (Right Click)".to_string(), - done: state.used_context_menu_right_click, - }); - if let Some(label) = radial_label { - items.push(OnboardingChecklistItem { - label: format!("Open radial menu ({label})"), - done: state.used_radial_menu, - }); - } - } - } else if let Some(label) = radial_label { - items.push(OnboardingChecklistItem { - label: format!("Open radial menu ({label})"), - done: state.used_radial_menu, - }); - } else { - items.push(OnboardingChecklistItem { - label: "Quick-access menus disabled in config".to_string(), - done: true, - }); - } - - if state.quick_access_requires_toolbar { - items.push(OnboardingChecklistItem { - label: format!( - "Show toolbars ({})", - self.shortcut_label(Action::ToggleToolbar, "Toggle toolbar") - ), - done: self.input_state.toolbar_visible() || state.used_toolbar_toggle, - }); - } - - items - } - fn shortcut_label(&self, action: Action, fallback: &str) -> String { self.shortcut_label_opt(action) .unwrap_or_else(|| fallback.to_string()) @@ -528,268 +167,5 @@ impl WaylandState { } } -fn quick_access_context_required( - context_enabled: bool, - radial_binding: RadialMenuMouseBinding, - radial_available: bool, - context_keyboard_available: bool, -) -> bool { - if !context_enabled { - return false; - } - if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { - return context_keyboard_available; - } - true -} - -fn quick_access_context_done( - state: &OnboardingState, - context_enabled: bool, - radial_binding: RadialMenuMouseBinding, - radial_available: bool, - context_keyboard_available: bool, -) -> bool { - if !quick_access_context_required( - context_enabled, - radial_binding, - radial_available, - context_keyboard_available, - ) { - return true; - } - if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { - state.used_context_menu_keyboard - } else { - state.used_context_menu_right_click - } -} - -fn quick_access_completed( - state: &OnboardingState, - context_enabled: bool, - radial_binding: RadialMenuMouseBinding, - radial_available: bool, - context_keyboard_available: bool, - toolbar_visible: bool, -) -> bool { - let mut done = true; - if radial_available { - done &= state.used_radial_menu; - } - done &= quick_access_context_done( - state, - context_enabled, - radial_binding, - radial_available, - context_keyboard_available, - ); - - if state.quick_access_requires_toolbar { - done &= toolbar_visible || state.used_toolbar_toggle; - } - - done -} - -fn background_mode_prompt_active(state: &OnboardingState, card_visible: bool) -> bool { - state.first_run_active() - && card_visible - && state.active_step == Some(FirstRunStep::BackgroundModeSetup) -} - -fn background_mode_prompt_choice(key: Key) -> Option { - let Key::Char(ch) = key else { - return None; - }; - - match ch.to_ascii_lowercase() { - 'y' => Some(true), - 'n' => Some(false), - _ => None, - } -} - -fn mark_background_mode_prompt(state: &mut OnboardingState, enabled: bool) { - state.first_run_background_mode_prompted = true; - state.first_run_background_mode_enabled = enabled; -} - -fn first_run_step_eyebrow(step: FirstRunStep) -> &'static str { - match step { - FirstRunStep::BackgroundModeSetup => "Step 1 / 5", - FirstRunStep::WaitDraw => "Step 2 / 5", - FirstRunStep::DrawUndo => "Step 3 / 5", - FirstRunStep::QuickAccess => "Step 4 / 5", - FirstRunStep::Reference => "Step 5 / 5", - } -} - -fn first_run_skip_allowed(first_run_active: bool, card_visible: bool) -> bool { - first_run_active && card_visible -} - -fn first_run_card_hidden_by_ui_state( - presenter_mode: bool, - command_palette_open: bool, - show_help: bool, - radial_menu_open: bool, - context_menu_open: bool, - tour_active: bool, - zoom_engaged: bool, -) -> bool { - presenter_mode - || command_palette_open - || show_help - || radial_menu_open - || context_menu_open - || tour_active - || zoom_engaged -} - #[cfg(test)] -mod tests { - use super::{ - FirstRunStep, OnboardingState, background_mode_prompt_active, - background_mode_prompt_choice, first_run_card_hidden_by_ui_state, first_run_skip_allowed, - first_run_step_eyebrow, quick_access_completed, - }; - use crate::config::RadialMenuMouseBinding; - use crate::input::Key; - - #[test] - fn first_run_skip_requires_active_onboarding_and_visible_card() { - assert!(first_run_skip_allowed(true, true)); - assert!(!first_run_skip_allowed(true, false)); - assert!(!first_run_skip_allowed(false, true)); - assert!(!first_run_skip_allowed(false, false)); - } - - #[test] - fn first_run_card_hides_for_each_modal_state() { - let modal_cases = [ - (true, false, false, false, false, false, false), // presenter - (false, true, false, false, false, false, false), // palette - (false, false, true, false, false, false, false), // help - (false, false, false, true, false, false, false), // radial - (false, false, false, false, true, false, false), // context menu - (false, false, false, false, false, true, false), // tour - (false, false, false, false, false, false, true), // zoom - ]; - - for case in modal_cases { - assert!( - first_run_card_hidden_by_ui_state( - case.0, case.1, case.2, case.3, case.4, case.5, case.6 - ), - "expected modal case to hide onboarding card" - ); - } - } - - #[test] - fn first_run_card_remains_visible_without_modal_states() { - assert!(!first_run_card_hidden_by_ui_state( - false, false, false, false, false, false, false - )); - } - - #[test] - fn first_run_eyebrow_shows_progress() { - assert_eq!( - first_run_step_eyebrow(FirstRunStep::BackgroundModeSetup), - "Step 1 / 5" - ); - assert_eq!(first_run_step_eyebrow(FirstRunStep::WaitDraw), "Step 2 / 5"); - assert_eq!(first_run_step_eyebrow(FirstRunStep::DrawUndo), "Step 3 / 5"); - assert_eq!( - first_run_step_eyebrow(FirstRunStep::QuickAccess), - "Step 4 / 5" - ); - assert_eq!( - first_run_step_eyebrow(FirstRunStep::Reference), - "Step 5 / 5" - ); - } - - #[test] - fn background_mode_prompt_choice_accepts_yes_and_no_keys() { - assert_eq!(background_mode_prompt_choice(Key::Char('y')), Some(true)); - assert_eq!(background_mode_prompt_choice(Key::Char('Y')), Some(true)); - assert_eq!(background_mode_prompt_choice(Key::Char('n')), Some(false)); - assert_eq!(background_mode_prompt_choice(Key::Char('N')), Some(false)); - assert_eq!(background_mode_prompt_choice(Key::Char('x')), None); - assert_eq!(background_mode_prompt_choice(Key::Escape), None); - } - - #[test] - fn background_mode_prompt_active_requires_step_and_visible_card() { - let mut state = OnboardingState { - active_step: Some(FirstRunStep::BackgroundModeSetup), - ..OnboardingState::default() - }; - assert!(background_mode_prompt_active(&state, true)); - assert!(!background_mode_prompt_active(&state, false)); - - state.active_step = Some(FirstRunStep::WaitDraw); - assert!(!background_mode_prompt_active(&state, true)); - - state.active_step = Some(FirstRunStep::BackgroundModeSetup); - state.first_run_completed = true; - assert!(!background_mode_prompt_active(&state, true)); - } - - #[test] - fn quick_access_completes_when_radial_unavailable_and_context_disabled() { - let state = OnboardingState::default(); - assert!(quick_access_completed( - &state, - false, - RadialMenuMouseBinding::Middle, - false, - false, - true, - )); - } - - #[test] - fn quick_access_waives_context_when_radial_uses_right_click_without_context_shortcut() { - let state = OnboardingState { - used_radial_menu: true, - ..OnboardingState::default() - }; - assert!(quick_access_completed( - &state, - true, - RadialMenuMouseBinding::Right, - true, - false, - true, - )); - } - - #[test] - fn quick_access_blocks_when_toolbar_required_and_still_hidden() { - let mut state = OnboardingState { - quick_access_requires_toolbar: true, - ..OnboardingState::default() - }; - assert!(!quick_access_completed( - &state, - false, - RadialMenuMouseBinding::Middle, - false, - false, - false, - )); - state.used_toolbar_toggle = true; - assert!(quick_access_completed( - &state, - false, - RadialMenuMouseBinding::Middle, - false, - false, - false, - )); - } -} +mod tests; diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs new file mode 100644 index 00000000..e01d7453 --- /dev/null +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -0,0 +1,487 @@ +use crate::backend::wayland::state::WaylandState; +use crate::config::{RadialMenuMouseBinding, keybindings::Action}; +use crate::input::{Key, state::UiToastKind}; +use crate::onboarding::{FirstRunStep, OnboardingState}; +use crate::ui::{OnboardingCard, OnboardingChecklistItem}; + +impl WaylandState { + pub(in crate::backend::wayland) fn try_handle_first_run_background_mode_choice( + &mut self, + key: Key, + ) -> bool { + if !background_mode_prompt_active( + self.onboarding.state(), + self.first_run_onboarding_card_visible(), + ) { + return false; + } + + let Some(enable_background_mode) = background_mode_prompt_choice(key) else { + return false; + }; + + if enable_background_mode { + match crate::daemon::setup::setup_background_mode() { + Ok(summary) => { + mark_background_mode_prompt(self.onboarding.state_mut(), true); + self.onboarding.save(); + self.input_state.set_ui_toast( + UiToastKind::Info, + format!( + "Background mode enabled. Service file: {}", + summary.service_path.display() + ), + ); + } + Err(err) => { + mark_background_mode_prompt(self.onboarding.state_mut(), false); + self.onboarding.save(); + self.input_state.set_ui_toast( + UiToastKind::Error, + format!( + "Background mode setup failed: {err}. You can set this up later in Background Mode settings." + ), + ); + } + } + } else { + mark_background_mode_prompt(self.onboarding.state_mut(), false); + self.onboarding.save(); + self.input_state + .set_ui_toast(UiToastKind::Info, "Skipped background mode setup for now."); + } + + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + true + } + + pub(in crate::backend::wayland) fn try_skip_first_run_onboarding(&mut self) -> bool { + if !first_run_skip_allowed( + self.onboarding.state().first_run_active(), + self.first_run_onboarding_card_visible(), + ) { + return false; + } + let state = self.onboarding.state_mut(); + state.first_run_skipped = true; + state.first_run_completed = true; + state.active_step = None; + state.quick_access_requires_toolbar = false; + self.onboarding.save(); + self.input_state + .set_ui_toast(UiToastKind::Info, "Onboarding skipped."); + true + } + + pub(in crate::backend::wayland) fn first_run_onboarding_card(&self) -> Option { + if !self.first_run_onboarding_card_visible() { + return None; + } + + let state = self.onboarding.state(); + if !state.first_run_active() { + return None; + } + let step = state.active_step?; + let eyebrow = first_run_step_eyebrow(step); + let footer = "Shift+Escape to skip".to_string(); + + let card = match step { + FirstRunStep::BackgroundModeSetup => OnboardingCard { + eyebrow: eyebrow.to_string(), + title: "Enable background mode?".to_string(), + body: "Keeps Wayscriber ready in the background so you can toggle overlay quickly." + .to_string(), + items: Vec::new(), + footer: "Y = set up now • N = skip • Shift+Escape = skip onboarding" + .to_string(), + }, + FirstRunStep::WaitDraw => OnboardingCard { + eyebrow: eyebrow.to_string(), + title: "Draw one mark".to_string(), + body: "Draw one quick stroke anywhere on the canvas.".to_string(), + items: vec![OnboardingChecklistItem { + label: "Draw a stroke".to_string(), + done: state.first_stroke_done, + }], + footer, + }, + FirstRunStep::DrawUndo => OnboardingCard { + eyebrow: eyebrow.to_string(), + title: "Try Undo".to_string(), + body: "You can always revert mistakes. Draw, then undo once.".to_string(), + items: vec![ + OnboardingChecklistItem { + label: "Draw a stroke".to_string(), + done: state.first_stroke_done, + }, + OnboardingChecklistItem { + label: format!("Undo once ({})", self.shortcut_label(Action::Undo, "Undo")), + done: state.first_undo_done, + }, + ], + footer, + }, + FirstRunStep::QuickAccess => { + let items = self.quick_access_checklist_items(state); + OnboardingCard { + eyebrow: eyebrow.to_string(), + title: "Quick access at cursor".to_string(), + body: "Open quick actions near the pointer.".to_string(), + items, + footer, + } + } + FirstRunStep::Reference => OnboardingCard { + eyebrow: eyebrow.to_string(), + title: "Find anything fast".to_string(), + body: "Use help for full shortcuts and command palette for searchable actions." + .to_string(), + items: vec![ + OnboardingChecklistItem { + label: format!( + "Open Help ({})", + self.shortcut_label(Action::ToggleHelp, "Help") + ), + done: state.used_help_overlay, + }, + OnboardingChecklistItem { + label: format!( + "Open Command Palette ({})", + self.shortcut_label(Action::ToggleCommandPalette, "Command Palette") + ), + done: state.used_command_palette, + }, + ], + footer, + }, + }; + + Some(card) + } + + fn first_run_onboarding_card_visible(&self) -> bool { + if !self.surface.is_configured() || self.overlay_suppressed() { + return false; + } + !first_run_card_hidden_by_ui_state( + self.input_state.presenter_mode, + self.input_state.command_palette_open, + self.input_state.show_help, + self.input_state.is_radial_menu_open(), + self.input_state.is_context_menu_open(), + self.input_state.tour_active, + self.zoom.is_engaged(), + ) + } + + pub(super) fn apply_first_run_progress(&mut self) { + let usage = std::mem::take(&mut self.input_state.pending_onboarding_usage); + let context_enabled = self.input_state.context_menu_enabled(); + let radial_binding = self.input_state.radial_menu_mouse_binding; + let radial_available = self.shortcut_label_opt(Action::ToggleRadialMenu).is_some(); + let context_keyboard_available = self.shortcut_label_opt(Action::OpenContextMenu).is_some(); + let toolbar_visible = self.input_state.toolbar_visible(); + + let mut changed = false; + let mut completed_now = false; + + { + let state = self.onboarding.state_mut(); + + if usage.first_stroke_done && !state.first_stroke_done { + state.first_stroke_done = true; + changed = true; + } + if usage.first_undo_done && !state.first_undo_done { + state.first_undo_done = true; + changed = true; + } + if usage.used_toolbar_toggle && !state.used_toolbar_toggle { + state.used_toolbar_toggle = true; + changed = true; + } + if usage.used_radial_menu && !state.used_radial_menu { + state.used_radial_menu = true; + changed = true; + } + if usage.used_context_menu_right_click && !state.used_context_menu_right_click { + state.used_context_menu_right_click = true; + changed = true; + } + if usage.used_context_menu_keyboard && !state.used_context_menu_keyboard { + state.used_context_menu_keyboard = true; + changed = true; + } + if usage.used_help_overlay && !state.used_help_overlay { + state.used_help_overlay = true; + changed = true; + } + if usage.used_command_palette && !state.used_command_palette { + state.used_command_palette = true; + changed = true; + } + + if !state.first_run_active() { + if state.active_step.is_some() || state.quick_access_requires_toolbar { + state.active_step = None; + state.quick_access_requires_toolbar = false; + changed = true; + } + } else if state.active_step.is_none() { + state.active_step = Some(FirstRunStep::BackgroundModeSetup); + changed = true; + } + + while let Some(step) = state.active_step { + match step { + FirstRunStep::BackgroundModeSetup => { + if !state.first_run_background_mode_prompted { + break; + } + state.active_step = Some(FirstRunStep::WaitDraw); + changed = true; + } + FirstRunStep::WaitDraw => { + if !state.first_stroke_done { + break; + } + state.active_step = Some(FirstRunStep::DrawUndo); + changed = true; + } + FirstRunStep::DrawUndo => { + if !state.first_undo_done { + break; + } + state.active_step = Some(FirstRunStep::QuickAccess); + state.quick_access_requires_toolbar = !toolbar_visible; + changed = true; + } + FirstRunStep::QuickAccess => { + if !quick_access_completed( + state, + context_enabled, + radial_binding, + radial_available, + context_keyboard_available, + toolbar_visible, + ) { + break; + } + state.active_step = Some(FirstRunStep::Reference); + state.quick_access_requires_toolbar = false; + changed = true; + } + FirstRunStep::Reference => { + if !(state.used_help_overlay && state.used_command_palette) { + break; + } + state.first_run_completed = true; + state.first_run_skipped = false; + state.active_step = None; + state.quick_access_requires_toolbar = false; + changed = true; + completed_now = true; + break; + } + } + } + } + + if changed { + self.onboarding.save(); + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + } + if completed_now && self.input_state.ui_toast.is_none() { + self.input_state + .set_ui_toast(UiToastKind::Info, "Nice work. Onboarding complete."); + } + } + fn quick_access_checklist_items( + &self, + state: &OnboardingState, + ) -> Vec { + let context_enabled = self.input_state.context_menu_enabled(); + let radial_binding = self.input_state.radial_menu_mouse_binding; + let radial_label = self.shortcut_label_opt(Action::ToggleRadialMenu); + let radial_available = radial_label.is_some(); + let context_keyboard = self.shortcut_label_opt(Action::OpenContextMenu); + let mut items = Vec::new(); + + if context_enabled { + if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { + if let Some(label) = radial_label { + items.push(OnboardingChecklistItem { + label: format!("Open radial menu ({label})"), + done: state.used_radial_menu, + }); + } + if let Some(label) = context_keyboard { + items.push(OnboardingChecklistItem { + label: format!("Open context menu ({label})"), + done: state.used_context_menu_keyboard, + }); + } else { + items.push(OnboardingChecklistItem { + label: "Context menu keyboard shortcut not configured".to_string(), + done: true, + }); + } + } else { + items.push(OnboardingChecklistItem { + label: "Open context menu (Right Click)".to_string(), + done: state.used_context_menu_right_click, + }); + if let Some(label) = radial_label { + items.push(OnboardingChecklistItem { + label: format!("Open radial menu ({label})"), + done: state.used_radial_menu, + }); + } + } + } else if let Some(label) = radial_label { + items.push(OnboardingChecklistItem { + label: format!("Open radial menu ({label})"), + done: state.used_radial_menu, + }); + } else { + items.push(OnboardingChecklistItem { + label: "Quick-access menus disabled in config".to_string(), + done: true, + }); + } + + if state.quick_access_requires_toolbar { + items.push(OnboardingChecklistItem { + label: format!( + "Show toolbars ({})", + self.shortcut_label(Action::ToggleToolbar, "Toggle toolbar") + ), + done: self.input_state.toolbar_visible() || state.used_toolbar_toggle, + }); + } + + items + } +} + +fn quick_access_context_required( + context_enabled: bool, + radial_binding: RadialMenuMouseBinding, + radial_available: bool, + context_keyboard_available: bool, +) -> bool { + if !context_enabled { + return false; + } + if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { + return context_keyboard_available; + } + true +} + +fn quick_access_context_done( + state: &OnboardingState, + context_enabled: bool, + radial_binding: RadialMenuMouseBinding, + radial_available: bool, + context_keyboard_available: bool, +) -> bool { + if !quick_access_context_required( + context_enabled, + radial_binding, + radial_available, + context_keyboard_available, + ) { + return true; + } + if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { + state.used_context_menu_keyboard + } else { + state.used_context_menu_right_click + } +} + +pub(super) fn quick_access_completed( + state: &OnboardingState, + context_enabled: bool, + radial_binding: RadialMenuMouseBinding, + radial_available: bool, + context_keyboard_available: bool, + toolbar_visible: bool, +) -> bool { + let mut done = true; + if radial_available { + done &= state.used_radial_menu; + } + done &= quick_access_context_done( + state, + context_enabled, + radial_binding, + radial_available, + context_keyboard_available, + ); + + if state.quick_access_requires_toolbar { + done &= toolbar_visible || state.used_toolbar_toggle; + } + + done +} + +pub(super) fn background_mode_prompt_active(state: &OnboardingState, card_visible: bool) -> bool { + state.first_run_active() + && card_visible + && state.active_step == Some(FirstRunStep::BackgroundModeSetup) +} + +pub(super) fn background_mode_prompt_choice(key: Key) -> Option { + let Key::Char(ch) = key else { + return None; + }; + + match ch.to_ascii_lowercase() { + 'y' => Some(true), + 'n' => Some(false), + _ => None, + } +} + +fn mark_background_mode_prompt(state: &mut OnboardingState, enabled: bool) { + state.first_run_background_mode_prompted = true; + state.first_run_background_mode_enabled = enabled; +} + +pub(super) fn first_run_step_eyebrow(step: FirstRunStep) -> &'static str { + match step { + FirstRunStep::BackgroundModeSetup => "Step 1 / 5", + FirstRunStep::WaitDraw => "Step 2 / 5", + FirstRunStep::DrawUndo => "Step 3 / 5", + FirstRunStep::QuickAccess => "Step 4 / 5", + FirstRunStep::Reference => "Step 5 / 5", + } +} + +pub(super) fn first_run_skip_allowed(first_run_active: bool, card_visible: bool) -> bool { + first_run_active && card_visible +} + +pub(super) fn first_run_card_hidden_by_ui_state( + presenter_mode: bool, + command_palette_open: bool, + show_help: bool, + radial_menu_open: bool, + context_menu_open: bool, + tour_active: bool, + zoom_engaged: bool, +) -> bool { + presenter_mode + || command_palette_open + || show_help + || radial_menu_open + || context_menu_open + || tour_active + || zoom_engaged +} diff --git a/src/backend/wayland/state/onboarding/tests.rs b/src/backend/wayland/state/onboarding/tests.rs new file mode 100644 index 00000000..8931f059 --- /dev/null +++ b/src/backend/wayland/state/onboarding/tests.rs @@ -0,0 +1,144 @@ +use super::first_run::{ + background_mode_prompt_active, background_mode_prompt_choice, + first_run_card_hidden_by_ui_state, first_run_skip_allowed, first_run_step_eyebrow, + quick_access_completed, +}; +use crate::config::RadialMenuMouseBinding; +use crate::input::Key; +use crate::onboarding::{FirstRunStep, OnboardingState}; + +#[test] +fn first_run_skip_requires_active_onboarding_and_visible_card() { + assert!(first_run_skip_allowed(true, true)); + assert!(!first_run_skip_allowed(true, false)); + assert!(!first_run_skip_allowed(false, true)); + assert!(!first_run_skip_allowed(false, false)); +} + +#[test] +fn first_run_card_hides_for_each_modal_state() { + let modal_cases = [ + (true, false, false, false, false, false, false), // presenter + (false, true, false, false, false, false, false), // palette + (false, false, true, false, false, false, false), // help + (false, false, false, true, false, false, false), // radial + (false, false, false, false, true, false, false), // context menu + (false, false, false, false, false, true, false), // tour + (false, false, false, false, false, false, true), // zoom + ]; + + for case in modal_cases { + assert!( + first_run_card_hidden_by_ui_state( + case.0, case.1, case.2, case.3, case.4, case.5, case.6 + ), + "expected modal case to hide onboarding card" + ); + } +} + +#[test] +fn first_run_card_remains_visible_without_modal_states() { + assert!(!first_run_card_hidden_by_ui_state( + false, false, false, false, false, false, false + )); +} + +#[test] +fn first_run_eyebrow_shows_progress() { + assert_eq!( + first_run_step_eyebrow(FirstRunStep::BackgroundModeSetup), + "Step 1 / 5" + ); + assert_eq!(first_run_step_eyebrow(FirstRunStep::WaitDraw), "Step 2 / 5"); + assert_eq!(first_run_step_eyebrow(FirstRunStep::DrawUndo), "Step 3 / 5"); + assert_eq!( + first_run_step_eyebrow(FirstRunStep::QuickAccess), + "Step 4 / 5" + ); + assert_eq!( + first_run_step_eyebrow(FirstRunStep::Reference), + "Step 5 / 5" + ); +} + +#[test] +fn background_mode_prompt_choice_accepts_yes_and_no_keys() { + assert_eq!(background_mode_prompt_choice(Key::Char('y')), Some(true)); + assert_eq!(background_mode_prompt_choice(Key::Char('Y')), Some(true)); + assert_eq!(background_mode_prompt_choice(Key::Char('n')), Some(false)); + assert_eq!(background_mode_prompt_choice(Key::Char('N')), Some(false)); + assert_eq!(background_mode_prompt_choice(Key::Char('x')), None); + assert_eq!(background_mode_prompt_choice(Key::Escape), None); +} + +#[test] +fn background_mode_prompt_active_requires_step_and_visible_card() { + let mut state = OnboardingState { + active_step: Some(FirstRunStep::BackgroundModeSetup), + ..OnboardingState::default() + }; + assert!(background_mode_prompt_active(&state, true)); + assert!(!background_mode_prompt_active(&state, false)); + + state.active_step = Some(FirstRunStep::WaitDraw); + assert!(!background_mode_prompt_active(&state, true)); + + state.active_step = Some(FirstRunStep::BackgroundModeSetup); + state.first_run_completed = true; + assert!(!background_mode_prompt_active(&state, true)); +} + +#[test] +fn quick_access_completes_when_radial_unavailable_and_context_disabled() { + let state = OnboardingState::default(); + assert!(quick_access_completed( + &state, + false, + RadialMenuMouseBinding::Middle, + false, + false, + true, + )); +} + +#[test] +fn quick_access_waives_context_when_radial_uses_right_click_without_context_shortcut() { + let state = OnboardingState { + used_radial_menu: true, + ..OnboardingState::default() + }; + assert!(quick_access_completed( + &state, + true, + RadialMenuMouseBinding::Right, + true, + false, + true, + )); +} + +#[test] +fn quick_access_blocks_when_toolbar_required_and_still_hidden() { + let mut state = OnboardingState { + quick_access_requires_toolbar: true, + ..OnboardingState::default() + }; + assert!(!quick_access_completed( + &state, + false, + RadialMenuMouseBinding::Middle, + false, + false, + false, + )); + state.used_toolbar_toggle = true; + assert!(quick_access_completed( + &state, + false, + RadialMenuMouseBinding::Middle, + false, + false, + false, + )); +} diff --git a/src/backend/wayland/toolbar/layout/spec/side/sizes.rs b/src/backend/wayland/toolbar/layout/spec/side/sizes.rs index d7955978..599529e8 100644 --- a/src/backend/wayland/toolbar/layout/spec/side/sizes.rs +++ b/src/backend/wayland/toolbar/layout/spec/side/sizes.rs @@ -1,13 +1,14 @@ -use crate::backend::wayland::toolbar::rows::capped_grid_columns; use crate::ui::toolbar::model::{ - ToolbarActionsModel, ToolbarCommandGroupKind, ToolbarSessionModel, ToolbarSettingsModel, - ordered_side_sections, toolbar_boards_model, toolbar_pages_model, + ToolbarActionsModel, ToolbarSessionModel, ToolbarSettingsModel, ordered_side_sections, + toolbar_boards_model, toolbar_pages_model, }; use crate::ui::toolbar::snapshot::ToolContext; use crate::ui::toolbar::{ToolbarSideSection, ToolbarSnapshot}; use super::super::ToolbarLayoutSpec; +mod sections; + impl ToolbarLayoutSpec { pub(in crate::backend::wayland::toolbar) fn side_size( &self, @@ -308,253 +309,6 @@ impl ToolbarLayoutSpec { ) } - pub(in crate::backend::wayland::toolbar) fn side_actions_content_height( - &self, - snapshot: &ToolbarSnapshot, - ) -> f64 { - let Some(model) = ToolbarActionsModel::from_snapshot(snapshot) else { - return 0.0; - }; - - if self.use_icons { - let icon_btn = Self::SIDE_ACTION_BUTTON_HEIGHT_ICON; - let icon_gap = Self::SIDE_ACTION_BUTTON_GAP; - let mut height = 0.0; - let mut has_group = false; - for group in model.groups() { - if group.buttons.is_empty() { - continue; - } - if has_group { - height += icon_gap; - } - let columns = match group.kind { - ToolbarCommandGroupKind::BasicActions => group.buttons.len(), - ToolbarCommandGroupKind::ViewActions - | ToolbarCommandGroupKind::AdvancedActions => 5, - ToolbarCommandGroupKind::Pages | ToolbarCommandGroupKind::Boards => { - group.buttons.len() - } - }; - let rows = group.buttons.len().div_ceil(columns); - height += icon_btn * rows as f64 + icon_gap * (rows as f64 - 1.0); - has_group = true; - } - height - } else { - let action_h = Self::SIDE_ACTION_BUTTON_HEIGHT_TEXT; - let action_gap = Self::SIDE_ACTION_CONTENT_GAP_TEXT; - let mut height = 0.0; - let mut has_group = false; - for group in model.groups() { - if group.buttons.is_empty() { - continue; - } - if has_group { - height += Self::SIDE_ACTION_BUTTON_GAP; - } - let columns = match group.kind { - ToolbarCommandGroupKind::BasicActions => 1, - ToolbarCommandGroupKind::ViewActions - | ToolbarCommandGroupKind::AdvancedActions => 2, - ToolbarCommandGroupKind::Pages | ToolbarCommandGroupKind::Boards => { - group.buttons.len().max(1) - } - }; - let rows = group.buttons.len().div_ceil(columns); - height += action_h * rows as f64 + action_gap * (rows as f64 - 1.0); - has_group = true; - } - height - } - } - - pub(in crate::backend::wayland::toolbar) fn side_actions_height( - &self, - snapshot: &ToolbarSnapshot, - ) -> f64 { - if snapshot.side_section_collapsed(ToolbarSideSection::Actions) { - return Self::SIDE_COLLAPSED_SECTION_HEIGHT; - } - let content = self.side_actions_content_height(snapshot); - if content <= 0.0 { - 0.0 - } else { - Self::SIDE_SECTION_TOGGLE_OFFSET_Y + content + Self::SIDE_ACTION_BUTTON_GAP - } - } - - pub(in crate::backend::wayland::toolbar) fn side_drawer_tabs_height( - &self, - snapshot: &ToolbarSnapshot, - ) -> f64 { - if !snapshot.drawer_open { - return 0.0; - } - let rows = crate::input::ToolbarDrawerTab::ALL.len().div_ceil(3); - Self::SIDE_SECTION_TOGGLE_OFFSET_Y - + Self::SIDE_TOGGLE_HEIGHT * rows as f64 - + Self::SIDE_TOGGLE_GAP * rows.saturating_sub(1) as f64 - + Self::SIDE_ACTION_BUTTON_GAP - } - - pub(in crate::backend::wayland::toolbar) fn side_pages_height( - &self, - snapshot: &ToolbarSnapshot, - ) -> f64 { - let Some(pages) = toolbar_pages_model(snapshot) else { - return 0.0; - }; - if snapshot.side_section_collapsed(ToolbarSideSection::Pages) { - return Self::SIDE_COLLAPSED_SECTION_HEIGHT; - } - let btn_h = if self.use_icons { - Self::SIDE_ACTION_BUTTON_HEIGHT_ICON - } else { - Self::SIDE_ACTION_BUTTON_HEIGHT_TEXT - }; - let columns = pages.buttons.len().max(1); - let rows = pages.buttons.len().div_ceil(columns); - Self::SIDE_SECTION_TOGGLE_OFFSET_Y - + btn_h * rows as f64 - + Self::SIDE_ACTION_BUTTON_GAP * rows as f64 - } - - pub(in crate::backend::wayland::toolbar) fn side_boards_height( - &self, - snapshot: &ToolbarSnapshot, - ) -> f64 { - let Some(boards) = toolbar_boards_model(snapshot) else { - return 0.0; - }; - if snapshot.side_section_collapsed(ToolbarSideSection::Boards) { - return Self::SIDE_COLLAPSED_SECTION_HEIGHT; - } - let btn_h = if self.use_icons { - Self::SIDE_ACTION_BUTTON_HEIGHT_ICON - } else { - Self::SIDE_ACTION_BUTTON_HEIGHT_TEXT - }; - let columns = capped_grid_columns(boards.buttons.len(), 5); - let rows = boards.buttons.len().div_ceil(columns); - Self::SIDE_SECTION_TOGGLE_OFFSET_Y - + btn_h * rows as f64 - + Self::SIDE_ACTION_BUTTON_GAP * rows as f64 - } - - pub(in crate::backend::wayland::toolbar) fn side_step_height( - &self, - snapshot: &ToolbarSnapshot, - ) -> f64 { - if snapshot.side_section_hidden(ToolbarSideSection::StepUndo) { - return 0.0; - } - if snapshot.side_section_collapsed(ToolbarSideSection::StepUndo) { - return Self::SIDE_COLLAPSED_SECTION_HEIGHT; - } - let delay_h = if snapshot.show_delay_sliders { - Self::SIDE_DELAY_SECTION_HEIGHT - } else { - 0.0 - }; - let toggles_h = Self::SIDE_TOGGLE_HEIGHT * 2.0 + Self::SIDE_TOGGLE_GAP; - Self::SIDE_STEP_HEADER_HEIGHT - + toggles_h - + if snapshot.custom_section_enabled { - Self::SIDE_CUSTOM_SECTION_HEIGHT - } else { - 0.0 - } - + delay_h - } - - pub(in crate::backend::wayland::toolbar) fn side_settings_height( - &self, - snapshot: &ToolbarSnapshot, - ) -> f64 { - let dedicated_panel = snapshot.customize_items_open - || matches!( - snapshot.drawer_tab, - crate::input::ToolbarDrawerTab::Sections - | crate::input::ToolbarDrawerTab::Customize - ); - if !dedicated_panel && snapshot.side_section_collapsed(ToolbarSideSection::Settings) { - return Self::SIDE_COLLAPSED_SECTION_HEIGHT; - } - let toggle_h = Self::SIDE_TOGGLE_HEIGHT; - let toggle_gap = Self::SIDE_TOGGLE_GAP; - let Some(settings) = ToolbarSettingsModel::from_snapshot(snapshot) else { - return 0.0; - }; - let toggle_count = settings.toggles().len(); - let rows = toggle_count.div_ceil(2); - let toggle_rows_h = if rows > 0 { - toggle_h * rows as f64 + toggle_gap * (rows as f64 - 1.0) - } else { - 0.0 - }; - let button_rows = settings.buttons().len().div_ceil(2); - let buttons_h = if button_rows > 0 { - Self::SIDE_SETTINGS_BUTTON_HEIGHT * button_rows as f64 - } else { - 0.0 - }; - let group_rows = settings.groups().len().div_ceil(2); - let group_rows_h = if group_rows > 0 { - toggle_h - + toggle_gap - + Self::SIDE_SETTINGS_BUTTON_HEIGHT * group_rows as f64 - + Self::SIDE_SETTINGS_BUTTON_GAP * (group_rows as f64 - 1.0) - } else { - 0.0 - }; - let item_rows = settings.item_overrides().len(); - let item_rows_h = if item_rows > 0 { - toggle_h - + toggle_gap - + toggle_h * item_rows as f64 - + toggle_gap * (item_rows as f64 - 1.0) - } else { - 0.0 - }; - let customize_h = group_rows_h + item_rows_h; - let customize_gap = if customize_h > 0.0 { toggle_gap } else { 0.0 }; - let content_h = toggle_rows_h + toggle_gap + buttons_h + customize_gap + customize_h; - Self::SIDE_SECTION_TOGGLE_OFFSET_Y + content_h + Self::SIDE_SETTINGS_BUTTON_GAP - } - - pub(in crate::backend::wayland::toolbar) fn side_session_height( - &self, - snapshot: &ToolbarSnapshot, - ) -> f64 { - let Some(session) = ToolbarSessionModel::from_snapshot(snapshot) else { - return 0.0; - }; - if snapshot.side_section_collapsed(ToolbarSideSection::Session) { - return Self::SIDE_COLLAPSED_SECTION_HEIGHT; - } - let button_rows = session.button_rows(); - let buttons_h = if button_rows == 0 { - 0.0 - } else { - Self::SIDE_SESSION_BUTTON_HEIGHT * button_rows as f64 - + Self::SIDE_SESSION_ROW_GAP * (button_rows as f64 - 1.0) - }; - let recents_h = if session.has_recent_sessions() { - Self::SIDE_SESSION_ROW_GAP - + session.recents.len() as f64 - * (Self::SIDE_SESSION_RECENT_HEIGHT + Self::SIDE_SESSION_ROW_GAP) - } else { - 0.0 - }; - Self::SIDE_SECTION_TOGGLE_OFFSET_Y - + Self::SIDE_SESSION_META_HEIGHT - + Self::SIDE_SESSION_ROW_GAP - + buttons_h - + recents_h - + Self::SIDE_SESSION_ROW_GAP - } - /// Y position where Row 2 (mode controls row) starts pub(in crate::backend::wayland::toolbar) fn side_header_row2_y(&self) -> f64 { Self::SIDE_TOP_PADDING + Self::SIDE_HEADER_ROW1_HEIGHT diff --git a/src/backend/wayland/toolbar/layout/spec/side/sizes/sections.rs b/src/backend/wayland/toolbar/layout/spec/side/sizes/sections.rs new file mode 100644 index 00000000..5d7c8e4b --- /dev/null +++ b/src/backend/wayland/toolbar/layout/spec/side/sizes/sections.rs @@ -0,0 +1,257 @@ +use crate::backend::wayland::toolbar::rows::capped_grid_columns; +use crate::ui::toolbar::model::{ + ToolbarActionsModel, ToolbarCommandGroupKind, ToolbarSessionModel, ToolbarSettingsModel, + toolbar_boards_model, toolbar_pages_model, +}; +use crate::ui::toolbar::{ToolbarSideSection, ToolbarSnapshot}; + +use super::super::super::ToolbarLayoutSpec; + +impl ToolbarLayoutSpec { + pub(in crate::backend::wayland::toolbar) fn side_actions_content_height( + &self, + snapshot: &ToolbarSnapshot, + ) -> f64 { + let Some(model) = ToolbarActionsModel::from_snapshot(snapshot) else { + return 0.0; + }; + + if self.use_icons { + let icon_btn = Self::SIDE_ACTION_BUTTON_HEIGHT_ICON; + let icon_gap = Self::SIDE_ACTION_BUTTON_GAP; + let mut height = 0.0; + let mut has_group = false; + for group in model.groups() { + if group.buttons.is_empty() { + continue; + } + if has_group { + height += icon_gap; + } + let columns = match group.kind { + ToolbarCommandGroupKind::BasicActions => group.buttons.len(), + ToolbarCommandGroupKind::ViewActions + | ToolbarCommandGroupKind::AdvancedActions => 5, + ToolbarCommandGroupKind::Pages | ToolbarCommandGroupKind::Boards => { + group.buttons.len() + } + }; + let rows = group.buttons.len().div_ceil(columns); + height += icon_btn * rows as f64 + icon_gap * (rows as f64 - 1.0); + has_group = true; + } + height + } else { + let action_h = Self::SIDE_ACTION_BUTTON_HEIGHT_TEXT; + let action_gap = Self::SIDE_ACTION_CONTENT_GAP_TEXT; + let mut height = 0.0; + let mut has_group = false; + for group in model.groups() { + if group.buttons.is_empty() { + continue; + } + if has_group { + height += Self::SIDE_ACTION_BUTTON_GAP; + } + let columns = match group.kind { + ToolbarCommandGroupKind::BasicActions => 1, + ToolbarCommandGroupKind::ViewActions + | ToolbarCommandGroupKind::AdvancedActions => 2, + ToolbarCommandGroupKind::Pages | ToolbarCommandGroupKind::Boards => { + group.buttons.len().max(1) + } + }; + let rows = group.buttons.len().div_ceil(columns); + height += action_h * rows as f64 + action_gap * (rows as f64 - 1.0); + has_group = true; + } + height + } + } + + pub(in crate::backend::wayland::toolbar) fn side_actions_height( + &self, + snapshot: &ToolbarSnapshot, + ) -> f64 { + if snapshot.side_section_collapsed(ToolbarSideSection::Actions) { + return Self::SIDE_COLLAPSED_SECTION_HEIGHT; + } + let content = self.side_actions_content_height(snapshot); + if content <= 0.0 { + 0.0 + } else { + Self::SIDE_SECTION_TOGGLE_OFFSET_Y + content + Self::SIDE_ACTION_BUTTON_GAP + } + } + + pub(in crate::backend::wayland::toolbar) fn side_drawer_tabs_height( + &self, + snapshot: &ToolbarSnapshot, + ) -> f64 { + if !snapshot.drawer_open { + return 0.0; + } + let rows = crate::input::ToolbarDrawerTab::ALL.len().div_ceil(3); + Self::SIDE_SECTION_TOGGLE_OFFSET_Y + + Self::SIDE_TOGGLE_HEIGHT * rows as f64 + + Self::SIDE_TOGGLE_GAP * rows.saturating_sub(1) as f64 + + Self::SIDE_ACTION_BUTTON_GAP + } + + pub(in crate::backend::wayland::toolbar) fn side_pages_height( + &self, + snapshot: &ToolbarSnapshot, + ) -> f64 { + let Some(pages) = toolbar_pages_model(snapshot) else { + return 0.0; + }; + if snapshot.side_section_collapsed(ToolbarSideSection::Pages) { + return Self::SIDE_COLLAPSED_SECTION_HEIGHT; + } + let btn_h = if self.use_icons { + Self::SIDE_ACTION_BUTTON_HEIGHT_ICON + } else { + Self::SIDE_ACTION_BUTTON_HEIGHT_TEXT + }; + let columns = pages.buttons.len().max(1); + let rows = pages.buttons.len().div_ceil(columns); + Self::SIDE_SECTION_TOGGLE_OFFSET_Y + + btn_h * rows as f64 + + Self::SIDE_ACTION_BUTTON_GAP * rows as f64 + } + + pub(in crate::backend::wayland::toolbar) fn side_boards_height( + &self, + snapshot: &ToolbarSnapshot, + ) -> f64 { + let Some(boards) = toolbar_boards_model(snapshot) else { + return 0.0; + }; + if snapshot.side_section_collapsed(ToolbarSideSection::Boards) { + return Self::SIDE_COLLAPSED_SECTION_HEIGHT; + } + let btn_h = if self.use_icons { + Self::SIDE_ACTION_BUTTON_HEIGHT_ICON + } else { + Self::SIDE_ACTION_BUTTON_HEIGHT_TEXT + }; + let columns = capped_grid_columns(boards.buttons.len(), 5); + let rows = boards.buttons.len().div_ceil(columns); + Self::SIDE_SECTION_TOGGLE_OFFSET_Y + + btn_h * rows as f64 + + Self::SIDE_ACTION_BUTTON_GAP * rows as f64 + } + + pub(in crate::backend::wayland::toolbar) fn side_step_height( + &self, + snapshot: &ToolbarSnapshot, + ) -> f64 { + if snapshot.side_section_hidden(ToolbarSideSection::StepUndo) { + return 0.0; + } + if snapshot.side_section_collapsed(ToolbarSideSection::StepUndo) { + return Self::SIDE_COLLAPSED_SECTION_HEIGHT; + } + let delay_h = if snapshot.show_delay_sliders { + Self::SIDE_DELAY_SECTION_HEIGHT + } else { + 0.0 + }; + let toggles_h = Self::SIDE_TOGGLE_HEIGHT * 2.0 + Self::SIDE_TOGGLE_GAP; + Self::SIDE_STEP_HEADER_HEIGHT + + toggles_h + + if snapshot.custom_section_enabled { + Self::SIDE_CUSTOM_SECTION_HEIGHT + } else { + 0.0 + } + + delay_h + } + + pub(in crate::backend::wayland::toolbar) fn side_settings_height( + &self, + snapshot: &ToolbarSnapshot, + ) -> f64 { + let dedicated_panel = snapshot.customize_items_open + || matches!( + snapshot.drawer_tab, + crate::input::ToolbarDrawerTab::Sections + | crate::input::ToolbarDrawerTab::Customize + ); + if !dedicated_panel && snapshot.side_section_collapsed(ToolbarSideSection::Settings) { + return Self::SIDE_COLLAPSED_SECTION_HEIGHT; + } + let toggle_h = Self::SIDE_TOGGLE_HEIGHT; + let toggle_gap = Self::SIDE_TOGGLE_GAP; + let Some(settings) = ToolbarSettingsModel::from_snapshot(snapshot) else { + return 0.0; + }; + let toggle_count = settings.toggles().len(); + let rows = toggle_count.div_ceil(2); + let toggle_rows_h = if rows > 0 { + toggle_h * rows as f64 + toggle_gap * (rows as f64 - 1.0) + } else { + 0.0 + }; + let button_rows = settings.buttons().len().div_ceil(2); + let buttons_h = if button_rows > 0 { + Self::SIDE_SETTINGS_BUTTON_HEIGHT * button_rows as f64 + } else { + 0.0 + }; + let group_rows = settings.groups().len().div_ceil(2); + let group_rows_h = if group_rows > 0 { + toggle_h + + toggle_gap + + Self::SIDE_SETTINGS_BUTTON_HEIGHT * group_rows as f64 + + Self::SIDE_SETTINGS_BUTTON_GAP * (group_rows as f64 - 1.0) + } else { + 0.0 + }; + let item_rows = settings.item_overrides().len(); + let item_rows_h = if item_rows > 0 { + toggle_h + + toggle_gap + + toggle_h * item_rows as f64 + + toggle_gap * (item_rows as f64 - 1.0) + } else { + 0.0 + }; + let customize_h = group_rows_h + item_rows_h; + let customize_gap = if customize_h > 0.0 { toggle_gap } else { 0.0 }; + let content_h = toggle_rows_h + toggle_gap + buttons_h + customize_gap + customize_h; + Self::SIDE_SECTION_TOGGLE_OFFSET_Y + content_h + Self::SIDE_SETTINGS_BUTTON_GAP + } + + pub(in crate::backend::wayland::toolbar) fn side_session_height( + &self, + snapshot: &ToolbarSnapshot, + ) -> f64 { + let Some(session) = ToolbarSessionModel::from_snapshot(snapshot) else { + return 0.0; + }; + if snapshot.side_section_collapsed(ToolbarSideSection::Session) { + return Self::SIDE_COLLAPSED_SECTION_HEIGHT; + } + let button_rows = session.button_rows(); + let buttons_h = if button_rows == 0 { + 0.0 + } else { + Self::SIDE_SESSION_BUTTON_HEIGHT * button_rows as f64 + + Self::SIDE_SESSION_ROW_GAP * (button_rows as f64 - 1.0) + }; + let recents_h = if session.has_recent_sessions() { + Self::SIDE_SESSION_ROW_GAP + + session.recents.len() as f64 + * (Self::SIDE_SESSION_RECENT_HEIGHT + Self::SIDE_SESSION_ROW_GAP) + } else { + 0.0 + }; + Self::SIDE_SECTION_TOGGLE_OFFSET_Y + + Self::SIDE_SESSION_META_HEIGHT + + Self::SIDE_SESSION_ROW_GAP + + buttons_h + + recents_h + + Self::SIDE_SESSION_ROW_GAP + } +} diff --git a/src/capture/file.rs b/src/capture/file.rs index 3e38910d..ff82216e 100644 --- a/src/capture/file.rs +++ b/src/capture/file.rs @@ -1,6 +1,7 @@ //! File saving functionality for screenshots. use super::types::CaptureError; +use crate::durable_io::{AtomicWriteOptions, OverwriteMode, PermissionPolicy, SymlinkPolicy}; use crate::paths::{expand_tilde as expand_tilde_global, home_dir, pictures_dir}; use crate::time_utils::{format_with_template, now_local}; use std::fs; @@ -119,20 +120,23 @@ pub fn save_screenshot( ); // Write file - fs::write(&file_path, image_data)?; + crate::durable_io::write_atomic( + &file_path, + image_data, + AtomicWriteOptions { + overwrite: OverwriteMode::Replace, + permissions: PermissionPolicy::FixedMode(0o600), + symlink: SymlinkPolicy::Reject, + sync_file: true, + sync_parent: true, + }, + ) + .map_err(|err| CaptureError::SaveError(std::io::Error::other(err)))?; // Verify the write let written_size = fs::metadata(&file_path)?.len(); log::debug!("File written: {} bytes", written_size); - // Set permissions to user read/write only (security) - #[cfg(unix)] - { - use std::fs::Permissions; - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&file_path, Permissions::from_mode(0o600))?; - } - log::info!("Screenshot saved successfully: {}", file_path.display()); Ok(file_path) diff --git a/src/config/io.rs b/src/config/io.rs index 62b6e47e..a8feb3c7 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -1,5 +1,6 @@ use super::Config; use super::paths::primary_config_dir; +use crate::durable_io::{AtomicWriteOptions, OverwriteMode, PermissionPolicy, SymlinkPolicy}; use crate::time_utils::{format_with_template, now_local}; use anyhow::{Context, Result, anyhow}; use log::{debug, info}; @@ -96,8 +97,12 @@ impl Config { let config_str = toml::to_string_pretty(self).context("Failed to serialize config")?; - fs::write(&config_path, config_str) - .with_context(|| format!("Failed to write config to {}", config_path.display()))?; + crate::durable_io::write_text_atomic( + &config_path, + &config_str, + AtomicWriteOptions::user_config_file(), + ) + .with_context(|| format!("Failed to write config to {}", config_path.display()))?; if let Some(path) = &backup_path { info!( @@ -176,7 +181,17 @@ impl Config { } let default_config = include_str!("../../config.example.toml"); - fs::write(&config_path, default_config)?; + crate::durable_io::write_text_atomic( + &config_path, + default_config, + AtomicWriteOptions { + overwrite: OverwriteMode::CreateNew, + permissions: PermissionPolicy::PreserveExistingOrMode(0o644), + symlink: SymlinkPolicy::FollowExistingTarget, + sync_file: true, + sync_parent: true, + }, + )?; info!("Created default config at {}", config_path.display()); Ok(()) diff --git a/src/config/tests/file_io.rs b/src/config/tests/file_io.rs index b2b6ba4d..518fc5fa 100644 --- a/src/config/tests/file_io.rs +++ b/src/config/tests/file_io.rs @@ -2,6 +2,9 @@ use super::super::*; use crate::config::test_helpers::with_temp_config_home; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::{PermissionsExt, symlink}; + #[test] fn save_with_backup_creates_timestamped_file() { with_temp_config_home(|config_root| { @@ -38,6 +41,59 @@ fn save_with_backup_creates_timestamped_file() { }); } +#[cfg(unix)] +#[test] +fn save_with_backup_preserves_symlinked_config_target_and_backup_contents() { + with_temp_config_home(|config_root| { + let config_dir = config_root.join(PRIMARY_CONFIG_DIR); + let managed_dir = config_root.join("managed-config"); + fs::create_dir_all(&config_dir).unwrap(); + fs::create_dir_all(&managed_dir).unwrap(); + + let target = managed_dir.join("config.toml"); + let config_link = config_dir.join("config.toml"); + fs::write(&target, "old_content = true").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&target, &config_link).unwrap(); + + let backup_path = Config::default() + .save_with_backup() + .expect("save_with_backup should succeed for symlinked config") + .expect("backup should be created for symlinked config"); + + assert!( + fs::symlink_metadata(&config_link) + .unwrap() + .file_type() + .is_symlink(), + "config path should remain a symlink" + ); + assert_eq!(fs::read_link(&config_link).unwrap(), target); + assert_eq!( + fs::read_to_string(&backup_path).unwrap(), + "old_content = true", + "backup should capture the pre-save target contents" + ); + assert!( + backup_path + .parent() + .is_some_and(|parent| parent == config_dir), + "backup should stay next to the user-facing config path" + ); + + let target_contents = fs::read_to_string(&target).unwrap(); + assert!( + target_contents.contains("[drawing]"), + "symlink target should receive the serialized config" + ); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o600, + "symlink target permissions should be preserved" + ); + }); +} + #[test] fn create_default_file_writes_example_when_missing() { with_temp_config_home(|config_root| { diff --git a/src/config/types/toolbar/items.rs b/src/config/types/toolbar/items.rs index ed14e46f..feefde9a 100644 --- a/src/config/types/toolbar/items.rs +++ b/src/config/types/toolbar/items.rs @@ -6,6 +6,14 @@ use serde::{Deserialize, Serialize}; use super::ids; +mod definitions; +mod order; +pub use definitions::toolbar_item_definitions; +pub use order::{ + ResolvedToolbarOrder, ToolbarItemOrderConfig, ToolbarItemOrderGroup, + toolbar_item_id_in_order_group, toolbar_item_order_group, +}; + /// User-authored item-level toolbar customization. /// /// The raw strings are intentionally preserved so unknown IDs from future @@ -196,288 +204,6 @@ impl ResolvedToolbarItems { } } -#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct ToolbarItemOrderConfig { - #[serde(default)] - pub top_tools: Vec, - #[serde(default)] - pub top_controls: Vec, - #[serde(default)] - pub side_sections: Vec, - #[serde(default)] - pub actions: Vec, - #[serde(default)] - pub pages: Vec, - #[serde(default)] - pub boards: Vec, - #[serde(default)] - pub presets: Vec, - #[serde(default)] - pub tool_options: Vec, - #[serde(default)] - pub sessions: Vec, -} - -impl ToolbarItemOrderConfig { - pub fn resolved(&self) -> ResolvedToolbarOrder { - ResolvedToolbarOrder { - top_tools: resolve_order_group(ToolbarItemOrderGroup::TopTools, &self.top_tools), - top_controls: resolve_order_group( - ToolbarItemOrderGroup::TopControls, - &self.top_controls, - ), - side_sections: resolve_order_group( - ToolbarItemOrderGroup::SideSections, - &self.side_sections, - ), - actions: resolve_order_group(ToolbarItemOrderGroup::Actions, &self.actions), - pages: resolve_order_group(ToolbarItemOrderGroup::Pages, &self.pages), - boards: resolve_order_group(ToolbarItemOrderGroup::Boards, &self.boards), - presets: resolve_order_group(ToolbarItemOrderGroup::Presets, &self.presets), - tool_options: resolve_order_group( - ToolbarItemOrderGroup::ToolOptions, - &self.tool_options, - ), - sessions: resolve_order_group(ToolbarItemOrderGroup::Sessions, &self.sessions), - } - } - - fn group_mut(&mut self, group: ToolbarItemOrderGroup) -> &mut Vec { - match group { - ToolbarItemOrderGroup::TopTools => &mut self.top_tools, - ToolbarItemOrderGroup::TopControls => &mut self.top_controls, - ToolbarItemOrderGroup::SideSections => &mut self.side_sections, - ToolbarItemOrderGroup::Actions => &mut self.actions, - ToolbarItemOrderGroup::Pages => &mut self.pages, - ToolbarItemOrderGroup::Boards => &mut self.boards, - ToolbarItemOrderGroup::Presets => &mut self.presets, - ToolbarItemOrderGroup::ToolOptions => &mut self.tool_options, - ToolbarItemOrderGroup::Sessions => &mut self.sessions, - } - } - - fn group(&self, group: ToolbarItemOrderGroup) -> &[String] { - match group { - ToolbarItemOrderGroup::TopTools => &self.top_tools, - ToolbarItemOrderGroup::TopControls => &self.top_controls, - ToolbarItemOrderGroup::SideSections => &self.side_sections, - ToolbarItemOrderGroup::Actions => &self.actions, - ToolbarItemOrderGroup::Pages => &self.pages, - ToolbarItemOrderGroup::Boards => &self.boards, - ToolbarItemOrderGroup::Presets => &self.presets, - ToolbarItemOrderGroup::ToolOptions => &self.tool_options, - ToolbarItemOrderGroup::Sessions => &self.sessions, - } - } - - fn set_known_group_order( - &mut self, - group: ToolbarItemOrderGroup, - ids: &[ToolbarItemId], - ) -> bool { - let original = self.group(group).to_vec(); - let mut next: Vec = ids - .iter() - .copied() - .filter(|id| toolbar_item_id_in_order_group(*id, group)) - .map(|id| id.as_str().to_string()) - .collect(); - append_preserved_order_strings(&original, group, &mut next); - let changed = next != original; - *self.group_mut(group) = next; - changed - } - - fn reset_known_group_to_defaults(&mut self, group: ToolbarItemOrderGroup) -> bool { - let original = self.group(group).to_vec(); - let mut next = Vec::new(); - append_preserved_order_strings(&original, group, &mut next); - let changed = next != original; - *self.group_mut(group) = next; - changed - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ResolvedToolbarOrder { - top_tools: ResolvedToolbarOrderGroup, - top_controls: ResolvedToolbarOrderGroup, - side_sections: ResolvedToolbarOrderGroup, - actions: ResolvedToolbarOrderGroup, - pages: ResolvedToolbarOrderGroup, - boards: ResolvedToolbarOrderGroup, - presets: ResolvedToolbarOrderGroup, - tool_options: ResolvedToolbarOrderGroup, - sessions: ResolvedToolbarOrderGroup, -} - -impl ResolvedToolbarOrder { - pub fn ordered_ids(&self, group: ToolbarItemOrderGroup) -> &[ToolbarItemId] { - &self.group(group).known - } - - pub fn index_of(&self, group: ToolbarItemOrderGroup, id: ToolbarItemId) -> Option { - self.ordered_ids(group) - .iter() - .position(|candidate| *candidate == id) - } - - fn group(&self, group: ToolbarItemOrderGroup) -> &ResolvedToolbarOrderGroup { - match group { - ToolbarItemOrderGroup::TopTools => &self.top_tools, - ToolbarItemOrderGroup::TopControls => &self.top_controls, - ToolbarItemOrderGroup::SideSections => &self.side_sections, - ToolbarItemOrderGroup::Actions => &self.actions, - ToolbarItemOrderGroup::Pages => &self.pages, - ToolbarItemOrderGroup::Boards => &self.boards, - ToolbarItemOrderGroup::Presets => &self.presets, - ToolbarItemOrderGroup::ToolOptions => &self.tool_options, - ToolbarItemOrderGroup::Sessions => &self.sessions, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -struct ResolvedToolbarOrderGroup { - known: Vec, - unknown: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ToolbarItemOrderGroup { - TopTools, - TopControls, - SideSections, - Actions, - Pages, - Boards, - Presets, - ToolOptions, - Sessions, -} - -pub fn toolbar_item_order_group( - definition: &ToolbarItemDefinition, -) -> Option { - match (definition.surface, definition.category, definition.group) { - (ToolbarItemSurface::Top, ToolbarItemCategory::Tool, _) => { - Some(ToolbarItemOrderGroup::TopTools) - } - (ToolbarItemSurface::Top, ToolbarItemCategory::Utility, _) - if top_control_orderable(definition.id) => - { - Some(ToolbarItemOrderGroup::TopControls) - } - (_, ToolbarItemCategory::Group, Some(group)) if side_section_orderable(group) => { - Some(ToolbarItemOrderGroup::SideSections) - } - (_, ToolbarItemCategory::Action, _) => Some(ToolbarItemOrderGroup::Actions), - (_, ToolbarItemCategory::Page, _) => Some(ToolbarItemOrderGroup::Pages), - (_, ToolbarItemCategory::Board, _) => Some(ToolbarItemOrderGroup::Boards), - (_, ToolbarItemCategory::ToolOption, _) => Some(ToolbarItemOrderGroup::ToolOptions), - (_, ToolbarItemCategory::Session, _) => Some(ToolbarItemOrderGroup::Sessions), - (_, _, Some(ToolbarGroupId::Presets)) => Some(ToolbarItemOrderGroup::Presets), - _ => None, - } -} - -fn top_control_orderable(id: ToolbarItemId) -> bool { - DEFAULT_TOP_CONTROLS_ORDER.contains(&id) -} - -fn side_section_orderable(group: ToolbarGroupId) -> bool { - matches!( - group, - ToolbarGroupId::Colors - | ToolbarGroupId::Thickness - | ToolbarGroupId::ArrowLabels - | ToolbarGroupId::StepMarkers - | ToolbarGroupId::MarkerOpacity - | ToolbarGroupId::TextSize - | ToolbarGroupId::Actions - | ToolbarGroupId::Pages - | ToolbarGroupId::Boards - | ToolbarGroupId::Presets - | ToolbarGroupId::StepUndo - | ToolbarGroupId::Session - | ToolbarGroupId::Settings - ) -} - -pub fn toolbar_item_id_in_order_group(id: ToolbarItemId, group: ToolbarItemOrderGroup) -> bool { - toolbar_item_definitions() - .iter() - .find(|definition| definition.id == id) - .and_then(toolbar_item_order_group) - == Some(group) -} - -fn resolve_order_group(group: ToolbarItemOrderGroup, raw: &[String]) -> ResolvedToolbarOrderGroup { - let defaults = default_order_for_group(group); - if raw.is_empty() { - return ResolvedToolbarOrderGroup { - known: defaults, - unknown: Vec::new(), - }; - } - - let mut known = Vec::with_capacity(defaults.len()); - let mut seen = BTreeSet::new(); - let mut unknown = Vec::new(); - for value in raw { - match value.parse::() { - Ok(id) if toolbar_item_id_in_order_group(id, group) => { - if seen.insert(id) { - known.push(id); - } - } - _ => unknown.push(value.clone()), - } - } - for id in defaults { - if seen.insert(id) { - known.push(id); - } - } - - ResolvedToolbarOrderGroup { known, unknown } -} - -fn default_order_for_group(group: ToolbarItemOrderGroup) -> Vec { - let default_visual_order = match group { - ToolbarItemOrderGroup::TopTools => Some(DEFAULT_TOP_TOOLS_ORDER), - ToolbarItemOrderGroup::TopControls => Some(DEFAULT_TOP_CONTROLS_ORDER), - ToolbarItemOrderGroup::SideSections => Some(DEFAULT_SIDE_SECTIONS_ORDER), - _ => None, - }; - if let Some(order) = default_visual_order { - return order.to_vec(); - } - - toolbar_item_definitions() - .iter() - .filter(|definition| toolbar_item_order_group(definition) == Some(group)) - .map(|definition| definition.id) - .collect() -} - -fn append_preserved_order_strings( - original: &[String], - group: ToolbarItemOrderGroup, - next: &mut Vec, -) { - for raw in original { - if raw - .parse::() - .is_ok_and(|id| toolbar_item_id_in_order_group(id, group)) - { - continue; - } - next.push(raw.clone()); - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ToolbarItemId(&'static str); @@ -659,806 +385,5 @@ impl FromStr for ToolbarGroupId { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UnknownToolbarGroupId; -pub fn toolbar_item_definitions() -> &'static [ToolbarItemDefinition] { - TOOLBAR_ITEM_DEFINITIONS -} - -const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[ - item(ids::TOP_CHROME_DRAG, "Move top toolbar", Top, Chrome, None), - item(ids::TOP_CHROME_PIN, "Pin top toolbar", Top, Chrome, None), - item( - ids::TOP_CHROME_CLOSE, - "Close top toolbar", - Top, - Chrome, - None, - ), - item(ids::TOP_TOOL_SELECT, "Select", Top, Tool, None), - item(ids::TOP_TOOL_PEN, "Pen", Top, Tool, None), - item(ids::TOP_TOOL_MARKER, "Marker", Top, Tool, None), - item(ids::TOP_TOOL_STEP_MARKER, "Step marker", Top, Tool, None), - item(ids::TOP_TOOL_ERASER, "Eraser", Top, Tool, None), - item(ids::TOP_TOOL_LINE, "Line", Top, Tool, None), - item(ids::TOP_TOOL_RECT, "Rectangle", Top, Tool, None), - item(ids::TOP_TOOL_ELLIPSE, "Ellipse", Top, Tool, None), - item(ids::TOP_TOOL_ARROW, "Arrow", Top, Tool, None), - item(ids::TOP_TOOL_BLUR, "Blur", Top, Tool, None), - item(ids::TOP_TOOL_TRIANGLE, "Triangle", Top, Tool, None), - item( - ids::TOP_TOOL_PARALLELOGRAM, - "Parallelogram", - Top, - Tool, - None, - ), - item(ids::TOP_TOOL_RHOMBUS, "Rhombus", Top, Tool, None), - item( - ids::TOP_TOOL_REGULAR_POLYGON, - "Regular polygon", - Top, - Tool, - None, - ), - item( - ids::TOP_TOOL_FREEFORM_POLYGON, - "Freeform polygon", - Top, - Tool, - None, - ), - item( - ids::TOP_UTILITY_SHAPE_PICKER, - "Shape picker", - Top, - Utility, - None, - ), - item(ids::TOP_UTILITY_FILL, "Fill", Top, Utility, None), - item(ids::TOP_UTILITY_TEXT, "Text", Top, Utility, None), - item( - ids::TOP_UTILITY_STICKY_NOTE, - "Sticky note", - Top, - Utility, - None, - ), - item( - ids::TOP_UTILITY_CLEAR_CANVAS, - "Clear canvas", - Top, - Utility, - None, - ), - item( - ids::TOP_UTILITY_SCREENSHOT, - "Screenshot", - Top, - Utility, - None, - ), - item(ids::TOP_UTILITY_HIGHLIGHT, "Highlight", Top, Utility, None), - item( - ids::TOP_UTILITY_HIGHLIGHT_RING, - "Highlight ring", - Top, - Utility, - None, - ), - item( - ids::TOP_UTILITY_ICON_MODE_ICONS, - "Use icons", - Top, - Utility, - None, - ), - item( - ids::TOP_UTILITY_ICON_MODE_TEXT, - "Use text labels", - Top, - Utility, - None, - ), - item( - ids::SIDE_GROUP_COLORS, - "Colors", - Side, - Group, - Some(ToolbarGroupId::Colors), - ), - item( - ids::SIDE_GROUP_THICKNESS, - "Thickness", - Side, - Group, - Some(ToolbarGroupId::Thickness), - ), - item( - ids::SIDE_GROUP_ERASER_MODE, - "Eraser mode", - Side, - Group, - Some(ToolbarGroupId::EraserMode), - ), - item( - ids::SIDE_GROUP_POLYGON_SIDES, - "Polygon sides", - Side, - Group, - Some(ToolbarGroupId::PolygonSides), - ), - item( - ids::SIDE_GROUP_ARROW_LABELS, - "Arrow labels", - Side, - Group, - Some(ToolbarGroupId::ArrowLabels), - ), - item( - ids::SIDE_GROUP_STEP_MARKERS, - "Step markers", - Side, - Group, - Some(ToolbarGroupId::StepMarkers), - ), - item( - ids::SIDE_GROUP_STEP_UNDO, - "Step Undo/Redo", - Side, - Group, - Some(ToolbarGroupId::StepUndo), - ), - item( - ids::SIDE_GROUP_MARKER_OPACITY, - "Marker opacity", - Side, - Group, - Some(ToolbarGroupId::MarkerOpacity), - ), - item( - ids::SIDE_GROUP_TEXT_SIZE, - "Text size", - Side, - Group, - Some(ToolbarGroupId::TextSize), - ), - item( - ids::SIDE_GROUP_FONT, - "Font", - Side, - Group, - Some(ToolbarGroupId::Font), - ), - item( - ids::SIDE_GROUP_ACTIONS, - "Actions", - Side, - Group, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_GROUP_PAGES, - "Pages", - Side, - Group, - Some(ToolbarGroupId::Pages), - ), - item( - ids::SIDE_GROUP_BOARDS, - "Boards", - Side, - Group, - Some(ToolbarGroupId::Boards), - ), - item( - ids::SIDE_GROUP_PRESETS, - "Presets", - Side, - Group, - Some(ToolbarGroupId::Presets), - ), - item( - ids::SIDE_GROUP_SETTINGS, - "Settings", - Side, - Group, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_GROUP_SESSION, - "Session", - Side, - Group, - Some(ToolbarGroupId::Session), - ), - item( - ids::SIDE_ACTIONS_UNDO, - "Undo", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_REDO, - "Redo", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_CLEAR_CANVAS, - "Clear canvas", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_ZOOM_IN, - "Zoom in", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_ZOOM_OUT, - "Zoom out", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_RESET_ZOOM, - "Reset zoom", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_TOGGLE_ZOOM_LOCK, - "Toggle zoom lock", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_UNDO_ALL, - "Undo all", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_REDO_ALL, - "Redo all", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_UNDO_ALL_DELAYED, - "Delayed undo all", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_REDO_ALL_DELAYED, - "Delayed redo all", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_ACTIONS_FREEZE, - "Freeze", - Side, - Action, - Some(ToolbarGroupId::Actions), - ), - item( - ids::SIDE_PAGES_PREVIOUS, - "Previous page", - Side, - Page, - Some(ToolbarGroupId::Pages), - ), - item( - ids::SIDE_PAGES_NEXT, - "Next page", - Side, - Page, - Some(ToolbarGroupId::Pages), - ), - item( - ids::SIDE_PAGES_NEW, - "New page", - Side, - Page, - Some(ToolbarGroupId::Pages), - ), - item( - ids::SIDE_PAGES_DUPLICATE, - "Duplicate page", - Side, - Page, - Some(ToolbarGroupId::Pages), - ), - item( - ids::SIDE_PAGES_DELETE, - "Delete page", - Side, - Page, - Some(ToolbarGroupId::Pages), - ), - item( - ids::SIDE_BOARDS_PREVIOUS, - "Previous board", - Side, - Board, - Some(ToolbarGroupId::Boards), - ), - item( - ids::SIDE_BOARDS_NEXT, - "Next board", - Side, - Board, - Some(ToolbarGroupId::Boards), - ), - item( - ids::SIDE_BOARDS_NEW, - "New board", - Side, - Board, - Some(ToolbarGroupId::Boards), - ), - item( - ids::SIDE_BOARDS_DUPLICATE, - "Duplicate board", - Side, - Board, - Some(ToolbarGroupId::Boards), - ), - item( - ids::SIDE_BOARDS_DELETE, - "Delete board", - Side, - Board, - Some(ToolbarGroupId::Boards), - ), - item( - ids::SIDE_BOARDS_RENAME, - "Rename board", - Side, - Board, - Some(ToolbarGroupId::Boards), - ), - item( - ids::SIDE_SETTINGS_CONTEXT_AWARE_UI, - "Context UI", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_TEXT_CONTROLS, - "Text controls", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_STATUS_BAR, - "Status bar", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_STATUS_BOARD_BADGE, - "Status board badge", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_STATUS_PAGE_BADGE, - "Status page badge", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_FLOATING_BADGE_ALWAYS, - "Floating board/page badge", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_PRESET_TOASTS, - "Preset toasts", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_PRESETS, - "Presets toggle", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_ACTIONS, - "Actions toggle", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_ZOOM_ACTIONS, - "Zoom actions toggle", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_ADVANCED_ACTIONS, - "Advanced actions toggle", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_BOARDS, - "Boards toggle", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_PAGES, - "Pages toggle", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_STEP_CONTROLS, - "Step controls toggle", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_CONFIGURATOR, - "Open configurator", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SETTINGS_CONFIG_FILE, - "Open config file", - Side, - Setting, - Some(ToolbarGroupId::Settings), - ), - item( - ids::SIDE_SESSION_OPEN, - "Open session", - Side, - Session, - Some(ToolbarGroupId::Session), - ), - item( - ids::SIDE_SESSION_SAVE_AS, - "Save session as", - Side, - Session, - Some(ToolbarGroupId::Session), - ), - item( - ids::SIDE_SESSION_INFO, - "Session info", - Side, - Session, - Some(ToolbarGroupId::Session), - ), - item( - ids::SIDE_SESSION_CLEAR, - "Clear session", - Side, - Session, - Some(ToolbarGroupId::Session), - ), - item( - ids::SIDE_SESSION_MANAGER, - "Session manager", - Side, - Session, - Some(ToolbarGroupId::Session), - ), - item( - ids::SIDE_TOOL_OPTIONS_COLOR, - "Color", - Side, - ToolOption, - Some(ToolbarGroupId::Colors), - ), - item( - ids::SIDE_TOOL_OPTIONS_THICKNESS, - "Thickness", - Side, - ToolOption, - Some(ToolbarGroupId::Thickness), - ), - item( - ids::SIDE_TOOL_OPTIONS_MARKER_OPACITY, - "Marker opacity", - Side, - ToolOption, - Some(ToolbarGroupId::MarkerOpacity), - ), - item( - ids::SIDE_TOOL_OPTIONS_ERASER_MODE, - "Eraser mode", - Side, - ToolOption, - Some(ToolbarGroupId::EraserMode), - ), - item( - ids::SIDE_TOOL_OPTIONS_FONT_SIZE, - "Font size", - Side, - ToolOption, - Some(ToolbarGroupId::TextSize), - ), - item( - ids::SIDE_TOOL_OPTIONS_FONT_FAMILY, - "Font family", - Side, - ToolOption, - Some(ToolbarGroupId::Font), - ), - item( - ids::SIDE_TOOL_OPTIONS_POLYGON_SIDES, - "Polygon sides", - Side, - ToolOption, - Some(ToolbarGroupId::PolygonSides), - ), - item( - ids::SIDE_TOOL_OPTIONS_ARROW_LABELS, - "Arrow labels", - Side, - ToolOption, - Some(ToolbarGroupId::ArrowLabels), - ), - item( - ids::SIDE_TOOL_OPTIONS_STEP_MARKER_RESET, - "Reset step marker", - Side, - ToolOption, - Some(ToolbarGroupId::StepMarkers), - ), -]; - -const fn item( - id: ToolbarItemId, - label: &'static str, - surface: ToolbarItemSurface, - category: ToolbarItemCategory, - group: Option, -) -> ToolbarItemDefinition { - ToolbarItemDefinition::new(id, label, surface, category, group) -} - -use ToolbarItemCategory::{ - Action, Board, Chrome, Group, Page, Session, Setting, Tool, ToolOption, Utility, -}; -use ToolbarItemSurface::{Side, Top}; - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn known_hidden_ids_resolve_and_unknown_ids_round_trip() { - let config = ToolbarItemsConfig { - hidden: vec![ - ids::SIDE_ACTIONS_UNDO_ALL.as_str().to_string(), - "future.toolbar.item".to_string(), - ], - order: ToolbarItemOrderConfig::default(), - }; - - let resolved = config.resolved(); - - assert!(resolved.is_hidden(ids::SIDE_ACTIONS_UNDO_ALL)); - assert_eq!(resolved.unknown_hidden, vec!["future.toolbar.item"]); - } - - #[test] - fn default_hidden_items_hide_screenshot_tool() { - let resolved = ToolbarItemsConfig::default().resolved(); - - assert!(resolved.is_hidden(ids::TOP_UTILITY_SCREENSHOT)); - } - - #[test] - fn set_hidden_preserves_unknown_ids_while_mutating_known_ids() { - let mut config = ToolbarItemsConfig { - hidden: vec![ - "future.toolbar.item".to_string(), - ids::SIDE_ACTIONS_UNDO_ALL.as_str().to_string(), - ids::SIDE_ACTIONS_UNDO_ALL.as_str().to_string(), - ids::SIDE_PAGES_DUPLICATE.as_str().to_string(), - ], - order: ToolbarItemOrderConfig::default(), - }; - - config.set_hidden(ids::SIDE_ACTIONS_UNDO_ALL, false); - config.set_hidden(ids::TOP_TOOL_PEN, true); - - assert_eq!( - config.hidden, - vec![ - "future.toolbar.item".to_string(), - ids::SIDE_PAGES_DUPLICATE.as_str().to_string(), - ids::TOP_TOOL_PEN.as_str().to_string() - ] - ); - } - - #[test] - fn reset_known_hidden_restores_defaults_and_preserves_unknown_ids() { - let mut config = ToolbarItemsConfig { - hidden: vec![ - "future.toolbar.item".to_string(), - ids::SIDE_ACTIONS_UNDO_ALL.as_str().to_string(), - ], - order: ToolbarItemOrderConfig::default(), - }; - - assert!(config.reset_known_hidden_to_defaults()); - assert_eq!( - config.hidden, - vec![ - ids::TOP_UTILITY_SCREENSHOT.as_str().to_string(), - "future.toolbar.item".to_string() - ] - ); - assert!(!config.reset_known_hidden_to_defaults()); - } - - #[test] - fn default_order_matches_visual_toolbar_defaults() { - let resolved = ToolbarItemsConfig::default().resolved(); - - assert_eq!( - resolved.order.ordered_ids(ToolbarItemOrderGroup::TopTools), - DEFAULT_TOP_TOOLS_ORDER - ); - assert_eq!( - resolved - .order - .ordered_ids(ToolbarItemOrderGroup::TopControls), - DEFAULT_TOP_CONTROLS_ORDER - ); - assert_eq!( - resolved - .order - .ordered_ids(ToolbarItemOrderGroup::SideSections), - DEFAULT_SIDE_SECTIONS_ORDER - ); - } - - #[test] - fn item_order_moves_known_ids_and_preserves_unknown_ids() { - let mut config = ToolbarItemsConfig { - hidden: Vec::new(), - order: ToolbarItemOrderConfig { - top_tools: vec![ - "future.toolbar.item".to_string(), - ids::TOP_TOOL_PEN.as_str().to_string(), - ids::TOP_TOOL_SELECT.as_str().to_string(), - ], - ..ToolbarItemOrderConfig::default() - }, - }; - - assert!(config.move_item_by(ToolbarItemOrderGroup::TopTools, ids::TOP_TOOL_PEN, 1,)); - - assert_eq!( - config.order.top_tools.last(), - Some(&"future.toolbar.item".to_string()) - ); - assert_eq!( - config - .resolved() - .order - .ordered_ids(ToolbarItemOrderGroup::TopTools)[1], - ids::TOP_TOOL_PEN - ); - } - - #[test] - fn top_control_order_excludes_visibility_only_utilities() { - let config = ToolbarItemsConfig { - hidden: Vec::new(), - order: ToolbarItemOrderConfig { - top_controls: vec![ - ids::TOP_UTILITY_SHAPE_PICKER.as_str().to_string(), - ids::TOP_UTILITY_TEXT.as_str().to_string(), - ids::TOP_UTILITY_FILL.as_str().to_string(), - ], - ..ToolbarItemOrderConfig::default() - }, - }; - - let resolved = config.resolved(); - let ordered = resolved - .order - .ordered_ids(ToolbarItemOrderGroup::TopControls); - assert_eq!(ordered[0], ids::TOP_UTILITY_TEXT); - assert!(!ordered.contains(&ids::TOP_UTILITY_SHAPE_PICKER)); - assert!(!ordered.contains(&ids::TOP_UTILITY_FILL)); - } - - #[test] - fn side_section_order_uses_runtime_representable_blocks() { - let config = ToolbarItemsConfig { - hidden: Vec::new(), - order: ToolbarItemOrderConfig { - side_sections: vec![ - ids::SIDE_GROUP_FONT.as_str().to_string(), - ids::SIDE_GROUP_THICKNESS.as_str().to_string(), - ids::SIDE_GROUP_POLYGON_SIDES.as_str().to_string(), - ], - ..ToolbarItemOrderConfig::default() - }, - }; - - let resolved = config.resolved(); - let ordered = resolved - .order - .ordered_ids(ToolbarItemOrderGroup::SideSections); - assert_eq!(ordered[0], ids::SIDE_GROUP_THICKNESS); - assert!(!ordered.contains(&ids::SIDE_GROUP_FONT)); - assert!(!ordered.contains(&ids::SIDE_GROUP_POLYGON_SIDES)); - } - - #[test] - fn toolbar_group_ids_include_step_markers_and_step_undo() { - assert_eq!( - "step-markers".parse::(), - Ok(ToolbarGroupId::StepMarkers) - ); - assert_eq!( - "step-undo".parse::(), - Ok(ToolbarGroupId::StepUndo) - ); - } - - #[test] - fn toolbar_item_definitions_are_unique_parseable_and_labeled() { - let mut seen = BTreeSet::new(); - - for definition in toolbar_item_definitions() { - assert!( - seen.insert(definition.id.as_str()), - "duplicate toolbar item id: {}", - definition.id - ); - assert_eq!( - definition.id.as_str().parse::(), - Ok(definition.id) - ); - assert!( - !definition.label.is_empty(), - "missing toolbar item label: {}", - definition.id - ); - } - } -} +mod tests; diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs new file mode 100644 index 00000000..8f4b03ff --- /dev/null +++ b/src/config/types/toolbar/items/definitions.rs @@ -0,0 +1,600 @@ +use super::*; + +pub fn toolbar_item_definitions() -> &'static [ToolbarItemDefinition] { + TOOLBAR_ITEM_DEFINITIONS +} + +const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[ + item(ids::TOP_CHROME_DRAG, "Move top toolbar", Top, Chrome, None), + item(ids::TOP_CHROME_PIN, "Pin top toolbar", Top, Chrome, None), + item( + ids::TOP_CHROME_CLOSE, + "Close top toolbar", + Top, + Chrome, + None, + ), + item(ids::TOP_TOOL_SELECT, "Select", Top, Tool, None), + item(ids::TOP_TOOL_PEN, "Pen", Top, Tool, None), + item(ids::TOP_TOOL_MARKER, "Marker", Top, Tool, None), + item(ids::TOP_TOOL_STEP_MARKER, "Step marker", Top, Tool, None), + item(ids::TOP_TOOL_ERASER, "Eraser", Top, Tool, None), + item(ids::TOP_TOOL_LINE, "Line", Top, Tool, None), + item(ids::TOP_TOOL_RECT, "Rectangle", Top, Tool, None), + item(ids::TOP_TOOL_ELLIPSE, "Ellipse", Top, Tool, None), + item(ids::TOP_TOOL_ARROW, "Arrow", Top, Tool, None), + item(ids::TOP_TOOL_BLUR, "Blur", Top, Tool, None), + item(ids::TOP_TOOL_TRIANGLE, "Triangle", Top, Tool, None), + item( + ids::TOP_TOOL_PARALLELOGRAM, + "Parallelogram", + Top, + Tool, + None, + ), + item(ids::TOP_TOOL_RHOMBUS, "Rhombus", Top, Tool, None), + item( + ids::TOP_TOOL_REGULAR_POLYGON, + "Regular polygon", + Top, + Tool, + None, + ), + item( + ids::TOP_TOOL_FREEFORM_POLYGON, + "Freeform polygon", + Top, + Tool, + None, + ), + item( + ids::TOP_UTILITY_SHAPE_PICKER, + "Shape picker", + Top, + Utility, + None, + ), + item(ids::TOP_UTILITY_FILL, "Fill", Top, Utility, None), + item(ids::TOP_UTILITY_TEXT, "Text", Top, Utility, None), + item( + ids::TOP_UTILITY_STICKY_NOTE, + "Sticky note", + Top, + Utility, + None, + ), + item( + ids::TOP_UTILITY_CLEAR_CANVAS, + "Clear canvas", + Top, + Utility, + None, + ), + item( + ids::TOP_UTILITY_SCREENSHOT, + "Screenshot", + Top, + Utility, + None, + ), + item(ids::TOP_UTILITY_HIGHLIGHT, "Highlight", Top, Utility, None), + item( + ids::TOP_UTILITY_HIGHLIGHT_RING, + "Highlight ring", + Top, + Utility, + None, + ), + item( + ids::TOP_UTILITY_ICON_MODE_ICONS, + "Use icons", + Top, + Utility, + None, + ), + item( + ids::TOP_UTILITY_ICON_MODE_TEXT, + "Use text labels", + Top, + Utility, + None, + ), + item( + ids::SIDE_GROUP_COLORS, + "Colors", + Side, + Group, + Some(ToolbarGroupId::Colors), + ), + item( + ids::SIDE_GROUP_THICKNESS, + "Thickness", + Side, + Group, + Some(ToolbarGroupId::Thickness), + ), + item( + ids::SIDE_GROUP_ERASER_MODE, + "Eraser mode", + Side, + Group, + Some(ToolbarGroupId::EraserMode), + ), + item( + ids::SIDE_GROUP_POLYGON_SIDES, + "Polygon sides", + Side, + Group, + Some(ToolbarGroupId::PolygonSides), + ), + item( + ids::SIDE_GROUP_ARROW_LABELS, + "Arrow labels", + Side, + Group, + Some(ToolbarGroupId::ArrowLabels), + ), + item( + ids::SIDE_GROUP_STEP_MARKERS, + "Step markers", + Side, + Group, + Some(ToolbarGroupId::StepMarkers), + ), + item( + ids::SIDE_GROUP_STEP_UNDO, + "Step Undo/Redo", + Side, + Group, + Some(ToolbarGroupId::StepUndo), + ), + item( + ids::SIDE_GROUP_MARKER_OPACITY, + "Marker opacity", + Side, + Group, + Some(ToolbarGroupId::MarkerOpacity), + ), + item( + ids::SIDE_GROUP_TEXT_SIZE, + "Text size", + Side, + Group, + Some(ToolbarGroupId::TextSize), + ), + item( + ids::SIDE_GROUP_FONT, + "Font", + Side, + Group, + Some(ToolbarGroupId::Font), + ), + item( + ids::SIDE_GROUP_ACTIONS, + "Actions", + Side, + Group, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_GROUP_PAGES, + "Pages", + Side, + Group, + Some(ToolbarGroupId::Pages), + ), + item( + ids::SIDE_GROUP_BOARDS, + "Boards", + Side, + Group, + Some(ToolbarGroupId::Boards), + ), + item( + ids::SIDE_GROUP_PRESETS, + "Presets", + Side, + Group, + Some(ToolbarGroupId::Presets), + ), + item( + ids::SIDE_GROUP_SETTINGS, + "Settings", + Side, + Group, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_GROUP_SESSION, + "Session", + Side, + Group, + Some(ToolbarGroupId::Session), + ), + item( + ids::SIDE_ACTIONS_UNDO, + "Undo", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_REDO, + "Redo", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_CLEAR_CANVAS, + "Clear canvas", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_ZOOM_IN, + "Zoom in", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_ZOOM_OUT, + "Zoom out", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_RESET_ZOOM, + "Reset zoom", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_TOGGLE_ZOOM_LOCK, + "Toggle zoom lock", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_UNDO_ALL, + "Undo all", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_REDO_ALL, + "Redo all", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_UNDO_ALL_DELAYED, + "Delayed undo all", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_REDO_ALL_DELAYED, + "Delayed redo all", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_ACTIONS_FREEZE, + "Freeze", + Side, + Action, + Some(ToolbarGroupId::Actions), + ), + item( + ids::SIDE_PAGES_PREVIOUS, + "Previous page", + Side, + Page, + Some(ToolbarGroupId::Pages), + ), + item( + ids::SIDE_PAGES_NEXT, + "Next page", + Side, + Page, + Some(ToolbarGroupId::Pages), + ), + item( + ids::SIDE_PAGES_NEW, + "New page", + Side, + Page, + Some(ToolbarGroupId::Pages), + ), + item( + ids::SIDE_PAGES_DUPLICATE, + "Duplicate page", + Side, + Page, + Some(ToolbarGroupId::Pages), + ), + item( + ids::SIDE_PAGES_DELETE, + "Delete page", + Side, + Page, + Some(ToolbarGroupId::Pages), + ), + item( + ids::SIDE_BOARDS_PREVIOUS, + "Previous board", + Side, + Board, + Some(ToolbarGroupId::Boards), + ), + item( + ids::SIDE_BOARDS_NEXT, + "Next board", + Side, + Board, + Some(ToolbarGroupId::Boards), + ), + item( + ids::SIDE_BOARDS_NEW, + "New board", + Side, + Board, + Some(ToolbarGroupId::Boards), + ), + item( + ids::SIDE_BOARDS_DUPLICATE, + "Duplicate board", + Side, + Board, + Some(ToolbarGroupId::Boards), + ), + item( + ids::SIDE_BOARDS_DELETE, + "Delete board", + Side, + Board, + Some(ToolbarGroupId::Boards), + ), + item( + ids::SIDE_BOARDS_RENAME, + "Rename board", + Side, + Board, + Some(ToolbarGroupId::Boards), + ), + item( + ids::SIDE_SETTINGS_CONTEXT_AWARE_UI, + "Context UI", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_TEXT_CONTROLS, + "Text controls", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_STATUS_BAR, + "Status bar", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_STATUS_BOARD_BADGE, + "Status board badge", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_STATUS_PAGE_BADGE, + "Status page badge", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_FLOATING_BADGE_ALWAYS, + "Floating board/page badge", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_PRESET_TOASTS, + "Preset toasts", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_PRESETS, + "Presets toggle", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_ACTIONS, + "Actions toggle", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_ZOOM_ACTIONS, + "Zoom actions toggle", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_ADVANCED_ACTIONS, + "Advanced actions toggle", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_BOARDS, + "Boards toggle", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_PAGES, + "Pages toggle", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_STEP_CONTROLS, + "Step controls toggle", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_CONFIGURATOR, + "Open configurator", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SETTINGS_CONFIG_FILE, + "Open config file", + Side, + Setting, + Some(ToolbarGroupId::Settings), + ), + item( + ids::SIDE_SESSION_OPEN, + "Open session", + Side, + Session, + Some(ToolbarGroupId::Session), + ), + item( + ids::SIDE_SESSION_SAVE_AS, + "Save session as", + Side, + Session, + Some(ToolbarGroupId::Session), + ), + item( + ids::SIDE_SESSION_INFO, + "Session info", + Side, + Session, + Some(ToolbarGroupId::Session), + ), + item( + ids::SIDE_SESSION_CLEAR, + "Clear session", + Side, + Session, + Some(ToolbarGroupId::Session), + ), + item( + ids::SIDE_SESSION_MANAGER, + "Session manager", + Side, + Session, + Some(ToolbarGroupId::Session), + ), + item( + ids::SIDE_TOOL_OPTIONS_COLOR, + "Color", + Side, + ToolOption, + Some(ToolbarGroupId::Colors), + ), + item( + ids::SIDE_TOOL_OPTIONS_THICKNESS, + "Thickness", + Side, + ToolOption, + Some(ToolbarGroupId::Thickness), + ), + item( + ids::SIDE_TOOL_OPTIONS_MARKER_OPACITY, + "Marker opacity", + Side, + ToolOption, + Some(ToolbarGroupId::MarkerOpacity), + ), + item( + ids::SIDE_TOOL_OPTIONS_ERASER_MODE, + "Eraser mode", + Side, + ToolOption, + Some(ToolbarGroupId::EraserMode), + ), + item( + ids::SIDE_TOOL_OPTIONS_FONT_SIZE, + "Font size", + Side, + ToolOption, + Some(ToolbarGroupId::TextSize), + ), + item( + ids::SIDE_TOOL_OPTIONS_FONT_FAMILY, + "Font family", + Side, + ToolOption, + Some(ToolbarGroupId::Font), + ), + item( + ids::SIDE_TOOL_OPTIONS_POLYGON_SIDES, + "Polygon sides", + Side, + ToolOption, + Some(ToolbarGroupId::PolygonSides), + ), + item( + ids::SIDE_TOOL_OPTIONS_ARROW_LABELS, + "Arrow labels", + Side, + ToolOption, + Some(ToolbarGroupId::ArrowLabels), + ), + item( + ids::SIDE_TOOL_OPTIONS_STEP_MARKER_RESET, + "Reset step marker", + Side, + ToolOption, + Some(ToolbarGroupId::StepMarkers), + ), +]; + +const fn item( + id: ToolbarItemId, + label: &'static str, + surface: ToolbarItemSurface, + category: ToolbarItemCategory, + group: Option, +) -> ToolbarItemDefinition { + ToolbarItemDefinition::new(id, label, surface, category, group) +} + +use ToolbarItemCategory::{ + Action, Board, Chrome, Group, Page, Session, Setting, Tool, ToolOption, Utility, +}; +use ToolbarItemSurface::{Side, Top}; diff --git a/src/config/types/toolbar/items/order.rs b/src/config/types/toolbar/items/order.rs new file mode 100644 index 00000000..f31641fb --- /dev/null +++ b/src/config/types/toolbar/items/order.rs @@ -0,0 +1,283 @@ +use super::*; + +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ToolbarItemOrderConfig { + #[serde(default)] + pub top_tools: Vec, + #[serde(default)] + pub top_controls: Vec, + #[serde(default)] + pub side_sections: Vec, + #[serde(default)] + pub actions: Vec, + #[serde(default)] + pub pages: Vec, + #[serde(default)] + pub boards: Vec, + #[serde(default)] + pub presets: Vec, + #[serde(default)] + pub tool_options: Vec, + #[serde(default)] + pub sessions: Vec, +} + +impl ToolbarItemOrderConfig { + pub fn resolved(&self) -> ResolvedToolbarOrder { + ResolvedToolbarOrder { + top_tools: resolve_order_group(ToolbarItemOrderGroup::TopTools, &self.top_tools), + top_controls: resolve_order_group( + ToolbarItemOrderGroup::TopControls, + &self.top_controls, + ), + side_sections: resolve_order_group( + ToolbarItemOrderGroup::SideSections, + &self.side_sections, + ), + actions: resolve_order_group(ToolbarItemOrderGroup::Actions, &self.actions), + pages: resolve_order_group(ToolbarItemOrderGroup::Pages, &self.pages), + boards: resolve_order_group(ToolbarItemOrderGroup::Boards, &self.boards), + presets: resolve_order_group(ToolbarItemOrderGroup::Presets, &self.presets), + tool_options: resolve_order_group( + ToolbarItemOrderGroup::ToolOptions, + &self.tool_options, + ), + sessions: resolve_order_group(ToolbarItemOrderGroup::Sessions, &self.sessions), + } + } + + fn group_mut(&mut self, group: ToolbarItemOrderGroup) -> &mut Vec { + match group { + ToolbarItemOrderGroup::TopTools => &mut self.top_tools, + ToolbarItemOrderGroup::TopControls => &mut self.top_controls, + ToolbarItemOrderGroup::SideSections => &mut self.side_sections, + ToolbarItemOrderGroup::Actions => &mut self.actions, + ToolbarItemOrderGroup::Pages => &mut self.pages, + ToolbarItemOrderGroup::Boards => &mut self.boards, + ToolbarItemOrderGroup::Presets => &mut self.presets, + ToolbarItemOrderGroup::ToolOptions => &mut self.tool_options, + ToolbarItemOrderGroup::Sessions => &mut self.sessions, + } + } + + fn group(&self, group: ToolbarItemOrderGroup) -> &[String] { + match group { + ToolbarItemOrderGroup::TopTools => &self.top_tools, + ToolbarItemOrderGroup::TopControls => &self.top_controls, + ToolbarItemOrderGroup::SideSections => &self.side_sections, + ToolbarItemOrderGroup::Actions => &self.actions, + ToolbarItemOrderGroup::Pages => &self.pages, + ToolbarItemOrderGroup::Boards => &self.boards, + ToolbarItemOrderGroup::Presets => &self.presets, + ToolbarItemOrderGroup::ToolOptions => &self.tool_options, + ToolbarItemOrderGroup::Sessions => &self.sessions, + } + } + + pub(super) fn set_known_group_order( + &mut self, + group: ToolbarItemOrderGroup, + ids: &[ToolbarItemId], + ) -> bool { + let original = self.group(group).to_vec(); + let mut next: Vec = ids + .iter() + .copied() + .filter(|id| toolbar_item_id_in_order_group(*id, group)) + .map(|id| id.as_str().to_string()) + .collect(); + append_preserved_order_strings(&original, group, &mut next); + let changed = next != original; + *self.group_mut(group) = next; + changed + } + + pub(super) fn reset_known_group_to_defaults(&mut self, group: ToolbarItemOrderGroup) -> bool { + let original = self.group(group).to_vec(); + let mut next = Vec::new(); + append_preserved_order_strings(&original, group, &mut next); + let changed = next != original; + *self.group_mut(group) = next; + changed + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ResolvedToolbarOrder { + top_tools: ResolvedToolbarOrderGroup, + top_controls: ResolvedToolbarOrderGroup, + side_sections: ResolvedToolbarOrderGroup, + actions: ResolvedToolbarOrderGroup, + pages: ResolvedToolbarOrderGroup, + boards: ResolvedToolbarOrderGroup, + presets: ResolvedToolbarOrderGroup, + tool_options: ResolvedToolbarOrderGroup, + sessions: ResolvedToolbarOrderGroup, +} + +impl ResolvedToolbarOrder { + pub fn ordered_ids(&self, group: ToolbarItemOrderGroup) -> &[ToolbarItemId] { + &self.group(group).known + } + + pub fn index_of(&self, group: ToolbarItemOrderGroup, id: ToolbarItemId) -> Option { + self.ordered_ids(group) + .iter() + .position(|candidate| *candidate == id) + } + + fn group(&self, group: ToolbarItemOrderGroup) -> &ResolvedToolbarOrderGroup { + match group { + ToolbarItemOrderGroup::TopTools => &self.top_tools, + ToolbarItemOrderGroup::TopControls => &self.top_controls, + ToolbarItemOrderGroup::SideSections => &self.side_sections, + ToolbarItemOrderGroup::Actions => &self.actions, + ToolbarItemOrderGroup::Pages => &self.pages, + ToolbarItemOrderGroup::Boards => &self.boards, + ToolbarItemOrderGroup::Presets => &self.presets, + ToolbarItemOrderGroup::ToolOptions => &self.tool_options, + ToolbarItemOrderGroup::Sessions => &self.sessions, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct ResolvedToolbarOrderGroup { + known: Vec, + unknown: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ToolbarItemOrderGroup { + TopTools, + TopControls, + SideSections, + Actions, + Pages, + Boards, + Presets, + ToolOptions, + Sessions, +} + +pub fn toolbar_item_order_group( + definition: &ToolbarItemDefinition, +) -> Option { + match (definition.surface, definition.category, definition.group) { + (ToolbarItemSurface::Top, ToolbarItemCategory::Tool, _) => { + Some(ToolbarItemOrderGroup::TopTools) + } + (ToolbarItemSurface::Top, ToolbarItemCategory::Utility, _) + if top_control_orderable(definition.id) => + { + Some(ToolbarItemOrderGroup::TopControls) + } + (_, ToolbarItemCategory::Group, Some(group)) if side_section_orderable(group) => { + Some(ToolbarItemOrderGroup::SideSections) + } + (_, ToolbarItemCategory::Action, _) => Some(ToolbarItemOrderGroup::Actions), + (_, ToolbarItemCategory::Page, _) => Some(ToolbarItemOrderGroup::Pages), + (_, ToolbarItemCategory::Board, _) => Some(ToolbarItemOrderGroup::Boards), + (_, ToolbarItemCategory::ToolOption, _) => Some(ToolbarItemOrderGroup::ToolOptions), + (_, ToolbarItemCategory::Session, _) => Some(ToolbarItemOrderGroup::Sessions), + (_, _, Some(ToolbarGroupId::Presets)) => Some(ToolbarItemOrderGroup::Presets), + _ => None, + } +} + +fn top_control_orderable(id: ToolbarItemId) -> bool { + DEFAULT_TOP_CONTROLS_ORDER.contains(&id) +} + +fn side_section_orderable(group: ToolbarGroupId) -> bool { + matches!( + group, + ToolbarGroupId::Colors + | ToolbarGroupId::Thickness + | ToolbarGroupId::ArrowLabels + | ToolbarGroupId::StepMarkers + | ToolbarGroupId::MarkerOpacity + | ToolbarGroupId::TextSize + | ToolbarGroupId::Actions + | ToolbarGroupId::Pages + | ToolbarGroupId::Boards + | ToolbarGroupId::Presets + | ToolbarGroupId::StepUndo + | ToolbarGroupId::Session + | ToolbarGroupId::Settings + ) +} + +pub fn toolbar_item_id_in_order_group(id: ToolbarItemId, group: ToolbarItemOrderGroup) -> bool { + toolbar_item_definitions() + .iter() + .find(|definition| definition.id == id) + .and_then(toolbar_item_order_group) + == Some(group) +} + +fn resolve_order_group(group: ToolbarItemOrderGroup, raw: &[String]) -> ResolvedToolbarOrderGroup { + let defaults = default_order_for_group(group); + if raw.is_empty() { + return ResolvedToolbarOrderGroup { + known: defaults, + unknown: Vec::new(), + }; + } + + let mut known = Vec::with_capacity(defaults.len()); + let mut seen = BTreeSet::new(); + let mut unknown = Vec::new(); + for value in raw { + match value.parse::() { + Ok(id) if toolbar_item_id_in_order_group(id, group) => { + if seen.insert(id) { + known.push(id); + } + } + _ => unknown.push(value.clone()), + } + } + for id in defaults { + if seen.insert(id) { + known.push(id); + } + } + + ResolvedToolbarOrderGroup { known, unknown } +} + +fn default_order_for_group(group: ToolbarItemOrderGroup) -> Vec { + let default_visual_order = match group { + ToolbarItemOrderGroup::TopTools => Some(DEFAULT_TOP_TOOLS_ORDER), + ToolbarItemOrderGroup::TopControls => Some(DEFAULT_TOP_CONTROLS_ORDER), + ToolbarItemOrderGroup::SideSections => Some(DEFAULT_SIDE_SECTIONS_ORDER), + _ => None, + }; + if let Some(order) = default_visual_order { + return order.to_vec(); + } + + toolbar_item_definitions() + .iter() + .filter(|definition| toolbar_item_order_group(definition) == Some(group)) + .map(|definition| definition.id) + .collect() +} + +fn append_preserved_order_strings( + original: &[String], + group: ToolbarItemOrderGroup, + next: &mut Vec, +) { + for raw in original { + if raw + .parse::() + .is_ok_and(|id| toolbar_item_id_in_order_group(id, group)) + { + continue; + } + next.push(raw.clone()); + } +} diff --git a/src/config/types/toolbar/items/tests.rs b/src/config/types/toolbar/items/tests.rs new file mode 100644 index 00000000..ffa2f4dc --- /dev/null +++ b/src/config/types/toolbar/items/tests.rs @@ -0,0 +1,201 @@ +use super::*; + +#[test] +fn known_hidden_ids_resolve_and_unknown_ids_round_trip() { + let config = ToolbarItemsConfig { + hidden: vec![ + ids::SIDE_ACTIONS_UNDO_ALL.as_str().to_string(), + "future.toolbar.item".to_string(), + ], + order: ToolbarItemOrderConfig::default(), + }; + + let resolved = config.resolved(); + + assert!(resolved.is_hidden(ids::SIDE_ACTIONS_UNDO_ALL)); + assert_eq!(resolved.unknown_hidden, vec!["future.toolbar.item"]); +} + +#[test] +fn default_hidden_items_hide_screenshot_tool() { + let resolved = ToolbarItemsConfig::default().resolved(); + + assert!(resolved.is_hidden(ids::TOP_UTILITY_SCREENSHOT)); +} + +#[test] +fn set_hidden_preserves_unknown_ids_while_mutating_known_ids() { + let mut config = ToolbarItemsConfig { + hidden: vec![ + "future.toolbar.item".to_string(), + ids::SIDE_ACTIONS_UNDO_ALL.as_str().to_string(), + ids::SIDE_ACTIONS_UNDO_ALL.as_str().to_string(), + ids::SIDE_PAGES_DUPLICATE.as_str().to_string(), + ], + order: ToolbarItemOrderConfig::default(), + }; + + config.set_hidden(ids::SIDE_ACTIONS_UNDO_ALL, false); + config.set_hidden(ids::TOP_TOOL_PEN, true); + + assert_eq!( + config.hidden, + vec![ + "future.toolbar.item".to_string(), + ids::SIDE_PAGES_DUPLICATE.as_str().to_string(), + ids::TOP_TOOL_PEN.as_str().to_string() + ] + ); +} + +#[test] +fn reset_known_hidden_restores_defaults_and_preserves_unknown_ids() { + let mut config = ToolbarItemsConfig { + hidden: vec![ + "future.toolbar.item".to_string(), + ids::SIDE_ACTIONS_UNDO_ALL.as_str().to_string(), + ], + order: ToolbarItemOrderConfig::default(), + }; + + assert!(config.reset_known_hidden_to_defaults()); + assert_eq!( + config.hidden, + vec![ + ids::TOP_UTILITY_SCREENSHOT.as_str().to_string(), + "future.toolbar.item".to_string() + ] + ); + assert!(!config.reset_known_hidden_to_defaults()); +} + +#[test] +fn default_order_matches_visual_toolbar_defaults() { + let resolved = ToolbarItemsConfig::default().resolved(); + + assert_eq!( + resolved.order.ordered_ids(ToolbarItemOrderGroup::TopTools), + DEFAULT_TOP_TOOLS_ORDER + ); + assert_eq!( + resolved + .order + .ordered_ids(ToolbarItemOrderGroup::TopControls), + DEFAULT_TOP_CONTROLS_ORDER + ); + assert_eq!( + resolved + .order + .ordered_ids(ToolbarItemOrderGroup::SideSections), + DEFAULT_SIDE_SECTIONS_ORDER + ); +} + +#[test] +fn item_order_moves_known_ids_and_preserves_unknown_ids() { + let mut config = ToolbarItemsConfig { + hidden: Vec::new(), + order: ToolbarItemOrderConfig { + top_tools: vec![ + "future.toolbar.item".to_string(), + ids::TOP_TOOL_PEN.as_str().to_string(), + ids::TOP_TOOL_SELECT.as_str().to_string(), + ], + ..ToolbarItemOrderConfig::default() + }, + }; + + assert!(config.move_item_by(ToolbarItemOrderGroup::TopTools, ids::TOP_TOOL_PEN, 1,)); + + assert_eq!( + config.order.top_tools.last(), + Some(&"future.toolbar.item".to_string()) + ); + assert_eq!( + config + .resolved() + .order + .ordered_ids(ToolbarItemOrderGroup::TopTools)[1], + ids::TOP_TOOL_PEN + ); +} + +#[test] +fn top_control_order_excludes_visibility_only_utilities() { + let config = ToolbarItemsConfig { + hidden: Vec::new(), + order: ToolbarItemOrderConfig { + top_controls: vec![ + ids::TOP_UTILITY_SHAPE_PICKER.as_str().to_string(), + ids::TOP_UTILITY_TEXT.as_str().to_string(), + ids::TOP_UTILITY_FILL.as_str().to_string(), + ], + ..ToolbarItemOrderConfig::default() + }, + }; + + let resolved = config.resolved(); + let ordered = resolved + .order + .ordered_ids(ToolbarItemOrderGroup::TopControls); + assert_eq!(ordered[0], ids::TOP_UTILITY_TEXT); + assert!(!ordered.contains(&ids::TOP_UTILITY_SHAPE_PICKER)); + assert!(!ordered.contains(&ids::TOP_UTILITY_FILL)); +} + +#[test] +fn side_section_order_uses_runtime_representable_blocks() { + let config = ToolbarItemsConfig { + hidden: Vec::new(), + order: ToolbarItemOrderConfig { + side_sections: vec![ + ids::SIDE_GROUP_FONT.as_str().to_string(), + ids::SIDE_GROUP_THICKNESS.as_str().to_string(), + ids::SIDE_GROUP_POLYGON_SIDES.as_str().to_string(), + ], + ..ToolbarItemOrderConfig::default() + }, + }; + + let resolved = config.resolved(); + let ordered = resolved + .order + .ordered_ids(ToolbarItemOrderGroup::SideSections); + assert_eq!(ordered[0], ids::SIDE_GROUP_THICKNESS); + assert!(!ordered.contains(&ids::SIDE_GROUP_FONT)); + assert!(!ordered.contains(&ids::SIDE_GROUP_POLYGON_SIDES)); +} + +#[test] +fn toolbar_group_ids_include_step_markers_and_step_undo() { + assert_eq!( + "step-markers".parse::(), + Ok(ToolbarGroupId::StepMarkers) + ); + assert_eq!( + "step-undo".parse::(), + Ok(ToolbarGroupId::StepUndo) + ); +} + +#[test] +fn toolbar_item_definitions_are_unique_parseable_and_labeled() { + let mut seen = BTreeSet::new(); + + for definition in toolbar_item_definitions() { + assert!( + seen.insert(definition.id.as_str()), + "duplicate toolbar item id: {}", + definition.id + ); + assert_eq!( + definition.id.as_str().parse::(), + Ok(definition.id) + ); + assert!( + !definition.label.is_empty(), + "missing toolbar item label: {}", + definition.id + ); + } +} diff --git a/src/daemon/control.rs b/src/daemon/control.rs index 1e81270d..a2316de9 100644 --- a/src/daemon/control.rs +++ b/src/daemon/control.rs @@ -1,21 +1,31 @@ use anyhow::{Context, Result, anyhow}; -use log::warn; use serde::{Deserialize, Serialize}; -use std::fs; -use std::fs::File; -use std::fs::OpenOptions; -use std::io::ErrorKind; -use std::io::Write; use std::path::{Path, PathBuf}; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[cfg(test)] use crate::env_vars::XDG_RUNTIME_DIR_ENV; -use crate::paths::{daemon_command_dir, daemon_command_file, daemon_lock_file, daemon_pid_file}; -use crate::session::try_lock_exclusive; +#[cfg(test)] +use crate::paths::daemon_command_dir; use crate::tray_action::TrayAction; +mod queue; +mod response; +mod runtime; + +use queue::write_daemon_toggle_request; +pub(crate) use queue::{clear_daemon_toggle_request_file, take_daemon_toggle_requests}; +#[cfg(test)] +pub(crate) use response::read_daemon_toggle_response; +use response::{clear_daemon_toggle_command_files, wait_daemon_toggle_command_response}; +#[cfg(test)] +use response::{wait_daemon_toggle_command_response_for, wait_daemon_toggle_response_for}; +pub(crate) use response::{write_daemon_toggle_command_error, write_daemon_toggle_command_success}; +#[cfg(test)] +use runtime::read_daemon_runtime_file; +pub(crate) use runtime::{clear_daemon_pid_file, write_daemon_pid_file}; +use runtime::{clear_stale_daemon_state_if_matches, read_daemon_runtime_info, signal_daemon_pid}; + const MAX_DAEMON_TOGGLE_REQUEST_AGE: Duration = Duration::from_secs(5); // Must exceed the request freshness window plus the overlay graceful stop path // so a valid hide request does not time out while the daemon is still handling it. @@ -165,487 +175,6 @@ pub(crate) fn generate_daemon_instance_token() -> String { format!("{:x}-{:x}", std::process::id(), now) } -fn clear_file(path: &std::path::Path) -> Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), - } -} - -fn clear_dir(path: &std::path::Path) -> Result<()> { - match fs::remove_dir_all(path) { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), - } -} - -fn atomic_temp_path(path: &std::path::Path) -> Result { - let parent = path - .parent() - .ok_or_else(|| anyhow!("{} has no parent directory", path.display()))?; - let file_name = path - .file_name() - .ok_or_else(|| anyhow!("{} has no file name", path.display()))? - .to_string_lossy(); - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - Ok(parent.join(format!( - ".{}.{}.{}.tmp", - file_name, - std::process::id(), - stamp - ))) -} - -fn next_daemon_toggle_request_path() -> Result { - let dir = daemon_command_dir(); - fs::create_dir_all(&dir) - .with_context(|| format!("failed to create runtime directory {}", dir.display()))?; - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - Ok(dir.join(format!("{:032x}-{:08x}.json", stamp, std::process::id()))) -} - -fn daemon_toggle_response_path_for_request(path: &Path) -> Result { - let parent = path - .parent() - .ok_or_else(|| anyhow!("{} has no parent directory", path.display()))?; - let file_name = path - .file_name() - .ok_or_else(|| anyhow!("{} has no file name", path.display()))?; - Ok(parent.join("responses").join(file_name)) -} - -fn write_file_atomic(path: &std::path::Path, payload: &[u8]) -> Result<()> { - let tmp_path = atomic_temp_path(path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; - } - - fs::write(&tmp_path, payload) - .with_context(|| format!("failed to write {}", tmp_path.display()))?; - - #[cfg(unix)] - { - if let Err(err) = fs::rename(&tmp_path, path) { - let _ = fs::remove_file(&tmp_path); - return Err(err).with_context(|| { - format!( - "failed to atomically replace {} via {}", - path.display(), - tmp_path.display() - ) - }); - } - } - - #[cfg(not(unix))] - { - let _ = fs::remove_file(path); - if let Err(err) = fs::rename(&tmp_path, path) { - let _ = fs::remove_file(&tmp_path); - return Err(err).with_context(|| { - format!( - "failed to replace {} via {}", - path.display(), - tmp_path.display() - ) - }); - } - } - - Ok(()) -} - -pub(crate) fn clear_daemon_toggle_request_file() -> Result<()> { - clear_file(&daemon_command_file())?; - clear_dir(&daemon_command_dir()) -} - -fn write_daemon_toggle_request( - request: &DaemonToggleRequest, - daemon_token: &str, -) -> Result { - let envelope = DaemonToggleEnvelope { - daemon_token: daemon_token.to_string(), - requested_at_unix_ms: current_unix_millis()?, - canceled: false, - request: request.clone(), - }; - let payload = - serde_json::to_vec(&envelope).context("failed to serialize daemon toggle request")?; - let path = next_daemon_toggle_request_path()?; - write_file_atomic(&path, &payload)?; - let response_path = daemon_toggle_response_path_for_request(&path)?; - Ok(DaemonToggleCommand { - daemon_token: daemon_token.to_string(), - request: request.clone(), - request_path: path, - response_path, - }) -} - -pub(crate) fn take_daemon_toggle_requests(expected_token: &str) -> Result { - let dir = daemon_command_dir(); - let mut paths = match fs::read_dir(&dir) { - Ok(entries) => entries - .filter_map(|entry| entry.ok().map(|entry| entry.path())) - .filter(|path| path.is_file()) - .collect::>(), - Err(err) if err.kind() == ErrorKind::NotFound => Vec::new(), - Err(err) => { - return Err(err).with_context(|| format!("failed to read {}", dir.display())); - } - }; - paths.sort_by(|left, right| left.file_name().cmp(&right.file_name())); - let saw_command_files = !paths.is_empty(); - - let mut requests = Vec::new(); - for path in paths { - let payload = match fs::read(&path) { - Ok(payload) => payload, - Err(err) if err.kind() == ErrorKind::NotFound => continue, - Err(err) => { - warn!("Failed to read {}: {}", path.display(), err); - continue; - } - }; - - if let Err(err) = clear_file(&path) { - warn!("Failed to remove {}: {}", path.display(), err); - continue; - } - - let envelope: DaemonToggleEnvelope = match serde_json::from_slice(&payload) { - Ok(envelope) => envelope, - Err(err) => { - warn!( - "Ignoring malformed daemon toggle request {}: {}", - path.display(), - err - ); - continue; - } - }; - - if envelope.daemon_token != expected_token { - warn!("Ignoring daemon toggle request for a different daemon instance"); - continue; - } - - if envelope.canceled { - warn!("Ignoring canceled daemon toggle request"); - continue; - } - - let age_ms = current_unix_millis()?.saturating_sub(envelope.requested_at_unix_ms); - if Duration::from_millis(age_ms) > MAX_DAEMON_TOGGLE_REQUEST_AGE { - warn!("Ignoring stale daemon toggle request older than 5s"); - continue; - } - - requests.push(DaemonToggleCommand { - daemon_token: envelope.daemon_token, - request: envelope.request, - response_path: daemon_toggle_response_path_for_request(&path)?, - request_path: path, - }); - } - - Ok(DaemonToggleCommands { - commands: requests, - saw_command_files, - }) -} - -pub(crate) fn write_daemon_toggle_command_success(command: &DaemonToggleCommand) -> Result<()> { - write_daemon_toggle_response(&command.response_path, DaemonToggleResponse::ok()) -} - -pub(crate) fn write_daemon_toggle_command_error( - command: &DaemonToggleCommand, - message: &str, -) -> Result<()> { - write_daemon_toggle_response( - &command.response_path, - DaemonToggleResponse::error(message.to_string()), - ) -} - -fn write_daemon_toggle_response(path: &Path, response: DaemonToggleResponse) -> Result<()> { - let payload = - serde_json::to_vec(&response).context("failed to serialize daemon toggle response")?; - write_file_atomic(path, &payload) -} - -#[cfg(test)] -pub(crate) fn read_daemon_toggle_response(path: &Path) -> Result<()> { - let payload = fs::read(path) - .with_context(|| format!("failed to read daemon response {}", path.display()))?; - let response: DaemonToggleResponse = - serde_json::from_slice(&payload).context("failed to parse daemon toggle response")?; - response.into_result() -} - -fn wait_daemon_toggle_response_for(path: &Path, timeout: Duration) -> Result<()> { - let deadline = Instant::now() + timeout; - loop { - match fs::read(path) { - Ok(payload) => { - let response: DaemonToggleResponse = serde_json::from_slice(&payload) - .context("failed to parse daemon toggle response")?; - if let Err(err) = clear_file(path) { - warn!( - "Failed to remove daemon response {}: {}", - path.display(), - err - ); - } - return response.into_result(); - } - Err(err) if err.kind() == ErrorKind::NotFound => { - if Instant::now() >= deadline { - return Err(anyhow!( - "timed out waiting for wayscriber daemon to process toggle request" - )); - } - thread::sleep(DAEMON_TOGGLE_RESPONSE_POLL); - } - Err(err) => { - return Err(err) - .with_context(|| format!("failed to read daemon response {}", path.display())); - } - } - } -} - -fn wait_daemon_toggle_command_response(command: &DaemonToggleCommand) -> Result<()> { - wait_daemon_toggle_command_response_for(command, MAX_DAEMON_TOGGLE_RESPONSE_WAIT) -} - -fn wait_daemon_toggle_command_response_for( - command: &DaemonToggleCommand, - timeout: Duration, -) -> Result<()> { - if let Err(err) = wait_daemon_toggle_response_for(&command.response_path, timeout) { - cancel_daemon_toggle_command(command); - return Err(err); - } - Ok(()) -} - -fn cancel_daemon_toggle_command(command: &DaemonToggleCommand) { - let payload = match canceled_daemon_toggle_payload(command) { - Ok(payload) => payload, - Err(err) => { - warn!("Failed to serialize daemon cancellation command: {}", err); - clear_daemon_toggle_command_files(command); - return; - } - }; - - match OpenOptions::new() - .write(true) - .truncate(true) - .open(&command.request_path) - { - Ok(mut file) => { - if let Err(err) = file.write_all(&payload) { - warn!( - "Failed to mark daemon request {} canceled after failed toggle wait: {}", - command.request_path.display(), - err - ); - } - } - Err(err) if err.kind() == ErrorKind::NotFound => {} - Err(err) => warn!( - "Failed to open daemon request {} for cancellation after failed toggle wait: {}", - command.request_path.display(), - err - ), - } - - if let Err(err) = clear_file(&command.response_path) { - warn!( - "Failed to remove daemon response {} after failed toggle wait: {}", - command.response_path.display(), - err - ); - } -} - -fn canceled_daemon_toggle_payload(command: &DaemonToggleCommand) -> Result> { - let envelope = DaemonToggleEnvelope { - daemon_token: command.daemon_token.clone(), - requested_at_unix_ms: current_unix_millis()?, - canceled: true, - request: DaemonToggleRequest::default(), - }; - serde_json::to_vec(&envelope).context("failed to serialize canceled daemon toggle request") -} - -fn clear_daemon_toggle_command_files(command: &DaemonToggleCommand) { - if let Err(err) = clear_file(&command.request_path) { - warn!( - "Failed to remove daemon request {} after failed toggle wait: {}", - command.request_path.display(), - err - ); - } - if let Err(err) = clear_file(&command.response_path) { - warn!( - "Failed to remove daemon response {} after failed toggle wait: {}", - command.response_path.display(), - err - ); - } -} - -pub(crate) fn write_daemon_pid_file(pid: u32, token: &str) -> Result<()> { - let path = daemon_pid_file(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; - } - let payload = serde_json::to_vec(&DaemonRuntimeInfo { - pid, - token: Some(token.to_string()), - }) - .context("failed to serialize daemon pid file")?; - fs::write(&path, payload).with_context(|| format!("failed to write {}", path.display()))?; - Ok(()) -} - -pub(crate) fn clear_daemon_pid_file() -> Result<()> { - clear_file(&daemon_pid_file()) -} - -fn try_acquire_daemon_lock() -> Result> { - let path = daemon_lock_file(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; - } - - let lock_file = OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(&path) - .with_context(|| format!("failed to open daemon lock {}", path.display()))?; - - match try_lock_exclusive(&lock_file) { - Ok(()) => Ok(Some(lock_file)), - Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(None), - Err(err) => Err(err).context("failed to inspect daemon lock"), - } -} - -fn clear_stale_daemon_state() { - if let Err(err) = clear_daemon_pid_file() { - warn!("Failed to clear stale daemon pid file: {}", err); - } - if let Err(err) = clear_daemon_toggle_request_file() { - warn!("Failed to clear stale daemon command file: {}", err); - } -} - -fn parse_daemon_runtime_info(raw: &str) -> Result { - if let Ok(info) = serde_json::from_str::(raw) { - return Ok(info); - } - - let pid = raw - .trim() - .parse::() - .context("failed to parse daemon pid file")?; - Ok(DaemonRuntimeInfo { pid, token: None }) -} - -fn read_daemon_runtime_file() -> Result { - let path = daemon_pid_file(); - let raw = - fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; - parse_daemon_runtime_info(&raw) -} - -fn read_daemon_runtime_file_if_exists() -> Result> { - let path = daemon_pid_file(); - let raw = match fs::read_to_string(&path) { - Ok(raw) => raw, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => { - return Err(err).with_context(|| format!("failed to read {}", path.display())); - } - }; - parse_daemon_runtime_info(&raw).map(Some) -} - -fn clear_stale_daemon_state_if_matches(expected: &DaemonRuntimeInfo) { - let Some(_lock_file) = (match try_acquire_daemon_lock() { - Ok(lock_file) => lock_file, - Err(err) => { - warn!( - "Failed to inspect daemon lock before stale cleanup: {}", - err - ); - return; - } - }) else { - return; - }; - - match read_daemon_runtime_file_if_exists() { - Ok(Some(current)) if ¤t == expected => clear_stale_daemon_state(), - Ok(_) => {} - Err(err) => warn!("Failed to inspect daemon pid before stale cleanup: {}", err), - } -} - -fn read_daemon_runtime_info() -> Result { - if let Some(_lock_file) = try_acquire_daemon_lock()? { - clear_stale_daemon_state(); - return Err(anyhow!("wayscriber daemon is not running")); - } - - read_daemon_runtime_file() -} - -fn signal_daemon_pid(pid: u32) -> Result<()> { - #[cfg(unix)] - { - let pid = i32::try_from(pid).context("daemon pid does not fit into i32")?; - if pid <= 0 { - return Err(anyhow!("invalid daemon pid {}", pid)); - } - - if unsafe { libc::kill(pid, libc::SIGUSR1) } != 0 { - return Err(anyhow!( - "failed to signal wayscriber daemon {}: {}", - pid, - std::io::Error::last_os_error() - )); - } - Ok(()) - } - - #[cfg(not(unix))] - { - Err(anyhow!( - "daemon control is only supported on Unix platforms" - )) - } -} - pub(crate) fn send_daemon_toggle_request(request: &DaemonToggleRequest) -> Result<()> { let runtime = read_daemon_runtime_info()?; let mut command = None; @@ -679,542 +208,4 @@ pub(crate) fn send_daemon_overlay_action(action: TrayAction) -> Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use std::env; - use std::sync::Mutex; - - static ENV_MUTEX: Mutex<()> = Mutex::new(()); - - #[test] - fn empty_toggle_request_reports_empty() { - assert!(DaemonToggleRequest::default().is_empty()); - } - - #[test] - fn overlay_action_request_is_not_empty() { - let request = DaemonToggleRequest { - overlay_action: Some(TrayAction::LightDrawToggle), - ..Default::default() - }; - assert!(!request.is_empty()); - } - - #[test] - fn session_file_request_is_not_empty() { - let request = DaemonToggleRequest { - session_file: Some(PathBuf::from("/tmp/lecture.wayscriber-session")), - ..Default::default() - }; - assert!(!request.is_empty()); - } - - #[test] - fn toggle_request_reports_session_override() { - let request = DaemonToggleRequest { - resume_session: true, - ..Default::default() - }; - assert_eq!(request.session_resume_override(), Some(true)); - - let request = DaemonToggleRequest { - no_resume_session: true, - ..Default::default() - }; - assert_eq!(request.session_resume_override(), Some(false)); - } - - #[test] - fn session_file_request_rejects_no_resume_session() { - let mut request = DaemonToggleRequest { - no_resume_session: true, - session_file: Some(PathBuf::from("/tmp/lecture.wayscriber-session")), - ..Default::default() - }; - - let err = request - .normalize_and_validate_session_file() - .expect_err("session file conflicts with disabled resume"); - - assert!( - format!("{err:#}").contains("--session-file conflicts with --no-resume-session"), - "{err:#}" - ); - } - - #[test] - fn session_file_request_rejects_relative_path() { - let mut request = DaemonToggleRequest { - session_file: Some(PathBuf::from("lecture.wayscriber-session")), - ..Default::default() - }; - - let err = request - .normalize_and_validate_session_file() - .expect_err("daemon protocol requires anchored paths"); - - assert!( - format!("{err:#}").contains("daemon --session-file request must use an absolute path"), - "{err:#}" - ); - } - - #[test] - fn daemon_toggle_response_round_trips_error_and_is_removed_after_wait() { - let tmp = crate::test_temp::tempdir().unwrap(); - let command = DaemonToggleCommand { - daemon_token: "daemon-token".into(), - request: DaemonToggleRequest::default(), - request_path: tmp.path().join("request.json"), - response_path: tmp.path().join("responses").join("request.json"), - }; - - write_daemon_toggle_command_error(&command, "cannot switch target").unwrap(); - - let err = wait_daemon_toggle_response_for( - &command.response_path, - MAX_DAEMON_TOGGLE_RESPONSE_WAIT, - ) - .expect_err("error response should surface to caller"); - assert!( - format!("{err:#}").contains("cannot switch target"), - "{err:#}" - ); - assert!(!command.response_path.exists()); - } - - #[test] - fn daemon_toggle_response_round_trips_success_and_is_removed_after_wait() { - let tmp = crate::test_temp::tempdir().unwrap(); - let command = DaemonToggleCommand { - daemon_token: "daemon-token".into(), - request: DaemonToggleRequest::default(), - request_path: tmp.path().join("request.json"), - response_path: tmp.path().join("responses").join("request.json"), - }; - - write_daemon_toggle_command_success(&command).unwrap(); - - wait_daemon_toggle_response_for(&command.response_path, MAX_DAEMON_TOGGLE_RESPONSE_WAIT) - .unwrap(); - assert!(!command.response_path.exists()); - } - - #[test] - fn daemon_toggle_response_wait_covers_request_age_and_overlay_stop_grace() { - assert!( - MAX_DAEMON_TOGGLE_RESPONSE_WAIT - > MAX_DAEMON_TOGGLE_REQUEST_AGE + Duration::from_secs(2), - "typed toggle response wait should exceed accepted request age plus overlay stop grace" - ); - } - - #[test] - fn daemon_toggle_response_wait_error_marks_existing_request_canceled() { - let tmp = crate::test_temp::tempdir().unwrap(); - let command = DaemonToggleCommand { - daemon_token: "daemon-token".into(), - request: DaemonToggleRequest::default(), - request_path: tmp.path().join("request.json"), - response_path: tmp.path().join("responses").join("request.json"), - }; - fs::write(&command.request_path, b"pending request").unwrap(); - - let err = wait_daemon_toggle_command_response_for(&command, Duration::ZERO) - .expect_err("missing response should time out immediately"); - - assert!( - format!("{err:#}") - .contains("timed out waiting for wayscriber daemon to process toggle request"), - "{err:#}" - ); - let canceled: DaemonToggleEnvelope = - serde_json::from_slice(&fs::read(&command.request_path).unwrap()).unwrap(); - assert_eq!(canceled.daemon_token, "daemon-token"); - assert!(canceled.canceled); - assert!(!command.response_path.exists()); - } - - #[test] - fn daemon_toggle_response_wait_error_does_not_create_missing_cancel_request() { - let tmp = crate::test_temp::tempdir().unwrap(); - let command = DaemonToggleCommand { - daemon_token: "daemon-token".into(), - request: DaemonToggleRequest::default(), - request_path: tmp.path().join("request.json"), - response_path: tmp.path().join("responses").join("request.json"), - }; - - let err = wait_daemon_toggle_command_response_for(&command, Duration::ZERO) - .expect_err("missing response should time out immediately"); - - assert!( - format!("{err:#}") - .contains("timed out waiting for wayscriber daemon to process toggle request"), - "{err:#}" - ); - assert!(!command.request_path.exists()); - assert!(!command.response_path.exists()); - } - - #[test] - fn daemon_toggle_response_parse_error_marks_existing_request_canceled() { - let tmp = crate::test_temp::tempdir().unwrap(); - let command = DaemonToggleCommand { - daemon_token: "daemon-token".into(), - request: DaemonToggleRequest::default(), - request_path: tmp.path().join("request.json"), - response_path: tmp.path().join("responses").join("request.json"), - }; - fs::write(&command.request_path, b"pending request").unwrap(); - fs::create_dir_all(command.response_path.parent().unwrap()).unwrap(); - fs::write(&command.response_path, b"not json").unwrap(); - - let err = wait_daemon_toggle_command_response_for(&command, Duration::ZERO) - .expect_err("malformed response should preserve parse error"); - - assert!( - format!("{err:#}").contains("failed to parse daemon toggle response"), - "{err:#}" - ); - let canceled: DaemonToggleEnvelope = - serde_json::from_slice(&fs::read(&command.request_path).unwrap()).unwrap(); - assert!(canceled.canceled); - assert!(!command.response_path.exists()); - } - - #[test] - fn daemon_pid_file_round_trips_runtime_info() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - write_daemon_pid_file(1234, "daemon-token").unwrap(); - let info = read_daemon_runtime_info().unwrap_err(); - assert!( - info.to_string() - .contains("wayscriber daemon is not running") - ); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } - - #[test] - fn stale_cleanup_removes_matching_runtime_while_lock_is_free() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - let runtime = DaemonRuntimeInfo { - pid: 1234, - token: Some("old-token".into()), - }; - write_daemon_pid_file(runtime.pid, runtime.token.as_deref().unwrap()).unwrap(); - write_daemon_toggle_request( - &DaemonToggleRequest { - freeze: true, - ..Default::default() - }, - "old-token", - ) - .unwrap(); - - clear_stale_daemon_state_if_matches(&runtime); - - assert!(!daemon_pid_file().exists()); - assert!(!daemon_command_dir().exists()); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } - - #[test] - fn stale_cleanup_preserves_mismatched_runtime() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - let current = DaemonRuntimeInfo { - pid: 5678, - token: Some("new-token".into()), - }; - write_daemon_pid_file(current.pid, current.token.as_deref().unwrap()).unwrap(); - write_daemon_toggle_request( - &DaemonToggleRequest { - freeze: true, - ..Default::default() - }, - "new-token", - ) - .unwrap(); - - clear_stale_daemon_state_if_matches(&DaemonRuntimeInfo { - pid: 1234, - token: Some("old-token".into()), - }); - - assert_eq!(read_daemon_runtime_file().unwrap(), current); - assert!(daemon_command_dir().exists()); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } - - #[test] - fn take_daemon_toggle_request_round_trips_payload() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - let request = DaemonToggleRequest { - mode: Some("whiteboard".into()), - freeze: true, - exit_after_capture: true, - session_file: Some(PathBuf::from("/tmp/lecture.wayscriber-session")), - ..Default::default() - }; - write_daemon_toggle_request(&request, "daemon-token").unwrap(); - let batch = take_daemon_toggle_requests("daemon-token").unwrap(); - let requests = batch - .commands - .iter() - .map(|command| command.request.clone()) - .collect::>(); - assert_eq!(requests, vec![request]); - assert!(batch.saw_command_files); - assert_eq!(batch.commands.len(), 1); - assert_eq!(batch.commands[0].daemon_token, "daemon-token"); - assert!( - batch.commands[0] - .request_path - .starts_with(daemon_command_dir()) - ); - assert!( - batch.commands[0] - .response_path - .starts_with(daemon_command_dir().join("responses")) - ); - let batch = take_daemon_toggle_requests("daemon-token").unwrap(); - assert!(!batch.saw_command_files); - assert!(batch.commands.is_empty()); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } - - #[test] - fn write_daemon_toggle_request_queues_multiple_files_without_leaking_temp_files() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - write_daemon_toggle_request( - &DaemonToggleRequest { - freeze: true, - ..Default::default() - }, - "daemon-token", - ) - .unwrap(); - write_daemon_toggle_request( - &DaemonToggleRequest { - mode: Some("whiteboard".into()), - ..Default::default() - }, - "daemon-token", - ) - .unwrap(); - - let command_dir = daemon_command_dir(); - let entries = fs::read_dir(&command_dir) - .unwrap() - .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) - .collect::>(); - assert_eq!(entries.len(), 2); - assert!(entries.iter().all(|name| name.ends_with(".json"))); - assert!(!entries.iter().any(|name| name.ends_with(".tmp"))); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } - - #[test] - fn take_daemon_toggle_request_drains_multiple_payloads_in_order() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - let first = DaemonToggleRequest { - freeze: true, - ..Default::default() - }; - let second = DaemonToggleRequest { - mode: Some("whiteboard".into()), - ..Default::default() - }; - write_daemon_toggle_request(&first, "daemon-token").unwrap(); - write_daemon_toggle_request(&second, "daemon-token").unwrap(); - - let batch = take_daemon_toggle_requests("daemon-token").unwrap(); - let requests = batch - .commands - .into_iter() - .map(|command| command.request) - .collect::>(); - assert_eq!(requests, vec![first, second]); - assert!(batch.saw_command_files); - assert!( - daemon_command_dir() - .read_dir() - .map(|mut entries| entries.next().is_none()) - .unwrap_or(true) - ); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } - - #[test] - fn take_daemon_toggle_request_ignores_mismatched_token() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - write_daemon_toggle_request( - &DaemonToggleRequest { - freeze: true, - ..Default::default() - }, - "other-daemon", - ) - .unwrap(); - - assert!( - take_daemon_toggle_requests("daemon-token") - .unwrap() - .commands - .is_empty() - ); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } - - #[test] - fn take_daemon_toggle_request_ignores_stale_payload() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - let payload = serde_json::to_vec(&DaemonToggleEnvelope { - daemon_token: "daemon-token".into(), - requested_at_unix_ms: current_unix_millis().unwrap() - 60_000, - canceled: false, - request: DaemonToggleRequest { - freeze: true, - ..Default::default() - }, - }) - .unwrap(); - fs::create_dir_all(daemon_command_dir()).unwrap(); - fs::write(daemon_command_dir().join("stale.json"), payload).unwrap(); - - let batch = take_daemon_toggle_requests("daemon-token").unwrap(); - assert!(batch.saw_command_files); - assert!(batch.commands.is_empty()); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } - - #[test] - fn take_daemon_toggle_request_ignores_canceled_payload_but_marks_typed_signal() { - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let tmp = crate::test_temp::tempdir().unwrap(); - let prev = env::var_os(XDG_RUNTIME_DIR_ENV); - unsafe { - env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); - } - - let payload = serde_json::to_vec(&DaemonToggleEnvelope { - daemon_token: "daemon-token".into(), - requested_at_unix_ms: current_unix_millis().unwrap(), - canceled: true, - request: DaemonToggleRequest { - freeze: true, - ..Default::default() - }, - }) - .unwrap(); - fs::create_dir_all(daemon_command_dir()).unwrap(); - fs::write(daemon_command_dir().join("canceled.json"), payload).unwrap(); - - let batch = take_daemon_toggle_requests("daemon-token").unwrap(); - assert!(batch.saw_command_files); - assert!(batch.commands.is_empty()); - - match prev { - Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, - None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, - } - } -} +mod tests; diff --git a/src/daemon/control/queue.rs b/src/daemon/control/queue.rs new file mode 100644 index 00000000..f83930e9 --- /dev/null +++ b/src/daemon/control/queue.rs @@ -0,0 +1,159 @@ +use super::{ + DaemonToggleCommand, DaemonToggleCommands, DaemonToggleEnvelope, DaemonToggleRequest, + MAX_DAEMON_TOGGLE_REQUEST_AGE, current_unix_millis, +}; +use crate::durable_io::AtomicWriteOptions; +use crate::paths::{daemon_command_dir, daemon_command_file}; +use anyhow::{Context, Result, anyhow}; +use log::warn; +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub(super) fn clear_file(path: &std::path::Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), + } +} + +fn clear_dir(path: &std::path::Path) -> Result<()> { + match fs::remove_dir_all(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), + } +} + +fn next_daemon_toggle_request_path() -> Result { + let dir = daemon_command_dir(); + fs::create_dir_all(&dir) + .with_context(|| format!("failed to create runtime directory {}", dir.display()))?; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + Ok(dir.join(format!("{:032x}-{:08x}.json", stamp, std::process::id()))) +} + +fn daemon_toggle_response_path_for_request(path: &Path) -> Result { + let parent = path + .parent() + .ok_or_else(|| anyhow!("{} has no parent directory", path.display()))?; + let file_name = path + .file_name() + .ok_or_else(|| anyhow!("{} has no file name", path.display()))?; + Ok(parent.join("responses").join(file_name)) +} + +pub(super) fn write_file_atomic(path: &std::path::Path, payload: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; + } + crate::durable_io::write_atomic(path, payload, AtomicWriteOptions::private_runtime_file()) + .with_context(|| format!("failed to write {}", path.display())) +} + +pub(crate) fn clear_daemon_toggle_request_file() -> Result<()> { + clear_file(&daemon_command_file())?; + clear_dir(&daemon_command_dir()) +} + +pub(super) fn write_daemon_toggle_request( + request: &DaemonToggleRequest, + daemon_token: &str, +) -> Result { + let envelope = DaemonToggleEnvelope { + daemon_token: daemon_token.to_string(), + requested_at_unix_ms: current_unix_millis()?, + canceled: false, + request: request.clone(), + }; + let payload = + serde_json::to_vec(&envelope).context("failed to serialize daemon toggle request")?; + let path = next_daemon_toggle_request_path()?; + write_file_atomic(&path, &payload)?; + let response_path = daemon_toggle_response_path_for_request(&path)?; + Ok(DaemonToggleCommand { + daemon_token: daemon_token.to_string(), + request: request.clone(), + request_path: path, + response_path, + }) +} + +pub(crate) fn take_daemon_toggle_requests(expected_token: &str) -> Result { + let dir = daemon_command_dir(); + let mut paths = match fs::read_dir(&dir) { + Ok(entries) => entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.is_file()) + .collect::>(), + Err(err) if err.kind() == ErrorKind::NotFound => Vec::new(), + Err(err) => { + return Err(err).with_context(|| format!("failed to read {}", dir.display())); + } + }; + paths.sort_by(|left, right| left.file_name().cmp(&right.file_name())); + let saw_command_files = !paths.is_empty(); + + let mut requests = Vec::new(); + for path in paths { + let payload = match fs::read(&path) { + Ok(payload) => payload, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => { + warn!("Failed to read {}: {}", path.display(), err); + continue; + } + }; + + if let Err(err) = clear_file(&path) { + warn!("Failed to remove {}: {}", path.display(), err); + continue; + } + + let envelope: DaemonToggleEnvelope = match serde_json::from_slice(&payload) { + Ok(envelope) => envelope, + Err(err) => { + warn!( + "Ignoring malformed daemon toggle request {}: {}", + path.display(), + err + ); + continue; + } + }; + + if envelope.daemon_token != expected_token { + warn!("Ignoring daemon toggle request for a different daemon instance"); + continue; + } + + if envelope.canceled { + warn!("Ignoring canceled daemon toggle request"); + continue; + } + + let age_ms = current_unix_millis()?.saturating_sub(envelope.requested_at_unix_ms); + if Duration::from_millis(age_ms) > MAX_DAEMON_TOGGLE_REQUEST_AGE { + warn!("Ignoring stale daemon toggle request older than 5s"); + continue; + } + + requests.push(DaemonToggleCommand { + daemon_token: envelope.daemon_token, + request: envelope.request, + response_path: daemon_toggle_response_path_for_request(&path)?, + request_path: path, + }); + } + + Ok(DaemonToggleCommands { + commands: requests, + saw_command_files, + }) +} diff --git a/src/daemon/control/response.rs b/src/daemon/control/response.rs new file mode 100644 index 00000000..30d68674 --- /dev/null +++ b/src/daemon/control/response.rs @@ -0,0 +1,220 @@ +use super::queue::{clear_file, write_file_atomic}; +use super::{ + DAEMON_TOGGLE_RESPONSE_POLL, DaemonToggleCommand, DaemonToggleEnvelope, DaemonToggleRequest, + DaemonToggleResponse, MAX_DAEMON_TOGGLE_RESPONSE_WAIT, current_unix_millis, +}; +use anyhow::{Context, Result, anyhow, bail}; +use log::warn; +use std::fs::{self, File, OpenOptions}; +use std::io::{ErrorKind, Write}; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::path::Path; +use std::thread; +use std::time::{Duration, Instant}; + +pub(crate) fn write_daemon_toggle_command_success(command: &DaemonToggleCommand) -> Result<()> { + write_daemon_toggle_response(&command.response_path, DaemonToggleResponse::ok()) +} + +pub(crate) fn write_daemon_toggle_command_error( + command: &DaemonToggleCommand, + message: &str, +) -> Result<()> { + write_daemon_toggle_response( + &command.response_path, + DaemonToggleResponse::error(message.to_string()), + ) +} + +fn write_daemon_toggle_response(path: &Path, response: DaemonToggleResponse) -> Result<()> { + let payload = + serde_json::to_vec(&response).context("failed to serialize daemon toggle response")?; + write_file_atomic(path, &payload) +} + +#[cfg(test)] +pub(crate) fn read_daemon_toggle_response(path: &Path) -> Result<()> { + let payload = fs::read(path) + .with_context(|| format!("failed to read daemon response {}", path.display()))?; + let response: DaemonToggleResponse = + serde_json::from_slice(&payload).context("failed to parse daemon toggle response")?; + response.into_result() +} + +pub(super) fn wait_daemon_toggle_response_for(path: &Path, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + match fs::read(path) { + Ok(payload) => { + let response: DaemonToggleResponse = serde_json::from_slice(&payload) + .context("failed to parse daemon toggle response")?; + if let Err(err) = clear_file(path) { + warn!( + "Failed to remove daemon response {}: {}", + path.display(), + err + ); + } + return response.into_result(); + } + Err(err) if err.kind() == ErrorKind::NotFound => { + if Instant::now() >= deadline { + return Err(anyhow!( + "timed out waiting for wayscriber daemon to process toggle request" + )); + } + thread::sleep(DAEMON_TOGGLE_RESPONSE_POLL); + } + Err(err) => { + return Err(err) + .with_context(|| format!("failed to read daemon response {}", path.display())); + } + } + } +} + +pub(super) fn wait_daemon_toggle_command_response(command: &DaemonToggleCommand) -> Result<()> { + wait_daemon_toggle_command_response_for(command, MAX_DAEMON_TOGGLE_RESPONSE_WAIT) +} + +pub(super) fn wait_daemon_toggle_command_response_for( + command: &DaemonToggleCommand, + timeout: Duration, +) -> Result<()> { + if let Err(err) = wait_daemon_toggle_response_for(&command.response_path, timeout) { + cancel_daemon_toggle_command(command); + return Err(err); + } + Ok(()) +} + +fn cancel_daemon_toggle_command(command: &DaemonToggleCommand) { + let payload = match canceled_daemon_toggle_payload(command) { + Ok(payload) => payload, + Err(err) => { + warn!("Failed to serialize daemon cancellation command: {}", err); + clear_daemon_toggle_command_files(command); + return; + } + }; + + match write_existing_daemon_request_file(&command.request_path, &payload) { + Ok(true) => {} + Ok(false) => return, + Err(err) => { + warn!( + "Failed to mark daemon request {} canceled after failed toggle wait: {}", + command.request_path.display(), + err + ); + } + } + + if let Err(err) = clear_file(&command.response_path) { + warn!( + "Failed to remove daemon response {} after failed toggle wait: {}", + command.response_path.display(), + err + ); + } +} + +fn write_existing_daemon_request_file(path: &Path, payload: &[u8]) -> Result { + let expected_metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => { + return Err(err).with_context(|| { + format!( + "failed to inspect daemon request {} before cancellation", + path.display() + ) + }); + } + }; + + if expected_metadata.file_type().is_symlink() { + bail!("daemon request {} is a symlink", path.display()); + } + + if !expected_metadata.is_file() { + bail!("daemon request {} is not a regular file", path.display()); + } + + let Some(mut file) = open_existing_daemon_request_file(path)? else { + return Ok(false); + }; + + let opened_metadata = file.metadata().with_context(|| { + format!( + "failed to inspect opened daemon request {} before cancellation", + path.display() + ) + })?; + + if !same_file_identity(&expected_metadata, &opened_metadata) { + return Ok(false); + } + + file.set_len(0) + .with_context(|| format!("failed to truncate daemon request {}", path.display()))?; + file.write_all(payload) + .with_context(|| format!("failed to write daemon request {}", path.display()))?; + Ok(true) +} + +fn open_existing_daemon_request_file(path: &Path) -> Result> { + let mut options = OpenOptions::new(); + options.write(true); + #[cfg(unix)] + options.custom_flags(libc::O_NOFOLLOW); + + match options.open(path) { + Ok(file) => Ok(Some(file)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err).with_context(|| { + format!( + "failed to open existing daemon request {} before cancellation", + path.display() + ) + }), + } +} + +#[cfg(unix)] +fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_file_identity(_left: &fs::Metadata, _right: &fs::Metadata) -> bool { + true +} + +fn canceled_daemon_toggle_payload(command: &DaemonToggleCommand) -> Result> { + let envelope = DaemonToggleEnvelope { + daemon_token: command.daemon_token.clone(), + requested_at_unix_ms: current_unix_millis()?, + canceled: true, + request: DaemonToggleRequest::default(), + }; + serde_json::to_vec(&envelope).context("failed to serialize canceled daemon toggle request") +} + +pub(super) fn clear_daemon_toggle_command_files(command: &DaemonToggleCommand) { + if let Err(err) = clear_file(&command.request_path) { + warn!( + "Failed to remove daemon request {} after failed toggle wait: {}", + command.request_path.display(), + err + ); + } + if let Err(err) = clear_file(&command.response_path) { + warn!( + "Failed to remove daemon response {} after failed toggle wait: {}", + command.response_path.display(), + err + ); + } +} diff --git a/src/daemon/control/runtime.rs b/src/daemon/control/runtime.rs new file mode 100644 index 00000000..50d115c8 --- /dev/null +++ b/src/daemon/control/runtime.rs @@ -0,0 +1,149 @@ +use super::DaemonRuntimeInfo; +use super::queue::{clear_daemon_toggle_request_file, clear_file, write_file_atomic}; +use crate::paths::{daemon_lock_file, daemon_pid_file}; +use crate::session::try_lock_exclusive; +use anyhow::{Context, Result, anyhow}; +use log::warn; +use std::fs; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::ErrorKind; + +pub(crate) fn write_daemon_pid_file(pid: u32, token: &str) -> Result<()> { + let path = daemon_pid_file(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; + } + let payload = serde_json::to_vec(&DaemonRuntimeInfo { + pid, + token: Some(token.to_string()), + }) + .context("failed to serialize daemon pid file")?; + write_file_atomic(&path, &payload)?; + Ok(()) +} + +pub(crate) fn clear_daemon_pid_file() -> Result<()> { + clear_file(&daemon_pid_file()) +} + +fn try_acquire_daemon_lock() -> Result> { + let path = daemon_lock_file(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; + } + + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .with_context(|| format!("failed to open daemon lock {}", path.display()))?; + + match try_lock_exclusive(&lock_file) { + Ok(()) => Ok(Some(lock_file)), + Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(None), + Err(err) => Err(err).context("failed to inspect daemon lock"), + } +} + +fn clear_stale_daemon_state() { + if let Err(err) = clear_daemon_pid_file() { + warn!("Failed to clear stale daemon pid file: {}", err); + } + if let Err(err) = clear_daemon_toggle_request_file() { + warn!("Failed to clear stale daemon command file: {}", err); + } +} + +fn parse_daemon_runtime_info(raw: &str) -> Result { + if let Ok(info) = serde_json::from_str::(raw) { + return Ok(info); + } + + let pid = raw + .trim() + .parse::() + .context("failed to parse daemon pid file")?; + Ok(DaemonRuntimeInfo { pid, token: None }) +} + +pub(super) fn read_daemon_runtime_file() -> Result { + let path = daemon_pid_file(); + let raw = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + parse_daemon_runtime_info(&raw) +} + +fn read_daemon_runtime_file_if_exists() -> Result> { + let path = daemon_pid_file(); + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(err).with_context(|| format!("failed to read {}", path.display())); + } + }; + parse_daemon_runtime_info(&raw).map(Some) +} + +pub(super) fn clear_stale_daemon_state_if_matches(expected: &DaemonRuntimeInfo) { + let Some(_lock_file) = (match try_acquire_daemon_lock() { + Ok(lock_file) => lock_file, + Err(err) => { + warn!( + "Failed to inspect daemon lock before stale cleanup: {}", + err + ); + return; + } + }) else { + return; + }; + + match read_daemon_runtime_file_if_exists() { + Ok(Some(current)) if ¤t == expected => clear_stale_daemon_state(), + Ok(_) => {} + Err(err) => warn!("Failed to inspect daemon pid before stale cleanup: {}", err), + } +} + +pub(super) fn read_daemon_runtime_info() -> Result { + if let Some(_lock_file) = try_acquire_daemon_lock()? { + clear_stale_daemon_state(); + return Err(anyhow!("wayscriber daemon is not running")); + } + + read_daemon_runtime_file() +} + +pub(super) fn signal_daemon_pid(pid: u32) -> Result<()> { + #[cfg(unix)] + { + let pid = i32::try_from(pid).context("daemon pid does not fit into i32")?; + if pid <= 0 { + return Err(anyhow!("invalid daemon pid {}", pid)); + } + + // SAFETY: `pid` has been checked to be a positive Unix process id and + // `SIGUSR1` is a valid signal constant. + if unsafe { libc::kill(pid, libc::SIGUSR1) } != 0 { + return Err(anyhow!( + "failed to signal wayscriber daemon {}: {}", + pid, + std::io::Error::last_os_error() + )); + } + Ok(()) + } + + #[cfg(not(unix))] + { + Err(anyhow!( + "daemon control is only supported on Unix platforms" + )) + } +} diff --git a/src/daemon/control/tests.rs b/src/daemon/control/tests.rs new file mode 100644 index 00000000..b6e0962e --- /dev/null +++ b/src/daemon/control/tests.rs @@ -0,0 +1,536 @@ +use super::*; +use crate::paths::daemon_pid_file; +use std::env; +use std::fs; +use std::sync::Mutex; + +static ENV_MUTEX: Mutex<()> = Mutex::new(()); + +#[test] +fn empty_toggle_request_reports_empty() { + assert!(DaemonToggleRequest::default().is_empty()); +} + +#[test] +fn overlay_action_request_is_not_empty() { + let request = DaemonToggleRequest { + overlay_action: Some(TrayAction::LightDrawToggle), + ..Default::default() + }; + assert!(!request.is_empty()); +} + +#[test] +fn session_file_request_is_not_empty() { + let request = DaemonToggleRequest { + session_file: Some(PathBuf::from("/tmp/lecture.wayscriber-session")), + ..Default::default() + }; + assert!(!request.is_empty()); +} + +#[test] +fn toggle_request_reports_session_override() { + let request = DaemonToggleRequest { + resume_session: true, + ..Default::default() + }; + assert_eq!(request.session_resume_override(), Some(true)); + + let request = DaemonToggleRequest { + no_resume_session: true, + ..Default::default() + }; + assert_eq!(request.session_resume_override(), Some(false)); +} + +#[test] +fn session_file_request_rejects_no_resume_session() { + let mut request = DaemonToggleRequest { + no_resume_session: true, + session_file: Some(PathBuf::from("/tmp/lecture.wayscriber-session")), + ..Default::default() + }; + + let err = request + .normalize_and_validate_session_file() + .expect_err("session file conflicts with disabled resume"); + + assert!( + format!("{err:#}").contains("--session-file conflicts with --no-resume-session"), + "{err:#}" + ); +} + +#[test] +fn session_file_request_rejects_relative_path() { + let mut request = DaemonToggleRequest { + session_file: Some(PathBuf::from("lecture.wayscriber-session")), + ..Default::default() + }; + + let err = request + .normalize_and_validate_session_file() + .expect_err("daemon protocol requires anchored paths"); + + assert!( + format!("{err:#}").contains("daemon --session-file request must use an absolute path"), + "{err:#}" + ); +} + +#[test] +fn daemon_toggle_response_round_trips_error_and_is_removed_after_wait() { + let tmp = crate::test_temp::tempdir().unwrap(); + let command = DaemonToggleCommand { + daemon_token: "daemon-token".into(), + request: DaemonToggleRequest::default(), + request_path: tmp.path().join("request.json"), + response_path: tmp.path().join("responses").join("request.json"), + }; + + write_daemon_toggle_command_error(&command, "cannot switch target").unwrap(); + + let err = + wait_daemon_toggle_response_for(&command.response_path, MAX_DAEMON_TOGGLE_RESPONSE_WAIT) + .expect_err("error response should surface to caller"); + assert!( + format!("{err:#}").contains("cannot switch target"), + "{err:#}" + ); + assert!(!command.response_path.exists()); +} + +#[test] +fn daemon_toggle_response_round_trips_success_and_is_removed_after_wait() { + let tmp = crate::test_temp::tempdir().unwrap(); + let command = DaemonToggleCommand { + daemon_token: "daemon-token".into(), + request: DaemonToggleRequest::default(), + request_path: tmp.path().join("request.json"), + response_path: tmp.path().join("responses").join("request.json"), + }; + + write_daemon_toggle_command_success(&command).unwrap(); + + wait_daemon_toggle_response_for(&command.response_path, MAX_DAEMON_TOGGLE_RESPONSE_WAIT) + .unwrap(); + assert!(!command.response_path.exists()); +} + +#[test] +fn daemon_toggle_response_wait_covers_request_age_and_overlay_stop_grace() { + assert!( + MAX_DAEMON_TOGGLE_RESPONSE_WAIT > MAX_DAEMON_TOGGLE_REQUEST_AGE + Duration::from_secs(2), + "typed toggle response wait should exceed accepted request age plus overlay stop grace" + ); +} + +#[test] +fn daemon_toggle_response_wait_error_marks_existing_request_canceled() { + let tmp = crate::test_temp::tempdir().unwrap(); + let command = DaemonToggleCommand { + daemon_token: "daemon-token".into(), + request: DaemonToggleRequest::default(), + request_path: tmp.path().join("request.json"), + response_path: tmp.path().join("responses").join("request.json"), + }; + fs::write(&command.request_path, b"pending request").unwrap(); + + let err = wait_daemon_toggle_command_response_for(&command, Duration::ZERO) + .expect_err("missing response should time out immediately"); + + assert!( + format!("{err:#}") + .contains("timed out waiting for wayscriber daemon to process toggle request"), + "{err:#}" + ); + let canceled: DaemonToggleEnvelope = + serde_json::from_slice(&fs::read(&command.request_path).unwrap()).unwrap(); + assert_eq!(canceled.daemon_token, "daemon-token"); + assert!(canceled.canceled); + assert!(!command.response_path.exists()); +} + +#[test] +fn daemon_toggle_response_wait_error_does_not_create_missing_cancel_request() { + let tmp = crate::test_temp::tempdir().unwrap(); + let command = DaemonToggleCommand { + daemon_token: "daemon-token".into(), + request: DaemonToggleRequest::default(), + request_path: tmp.path().join("request.json"), + response_path: tmp.path().join("responses").join("request.json"), + }; + + let err = wait_daemon_toggle_command_response_for(&command, Duration::ZERO) + .expect_err("missing response should time out immediately"); + + assert!( + format!("{err:#}") + .contains("timed out waiting for wayscriber daemon to process toggle request"), + "{err:#}" + ); + assert!(!command.request_path.exists()); + assert!(!command.response_path.exists()); +} + +#[test] +fn daemon_toggle_response_parse_error_marks_existing_request_canceled() { + let tmp = crate::test_temp::tempdir().unwrap(); + let command = DaemonToggleCommand { + daemon_token: "daemon-token".into(), + request: DaemonToggleRequest::default(), + request_path: tmp.path().join("request.json"), + response_path: tmp.path().join("responses").join("request.json"), + }; + fs::write(&command.request_path, b"pending request").unwrap(); + fs::create_dir_all(command.response_path.parent().unwrap()).unwrap(); + fs::write(&command.response_path, b"not json").unwrap(); + + let err = wait_daemon_toggle_command_response_for(&command, Duration::ZERO) + .expect_err("malformed response should preserve parse error"); + + assert!( + format!("{err:#}").contains("failed to parse daemon toggle response"), + "{err:#}" + ); + let canceled: DaemonToggleEnvelope = + serde_json::from_slice(&fs::read(&command.request_path).unwrap()).unwrap(); + assert!(canceled.canceled); + assert!(!command.response_path.exists()); +} + +#[test] +fn daemon_pid_file_round_trips_runtime_info() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + write_daemon_pid_file(1234, "daemon-token").unwrap(); + let info = read_daemon_runtime_info().unwrap_err(); + assert!( + info.to_string() + .contains("wayscriber daemon is not running") + ); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} + +#[test] +fn stale_cleanup_removes_matching_runtime_while_lock_is_free() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + let runtime = DaemonRuntimeInfo { + pid: 1234, + token: Some("old-token".into()), + }; + write_daemon_pid_file(runtime.pid, runtime.token.as_deref().unwrap()).unwrap(); + write_daemon_toggle_request( + &DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + "old-token", + ) + .unwrap(); + + clear_stale_daemon_state_if_matches(&runtime); + + assert!(!daemon_pid_file().exists()); + assert!(!daemon_command_dir().exists()); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} + +#[test] +fn stale_cleanup_preserves_mismatched_runtime() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + let current = DaemonRuntimeInfo { + pid: 5678, + token: Some("new-token".into()), + }; + write_daemon_pid_file(current.pid, current.token.as_deref().unwrap()).unwrap(); + write_daemon_toggle_request( + &DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + "new-token", + ) + .unwrap(); + + clear_stale_daemon_state_if_matches(&DaemonRuntimeInfo { + pid: 1234, + token: Some("old-token".into()), + }); + + assert_eq!(read_daemon_runtime_file().unwrap(), current); + assert!(daemon_command_dir().exists()); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} + +#[test] +fn take_daemon_toggle_request_round_trips_payload() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + let request = DaemonToggleRequest { + mode: Some("whiteboard".into()), + freeze: true, + exit_after_capture: true, + session_file: Some(PathBuf::from("/tmp/lecture.wayscriber-session")), + ..Default::default() + }; + write_daemon_toggle_request(&request, "daemon-token").unwrap(); + let batch = take_daemon_toggle_requests("daemon-token").unwrap(); + let requests = batch + .commands + .iter() + .map(|command| command.request.clone()) + .collect::>(); + assert_eq!(requests, vec![request]); + assert!(batch.saw_command_files); + assert_eq!(batch.commands.len(), 1); + assert_eq!(batch.commands[0].daemon_token, "daemon-token"); + assert!( + batch.commands[0] + .request_path + .starts_with(daemon_command_dir()) + ); + assert!( + batch.commands[0] + .response_path + .starts_with(daemon_command_dir().join("responses")) + ); + let batch = take_daemon_toggle_requests("daemon-token").unwrap(); + assert!(!batch.saw_command_files); + assert!(batch.commands.is_empty()); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} + +#[test] +fn write_daemon_toggle_request_queues_multiple_files_without_leaking_temp_files() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + write_daemon_toggle_request( + &DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + "daemon-token", + ) + .unwrap(); + write_daemon_toggle_request( + &DaemonToggleRequest { + mode: Some("whiteboard".into()), + ..Default::default() + }, + "daemon-token", + ) + .unwrap(); + + let command_dir = daemon_command_dir(); + let entries = fs::read_dir(&command_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|name| name.ends_with(".json"))); + assert!(!entries.iter().any(|name| name.ends_with(".tmp"))); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} + +#[test] +fn take_daemon_toggle_request_drains_multiple_payloads_in_order() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + let first = DaemonToggleRequest { + freeze: true, + ..Default::default() + }; + let second = DaemonToggleRequest { + mode: Some("whiteboard".into()), + ..Default::default() + }; + write_daemon_toggle_request(&first, "daemon-token").unwrap(); + write_daemon_toggle_request(&second, "daemon-token").unwrap(); + + let batch = take_daemon_toggle_requests("daemon-token").unwrap(); + let requests = batch + .commands + .into_iter() + .map(|command| command.request) + .collect::>(); + assert_eq!(requests, vec![first, second]); + assert!(batch.saw_command_files); + assert!( + daemon_command_dir() + .read_dir() + .map(|mut entries| entries.next().is_none()) + .unwrap_or(true) + ); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} + +#[test] +fn take_daemon_toggle_request_ignores_mismatched_token() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + write_daemon_toggle_request( + &DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + "other-daemon", + ) + .unwrap(); + + assert!( + take_daemon_toggle_requests("daemon-token") + .unwrap() + .commands + .is_empty() + ); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} + +#[test] +fn take_daemon_toggle_request_ignores_stale_payload() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + let payload = serde_json::to_vec(&DaemonToggleEnvelope { + daemon_token: "daemon-token".into(), + requested_at_unix_ms: current_unix_millis().unwrap() - 60_000, + canceled: false, + request: DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + }) + .unwrap(); + fs::create_dir_all(daemon_command_dir()).unwrap(); + fs::write(daemon_command_dir().join("stale.json"), payload).unwrap(); + + let batch = take_daemon_toggle_requests("daemon-token").unwrap(); + assert!(batch.saw_command_files); + assert!(batch.commands.is_empty()); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} + +#[test] +fn take_daemon_toggle_request_ignores_canceled_payload_but_marks_typed_signal() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = crate::test_temp::tempdir().unwrap(); + let prev = env::var_os(XDG_RUNTIME_DIR_ENV); + unsafe { + env::set_var(XDG_RUNTIME_DIR_ENV, tmp.path()); + } + + let payload = serde_json::to_vec(&DaemonToggleEnvelope { + daemon_token: "daemon-token".into(), + requested_at_unix_ms: current_unix_millis().unwrap(), + canceled: true, + request: DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + }) + .unwrap(); + fs::create_dir_all(daemon_command_dir()).unwrap(); + fs::write(daemon_command_dir().join("canceled.json"), payload).unwrap(); + + let batch = take_daemon_toggle_requests("daemon-token").unwrap(); + assert!(batch.saw_command_files); + assert!(batch.commands.is_empty()); + + match prev { + Some(value) => unsafe { env::set_var(XDG_RUNTIME_DIR_ENV, value) }, + None => unsafe { env::remove_var(XDG_RUNTIME_DIR_ENV) }, + } +} diff --git a/src/daemon/core.rs b/src/daemon/core.rs index 061e3ad2..85334189 100644 --- a/src/daemon/core.rs +++ b/src/daemon/core.rs @@ -3,7 +3,7 @@ use log::{info, warn}; use std::fs; use std::fs::OpenOptions; use std::io::ErrorKind; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::{Child, Command}; use std::sync::Arc; use std::sync::Mutex; @@ -17,16 +17,16 @@ use crate::SESSION_OVERRIDE_FOLLOW_CONFIG; use crate::env_vars::NO_TRAY_ENV; use crate::paths::daemon_lock_file; use crate::session::try_lock_exclusive; +#[cfg(test)] use crate::tray_action::TrayAction; use crate::{RESUME_SESSION_ENV, decode_session_override, encode_session_override}; use wayscriber::shortcut_hint::{ShortcutRuntimeBackend, current_shortcut_runtime_backend}; +use super::control::DaemonToggleRequest; #[cfg(test)] use super::control::read_daemon_toggle_response; -use super::control::{ - DaemonToggleCommand, DaemonToggleCommands, DaemonToggleRequest, - write_daemon_toggle_command_error, write_daemon_toggle_command_success, -}; +#[cfg(test)] +use super::control::{DaemonToggleCommand, DaemonToggleCommands}; use super::global_shortcuts::start_global_shortcuts_listener; use super::tray::start_system_tray; #[cfg(feature = "tray")] @@ -39,6 +39,8 @@ use super::types::{AlreadyRunningError, BackendRunner, OverlayState}; // typed requests still run. const DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW: Duration = Duration::from_millis(700); +mod toggles; + pub struct Daemon { pub(super) overlay_state: OverlayState, pub(super) should_quit: Arc, @@ -163,201 +165,6 @@ impl Daemon { .or_else(|| self.initial_named_session_file.clone()) } - fn ensure_visible_overlay_can_accept_request( - &self, - request: Option<&DaemonToggleRequest>, - ) -> Result<()> { - let Some(requested) = request.and_then(|request| request.session_file.as_ref()) else { - return Ok(()); - }; - if self.overlay_state != OverlayState::Visible { - return Ok(()); - } - if self - .active_named_session_file - .as_ref() - .is_some_and(|active| named_session_paths_match(active, requested)) - { - return Ok(()); - } - - Err(anyhow::anyhow!( - "cannot switch named session target while overlay is visible; hide the overlay first" - )) - } - - fn process_single_toggle( - &mut self, - request: Option, - activation_token: Option, - suppress_overlay_action_signal: bool, - ) -> Result { - let request = request - .map(|mut request| { - request.normalize_and_validate_session_file()?; - Ok::<_, anyhow::Error>(request) - }) - .transpose()?; - self.ensure_visible_overlay_can_accept_request(request.as_ref())?; - let plain_visibility_toggle_requested = - request.as_ref().is_none_or(DaemonToggleRequest::is_empty); - if plain_visibility_toggle_requested { - let now = Instant::now(); - if self - .last_plain_visibility_toggle_completed_at - .is_some_and(|previous| { - now.saturating_duration_since(previous) < DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW - }) - { - info!("Ignoring duplicate plain daemon visibility toggle"); - return Ok(false); - } - } - if let Some(action) = request.as_ref().and_then(|request| request.overlay_action) { - self.pending_activation_token = activation_token; - self.pending_toggle_request = request.filter(|request| !request.is_empty()); - if self.overlay_state == OverlayState::Hidden - && matches!(action, TrayAction::LightDrawOff) - { - self.pending_activation_token = None; - self.pending_toggle_request = None; - return Ok(false); - } - let was_hidden = self.overlay_state == OverlayState::Hidden; - self.dispatch_overlay_action(action, !suppress_overlay_action_signal)?; - if self.overlay_state == OverlayState::Hidden { - self.show_overlay()?; - return Ok(was_hidden); - } else { - self.pending_activation_token = None; - self.pending_toggle_request = None; - } - return Ok(false); - } - - self.pending_activation_token = activation_token; - self.pending_toggle_request = request.filter(|request| !request.is_empty()); - if let Err(err) = self.toggle_overlay() { - self.pending_activation_token = None; - self.pending_toggle_request = None; - return Err(err); - } - if plain_visibility_toggle_requested { - self.last_plain_visibility_toggle_completed_at = Some(Instant::now()); - } - Ok(false) - } - - fn process_queued_toggle_command( - &mut self, - command: DaemonToggleCommand, - suppress_overlay_action_signal: &mut bool, - ) { - let result = self.process_single_toggle( - Some(command.request.clone()), - None, - *suppress_overlay_action_signal, - ); - match result { - Ok(spawned_overlay) => { - *suppress_overlay_action_signal |= spawned_overlay; - if let Err(err) = write_daemon_toggle_command_success(&command) { - warn!("Failed to write daemon toggle response: {}", err); - } - } - Err(err) => { - let message = format!("{err:#}"); - warn!("Toggle overlay failed: {}", message); - if let Err(response_err) = write_daemon_toggle_command_error(&command, &message) { - warn!( - "Failed to write daemon toggle error response: {}", - response_err - ); - } - } - } - } - - fn dispatch_overlay_action( - &self, - action: TrayAction, - signal_visible_overlay: bool, - ) -> Result<()> { - let action_path = crate::tray_action::queue_action(action)?; - - let pid = self.overlay_pid.load(Ordering::Acquire); - if signal_visible_overlay && self.overlay_state == OverlayState::Visible && pid != 0 { - #[cfg(unix)] - { - let pid = i32::try_from(pid).context("overlay pid does not fit into i32")?; - if unsafe { libc::kill(pid, libc::SIGUSR2) } != 0 { - warn!( - "Failed to signal overlay process {} for action {}: {}", - pid, - action.as_str(), - std::io::Error::last_os_error() - ); - } - } - #[cfg(not(unix))] - { - warn!("Overlay actions are only supported on Unix platforms"); - } - } - log::debug!( - "Queued overlay action {} at {}", - action.as_str(), - action_path.display() - ); - - Ok(()) - } - - fn process_pending_toggles( - &mut self, - activation_token: Option, - signal_toggle_requested: bool, - ) -> Result<()> { - let queued_requests = if signal_toggle_requested { - crate::daemon::take_daemon_toggle_requests(&self.instance_token)? - } else { - DaemonToggleCommands { - commands: Vec::new(), - saw_command_files: false, - } - }; - - if !signal_toggle_requested || activation_token.is_some() { - self.process_single_toggle(None, activation_token, false)?; - } - - if signal_toggle_requested { - self.process_signal_toggle_commands(queued_requests)?; - } - - Ok(()) - } - - fn process_signal_toggle_commands( - &mut self, - queued_requests: DaemonToggleCommands, - ) -> Result<()> { - if queued_requests.commands.is_empty() { - if queued_requests.saw_command_files { - return Ok(()); - } - return self - .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) - .map(drop); - } - - let mut suppress_overlay_action_signal = false; - for command in queued_requests.commands { - self.process_queued_toggle_command(command, &mut suppress_overlay_action_signal); - } - Ok(()) - } - pub(super) fn session_resume_override(&self) -> Option { decode_session_override(self.session_resume_override.load(Ordering::Acquire)) } @@ -583,10 +390,6 @@ impl Daemon { } } -fn named_session_paths_match(left: &Path, right: &Path) -> bool { - crate::session::catalog::session_paths_match(left, right) -} - #[cfg(test)] impl Daemon { pub fn test_state(&self) -> OverlayState { @@ -595,254 +398,4 @@ impl Daemon { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; - - #[test] - fn light_draw_off_request_does_not_show_hidden_overlay() { - let called = Arc::new(AtomicUsize::new(0)); - let called_clone = Arc::clone(&called); - let runner: Arc = Arc::new(move |_| { - called_clone.fetch_add(1, AtomicOrdering::SeqCst); - Ok(()) - }); - let mut daemon = Daemon::with_backend_runner(None, runner); - - daemon - .process_single_toggle( - Some(DaemonToggleRequest { - overlay_action: Some(TrayAction::LightDrawOff), - ..Default::default() - }), - None, - false, - ) - .unwrap(); - - assert_eq!(called.load(AtomicOrdering::SeqCst), 0); - assert_eq!(daemon.test_state(), OverlayState::Hidden); - assert!(daemon.pending_toggle_request.is_none()); - assert!(daemon.pending_activation_token.is_none()); - } - - #[test] - fn visible_overlay_rejects_different_named_session_request() { - let runner: Arc = Arc::new(|_| Ok(())); - let mut daemon = Daemon::with_backend_runner(None, runner); - daemon.overlay_state = OverlayState::Visible; - daemon.active_named_session_file = - Some(std::path::PathBuf::from("/tmp/current.wayscriber-session")); - - let err = daemon - .process_single_toggle( - Some(DaemonToggleRequest { - session_file: Some(std::path::PathBuf::from("/tmp/other.wayscriber-session")), - ..Default::default() - }), - None, - false, - ) - .expect_err("different visible named target should be rejected"); - - assert!( - format!("{err:#}") - .contains("cannot switch named session target while overlay is visible"), - "{err:#}" - ); - assert_eq!(daemon.test_state(), OverlayState::Visible); - assert_eq!( - daemon.active_named_session_file.as_deref(), - Some(std::path::Path::new("/tmp/current.wayscriber-session")) - ); - } - - #[test] - fn visible_overlay_rejection_writes_daemon_toggle_error_response() { - let temp = crate::test_temp::tempdir().expect("tempdir"); - let runner: Arc = Arc::new(|_| Ok(())); - let mut daemon = Daemon::with_backend_runner(None, runner); - daemon.overlay_state = OverlayState::Visible; - daemon.active_named_session_file = - Some(std::path::PathBuf::from("/tmp/current.wayscriber-session")); - let command = DaemonToggleCommand { - daemon_token: "daemon-token".into(), - request: DaemonToggleRequest { - session_file: Some(std::path::PathBuf::from("/tmp/other.wayscriber-session")), - ..Default::default() - }, - request_path: temp.path().join("request.json"), - response_path: temp.path().join("responses").join("request.json"), - }; - - let mut suppress_overlay_action_signal = false; - daemon.process_queued_toggle_command(command.clone(), &mut suppress_overlay_action_signal); - - let err = read_daemon_toggle_response(&command.response_path) - .expect_err("visible target mismatch should be written to response"); - assert!( - format!("{err:#}") - .contains("cannot switch named session target while overlay is visible"), - "{err:#}" - ); - assert_eq!(daemon.test_state(), OverlayState::Visible); - assert!(!suppress_overlay_action_signal); - } - - #[test] - fn typed_signal_with_no_executable_commands_does_not_fallback_to_raw_toggle() { - let called = Arc::new(AtomicUsize::new(0)); - let called_clone = Arc::clone(&called); - let runner: Arc = Arc::new(move |_| { - called_clone.fetch_add(1, AtomicOrdering::SeqCst); - Ok(()) - }); - let mut daemon = Daemon::with_backend_runner(None, runner); - - daemon - .process_signal_toggle_commands(DaemonToggleCommands { - commands: Vec::new(), - saw_command_files: true, - }) - .expect("typed command marker should suppress raw fallback"); - - assert_eq!(called.load(AtomicOrdering::SeqCst), 0); - assert_eq!(daemon.test_state(), OverlayState::Hidden); - } - - #[test] - fn duplicate_plain_toggle_requests_are_debounced() { - let called = Arc::new(AtomicUsize::new(0)); - let called_clone = Arc::clone(&called); - let runner: Arc = Arc::new(move |_| { - called_clone.fetch_add(1, AtomicOrdering::SeqCst); - Ok(()) - }); - let mut daemon = Daemon::with_backend_runner(None, runner); - - daemon - .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) - .unwrap(); - daemon - .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) - .unwrap(); - - assert_eq!(called.load(AtomicOrdering::SeqCst), 1); - assert_eq!(daemon.test_state(), OverlayState::Hidden); - } - - #[test] - fn typed_visibility_toggle_request_is_not_debounced() { - let modes = Arc::new(std::sync::Mutex::new(Vec::new())); - let modes_clone = Arc::clone(&modes); - let runner: Arc = Arc::new(move |mode| { - modes_clone - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(mode); - Ok(()) - }); - let mut daemon = Daemon::with_backend_runner(None, runner); - - daemon - .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) - .unwrap(); - daemon - .process_single_toggle( - Some(DaemonToggleRequest { - mode: Some("whiteboard".to_string()), - ..Default::default() - }), - None, - false, - ) - .unwrap(); - - let modes = modes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(modes.as_slice(), &[None, Some("whiteboard".to_string())]); - assert_eq!(daemon.test_state(), OverlayState::Hidden); - } - - #[cfg(unix)] - #[test] - fn duplicate_plain_toggle_after_slow_hide_is_debounced() { - let mut daemon = Daemon::new(None, false, None, None); - let child = std::process::Command::new("sleep") - .arg("10") - .spawn() - .expect("spawn slow-terminating test process"); - let child_pid = child.id(); - assert_eq!(unsafe { libc::kill(child_pid as i32, libc::SIGSTOP) }, 0); - let mut stopped = false; - for _ in 0..20 { - let mut status = 0; - let result = unsafe { - libc::waitpid( - child_pid as i32, - &mut status, - libc::WNOHANG | libc::WUNTRACED, - ) - }; - if result == child_pid as i32 && libc::WIFSTOPPED(status) { - stopped = true; - break; - } - thread::sleep(Duration::from_millis(10)); - } - assert!(stopped, "test child should stop before hide starts"); - daemon - .overlay_pid - .store(child.id(), std::sync::atomic::Ordering::Release); - daemon.overlay_child = Some(child); - daemon.overlay_state = OverlayState::Visible; - - let hide_started = Instant::now(); - daemon - .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) - .unwrap(); - assert!( - hide_started.elapsed() >= DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW, - "test setup should keep hide slow enough to cross the debounce window" - ); - assert_eq!(daemon.test_state(), OverlayState::Hidden); - - let called = Arc::new(AtomicUsize::new(0)); - let called_clone = Arc::clone(&called); - daemon.backend_runner = Some(Arc::new(move |_| { - called_clone.fetch_add(1, AtomicOrdering::SeqCst); - Ok(()) - })); - - daemon - .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) - .unwrap(); - - assert_eq!(called.load(AtomicOrdering::SeqCst), 0); - assert_eq!(daemon.test_state(), OverlayState::Hidden); - } - - #[test] - fn plain_toggle_after_debounce_window_is_processed() { - let called = Arc::new(AtomicUsize::new(0)); - let called_clone = Arc::clone(&called); - let runner: Arc = Arc::new(move |_| { - called_clone.fetch_add(1, AtomicOrdering::SeqCst); - Ok(()) - }); - let mut daemon = Daemon::with_backend_runner(None, runner); - - daemon - .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) - .unwrap(); - daemon.last_plain_visibility_toggle_completed_at = - Some(Instant::now() - DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW - Duration::from_millis(1)); - daemon - .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) - .unwrap(); - - assert_eq!(called.load(AtomicOrdering::SeqCst), 2); - assert_eq!(daemon.test_state(), OverlayState::Hidden); - } -} +mod tests; diff --git a/src/daemon/core/tests.rs b/src/daemon/core/tests.rs new file mode 100644 index 00000000..945a9524 --- /dev/null +++ b/src/daemon/core/tests.rs @@ -0,0 +1,247 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + +#[test] +fn light_draw_off_request_does_not_show_hidden_overlay() { + let called = Arc::new(AtomicUsize::new(0)); + let called_clone = Arc::clone(&called); + let runner: Arc = Arc::new(move |_| { + called_clone.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + }); + let mut daemon = Daemon::with_backend_runner(None, runner); + + daemon + .process_single_toggle( + Some(DaemonToggleRequest { + overlay_action: Some(TrayAction::LightDrawOff), + ..Default::default() + }), + None, + false, + ) + .unwrap(); + + assert_eq!(called.load(AtomicOrdering::SeqCst), 0); + assert_eq!(daemon.test_state(), OverlayState::Hidden); + assert!(daemon.pending_toggle_request.is_none()); + assert!(daemon.pending_activation_token.is_none()); +} + +#[test] +fn visible_overlay_rejects_different_named_session_request() { + let runner: Arc = Arc::new(|_| Ok(())); + let mut daemon = Daemon::with_backend_runner(None, runner); + daemon.overlay_state = OverlayState::Visible; + daemon.active_named_session_file = + Some(std::path::PathBuf::from("/tmp/current.wayscriber-session")); + + let err = daemon + .process_single_toggle( + Some(DaemonToggleRequest { + session_file: Some(std::path::PathBuf::from("/tmp/other.wayscriber-session")), + ..Default::default() + }), + None, + false, + ) + .expect_err("different visible named target should be rejected"); + + assert!( + format!("{err:#}").contains("cannot switch named session target while overlay is visible"), + "{err:#}" + ); + assert_eq!(daemon.test_state(), OverlayState::Visible); + assert_eq!( + daemon.active_named_session_file.as_deref(), + Some(std::path::Path::new("/tmp/current.wayscriber-session")) + ); +} + +#[test] +fn visible_overlay_rejection_writes_daemon_toggle_error_response() { + let temp = crate::test_temp::tempdir().expect("tempdir"); + let runner: Arc = Arc::new(|_| Ok(())); + let mut daemon = Daemon::with_backend_runner(None, runner); + daemon.overlay_state = OverlayState::Visible; + daemon.active_named_session_file = + Some(std::path::PathBuf::from("/tmp/current.wayscriber-session")); + let command = DaemonToggleCommand { + daemon_token: "daemon-token".into(), + request: DaemonToggleRequest { + session_file: Some(std::path::PathBuf::from("/tmp/other.wayscriber-session")), + ..Default::default() + }, + request_path: temp.path().join("request.json"), + response_path: temp.path().join("responses").join("request.json"), + }; + + let mut suppress_overlay_action_signal = false; + daemon.process_queued_toggle_command(command.clone(), &mut suppress_overlay_action_signal); + + let err = read_daemon_toggle_response(&command.response_path) + .expect_err("visible target mismatch should be written to response"); + assert!( + format!("{err:#}").contains("cannot switch named session target while overlay is visible"), + "{err:#}" + ); + assert_eq!(daemon.test_state(), OverlayState::Visible); + assert!(!suppress_overlay_action_signal); +} + +#[test] +fn typed_signal_with_no_executable_commands_does_not_fallback_to_raw_toggle() { + let called = Arc::new(AtomicUsize::new(0)); + let called_clone = Arc::clone(&called); + let runner: Arc = Arc::new(move |_| { + called_clone.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + }); + let mut daemon = Daemon::with_backend_runner(None, runner); + + daemon + .process_signal_toggle_commands(DaemonToggleCommands { + commands: Vec::new(), + saw_command_files: true, + }) + .expect("typed command marker should suppress raw fallback"); + + assert_eq!(called.load(AtomicOrdering::SeqCst), 0); + assert_eq!(daemon.test_state(), OverlayState::Hidden); +} + +#[test] +fn duplicate_plain_toggle_requests_are_debounced() { + let called = Arc::new(AtomicUsize::new(0)); + let called_clone = Arc::clone(&called); + let runner: Arc = Arc::new(move |_| { + called_clone.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + }); + let mut daemon = Daemon::with_backend_runner(None, runner); + + daemon + .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) + .unwrap(); + daemon + .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) + .unwrap(); + + assert_eq!(called.load(AtomicOrdering::SeqCst), 1); + assert_eq!(daemon.test_state(), OverlayState::Hidden); +} + +#[test] +fn typed_visibility_toggle_request_is_not_debounced() { + let modes = Arc::new(std::sync::Mutex::new(Vec::new())); + let modes_clone = Arc::clone(&modes); + let runner: Arc = Arc::new(move |mode| { + modes_clone + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(mode); + Ok(()) + }); + let mut daemon = Daemon::with_backend_runner(None, runner); + + daemon + .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) + .unwrap(); + daemon + .process_single_toggle( + Some(DaemonToggleRequest { + mode: Some("whiteboard".to_string()), + ..Default::default() + }), + None, + false, + ) + .unwrap(); + + let modes = modes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert_eq!(modes.as_slice(), &[None, Some("whiteboard".to_string())]); + assert_eq!(daemon.test_state(), OverlayState::Hidden); +} + +#[cfg(unix)] +#[test] +fn duplicate_plain_toggle_after_slow_hide_is_debounced() { + let mut daemon = Daemon::new(None, false, None, None); + let child = std::process::Command::new("sleep") + .arg("10") + .spawn() + .expect("spawn slow-terminating test process"); + let child_pid = child.id(); + assert_eq!(unsafe { libc::kill(child_pid as i32, libc::SIGSTOP) }, 0); + let mut stopped = false; + for _ in 0..20 { + let mut status = 0; + let result = unsafe { + libc::waitpid( + child_pid as i32, + &mut status, + libc::WNOHANG | libc::WUNTRACED, + ) + }; + if result == child_pid as i32 && libc::WIFSTOPPED(status) { + stopped = true; + break; + } + thread::sleep(Duration::from_millis(10)); + } + assert!(stopped, "test child should stop before hide starts"); + daemon + .overlay_pid + .store(child.id(), std::sync::atomic::Ordering::Release); + daemon.overlay_child = Some(child); + daemon.overlay_state = OverlayState::Visible; + + let hide_started = Instant::now(); + daemon + .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) + .unwrap(); + assert!( + hide_started.elapsed() >= DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW, + "test setup should keep hide slow enough to cross the debounce window" + ); + assert_eq!(daemon.test_state(), OverlayState::Hidden); + + let called = Arc::new(AtomicUsize::new(0)); + let called_clone = Arc::clone(&called); + daemon.backend_runner = Some(Arc::new(move |_| { + called_clone.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + })); + + daemon + .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) + .unwrap(); + + assert_eq!(called.load(AtomicOrdering::SeqCst), 0); + assert_eq!(daemon.test_state(), OverlayState::Hidden); +} + +#[test] +fn plain_toggle_after_debounce_window_is_processed() { + let called = Arc::new(AtomicUsize::new(0)); + let called_clone = Arc::clone(&called); + let runner: Arc = Arc::new(move |_| { + called_clone.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + }); + let mut daemon = Daemon::with_backend_runner(None, runner); + + daemon + .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) + .unwrap(); + daemon.last_plain_visibility_toggle_completed_at = + Some(Instant::now() - DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW - Duration::from_millis(1)); + daemon + .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) + .unwrap(); + + assert_eq!(called.load(AtomicOrdering::SeqCst), 2); + assert_eq!(daemon.test_state(), OverlayState::Hidden); +} diff --git a/src/daemon/core/toggles.rs b/src/daemon/core/toggles.rs new file mode 100644 index 00000000..dc431dc0 --- /dev/null +++ b/src/daemon/core/toggles.rs @@ -0,0 +1,215 @@ +use super::super::control::{ + DaemonToggleCommand, DaemonToggleCommands, DaemonToggleRequest, + write_daemon_toggle_command_error, write_daemon_toggle_command_success, +}; +use super::super::types::OverlayState; +use super::{DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW, Daemon}; +use crate::tray_action::TrayAction; +use anyhow::{Context, Result}; +use log::{info, warn}; +use std::path::Path; +use std::sync::atomic::Ordering; +use std::time::Instant; + +impl Daemon { + pub(super) fn ensure_visible_overlay_can_accept_request( + &self, + request: Option<&DaemonToggleRequest>, + ) -> Result<()> { + let Some(requested) = request.and_then(|request| request.session_file.as_ref()) else { + return Ok(()); + }; + if self.overlay_state != OverlayState::Visible { + return Ok(()); + } + if self + .active_named_session_file + .as_ref() + .is_some_and(|active| named_session_paths_match(active, requested)) + { + return Ok(()); + } + + Err(anyhow::anyhow!( + "cannot switch named session target while overlay is visible; hide the overlay first" + )) + } + + pub(super) fn process_single_toggle( + &mut self, + request: Option, + activation_token: Option, + suppress_overlay_action_signal: bool, + ) -> Result { + let request = request + .map(|mut request| { + request.normalize_and_validate_session_file()?; + Ok::<_, anyhow::Error>(request) + }) + .transpose()?; + self.ensure_visible_overlay_can_accept_request(request.as_ref())?; + let plain_visibility_toggle_requested = + request.as_ref().is_none_or(DaemonToggleRequest::is_empty); + if plain_visibility_toggle_requested { + let now = Instant::now(); + if self + .last_plain_visibility_toggle_completed_at + .is_some_and(|previous| { + now.saturating_duration_since(previous) < DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW + }) + { + info!("Ignoring duplicate plain daemon visibility toggle"); + return Ok(false); + } + } + if let Some(action) = request.as_ref().and_then(|request| request.overlay_action) { + self.pending_activation_token = activation_token; + self.pending_toggle_request = request.filter(|request| !request.is_empty()); + if self.overlay_state == OverlayState::Hidden + && matches!(action, TrayAction::LightDrawOff) + { + self.pending_activation_token = None; + self.pending_toggle_request = None; + return Ok(false); + } + let was_hidden = self.overlay_state == OverlayState::Hidden; + self.dispatch_overlay_action(action, !suppress_overlay_action_signal)?; + if self.overlay_state == OverlayState::Hidden { + self.show_overlay()?; + return Ok(was_hidden); + } else { + self.pending_activation_token = None; + self.pending_toggle_request = None; + } + return Ok(false); + } + + self.pending_activation_token = activation_token; + self.pending_toggle_request = request.filter(|request| !request.is_empty()); + if let Err(err) = self.toggle_overlay() { + self.pending_activation_token = None; + self.pending_toggle_request = None; + return Err(err); + } + if plain_visibility_toggle_requested { + self.last_plain_visibility_toggle_completed_at = Some(Instant::now()); + } + Ok(false) + } + + pub(super) fn process_queued_toggle_command( + &mut self, + command: DaemonToggleCommand, + suppress_overlay_action_signal: &mut bool, + ) { + let result = self.process_single_toggle( + Some(command.request.clone()), + None, + *suppress_overlay_action_signal, + ); + match result { + Ok(spawned_overlay) => { + *suppress_overlay_action_signal |= spawned_overlay; + if let Err(err) = write_daemon_toggle_command_success(&command) { + warn!("Failed to write daemon toggle response: {}", err); + } + } + Err(err) => { + let message = format!("{err:#}"); + warn!("Toggle overlay failed: {}", message); + if let Err(response_err) = write_daemon_toggle_command_error(&command, &message) { + warn!( + "Failed to write daemon toggle error response: {}", + response_err + ); + } + } + } + } + + fn dispatch_overlay_action( + &self, + action: TrayAction, + signal_visible_overlay: bool, + ) -> Result<()> { + let action_path = crate::tray_action::queue_action(action)?; + + let pid = self.overlay_pid.load(Ordering::Acquire); + if signal_visible_overlay && self.overlay_state == OverlayState::Visible && pid != 0 { + #[cfg(unix)] + { + let pid = i32::try_from(pid).context("overlay pid does not fit into i32")?; + // SAFETY: `pid` has been checked to fit the Unix pid range and + // `SIGUSR2` is a valid signal constant. + if unsafe { libc::kill(pid, libc::SIGUSR2) } != 0 { + warn!( + "Failed to signal overlay process {} for action {}: {}", + pid, + action.as_str(), + std::io::Error::last_os_error() + ); + } + } + #[cfg(not(unix))] + { + warn!("Overlay actions are only supported on Unix platforms"); + } + } + log::debug!( + "Queued overlay action {} at {}", + action.as_str(), + action_path.display() + ); + + Ok(()) + } + + pub(super) fn process_pending_toggles( + &mut self, + activation_token: Option, + signal_toggle_requested: bool, + ) -> Result<()> { + let queued_requests = if signal_toggle_requested { + crate::daemon::take_daemon_toggle_requests(&self.instance_token)? + } else { + DaemonToggleCommands { + commands: Vec::new(), + saw_command_files: false, + } + }; + + if !signal_toggle_requested || activation_token.is_some() { + self.process_single_toggle(None, activation_token, false)?; + } + + if signal_toggle_requested { + self.process_signal_toggle_commands(queued_requests)?; + } + + Ok(()) + } + + pub(super) fn process_signal_toggle_commands( + &mut self, + queued_requests: DaemonToggleCommands, + ) -> Result<()> { + if queued_requests.commands.is_empty() { + if queued_requests.saw_command_files { + return Ok(()); + } + return self + .process_single_toggle(Some(DaemonToggleRequest::default()), None, false) + .map(drop); + } + + let mut suppress_overlay_action_signal = false; + for command in queued_requests.commands { + self.process_queued_toggle_command(command, &mut suppress_overlay_action_signal); + } + Ok(()) + } +} + +fn named_session_paths_match(left: &Path, right: &Path) -> bool { + crate::session::catalog::session_paths_match(left, right) +} diff --git a/src/daemon/setup.rs b/src/daemon/setup.rs index c647e2bd..14ab485b 100644 --- a/src/daemon/setup.rs +++ b/src/daemon/setup.rs @@ -1,3 +1,4 @@ +use crate::durable_io::{AtomicWriteOptions, OverwriteMode, PermissionPolicy, SymlinkPolicy}; use anyhow::{Context, Result, bail}; use std::fs; use std::io::ErrorKind; @@ -48,7 +49,18 @@ fn write_if_changed(path: &Path, content: &str) -> Result<()> { } } - fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?; + crate::durable_io::write_text_atomic( + path, + content, + AtomicWriteOptions { + overwrite: OverwriteMode::Replace, + permissions: PermissionPolicy::PreserveExistingOrMode(0o644), + symlink: SymlinkPolicy::FollowExistingTarget, + sync_file: true, + sync_parent: true, + }, + ) + .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } diff --git a/src/draw/render/blur.rs b/src/draw/render/blur.rs index 48337e41..37df6f4d 100644 --- a/src/draw/render/blur.rs +++ b/src/draw/render/blur.rs @@ -459,122 +459,4 @@ pub fn render_blur_rect( } #[cfg(test)] -mod tests { - use super::{ - BlurCacheKey, BlurRenderCache, BlurSurfaceStats, CachedBlurRegion, blur_overlay_palette, - blur_recipe, - }; - - #[test] - fn blur_recipe_keeps_default_strength_heavily_blurred_but_not_overwashed() { - let recipe = blur_recipe(12.0); - - assert!(recipe.primary_factor >= 18.0); - assert!(recipe.secondary_factor > recipe.primary_factor); - assert!((0.05..=0.11).contains(&recipe.overlay_alpha)); - } - - #[test] - fn blur_recipe_clamps_extremes() { - let min = blur_recipe(-10.0); - let max = blur_recipe(500.0); - - assert_eq!(min.primary_factor, 8.0); - assert_eq!(min.secondary_factor, 10.0); - assert!((0.05..=0.11).contains(&min.overlay_alpha)); - - assert!(max.primary_factor <= 36.0); - assert!(max.secondary_factor <= 52.0); - assert!((0.05..=0.11).contains(&max.overlay_alpha)); - } - - #[test] - fn overlay_palette_switches_contrast_for_light_and_dark_regions() { - let dark_region = blur_overlay_palette( - BlurSurfaceStats { - red: 0.2, - green: 0.24, - blue: 0.3, - luminance: 0.22, - }, - 0.1, - ); - let light_region = blur_overlay_palette( - BlurSurfaceStats { - red: 0.82, - green: 0.84, - blue: 0.88, - luminance: 0.84, - }, - 0.1, - ); - - assert!((dark_region.0.0 - 0.2).abs() < f64::EPSILON); - assert!(dark_region.1.0 > dark_region.0.0); - assert!((light_region.0.0 - 0.82).abs() < f64::EPSILON); - assert!(light_region.1.0 < light_region.0.0); - } - - #[test] - fn blur_render_cache_returns_cached_entry_for_same_key() { - let mut cache = BlurRenderCache::new(4, 1024); - let key = BlurCacheKey { - backdrop_cache_key: 1, - src_x: 10, - src_y: 20, - src_w: 30, - src_h: 40, - primary_factor: 18, - secondary_factor: 24, - }; - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).expect("surface"); - cache.insert( - key, - CachedBlurRegion { - surface, - stats: BlurSurfaceStats { - red: 0.4, - green: 0.42, - blue: 0.45, - luminance: 0.42, - }, - approx_bytes: 64, - }, - ); - - let cached = cache.get(&key).expect("cached entry"); - assert!((cached.stats.luminance - 0.42).abs() < f64::EPSILON); - assert_eq!(cached.surface.width(), 4); - assert_eq!(cached.surface.height(), 4); - } - - #[test] - fn blur_render_cache_evicts_oldest_entry_when_budget_is_exceeded() { - let mut cache = BlurRenderCache::new(2, 96); - let make_key = |backdrop_cache_key| BlurCacheKey { - backdrop_cache_key, - src_x: 0, - src_y: 0, - src_w: 2, - src_h: 2, - primary_factor: 18, - secondary_factor: 24, - }; - let make_entry = || CachedBlurRegion { - surface: cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).expect("surface"), - stats: BlurSurfaceStats { - red: 0.5, - green: 0.52, - blue: 0.55, - luminance: 0.5, - }, - approx_bytes: 64, - }; - - cache.insert(make_key(1), make_entry()); - cache.insert(make_key(2), make_entry()); - - assert!(cache.get(&make_key(1)).is_none()); - assert!(cache.get(&make_key(2)).is_some()); - } -} +mod tests; diff --git a/src/draw/render/blur/tests.rs b/src/draw/render/blur/tests.rs new file mode 100644 index 00000000..aeefb16d --- /dev/null +++ b/src/draw/render/blur/tests.rs @@ -0,0 +1,117 @@ +use super::{ + BlurCacheKey, BlurRenderCache, BlurSurfaceStats, CachedBlurRegion, blur_overlay_palette, + blur_recipe, +}; + +#[test] +fn blur_recipe_keeps_default_strength_heavily_blurred_but_not_overwashed() { + let recipe = blur_recipe(12.0); + + assert!(recipe.primary_factor >= 18.0); + assert!(recipe.secondary_factor > recipe.primary_factor); + assert!((0.05..=0.11).contains(&recipe.overlay_alpha)); +} + +#[test] +fn blur_recipe_clamps_extremes() { + let min = blur_recipe(-10.0); + let max = blur_recipe(500.0); + + assert_eq!(min.primary_factor, 8.0); + assert_eq!(min.secondary_factor, 10.0); + assert!((0.05..=0.11).contains(&min.overlay_alpha)); + + assert!(max.primary_factor <= 36.0); + assert!(max.secondary_factor <= 52.0); + assert!((0.05..=0.11).contains(&max.overlay_alpha)); +} + +#[test] +fn overlay_palette_switches_contrast_for_light_and_dark_regions() { + let dark_region = blur_overlay_palette( + BlurSurfaceStats { + red: 0.2, + green: 0.24, + blue: 0.3, + luminance: 0.22, + }, + 0.1, + ); + let light_region = blur_overlay_palette( + BlurSurfaceStats { + red: 0.82, + green: 0.84, + blue: 0.88, + luminance: 0.84, + }, + 0.1, + ); + + assert!((dark_region.0.0 - 0.2).abs() < f64::EPSILON); + assert!(dark_region.1.0 > dark_region.0.0); + assert!((light_region.0.0 - 0.82).abs() < f64::EPSILON); + assert!(light_region.1.0 < light_region.0.0); +} + +#[test] +fn blur_render_cache_returns_cached_entry_for_same_key() { + let mut cache = BlurRenderCache::new(4, 1024); + let key = BlurCacheKey { + backdrop_cache_key: 1, + src_x: 10, + src_y: 20, + src_w: 30, + src_h: 40, + primary_factor: 18, + secondary_factor: 24, + }; + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).expect("surface"); + cache.insert( + key, + CachedBlurRegion { + surface, + stats: BlurSurfaceStats { + red: 0.4, + green: 0.42, + blue: 0.45, + luminance: 0.42, + }, + approx_bytes: 64, + }, + ); + + let cached = cache.get(&key).expect("cached entry"); + assert!((cached.stats.luminance - 0.42).abs() < f64::EPSILON); + assert_eq!(cached.surface.width(), 4); + assert_eq!(cached.surface.height(), 4); +} + +#[test] +fn blur_render_cache_evicts_oldest_entry_when_budget_is_exceeded() { + let mut cache = BlurRenderCache::new(2, 96); + let make_key = |backdrop_cache_key| BlurCacheKey { + backdrop_cache_key, + src_x: 0, + src_y: 0, + src_w: 2, + src_h: 2, + primary_factor: 18, + secondary_factor: 24, + }; + let make_entry = || CachedBlurRegion { + surface: cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).expect("surface"), + stats: BlurSurfaceStats { + red: 0.5, + green: 0.52, + blue: 0.55, + luminance: 0.5, + }, + approx_bytes: 64, + }; + + cache.insert(make_key(1), make_entry()); + cache.insert(make_key(2), make_entry()); + + assert!(cache.get(&make_key(1)).is_none()); + assert!(cache.get(&make_key(2)).is_some()); +} diff --git a/src/durable_io.rs b/src/durable_io.rs new file mode 100644 index 00000000..342412f7 --- /dev/null +++ b/src/durable_io.rs @@ -0,0 +1,485 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + +mod model; + +pub use model::{ + AtomicWriteOptions, DurableIoError, DurableIoOperation, OverwriteMode, PermissionPolicy, + SymlinkPolicy, +}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug)] +struct Destination { + original_path: PathBuf, + final_path: PathBuf, + followed_target: Option, + existing_mode: Option, + existing_identity: Option, + existed_at_inspect: bool, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileIdentity { + device: u64, + inode: u64, +} + +#[cfg(not(unix))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileIdentity; + +pub fn write_text_atomic( + path: &Path, + contents: &str, + options: AtomicWriteOptions, +) -> Result<(), DurableIoError> { + write_atomic(path, contents.as_bytes(), options) +} + +pub fn write_atomic( + path: &Path, + bytes: &[u8], + options: AtomicWriteOptions, +) -> Result<(), DurableIoError> { + let destination = inspect_destination(path, options)?; + let parent = destination + .final_path + .parent() + .ok_or_else(|| DurableIoError::MissingParent { + path: destination.final_path.clone(), + })?; + let file_name = + destination + .final_path + .file_name() + .ok_or_else(|| DurableIoError::MissingParent { + path: destination.final_path.clone(), + })?; + let (temp_path, mut temp_file) = create_temp_file(parent, file_name)?; + + let result = (|| { + temp_file + .write_all(bytes) + .map_err(|source| io_error(DurableIoOperation::WriteTemporary, &temp_path, source))?; + apply_final_permissions(&temp_path, &destination, options.permissions)?; + if options.sync_file { + temp_file.sync_all().map_err(|source| { + io_error(DurableIoOperation::SyncTemporary, &temp_path, source) + })?; + } + drop(temp_file); + revalidate_destination(&destination, options)?; + finalize_temp_file( + &temp_path, + &destination.final_path, + finalize_overwrite_mode(&destination, options), + )?; + if options.sync_parent { + sync_parent_dir(&destination.final_path)?; + } + Ok(()) + })(); + + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +pub fn sync_parent_dir(path: &Path) -> Result<(), DurableIoError> { + let parent = path.parent().ok_or_else(|| DurableIoError::MissingParent { + path: path.to_path_buf(), + })?; + sync_dir(parent) +} + +fn inspect_destination( + path: &Path, + options: AtomicWriteOptions, +) -> Result { + match options.symlink { + SymlinkPolicy::FollowExistingTarget => inspect_follow_destination(path), + SymlinkPolicy::Reject => inspect_reject_destination(path), + } +} + +fn inspect_follow_destination(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let target = read_resolved_link(path)?; + let target_metadata = fs::symlink_metadata(&target).map_err(|source| { + io_error(DurableIoOperation::InspectDestination, &target, source) + })?; + if target_metadata.file_type().is_symlink() { + return Err(DurableIoError::SymlinkRejected { path: target }); + } + if !target_metadata.is_file() { + return Err(DurableIoError::UnsupportedFileType { path: target }); + } + Ok(Destination { + original_path: path.to_path_buf(), + final_path: target.clone(), + followed_target: Some(target), + existing_mode: metadata_mode(&target_metadata), + existing_identity: file_identity(&target_metadata), + existed_at_inspect: true, + }) + } + Ok(metadata) if metadata.is_file() => Ok(Destination { + original_path: path.to_path_buf(), + final_path: path.to_path_buf(), + followed_target: None, + existing_mode: metadata_mode(&metadata), + existing_identity: file_identity(&metadata), + existed_at_inspect: true, + }), + Ok(_) => Err(DurableIoError::UnsupportedFileType { + path: path.to_path_buf(), + }), + Err(source) if source.kind() == ErrorKind::NotFound => Ok(Destination { + original_path: path.to_path_buf(), + final_path: path.to_path_buf(), + followed_target: None, + existing_mode: None, + existing_identity: None, + existed_at_inspect: false, + }), + Err(source) => Err(io_error( + DurableIoOperation::InspectDestination, + path, + source, + )), + } +} + +fn inspect_reject_destination(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(DurableIoError::SymlinkRejected { + path: path.to_path_buf(), + }), + Ok(metadata) if metadata.is_file() => Ok(Destination { + original_path: path.to_path_buf(), + final_path: path.to_path_buf(), + followed_target: None, + existing_mode: metadata_mode(&metadata), + existing_identity: file_identity(&metadata), + existed_at_inspect: true, + }), + Ok(_) => Err(DurableIoError::UnsupportedFileType { + path: path.to_path_buf(), + }), + Err(source) if source.kind() == ErrorKind::NotFound => Ok(Destination { + original_path: path.to_path_buf(), + final_path: path.to_path_buf(), + followed_target: None, + existing_mode: None, + existing_identity: None, + existed_at_inspect: false, + }), + Err(source) => Err(io_error( + DurableIoOperation::InspectDestination, + path, + source, + )), + } +} + +fn revalidate_destination( + destination: &Destination, + options: AtomicWriteOptions, +) -> Result<(), DurableIoError> { + if let Some(expected) = &destination.followed_target { + let current = read_resolved_link(&destination.original_path).map_err(|_| { + DurableIoError::DestinationChanged { + operation: DurableIoOperation::ReadLink, + path: destination.original_path.clone(), + } + })?; + if current != *expected { + return Err(DurableIoError::DestinationChanged { + operation: DurableIoOperation::ReadLink, + path: destination.original_path.clone(), + }); + } + } + + match options.overwrite { + OverwriteMode::CreateNew => match fs::symlink_metadata(&destination.final_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(DurableIoError::SymlinkRejected { + path: destination.final_path.clone(), + }) + } + Ok(_) => Err(DurableIoError::AlreadyExists { + path: destination.final_path.clone(), + }), + Err(source) if source.kind() == ErrorKind::NotFound => Ok(()), + Err(source) => Err(io_error( + DurableIoOperation::InspectDestination, + &destination.final_path, + source, + )), + }, + OverwriteMode::Replace => revalidate_replace_destination(destination, options.symlink), + } +} + +fn revalidate_replace_destination( + destination: &Destination, + symlink: SymlinkPolicy, +) -> Result<(), DurableIoError> { + match fs::symlink_metadata(&destination.final_path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(DurableIoError::SymlinkRejected { + path: destination.final_path.clone(), + }), + Ok(metadata) if !metadata.is_file() => Err(DurableIoError::UnsupportedFileType { + path: destination.final_path.clone(), + }), + Ok(metadata) => { + if symlink == SymlinkPolicy::Reject && !destination.existed_at_inspect { + return Err(DurableIoError::DestinationChanged { + operation: DurableIoOperation::InspectDestination, + path: destination.final_path.clone(), + }); + } + if symlink == SymlinkPolicy::Reject + && let Some(expected) = destination.existing_identity + && file_identity(&metadata) != Some(expected) + { + return Err(DurableIoError::DestinationChanged { + operation: DurableIoOperation::InspectDestination, + path: destination.final_path.clone(), + }); + } + Ok(()) + } + Err(source) if source.kind() == ErrorKind::NotFound => { + if destination.existing_identity.is_some() { + Err(DurableIoError::DestinationChanged { + operation: DurableIoOperation::InspectDestination, + path: destination.final_path.clone(), + }) + } else { + Ok(()) + } + } + Err(source) => Err(io_error( + DurableIoOperation::InspectDestination, + &destination.final_path, + source, + )), + } +} + +fn finalize_overwrite_mode( + destination: &Destination, + options: AtomicWriteOptions, +) -> OverwriteMode { + if options.overwrite == OverwriteMode::Replace + && options.symlink == SymlinkPolicy::Reject + && !destination.existed_at_inspect + { + OverwriteMode::CreateNew + } else { + options.overwrite + } +} + +fn create_temp_file( + parent: &Path, + file_name: &std::ffi::OsStr, +) -> Result<(PathBuf, File), DurableIoError> { + create_temp_file_from_candidates((0..64).map(|_| next_temp_path(parent, file_name))) +} + +fn create_temp_file_from_candidates(candidates: I) -> Result<(PathBuf, File), DurableIoError> +where + I: IntoIterator, +{ + let mut last_path = None; + for path in candidates { + last_path = Some(path.clone()); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + match options.open(&path) { + Ok(file) => return Ok((path, file)), + Err(source) if source.kind() == ErrorKind::AlreadyExists => continue, + Err(source) => return Err(io_error(DurableIoOperation::OpenTemporary, &path, source)), + } + } + Err(DurableIoError::Conflict { + operation: DurableIoOperation::OpenTemporary, + path: last_path.unwrap_or_default(), + reason: "temporary path collision retry budget exhausted".to_string(), + }) +} + +fn next_temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let mut name = std::ffi::OsString::from("."); + name.push(file_name); + name.push(format!( + ".{}.{}.{}.tmp", + std::process::id(), + stamp, + sequence + )); + parent.join(name) +} + +fn finalize_temp_file( + temp_path: &Path, + final_path: &Path, + overwrite: OverwriteMode, +) -> Result<(), DurableIoError> { + let result = match overwrite { + OverwriteMode::Replace => fs::rename(temp_path, final_path), + OverwriteMode::CreateNew => rename_no_replace(temp_path, final_path), + }; + result.map_err(|source| { + if overwrite == OverwriteMode::CreateNew && source.kind() == ErrorKind::AlreadyExists { + DurableIoError::AlreadyExists { + path: final_path.to_path_buf(), + } + } else { + io_error(DurableIoOperation::FinalizeRename, final_path, source) + } + }) +} + +#[cfg(unix)] +fn apply_final_permissions( + temp_path: &Path, + destination: &Destination, + policy: PermissionPolicy, +) -> Result<(), DurableIoError> { + let mode = match policy { + PermissionPolicy::FixedMode(mode) => mode, + PermissionPolicy::PreserveExistingOrMode(mode) => destination.existing_mode.unwrap_or(mode), + }; + fs::set_permissions(temp_path, fs::Permissions::from_mode(mode)) + .map_err(|source| io_error(DurableIoOperation::SetPermissions, temp_path, source)) +} + +#[cfg(not(unix))] +fn apply_final_permissions( + _temp_path: &Path, + _destination: &Destination, + _policy: PermissionPolicy, +) -> Result<(), DurableIoError> { + Ok(()) +} + +fn read_resolved_link(path: &Path) -> Result { + let target = fs::read_link(path) + .map_err(|source| io_error(DurableIoOperation::ReadLink, path, source))?; + Ok(if target.is_absolute() { + target + } else { + path.parent().unwrap_or_else(|| Path::new(".")).join(target) + }) +} + +#[cfg(unix)] +fn metadata_mode(metadata: &fs::Metadata) -> Option { + Some(metadata.permissions().mode() & 0o777) +} + +#[cfg(not(unix))] +fn metadata_mode(_metadata: &fs::Metadata) -> Option { + None +} + +#[cfg(unix)] +fn file_identity(metadata: &fs::Metadata) -> Option { + Some(FileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[cfg(not(unix))] +fn file_identity(_metadata: &fs::Metadata) -> Option { + None +} + +#[cfg(target_os = "linux")] +pub fn rename_no_replace(source: &Path, target: &Path) -> io::Result<()> { + let source = path_to_cstring(source)?; + let target = path_to_cstring(target)?; + // SAFETY: The C strings are valid, NUL-terminated paths. AT_FDCWD makes both + // paths relative to the process cwd, matching std::fs::rename path semantics. + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + target.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(target_os = "linux")] +fn path_to_cstring(path: &Path) -> io::Result { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + CString::new(path.as_os_str().as_bytes()).map_err(|_| { + io::Error::new( + ErrorKind::InvalidInput, + format!("path contains an interior NUL byte: {}", path.display()), + ) + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn rename_no_replace(source: &Path, target: &Path) -> io::Result<()> { + fs::hard_link(source, target)?; + fs::remove_file(source) +} + +#[cfg(unix)] +fn sync_dir(parent: &Path) -> Result<(), DurableIoError> { + let dir = File::open(parent) + .map_err(|source| io_error(DurableIoOperation::SyncParent, parent, source))?; + dir.sync_all() + .map_err(|source| io_error(DurableIoOperation::SyncParent, parent, source)) +} + +#[cfg(not(unix))] +fn sync_dir(_parent: &Path) -> Result<(), DurableIoError> { + Ok(()) +} + +fn io_error(operation: DurableIoOperation, path: &Path, source: io::Error) -> DurableIoError { + DurableIoError::Io { + operation, + path: path.to_path_buf(), + source, + } +} + +#[cfg(test)] +mod tests; diff --git a/src/durable_io/model.rs b/src/durable_io/model.rs new file mode 100644 index 00000000..4cb96a33 --- /dev/null +++ b/src/durable_io/model.rs @@ -0,0 +1,133 @@ +use std::fmt; +use std::io; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DurableIoOperation { + InspectDestination, + ReadLink, + OpenTemporary, + WriteTemporary, + SetPermissions, + SyncTemporary, + FinalizeRename, + SyncParent, +} + +#[derive(Debug)] +pub enum DurableIoError { + Io { + operation: DurableIoOperation, + path: PathBuf, + source: io::Error, + }, + AlreadyExists { + path: PathBuf, + }, + SymlinkRejected { + path: PathBuf, + }, + UnsupportedFileType { + path: PathBuf, + }, + DestinationChanged { + operation: DurableIoOperation, + path: PathBuf, + }, + Conflict { + operation: DurableIoOperation, + path: PathBuf, + reason: String, + }, + MissingParent { + path: PathBuf, + }, +} + +impl fmt::Display for DurableIoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { + operation, + path, + source, + } => write!(f, "{operation:?} failed for {}: {source}", path.display()), + Self::AlreadyExists { path } => write!(f, "{} already exists", path.display()), + Self::SymlinkRejected { path } => write!(f, "symlink rejected: {}", path.display()), + Self::UnsupportedFileType { path } => { + write!(f, "unsupported file type: {}", path.display()) + } + Self::DestinationChanged { operation, path } => write!( + f, + "destination changed during {operation:?}: {}", + path.display() + ), + Self::Conflict { + operation, + path, + reason, + } => write!(f, "{operation:?} conflict for {}: {reason}", path.display()), + Self::MissingParent { path } => { + write!(f, "{} has no parent directory", path.display()) + } + } + } +} + +impl std::error::Error for DurableIoError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverwriteMode { + Replace, + CreateNew, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionPolicy { + PreserveExistingOrMode(u32), + FixedMode(u32), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SymlinkPolicy { + FollowExistingTarget, + Reject, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AtomicWriteOptions { + pub overwrite: OverwriteMode, + pub permissions: PermissionPolicy, + pub symlink: SymlinkPolicy, + pub sync_file: bool, + pub sync_parent: bool, +} + +impl AtomicWriteOptions { + pub const fn private_runtime_file() -> Self { + Self { + overwrite: OverwriteMode::Replace, + permissions: PermissionPolicy::FixedMode(0o600), + symlink: SymlinkPolicy::Reject, + sync_file: false, + sync_parent: false, + } + } + + pub const fn user_config_file() -> Self { + Self { + overwrite: OverwriteMode::Replace, + permissions: PermissionPolicy::PreserveExistingOrMode(0o644), + symlink: SymlinkPolicy::FollowExistingTarget, + sync_file: true, + sync_parent: true, + } + } +} diff --git a/src/durable_io/tests.rs b/src/durable_io/tests.rs new file mode 100644 index 00000000..32a42e72 --- /dev/null +++ b/src/durable_io/tests.rs @@ -0,0 +1,248 @@ +use super::*; +use std::fs; +use std::io::Write; + +#[cfg(unix)] +use std::os::unix::fs::{PermissionsExt, symlink}; + +fn replace_reject_options() -> AtomicWriteOptions { + AtomicWriteOptions { + overwrite: OverwriteMode::Replace, + permissions: PermissionPolicy::PreserveExistingOrMode(0o644), + symlink: SymlinkPolicy::Reject, + sync_file: true, + sync_parent: true, + } +} + +fn create_new_reject_options() -> AtomicWriteOptions { + AtomicWriteOptions { + overwrite: OverwriteMode::CreateNew, + permissions: PermissionPolicy::FixedMode(0o640), + symlink: SymlinkPolicy::Reject, + sync_file: true, + sync_parent: true, + } +} + +#[cfg(unix)] +#[test] +fn replacing_existing_file_preserves_mode() { + let temp = crate::test_temp::tempdir().unwrap(); + let path = temp.path().join("state.toml"); + fs::write(&path, "old").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + write_text_atomic(&path, "new", replace_reject_options()).unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "new"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +#[cfg(unix)] +#[test] +fn creating_new_file_uses_fixed_mode() { + let temp = crate::test_temp::tempdir().unwrap(); + let path = temp.path().join("created.toml"); + + write_text_atomic(&path, "created", create_new_reject_options()).unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "created"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} + +#[test] +fn create_new_reports_existing_destination() { + let temp = crate::test_temp::tempdir().unwrap(); + let path = temp.path().join("existing.txt"); + fs::write(&path, "old").unwrap(); + + let err = write_text_atomic(&path, "new", create_new_reject_options()).unwrap_err(); + + assert!(matches!(err, DurableIoError::AlreadyExists { path: actual } if actual == path)); + assert_eq!(fs::read_to_string(&path).unwrap(), "old"); +} + +#[test] +fn temporary_file_creation_skips_colliding_paths() { + let temp = crate::test_temp::tempdir().unwrap(); + let first = temp.path().join(".first.tmp"); + let second = temp.path().join(".second.tmp"); + fs::write(&first, "existing").unwrap(); + + let (path, mut file) = + create_temp_file_from_candidates([first.clone(), second.clone()]).unwrap(); + file.write_all(b"new").unwrap(); + drop(file); + + assert_eq!(path, second); + assert_eq!(fs::read_to_string(first).unwrap(), "existing"); + assert_eq!(fs::read_to_string(second).unwrap(), "new"); +} + +#[cfg(unix)] +#[test] +fn temporary_file_creation_skips_symlink_collisions() { + let temp = crate::test_temp::tempdir().unwrap(); + let target = temp.path().join("target"); + let link = temp.path().join(".link.tmp"); + let second = temp.path().join(".second.tmp"); + fs::write(&target, "target").unwrap(); + symlink(&target, &link).unwrap(); + + let (path, _file) = create_temp_file_from_candidates([link, second.clone()]).unwrap(); + + assert_eq!(path, second); + assert_eq!(fs::read_to_string(target).unwrap(), "target"); +} + +#[cfg(unix)] +#[test] +fn follows_existing_symlink_and_preserves_link() { + let temp = crate::test_temp::tempdir().unwrap(); + let target = temp.path().join("target.toml"); + let link = temp.path().join("config.toml"); + fs::write(&target, "old").unwrap(); + symlink("target.toml", &link).unwrap(); + + write_text_atomic(&link, "new", AtomicWriteOptions::user_config_file()).unwrap(); + + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + assert!( + fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!( + fs::read_link(&link).unwrap(), + std::path::PathBuf::from("target.toml") + ); +} + +#[cfg(unix)] +#[test] +fn followed_symlink_change_is_rejected_before_finalization() { + let temp = crate::test_temp::tempdir().unwrap(); + let first = temp.path().join("first.toml"); + let second = temp.path().join("second.toml"); + let link = temp.path().join("config.toml"); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + symlink(&first, &link).unwrap(); + let options = AtomicWriteOptions::user_config_file(); + let destination = inspect_destination(&link, options).unwrap(); + fs::remove_file(&link).unwrap(); + symlink(&second, &link).unwrap(); + + let err = revalidate_destination(&destination, options).unwrap_err(); + + assert!(matches!(err, DurableIoError::DestinationChanged { .. })); + assert_eq!(fs::read_to_string(first).unwrap(), "first"); + assert_eq!(fs::read_to_string(second).unwrap(), "second"); +} + +#[cfg(unix)] +#[test] +fn reject_policy_rejects_destination_symlink() { + let temp = crate::test_temp::tempdir().unwrap(); + let target = temp.path().join("target"); + let link = temp.path().join("state"); + fs::write(&target, "target").unwrap(); + symlink(&target, &link).unwrap(); + + let err = write_text_atomic(&link, "new", replace_reject_options()).unwrap_err(); + + assert!(matches!(err, DurableIoError::SymlinkRejected { path } if path == link)); + assert_eq!(fs::read_to_string(target).unwrap(), "target"); +} + +#[cfg(unix)] +#[test] +fn reject_policy_rejects_final_path_changed_to_symlink() { + let temp = crate::test_temp::tempdir().unwrap(); + let path = temp.path().join("state"); + let target = temp.path().join("target"); + fs::write(&path, "old").unwrap(); + fs::write(&target, "target").unwrap(); + let options = replace_reject_options(); + let destination = inspect_destination(&path, options).unwrap(); + fs::remove_file(&path).unwrap(); + symlink(&target, &path).unwrap(); + + let err = revalidate_destination(&destination, options).unwrap_err(); + + assert!(matches!(err, DurableIoError::SymlinkRejected { path: actual } if actual == path)); +} + +#[cfg(unix)] +#[test] +fn reject_policy_detects_replaced_regular_file() { + let temp = crate::test_temp::tempdir().unwrap(); + let path = temp.path().join("state"); + fs::write(&path, "old").unwrap(); + let options = replace_reject_options(); + let destination = inspect_destination(&path, options).unwrap(); + let _old_file = fs::File::open(&path).unwrap(); + fs::remove_file(&path).unwrap(); + fs::write(&path, "replacement").unwrap(); + + let err = revalidate_destination(&destination, options).unwrap_err(); + + assert!(matches!(err, DurableIoError::DestinationChanged { .. })); +} + +#[test] +fn reject_policy_detects_created_regular_file_after_missing_inspect() { + let temp = crate::test_temp::tempdir().unwrap(); + let path = temp.path().join("state"); + let options = replace_reject_options(); + let destination = inspect_destination(&path, options).unwrap(); + fs::write(&path, "created-by-other-writer").unwrap(); + + let err = revalidate_destination(&destination, options).unwrap_err(); + + assert!(matches!(err, DurableIoError::DestinationChanged { + operation: DurableIoOperation::InspectDestination, + path: actual, + } if actual == path)); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "created-by-other-writer" + ); +} + +#[cfg(unix)] +#[test] +fn dangling_followed_symlink_is_rejected() { + let temp = crate::test_temp::tempdir().unwrap(); + let link = temp.path().join("config.toml"); + symlink("missing-target.toml", &link).unwrap(); + + let err = write_text_atomic(&link, "new", AtomicWriteOptions::user_config_file()).unwrap_err(); + + assert!(matches!( + err, + DurableIoError::Io { + operation: DurableIoOperation::InspectDestination, + .. + } + )); + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); +} + +#[cfg(unix)] +#[test] +fn parent_directory_sync_succeeds() { + let temp = crate::test_temp::tempdir().unwrap(); + let path = temp.path().join("state"); + fs::write(&path, "state").unwrap(); + + sync_parent_dir(&path).unwrap(); +} diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 8a41dd5d..5967e7ad 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -487,59 +487,4 @@ pub(crate) struct PendingOnboardingUsage { } #[cfg(test)] -mod tests { - use super::CompositorCapabilities; - - #[test] - fn compositor_capabilities_limitations_summary_returns_none_when_fully_available() { - assert_eq!( - CompositorCapabilities { - layer_shell: true, - screencopy: true, - freeze_capture: true, - pointer_constraints: true, - desktop_environment: Default::default(), - shell_mode: Default::default(), - } - .limitations_summary(), - None - ); - } - - #[test] - fn compositor_capabilities_limitations_summary_lists_missing_features_in_order() { - assert_eq!( - CompositorCapabilities { - layer_shell: false, - screencopy: true, - freeze_capture: true, - pointer_constraints: false, - desktop_environment: Default::default(), - shell_mode: Default::default(), - } - .limitations_summary(), - Some( - "Toolbars limited, light passthrough unavailable, Pointer lock unavailable" - .to_string() - ) - ); - } - - #[test] - fn compositor_capabilities_reports_portal_freeze_without_hiding_limitations() { - let caps = CompositorCapabilities { - layer_shell: true, - screencopy: false, - freeze_capture: true, - pointer_constraints: true, - desktop_environment: Default::default(), - shell_mode: Default::default(), - }; - - assert!(!caps.all_available()); - assert_eq!( - caps.limitations_summary(), - Some("Freeze uses portal capture".to_string()) - ); - } -} +mod tests; diff --git a/src/input/state/core/base/types/tests.rs b/src/input/state/core/base/types/tests.rs new file mode 100644 index 00000000..3a09b86f --- /dev/null +++ b/src/input/state/core/base/types/tests.rs @@ -0,0 +1,53 @@ +use super::CompositorCapabilities; + +#[test] +fn compositor_capabilities_limitations_summary_returns_none_when_fully_available() { + assert_eq!( + CompositorCapabilities { + layer_shell: true, + screencopy: true, + freeze_capture: true, + pointer_constraints: true, + desktop_environment: Default::default(), + shell_mode: Default::default(), + } + .limitations_summary(), + None + ); +} + +#[test] +fn compositor_capabilities_limitations_summary_lists_missing_features_in_order() { + assert_eq!( + CompositorCapabilities { + layer_shell: false, + screencopy: true, + freeze_capture: true, + pointer_constraints: false, + desktop_environment: Default::default(), + shell_mode: Default::default(), + } + .limitations_summary(), + Some( + "Toolbars limited, light passthrough unavailable, Pointer lock unavailable".to_string() + ) + ); +} + +#[test] +fn compositor_capabilities_reports_portal_freeze_without_hiding_limitations() { + let caps = CompositorCapabilities { + layer_shell: true, + screencopy: false, + freeze_capture: true, + pointer_constraints: true, + desktop_environment: Default::default(), + shell_mode: Default::default(), + }; + + assert!(!caps.all_available()); + assert_eq!( + caps.limitations_summary(), + Some("Freeze uses portal capture".to_string()) + ); +} diff --git a/src/input/state/core/board/delete_restore.rs b/src/input/state/core/board/delete_restore.rs index 0c9b70f7..03773658 100644 --- a/src/input/state/core/board/delete_restore.rs +++ b/src/input/state/core/board/delete_restore.rs @@ -1,18 +1,15 @@ use super::super::base::{ - BOARD_DELETE_CONFIRM_MS, BOARD_UNDO_EXPIRE_MS, InputState, PAGE_DELETE_CONFIRM_MS, - PAGE_UNDO_EXPIRE_MS, PendingBoardDelete, PendingPageDelete, UiToastKind, + BOARD_DELETE_CONFIRM_MS, BOARD_UNDO_EXPIRE_MS, InputState, PendingBoardDelete, UiToastKind, }; use crate::config::Action; -use crate::draw::PageDeleteOutcome as CanvasPageDeleteOutcome; use crate::input::boards::{ BoardDeleteOutcome, BoardDeleteRejection, BoardDeleteRequest, BoardDeleteTarget, BoardIdentityGeneration, BoardRestoreOutcome, BoardRestoreRejection, BoardRestoreRequest, - PageDeleteBoardTarget, PageDeleteOutcome, PageDeleteRequest, PageDeleteTarget, - PageOperationRejection, PageRestoreOutcome, PageRestorePlacement, PageRestoreRejection, - PageRestoreRequest, }; use std::time::{Duration, Instant}; +mod page; + impl InputState { /// Returns true if there's a pending board deletion confirmation. pub fn has_pending_board_delete(&self) -> bool { @@ -239,484 +236,7 @@ impl InputState { now.saturating_duration_since(*timestamp) < expire_duration }); } - - pub(crate) fn delete_page_in_board( - &mut self, - board_index: usize, - page_index: usize, - ) -> CanvasPageDeleteOutcome { - self.delete_page_in_board_at(board_index, page_index, Instant::now()) - } - - pub(crate) fn delete_page_in_board_at( - &mut self, - board_index: usize, - page_index: usize, - now: Instant, - ) -> CanvasPageDeleteOutcome { - let is_active_board = self.boards.active_index() == board_index; - let Some(board) = self.boards.board_states().get(board_index) else { - return CanvasPageDeleteOutcome::Pending; - }; - let page_count = board.pages.page_count(); - if page_index >= page_count { - return CanvasPageDeleteOutcome::Pending; - } - let board_name = board.spec.name.clone(); - let board_id = board.spec.id.clone(); - - if self - .pending_page_delete - .as_ref() - .is_some_and(|pending| now > pending.expires_at) - { - self.pending_page_delete = None; - } - - let request = self - .pending_page_delete - .as_ref() - .filter(|pending| { - pending.confirmation.board_id == board_id - && pending.confirmation.page_index == page_index - }) - .map(|pending| PageDeleteRequest::Confirm(pending.confirmation.clone())) - .unwrap_or_else(|| { - PageDeleteRequest::Request(PageDeleteTarget { - board: PageDeleteBoardTarget::BoardIndex(board_index), - page_index, - }) - }); - let confirmation_is_current = matches!(&request, PageDeleteRequest::Confirm(confirmation) if self.page_delete_confirmation_is_current(confirmation)); - let should_prepare_active = is_active_board - && ((matches!(&request, PageDeleteRequest::Request(_)) && page_count <= 1) - || confirmation_is_current); - if should_prepare_active { - self.prepare_active_page_content_change(); - } - - match self.boards.delete_page(request) { - PageDeleteOutcome::RequiresConfirmation { confirmation } => { - self.pending_page_delete = Some(PendingPageDelete { - confirmation, - expires_at: now + Duration::from_millis(PAGE_DELETE_CONFIRM_MS), - }); - self.set_ui_toast_with_duration( - UiToastKind::Warning, - format!( - "Delete page {}/{} on '{board_name}' ({board_id})? Click delete again to confirm.", - page_index + 1, - page_count - ), - PAGE_DELETE_CONFIRM_MS, - ); - CanvasPageDeleteOutcome::Pending - } - PageDeleteOutcome::ClearedLastPage { .. } => { - self.pending_page_delete = None; - self.finish_page_delete_surface_change(is_active_board); - self.set_ui_toast( - UiToastKind::Info, - format!("Page cleared on '{board_name}' ({board_id})"), - ); - CanvasPageDeleteOutcome::Cleared - } - PageDeleteOutcome::Removed { - new_page_index, - new_page_count, - .. - } => { - self.pending_page_delete = None; - self.finish_page_delete_surface_change(is_active_board); - self.set_ui_toast( - UiToastKind::Info, - format!( - "Page deleted on '{board_name}' ({board_id}) ({}/{})", - new_page_index + 1, - new_page_count - ), - ); - CanvasPageDeleteOutcome::Removed - } - PageDeleteOutcome::Rejected(rejection) => { - self.pending_page_delete = None; - self.set_page_delete_rejection_toast(rejection); - CanvasPageDeleteOutcome::Pending - } - } - } - - pub fn page_delete(&mut self) -> CanvasPageDeleteOutcome { - self.delete_active_page_at(Instant::now()) - } - - pub(crate) fn delete_active_page_at(&mut self, now: Instant) -> CanvasPageDeleteOutcome { - let page_count = self.boards.page_count(); - let page_index = self.boards.active_page_index(); - - if self - .pending_page_delete - .as_ref() - .is_some_and(|pending| now > pending.expires_at) - { - self.pending_page_delete = None; - } - - let request = self - .pending_page_delete - .as_ref() - .map(|pending| PageDeleteRequest::Confirm(pending.confirmation.clone())) - .unwrap_or_else(|| { - PageDeleteRequest::Request(PageDeleteTarget { - board: PageDeleteBoardTarget::ActiveBoard, - page_index, - }) - }); - let active_target = match &request { - PageDeleteRequest::Confirm(confirmation) => { - confirmation.board_id == self.boards.active_board_id() - } - PageDeleteRequest::Request(_) => true, - }; - let confirmation_is_current = matches!(&request, PageDeleteRequest::Confirm(confirmation) if self.page_delete_confirmation_is_current(confirmation)); - let should_prepare_active = active_target - && ((matches!(&request, PageDeleteRequest::Request(_)) && page_count <= 1) - || confirmation_is_current); - if should_prepare_active { - self.prepare_active_page_content_change(); - } - - match self.boards.delete_page(request) { - PageDeleteOutcome::RequiresConfirmation { confirmation } => { - self.pending_page_delete = Some(PendingPageDelete { - confirmation, - expires_at: now + Duration::from_millis(PAGE_DELETE_CONFIRM_MS), - }); - self.set_ui_toast_with_action_and_duration( - UiToastKind::Warning, - format!( - "Delete page {}/{}? Click to confirm.", - page_index + 1, - page_count - ), - "Delete", - Action::PageDelete, - PAGE_DELETE_CONFIRM_MS, - ); - CanvasPageDeleteOutcome::Pending - } - PageDeleteOutcome::ClearedLastPage { .. } => { - self.pending_page_delete = None; - self.finish_page_delete_surface_change(active_target); - self.set_ui_toast(UiToastKind::Info, "Page cleared (last page)"); - CanvasPageDeleteOutcome::Cleared - } - PageDeleteOutcome::Removed { - board_id, - deleted_page, - new_page_index, - new_page_count, - .. - } => { - self.pending_page_delete = None; - self.finish_page_delete_surface_change(active_target); - self.deleted_pages.push(( - PageRestoreRequest { - board_id, - page: deleted_page, - placement: PageRestorePlacement::AfterActivePage, - }, - now, - )); - self.set_ui_toast_with_action( - UiToastKind::Info, - format!("Page deleted ({}/{new_page_count})", new_page_index + 1), - "Undo", - Action::PageRestoreDeleted, - ); - CanvasPageDeleteOutcome::Removed - } - PageDeleteOutcome::Rejected(rejection) => { - self.pending_page_delete = None; - self.set_page_delete_rejection_toast(rejection); - CanvasPageDeleteOutcome::Pending - } - } - } - - fn finish_page_delete_surface_change(&mut self, active_target: bool) { - if active_target { - self.finish_active_page_content_change(); - } else { - self.mark_board_surface_changed(); - } - } - - fn page_delete_confirmation_is_current( - &self, - confirmation: &crate::input::boards::PageDeleteConfirmation, - ) -> bool { - if confirmation.board_identity_generation != self.boards.board_identity_generation() { - return false; - } - self.boards - .board_states() - .iter() - .find(|board| board.spec.id == confirmation.board_id) - .is_some_and(|board| { - confirmation.matches_identity( - &board.spec.id, - self.boards.board_identity_generation(), - confirmation.page_index, - board.pages.page_count(), - board.pages.generation(), - ) && confirmation.page_index < board.pages.page_count() - }) - } - - fn set_page_delete_rejection_toast(&mut self, rejection: PageOperationRejection) { - if matches!(rejection, PageOperationRejection::StaleConfirmation) { - self.set_ui_toast(UiToastKind::Warning, "Page deletion changed; try again."); - } - } - - /// Restore the most recently deleted page. - pub fn restore_deleted_page(&mut self) { - self.restore_deleted_page_at(Instant::now()); - } - - pub(crate) fn restore_deleted_page_at(&mut self, now: Instant) { - // Expire old entries - let expire_duration = Duration::from_millis(PAGE_UNDO_EXPIRE_MS); - self.deleted_pages - .retain(|(_, deleted_at)| now.saturating_duration_since(*deleted_at) < expire_duration); - - if let Some((request, deleted_at)) = self.deleted_pages.pop() { - let active_target = request.board_id == self.boards.active_board_id(); - if active_target { - self.prepare_active_page_content_change(); - } - match self.boards.restore_page(request) { - PageRestoreOutcome::Restored { - page_index, - page_count, - .. - } => { - if active_target { - self.finish_active_page_content_change(); - } else { - self.mark_board_surface_changed(); - } - self.set_ui_toast( - UiToastKind::Info, - format!("Page restored ({}/{page_count})", page_index + 1), - ); - } - PageRestoreOutcome::Rejected(PageRestoreRejection::MissingBoard { request }) => { - self.deleted_pages.push((request, deleted_at)); - self.set_ui_toast(UiToastKind::Warning, "Board missing; cannot restore page."); - } - } - } else { - self.set_ui_toast(UiToastKind::Info, "No deleted page to restore."); - } - } } #[cfg(test)] -mod tests { - use super::*; - use crate::draw::Frame; - use crate::input::BOARD_ID_BLACKBOARD; - use crate::input::state::test_support::make_test_input_state; - - fn board_index(state: &InputState, id: &str) -> usize { - state - .boards - .board_states() - .iter() - .position(|board| board.spec.id == id) - .expect("board index") - } - - fn set_page_count(state: &mut InputState, board_index: usize, count: usize) { - let pages = state.boards.board_states_mut()[board_index] - .pages - .pages_mut(); - pages.clear(); - pages.extend((0..count.max(1)).map(|_| Frame::new())); - } - - #[test] - fn confirmed_board_delete_uses_supplied_now_for_undo_timestamp() { - let mut state = make_test_input_state(); - state.switch_board(BOARD_ID_BLACKBOARD); - let requested_at = Instant::now(); - let confirmed_at = requested_at + Duration::from_millis(1); - - state.delete_active_board_at(requested_at); - state.delete_active_board_at(confirmed_at); - - let (_, deleted_at) = state.deleted_boards.last().expect("deleted board undo"); - assert_eq!(*deleted_at, confirmed_at); - } - - #[test] - fn expired_board_delete_confirmation_is_replaced_with_supplied_now() { - let mut state = make_test_input_state(); - state.switch_board(BOARD_ID_BLACKBOARD); - let requested_at = Instant::now(); - let expired_at = requested_at + Duration::from_millis(BOARD_DELETE_CONFIRM_MS + 1); - let board_count = state.boards.board_count(); - - state.delete_active_board_at(requested_at); - state.delete_active_board_at(expired_at); - - assert_eq!(state.boards.board_count(), board_count); - let pending = state - .pending_board_delete - .as_ref() - .expect("replacement confirmation"); - assert_eq!( - pending.expires_at, - expired_at + Duration::from_millis(BOARD_DELETE_CONFIRM_MS) - ); - } - - #[test] - fn restore_deleted_board_expires_old_entries_with_supplied_now() { - let mut state = make_test_input_state(); - state.switch_board(BOARD_ID_BLACKBOARD); - let requested_at = Instant::now(); - let confirmed_at = requested_at + Duration::from_millis(1); - - state.delete_active_board_at(requested_at); - state.delete_active_board_at(confirmed_at); - let board_count_after_delete = state.boards.board_count(); - - state.restore_deleted_board_at( - confirmed_at + Duration::from_millis(BOARD_UNDO_EXPIRE_MS + 1), - ); - - assert!(state.deleted_boards.is_empty()); - assert_eq!(state.boards.board_count(), board_count_after_delete); - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("No deleted board to restore.") - ); - } - - #[test] - fn confirmed_active_page_delete_uses_supplied_now_for_undo_timestamp() { - let mut state = make_test_input_state(); - let board = board_index(&state, BOARD_ID_BLACKBOARD); - state.switch_board(BOARD_ID_BLACKBOARD); - set_page_count(&mut state, board, 2); - let requested_at = Instant::now(); - let confirmed_at = requested_at + Duration::from_millis(1); - - assert_eq!( - state.delete_active_page_at(requested_at), - crate::draw::PageDeleteOutcome::Pending - ); - assert_eq!( - state.delete_active_page_at(confirmed_at), - crate::draw::PageDeleteOutcome::Removed - ); - - let (_, deleted_at) = state.deleted_pages.last().expect("deleted page undo"); - assert_eq!(*deleted_at, confirmed_at); - } - - #[test] - fn expired_active_page_delete_confirmation_is_replaced_with_supplied_now() { - let mut state = make_test_input_state(); - let board = board_index(&state, BOARD_ID_BLACKBOARD); - state.switch_board(BOARD_ID_BLACKBOARD); - set_page_count(&mut state, board, 2); - let requested_at = Instant::now(); - let expired_at = requested_at + Duration::from_millis(PAGE_DELETE_CONFIRM_MS + 1); - let page_count = state.boards.page_count(); - - assert_eq!( - state.delete_active_page_at(requested_at), - crate::draw::PageDeleteOutcome::Pending - ); - assert_eq!( - state.delete_active_page_at(expired_at), - crate::draw::PageDeleteOutcome::Pending - ); - - assert_eq!(state.boards.page_count(), page_count); - let pending = state - .pending_page_delete - .as_ref() - .expect("replacement confirmation"); - assert_eq!( - pending.expires_at, - expired_at + Duration::from_millis(PAGE_DELETE_CONFIRM_MS) - ); - } - - #[test] - fn expired_page_in_board_delete_confirmation_is_replaced_with_supplied_now() { - let mut state = make_test_input_state(); - let board = board_index(&state, BOARD_ID_BLACKBOARD); - set_page_count(&mut state, board, 2); - let requested_at = Instant::now(); - let expired_at = requested_at + Duration::from_millis(PAGE_DELETE_CONFIRM_MS + 1); - let page_count = state.boards.board_states()[board].pages.page_count(); - - assert_eq!( - state.delete_page_in_board_at(board, 1, requested_at), - crate::draw::PageDeleteOutcome::Pending - ); - assert_eq!( - state.delete_page_in_board_at(board, 1, expired_at), - crate::draw::PageDeleteOutcome::Pending - ); - - assert_eq!( - state.boards.board_states()[board].pages.page_count(), - page_count - ); - let pending = state - .pending_page_delete - .as_ref() - .expect("replacement confirmation"); - assert_eq!( - pending.expires_at, - expired_at + Duration::from_millis(PAGE_DELETE_CONFIRM_MS) - ); - } - - #[test] - fn restore_deleted_page_expires_old_entries_with_supplied_now() { - let mut state = make_test_input_state(); - let board = board_index(&state, BOARD_ID_BLACKBOARD); - state.switch_board(BOARD_ID_BLACKBOARD); - set_page_count(&mut state, board, 2); - let requested_at = Instant::now(); - let confirmed_at = requested_at + Duration::from_millis(1); - - assert_eq!( - state.delete_active_page_at(requested_at), - crate::draw::PageDeleteOutcome::Pending - ); - assert_eq!( - state.delete_active_page_at(confirmed_at), - crate::draw::PageDeleteOutcome::Removed - ); - let page_count_after_delete = state.boards.page_count(); - - state - .restore_deleted_page_at(confirmed_at + Duration::from_millis(PAGE_UNDO_EXPIRE_MS + 1)); - - assert!(state.deleted_pages.is_empty()); - assert_eq!(state.boards.page_count(), page_count_after_delete); - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("No deleted page to restore.") - ); - } -} +mod tests; diff --git a/src/input/state/core/board/delete_restore/page.rs b/src/input/state/core/board/delete_restore/page.rs new file mode 100644 index 00000000..b3c5dfac --- /dev/null +++ b/src/input/state/core/board/delete_restore/page.rs @@ -0,0 +1,295 @@ +use super::super::super::base::{ + InputState, PAGE_DELETE_CONFIRM_MS, PAGE_UNDO_EXPIRE_MS, PendingPageDelete, UiToastKind, +}; +use crate::config::Action; +use crate::draw::PageDeleteOutcome as CanvasPageDeleteOutcome; +use crate::input::boards::{ + PageDeleteBoardTarget, PageDeleteOutcome, PageDeleteRequest, PageDeleteTarget, + PageOperationRejection, PageRestoreOutcome, PageRestorePlacement, PageRestoreRejection, + PageRestoreRequest, +}; +use std::time::{Duration, Instant}; + +impl InputState { + pub(crate) fn delete_page_in_board( + &mut self, + board_index: usize, + page_index: usize, + ) -> CanvasPageDeleteOutcome { + self.delete_page_in_board_at(board_index, page_index, Instant::now()) + } + + pub(crate) fn delete_page_in_board_at( + &mut self, + board_index: usize, + page_index: usize, + now: Instant, + ) -> CanvasPageDeleteOutcome { + let is_active_board = self.boards.active_index() == board_index; + let Some(board) = self.boards.board_states().get(board_index) else { + return CanvasPageDeleteOutcome::Pending; + }; + let page_count = board.pages.page_count(); + if page_index >= page_count { + return CanvasPageDeleteOutcome::Pending; + } + let board_name = board.spec.name.clone(); + let board_id = board.spec.id.clone(); + + if self + .pending_page_delete + .as_ref() + .is_some_and(|pending| now > pending.expires_at) + { + self.pending_page_delete = None; + } + + let request = self + .pending_page_delete + .as_ref() + .filter(|pending| { + pending.confirmation.board_id == board_id + && pending.confirmation.page_index == page_index + }) + .map(|pending| PageDeleteRequest::Confirm(pending.confirmation.clone())) + .unwrap_or_else(|| { + PageDeleteRequest::Request(PageDeleteTarget { + board: PageDeleteBoardTarget::BoardIndex(board_index), + page_index, + }) + }); + let confirmation_is_current = matches!(&request, PageDeleteRequest::Confirm(confirmation) if self.page_delete_confirmation_is_current(confirmation)); + let should_prepare_active = is_active_board + && ((matches!(&request, PageDeleteRequest::Request(_)) && page_count <= 1) + || confirmation_is_current); + if should_prepare_active { + self.prepare_active_page_content_change(); + } + + match self.boards.delete_page(request) { + PageDeleteOutcome::RequiresConfirmation { confirmation } => { + self.pending_page_delete = Some(PendingPageDelete { + confirmation, + expires_at: now + Duration::from_millis(PAGE_DELETE_CONFIRM_MS), + }); + self.set_ui_toast_with_duration( + UiToastKind::Warning, + format!( + "Delete page {}/{} on '{board_name}' ({board_id})? Click delete again to confirm.", + page_index + 1, + page_count + ), + PAGE_DELETE_CONFIRM_MS, + ); + CanvasPageDeleteOutcome::Pending + } + PageDeleteOutcome::ClearedLastPage { .. } => { + self.pending_page_delete = None; + self.finish_page_delete_surface_change(is_active_board); + self.set_ui_toast( + UiToastKind::Info, + format!("Page cleared on '{board_name}' ({board_id})"), + ); + CanvasPageDeleteOutcome::Cleared + } + PageDeleteOutcome::Removed { + new_page_index, + new_page_count, + .. + } => { + self.pending_page_delete = None; + self.finish_page_delete_surface_change(is_active_board); + self.set_ui_toast( + UiToastKind::Info, + format!( + "Page deleted on '{board_name}' ({board_id}) ({}/{})", + new_page_index + 1, + new_page_count + ), + ); + CanvasPageDeleteOutcome::Removed + } + PageDeleteOutcome::Rejected(rejection) => { + self.pending_page_delete = None; + self.set_page_delete_rejection_toast(rejection); + CanvasPageDeleteOutcome::Pending + } + } + } + + pub fn page_delete(&mut self) -> CanvasPageDeleteOutcome { + self.delete_active_page_at(Instant::now()) + } + + pub(crate) fn delete_active_page_at(&mut self, now: Instant) -> CanvasPageDeleteOutcome { + let page_count = self.boards.page_count(); + let page_index = self.boards.active_page_index(); + + if self + .pending_page_delete + .as_ref() + .is_some_and(|pending| now > pending.expires_at) + { + self.pending_page_delete = None; + } + + let request = self + .pending_page_delete + .as_ref() + .map(|pending| PageDeleteRequest::Confirm(pending.confirmation.clone())) + .unwrap_or_else(|| { + PageDeleteRequest::Request(PageDeleteTarget { + board: PageDeleteBoardTarget::ActiveBoard, + page_index, + }) + }); + let active_target = match &request { + PageDeleteRequest::Confirm(confirmation) => { + confirmation.board_id == self.boards.active_board_id() + } + PageDeleteRequest::Request(_) => true, + }; + let confirmation_is_current = matches!(&request, PageDeleteRequest::Confirm(confirmation) if self.page_delete_confirmation_is_current(confirmation)); + let should_prepare_active = active_target + && ((matches!(&request, PageDeleteRequest::Request(_)) && page_count <= 1) + || confirmation_is_current); + if should_prepare_active { + self.prepare_active_page_content_change(); + } + + match self.boards.delete_page(request) { + PageDeleteOutcome::RequiresConfirmation { confirmation } => { + self.pending_page_delete = Some(PendingPageDelete { + confirmation, + expires_at: now + Duration::from_millis(PAGE_DELETE_CONFIRM_MS), + }); + self.set_ui_toast_with_action_and_duration( + UiToastKind::Warning, + format!( + "Delete page {}/{}? Click to confirm.", + page_index + 1, + page_count + ), + "Delete", + Action::PageDelete, + PAGE_DELETE_CONFIRM_MS, + ); + CanvasPageDeleteOutcome::Pending + } + PageDeleteOutcome::ClearedLastPage { .. } => { + self.pending_page_delete = None; + self.finish_page_delete_surface_change(active_target); + self.set_ui_toast(UiToastKind::Info, "Page cleared (last page)"); + CanvasPageDeleteOutcome::Cleared + } + PageDeleteOutcome::Removed { + board_id, + deleted_page, + new_page_index, + new_page_count, + .. + } => { + self.pending_page_delete = None; + self.finish_page_delete_surface_change(active_target); + self.deleted_pages.push(( + PageRestoreRequest { + board_id, + page: deleted_page, + placement: PageRestorePlacement::AfterActivePage, + }, + now, + )); + self.set_ui_toast_with_action( + UiToastKind::Info, + format!("Page deleted ({}/{new_page_count})", new_page_index + 1), + "Undo", + Action::PageRestoreDeleted, + ); + CanvasPageDeleteOutcome::Removed + } + PageDeleteOutcome::Rejected(rejection) => { + self.pending_page_delete = None; + self.set_page_delete_rejection_toast(rejection); + CanvasPageDeleteOutcome::Pending + } + } + } + + fn finish_page_delete_surface_change(&mut self, active_target: bool) { + if active_target { + self.finish_active_page_content_change(); + } else { + self.mark_board_surface_changed(); + } + } + + fn page_delete_confirmation_is_current( + &self, + confirmation: &crate::input::boards::PageDeleteConfirmation, + ) -> bool { + if confirmation.board_identity_generation != self.boards.board_identity_generation() { + return false; + } + self.boards + .board_states() + .iter() + .find(|board| board.spec.id == confirmation.board_id) + .is_some_and(|board| { + confirmation.matches_identity( + &board.spec.id, + self.boards.board_identity_generation(), + confirmation.page_index, + board.pages.page_count(), + board.pages.generation(), + ) && confirmation.page_index < board.pages.page_count() + }) + } + + fn set_page_delete_rejection_toast(&mut self, rejection: PageOperationRejection) { + if matches!(rejection, PageOperationRejection::StaleConfirmation) { + self.set_ui_toast(UiToastKind::Warning, "Page deletion changed; try again."); + } + } + + /// Restore the most recently deleted page. + pub fn restore_deleted_page(&mut self) { + self.restore_deleted_page_at(Instant::now()); + } + + pub(crate) fn restore_deleted_page_at(&mut self, now: Instant) { + // Expire old entries + let expire_duration = Duration::from_millis(PAGE_UNDO_EXPIRE_MS); + self.deleted_pages + .retain(|(_, deleted_at)| now.saturating_duration_since(*deleted_at) < expire_duration); + + if let Some((request, deleted_at)) = self.deleted_pages.pop() { + let active_target = request.board_id == self.boards.active_board_id(); + if active_target { + self.prepare_active_page_content_change(); + } + match self.boards.restore_page(request) { + PageRestoreOutcome::Restored { + page_index, + page_count, + .. + } => { + if active_target { + self.finish_active_page_content_change(); + } else { + self.mark_board_surface_changed(); + } + self.set_ui_toast( + UiToastKind::Info, + format!("Page restored ({}/{page_count})", page_index + 1), + ); + } + PageRestoreOutcome::Rejected(PageRestoreRejection::MissingBoard { request }) => { + self.deleted_pages.push((request, deleted_at)); + self.set_ui_toast(UiToastKind::Warning, "Board missing; cannot restore page."); + } + } + } else { + self.set_ui_toast(UiToastKind::Info, "No deleted page to restore."); + } + } +} diff --git a/src/input/state/core/board/delete_restore/tests.rs b/src/input/state/core/board/delete_restore/tests.rs new file mode 100644 index 00000000..1184367d --- /dev/null +++ b/src/input/state/core/board/delete_restore/tests.rs @@ -0,0 +1,195 @@ +use crate::draw::Frame; +use crate::input::BOARD_ID_BLACKBOARD; +use crate::input::state::core::base::{ + BOARD_DELETE_CONFIRM_MS, BOARD_UNDO_EXPIRE_MS, InputState, PAGE_DELETE_CONFIRM_MS, + PAGE_UNDO_EXPIRE_MS, +}; +use crate::input::state::test_support::make_test_input_state; +use std::time::{Duration, Instant}; + +fn board_index(state: &InputState, id: &str) -> usize { + state + .boards + .board_states() + .iter() + .position(|board| board.spec.id == id) + .expect("board index") +} + +fn set_page_count(state: &mut InputState, board_index: usize, count: usize) { + let pages = state.boards.board_states_mut()[board_index] + .pages + .pages_mut(); + pages.clear(); + pages.extend((0..count.max(1)).map(|_| Frame::new())); +} + +#[test] +fn confirmed_board_delete_uses_supplied_now_for_undo_timestamp() { + let mut state = make_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + let requested_at = Instant::now(); + let confirmed_at = requested_at + Duration::from_millis(1); + + state.delete_active_board_at(requested_at); + state.delete_active_board_at(confirmed_at); + + let (_, deleted_at) = state.deleted_boards.last().expect("deleted board undo"); + assert_eq!(*deleted_at, confirmed_at); +} + +#[test] +fn expired_board_delete_confirmation_is_replaced_with_supplied_now() { + let mut state = make_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + let requested_at = Instant::now(); + let expired_at = requested_at + Duration::from_millis(BOARD_DELETE_CONFIRM_MS + 1); + let board_count = state.boards.board_count(); + + state.delete_active_board_at(requested_at); + state.delete_active_board_at(expired_at); + + assert_eq!(state.boards.board_count(), board_count); + let pending = state + .pending_board_delete + .as_ref() + .expect("replacement confirmation"); + assert_eq!( + pending.expires_at, + expired_at + Duration::from_millis(BOARD_DELETE_CONFIRM_MS) + ); +} + +#[test] +fn restore_deleted_board_expires_old_entries_with_supplied_now() { + let mut state = make_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + let requested_at = Instant::now(); + let confirmed_at = requested_at + Duration::from_millis(1); + + state.delete_active_board_at(requested_at); + state.delete_active_board_at(confirmed_at); + let board_count_after_delete = state.boards.board_count(); + + state.restore_deleted_board_at(confirmed_at + Duration::from_millis(BOARD_UNDO_EXPIRE_MS + 1)); + + assert!(state.deleted_boards.is_empty()); + assert_eq!(state.boards.board_count(), board_count_after_delete); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No deleted board to restore.") + ); +} + +#[test] +fn confirmed_active_page_delete_uses_supplied_now_for_undo_timestamp() { + let mut state = make_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + set_page_count(&mut state, board, 2); + let requested_at = Instant::now(); + let confirmed_at = requested_at + Duration::from_millis(1); + + assert_eq!( + state.delete_active_page_at(requested_at), + crate::draw::PageDeleteOutcome::Pending + ); + assert_eq!( + state.delete_active_page_at(confirmed_at), + crate::draw::PageDeleteOutcome::Removed + ); + + let (_, deleted_at) = state.deleted_pages.last().expect("deleted page undo"); + assert_eq!(*deleted_at, confirmed_at); +} + +#[test] +fn expired_active_page_delete_confirmation_is_replaced_with_supplied_now() { + let mut state = make_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + set_page_count(&mut state, board, 2); + let requested_at = Instant::now(); + let expired_at = requested_at + Duration::from_millis(PAGE_DELETE_CONFIRM_MS + 1); + let page_count = state.boards.page_count(); + + assert_eq!( + state.delete_active_page_at(requested_at), + crate::draw::PageDeleteOutcome::Pending + ); + assert_eq!( + state.delete_active_page_at(expired_at), + crate::draw::PageDeleteOutcome::Pending + ); + + assert_eq!(state.boards.page_count(), page_count); + let pending = state + .pending_page_delete + .as_ref() + .expect("replacement confirmation"); + assert_eq!( + pending.expires_at, + expired_at + Duration::from_millis(PAGE_DELETE_CONFIRM_MS) + ); +} + +#[test] +fn expired_page_in_board_delete_confirmation_is_replaced_with_supplied_now() { + let mut state = make_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + set_page_count(&mut state, board, 2); + let requested_at = Instant::now(); + let expired_at = requested_at + Duration::from_millis(PAGE_DELETE_CONFIRM_MS + 1); + let page_count = state.boards.board_states()[board].pages.page_count(); + + assert_eq!( + state.delete_page_in_board_at(board, 1, requested_at), + crate::draw::PageDeleteOutcome::Pending + ); + assert_eq!( + state.delete_page_in_board_at(board, 1, expired_at), + crate::draw::PageDeleteOutcome::Pending + ); + + assert_eq!( + state.boards.board_states()[board].pages.page_count(), + page_count + ); + let pending = state + .pending_page_delete + .as_ref() + .expect("replacement confirmation"); + assert_eq!( + pending.expires_at, + expired_at + Duration::from_millis(PAGE_DELETE_CONFIRM_MS) + ); +} + +#[test] +fn restore_deleted_page_expires_old_entries_with_supplied_now() { + let mut state = make_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + set_page_count(&mut state, board, 2); + let requested_at = Instant::now(); + let confirmed_at = requested_at + Duration::from_millis(1); + + assert_eq!( + state.delete_active_page_at(requested_at), + crate::draw::PageDeleteOutcome::Pending + ); + assert_eq!( + state.delete_active_page_at(confirmed_at), + crate::draw::PageDeleteOutcome::Removed + ); + let page_count_after_delete = state.boards.page_count(); + + state.restore_deleted_page_at(confirmed_at + Duration::from_millis(PAGE_UNDO_EXPIRE_MS + 1)); + + assert!(state.deleted_pages.is_empty()); + assert_eq!(state.boards.page_count(), page_count_after_delete); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No deleted page to restore.") + ); +} diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs index 10d513d3..9971c28f 100644 --- a/src/input/state/core/properties/apply_selection/helpers.rs +++ b/src/input/state/core/properties/apply_selection/helpers.rs @@ -118,7 +118,10 @@ impl InputState { } let undo_action = if actions.len() == 1 { - actions.into_iter().next().unwrap() + let Some(action) = actions.pop() else { + return result; + }; + action } else { crate::draw::frame::UndoAction::Compound { actions } }; @@ -174,332 +177,4 @@ impl InputState { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; - use crate::draw::{Color, FontDescriptor}; - use crate::input::{ClickHighlightSettings, EraserMode}; - - fn make_state() -> InputState { - let keybindings = KeybindingsConfig::default(); - let action_map = keybindings - .build_action_map() - .expect("default keybindings map"); - - let mut state = InputState::with_defaults( - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - 4.0, - 4.0, - EraserMode::Brush, - 0.32, - false, - 32.0, - FontDescriptor::default(), - false, - 20.0, - 30.0, - false, - true, - BoardsConfig::default(), - action_map, - usize::MAX, - ClickHighlightSettings::disabled(), - 0, - 0, - true, - 0, - 0, - 5, - 5, - PresenterModeConfig::default(), - ); - state.update_screen_dimensions(200, 120); - let _ = state.take_dirty_regions(); - state - } - - fn add_rect( - state: &mut InputState, - color: Color, - fill: bool, - locked: bool, - ) -> crate::draw::ShapeId { - let id = state.boards.active_frame_mut().add_shape(Shape::Rect { - x: 10, - y: 20, - w: 30, - h: 40, - fill, - color, - thick: 2.0, - }); - if locked { - let index = state - .boards - .active_frame() - .find_index(id) - .expect("shape index"); - state.boards.active_frame_mut().shapes[index].locked = true; - } - id - } - - #[test] - fn selection_primary_color_skips_locked_shapes() { - let mut state = make_state(); - let locked = add_rect( - &mut state, - Color { - r: 0.0, - g: 0.0, - b: 1.0, - a: 1.0, - }, - false, - true, - ); - let unlocked = add_rect( - &mut state, - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - false, - false, - ); - state.set_selection(vec![locked, unlocked]); - - assert_eq!( - state.selection_primary_color(), - Some(Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0 - }) - ); - } - - #[test] - fn selection_bool_target_returns_true_for_mixed_or_locked_only_values() { - let mut state = make_state(); - let first = add_rect( - &mut state, - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - false, - false, - ); - let second = add_rect( - &mut state, - Color { - r: 0.0, - g: 1.0, - b: 0.0, - a: 1.0, - }, - true, - false, - ); - state.set_selection(vec![first, second]); - - assert_eq!( - state.selection_bool_target(|shape| match shape { - Shape::Rect { fill, .. } => Some(*fill), - _ => None, - }), - Some(true) - ); - - let mut locked_state = make_state(); - let locked = add_rect( - &mut locked_state, - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - false, - true, - ); - locked_state.set_selection(vec![locked]); - assert_eq!( - locked_state.selection_bool_target(|shape| match shape { - Shape::Rect { fill, .. } => Some(*fill), - _ => None, - }), - Some(true) - ); - } - - #[test] - fn selection_bool_target_flips_uniform_unlocked_value() { - let mut state = make_state(); - let first = add_rect( - &mut state, - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - false, - false, - ); - let second = add_rect( - &mut state, - Color { - r: 0.0, - g: 1.0, - b: 0.0, - a: 1.0, - }, - false, - false, - ); - state.set_selection(vec![first, second]); - - assert_eq!( - state.selection_bool_target(|shape| match shape { - Shape::Rect { fill, .. } => Some(*fill), - _ => None, - }), - Some(true) - ); - - let frame = state.boards.active_frame_mut(); - if let Shape::Rect { fill, .. } = &mut frame.shape_mut(first).expect("first shape").shape { - *fill = true; - } - if let Shape::Rect { fill, .. } = &mut frame.shape_mut(second).expect("second shape").shape - { - *fill = true; - } - - assert_eq!( - state.selection_bool_target(|shape| match shape { - Shape::Rect { fill, .. } => Some(*fill), - _ => None, - }), - Some(false) - ); - } - - #[test] - fn apply_selection_change_reports_applicable_locked_and_changed_counts() { - let mut state = make_state(); - let unlocked = add_rect( - &mut state, - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - false, - false, - ); - let locked = add_rect( - &mut state, - Color { - r: 0.0, - g: 0.0, - b: 1.0, - a: 1.0, - }, - false, - true, - ); - state.set_selection(vec![unlocked, locked]); - state.needs_redraw = false; - state.session_dirty = false; - - let result = state.apply_selection_change( - |shape| matches!(shape, Shape::Rect { .. }), - |shape| match shape { - Shape::Rect { fill, .. } => { - *fill = true; - true - } - _ => false, - }, - ); - - assert_eq!(result.applicable, 2); - assert_eq!(result.locked, 1); - assert_eq!(result.changed, 1); - assert!(state.needs_redraw); - assert!(state.session_dirty); - assert_eq!(state.boards.active_frame().undo_stack_len(), 1); - assert!(!state.take_dirty_regions().is_empty()); - } - - #[test] - fn report_selection_apply_result_emits_expected_toasts() { - let mut state = make_state(); - - assert!(!state.report_selection_apply_result( - SelectionApplyResult { - changed: 0, - locked: 0, - applicable: 0, - }, - "fill", - )); - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("No fill to edit in selection.") - ); - - assert!(!state.report_selection_apply_result( - SelectionApplyResult { - changed: 0, - locked: 2, - applicable: 2, - }, - "color", - )); - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("All color shapes are locked.") - ); - - assert!(!state.report_selection_apply_result( - SelectionApplyResult { - changed: 0, - locked: 1, - applicable: 2, - }, - "fill", - )); - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("No changes applied.") - ); - - assert!(state.report_selection_apply_result( - SelectionApplyResult { - changed: 1, - locked: 2, - applicable: 3, - }, - "fill", - )); - assert_eq!( - state.ui_toast.as_ref().map(|toast| toast.message.as_str()), - Some("2 locked shape(s) unchanged.") - ); - } -} +mod tests; diff --git a/src/input/state/core/properties/apply_selection/helpers/tests.rs b/src/input/state/core/properties/apply_selection/helpers/tests.rs new file mode 100644 index 00000000..85c46f96 --- /dev/null +++ b/src/input/state/core/properties/apply_selection/helpers/tests.rs @@ -0,0 +1,326 @@ +use super::*; +use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; +use crate::draw::{Color, FontDescriptor}; +use crate::input::{ClickHighlightSettings, EraserMode}; + +fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + let mut state = InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ); + state.update_screen_dimensions(200, 120); + let _ = state.take_dirty_regions(); + state +} + +fn add_rect( + state: &mut InputState, + color: Color, + fill: bool, + locked: bool, +) -> crate::draw::ShapeId { + let id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 20, + w: 30, + h: 40, + fill, + color, + thick: 2.0, + }); + if locked { + let index = state + .boards + .active_frame() + .find_index(id) + .expect("shape index"); + state.boards.active_frame_mut().shapes[index].locked = true; + } + id +} + +#[test] +fn selection_primary_color_skips_locked_shapes() { + let mut state = make_state(); + let locked = add_rect( + &mut state, + Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }, + false, + true, + ); + let unlocked = add_rect( + &mut state, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + false, + ); + state.set_selection(vec![locked, unlocked]); + + assert_eq!( + state.selection_primary_color(), + Some(Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0 + }) + ); +} + +#[test] +fn selection_bool_target_returns_true_for_mixed_or_locked_only_values() { + let mut state = make_state(); + let first = add_rect( + &mut state, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + false, + ); + let second = add_rect( + &mut state, + Color { + r: 0.0, + g: 1.0, + b: 0.0, + a: 1.0, + }, + true, + false, + ); + state.set_selection(vec![first, second]); + + assert_eq!( + state.selection_bool_target(|shape| match shape { + Shape::Rect { fill, .. } => Some(*fill), + _ => None, + }), + Some(true) + ); + + let mut locked_state = make_state(); + let locked = add_rect( + &mut locked_state, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + true, + ); + locked_state.set_selection(vec![locked]); + assert_eq!( + locked_state.selection_bool_target(|shape| match shape { + Shape::Rect { fill, .. } => Some(*fill), + _ => None, + }), + Some(true) + ); +} + +#[test] +fn selection_bool_target_flips_uniform_unlocked_value() { + let mut state = make_state(); + let first = add_rect( + &mut state, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + false, + ); + let second = add_rect( + &mut state, + Color { + r: 0.0, + g: 1.0, + b: 0.0, + a: 1.0, + }, + false, + false, + ); + state.set_selection(vec![first, second]); + + assert_eq!( + state.selection_bool_target(|shape| match shape { + Shape::Rect { fill, .. } => Some(*fill), + _ => None, + }), + Some(true) + ); + + let frame = state.boards.active_frame_mut(); + if let Shape::Rect { fill, .. } = &mut frame.shape_mut(first).expect("first shape").shape { + *fill = true; + } + if let Shape::Rect { fill, .. } = &mut frame.shape_mut(second).expect("second shape").shape { + *fill = true; + } + + assert_eq!( + state.selection_bool_target(|shape| match shape { + Shape::Rect { fill, .. } => Some(*fill), + _ => None, + }), + Some(false) + ); +} + +#[test] +fn apply_selection_change_reports_applicable_locked_and_changed_counts() { + let mut state = make_state(); + let unlocked = add_rect( + &mut state, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + false, + ); + let locked = add_rect( + &mut state, + Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }, + false, + true, + ); + state.set_selection(vec![unlocked, locked]); + state.needs_redraw = false; + state.session_dirty = false; + + let result = state.apply_selection_change( + |shape| matches!(shape, Shape::Rect { .. }), + |shape| match shape { + Shape::Rect { fill, .. } => { + *fill = true; + true + } + _ => false, + }, + ); + + assert_eq!(result.applicable, 2); + assert_eq!(result.locked, 1); + assert_eq!(result.changed, 1); + assert!(state.needs_redraw); + assert!(state.session_dirty); + assert_eq!(state.boards.active_frame().undo_stack_len(), 1); + assert!(!state.take_dirty_regions().is_empty()); +} + +#[test] +fn report_selection_apply_result_emits_expected_toasts() { + let mut state = make_state(); + + assert!(!state.report_selection_apply_result( + SelectionApplyResult { + changed: 0, + locked: 0, + applicable: 0, + }, + "fill", + )); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No fill to edit in selection.") + ); + + assert!(!state.report_selection_apply_result( + SelectionApplyResult { + changed: 0, + locked: 2, + applicable: 2, + }, + "color", + )); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("All color shapes are locked.") + ); + + assert!(!state.report_selection_apply_result( + SelectionApplyResult { + changed: 0, + locked: 1, + applicable: 2, + }, + "fill", + )); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No changes applied.") + ); + + assert!(state.report_selection_apply_result( + SelectionApplyResult { + changed: 1, + locked: 2, + applicable: 3, + }, + "fill", + )); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("2 locked shape(s) unchanged.") + ); +} diff --git a/src/input/state/core/selection_actions/clipboard.rs b/src/input/state/core/selection_actions/clipboard.rs index 29f753fa..c8096c79 100644 --- a/src/input/state/core/selection_actions/clipboard.rs +++ b/src/input/state/core/selection_actions/clipboard.rs @@ -3,70 +3,17 @@ use super::super::base::{ PendingSelectionClipboardPublish, SelectionPublishState, UiToastKind, WayscriberClipboardSelection, }; +use crate::draw::Shape; use crate::draw::frame::UndoAction; -use crate::draw::{EmbeddedImage, Shape}; use crate::util::Rect; -const DUPLICATE_OFFSET: i32 = 12; const PRIVATE_CLIPBOARD_SCHEMA_VERSION: u32 = 1; +mod duplicate; +mod image_paste; + #[allow(dead_code)] impl InputState { - pub(crate) fn duplicate_selection(&mut self) -> bool { - let ids_len = self.selected_shape_ids().len(); - if ids_len == 0 { - return false; - } - - let mut created = Vec::new(); - let mut new_ids = Vec::new(); - for idx in 0..ids_len { - let id = self.selected_shape_ids()[idx]; - let original = { - let frame = self.boards.active_frame(); - frame.shape(id).cloned() - }; - let Some(shape) = original else { - continue; - }; - if shape.locked { - continue; - } - - let mut cloned_shape = shape.shape.clone(); - Self::translate_shape(&mut cloned_shape, DUPLICATE_OFFSET, DUPLICATE_OFFSET); - let new_id = { - let frame = self.boards.active_frame_mut(); - frame.add_shape(cloned_shape) - }; - - if let Some((index, stored)) = { - let frame = self.boards.active_frame(); - frame - .find_index(new_id) - .and_then(|idx| frame.shape(new_id).map(|s| (idx, s.clone()))) - } { - self.mark_selection_dirty_region(stored.shape.bounding_box()); - self.invalidate_hit_cache_for(new_id); - created.push((index, stored)); - new_ids.push(new_id); - } - } - - if created.is_empty() { - return false; - } - - self.boards.active_frame_mut().push_undo_action( - UndoAction::Create { shapes: created }, - self.undo_stack_limit, - ); - self.mark_session_dirty(); - self.needs_redraw = true; - self.set_selection(new_ids); - true - } - pub(crate) fn copy_selection(&mut self) -> usize { let copied = { let ids = self.selected_shape_ids(); @@ -481,121 +428,6 @@ impl InputState { } created_len } - - pub(crate) fn paste_external_image_from_request( - &mut self, - request: &ClipboardPasteRequest, - image: EmbeddedImage, - ) -> bool { - if self.active_clipboard_paste_request_id != Some(request.id) { - log::info!( - "Ignoring external image paste request {} because active request is {:?}", - request.id, - self.active_clipboard_paste_request_id - ); - return false; - } - - let image_mime_type = image.mime_type.clone(); - let image_width = image.width; - let image_height = image.height; - let image_bytes = image.bytes.len(); - let target_active = self.clipboard_request_targets_active_page(request); - let max_shapes = self.max_shapes_per_frame; - let undo_limit = self.undo_stack_limit; - let target = self - .boards - .board_state_by_id_mut(&request.target_board_id) - .filter(|board| board.pages.generation() == request.target_page_generation) - .and_then(|board| board.pages.frame_mut(request.target_page_index)); - - let Some(frame) = target else { - log::warn!( - "External image paste request {} cancelled because target board '{}' page {} generation {} is no longer available", - request.id, - request.target_board_id, - request.target_page_index, - request.target_page_generation - ); - self.set_ui_toast( - UiToastKind::Warning, - "Paste target changed; image paste was cancelled.", - ); - self.trigger_blocked_feedback(); - return false; - }; - - let (x, y, w, h) = image_display_bounds(request, image.width, image.height); - let shape = Shape::Image { - x, - y, - w, - h, - data: image, - }; - let Some(new_id) = frame.try_add_shape_with_id(shape, max_shapes) else { - log::warn!( - "External image paste request {} rejected by shape limit on board '{}' page {} (max_shapes={}, image_bytes={})", - request.id, - request.target_board_id, - request.target_page_index, - max_shapes, - image_bytes - ); - self.set_ui_toast( - UiToastKind::Warning, - "Shape limit reached; image not pasted.", - ); - self.trigger_blocked_feedback(); - return false; - }; - - let Some((index, stored)) = frame - .find_index(new_id) - .and_then(|index| frame.shape(new_id).map(|shape| (index, shape.clone()))) - else { - return false; - }; - let bounds = stored.shape.bounding_box(); - frame.push_undo_action( - UndoAction::Create { - shapes: vec![(index, stored)], - }, - undo_limit, - ); - let undo_entries = frame.undo_stack_len(); - self.mark_session_dirty(); - if target_active { - self.mark_selection_dirty_region(bounds); - self.invalidate_hit_cache_for(new_id); - self.set_selection(vec![new_id]); - self.needs_redraw = true; - } - log::info!( - "Pasted external image shape {} from request {} into board '{}' page {}: target_active={}, mime={}, image={}x{}, bytes={}, display_bounds=({}, {}, {}, {}), undo_entries={}", - new_id, - request.id, - request.target_board_id, - request.target_page_index, - target_active, - image_mime_type, - image_width, - image_height, - image_bytes, - x, - y, - w, - h, - undo_entries - ); - true - } - - fn clipboard_request_targets_active_page(&self, request: &ClipboardPasteRequest) -> bool { - self.boards.active_board_id() == request.target_board_id - && self.boards.active_page_index() == request.target_page_index - && self.boards.active_page_generation() == request.target_page_generation - } } fn non_empty_shapes(shapes: Vec) -> Option> { @@ -640,42 +472,3 @@ fn shapes_bounding_box(shapes: &[Shape]) -> Option { .then(|| Rect::from_min_max(min_x, min_y, max_x, max_y)) .flatten() } - -fn image_display_bounds( - request: &ClipboardPasteRequest, - natural_width: u32, - natural_height: u32, -) -> (i32, i32, i32, i32) { - let natural_width = natural_width.max(1) as f64; - let natural_height = natural_height.max(1) as f64; - let max_width = (request.visible_canvas_rect.width.max(1) as f64 * 0.7).max(1.0); - let max_height = (request.visible_canvas_rect.height.max(1) as f64 * 0.7).max(1.0); - let scale = (max_width / natural_width) - .min(max_height / natural_height) - .min(1.0); - let w = (natural_width * scale).round().max(1.0) as i32; - let h = (natural_height * scale).round().max(1.0) as i32; - let (anchor_x, anchor_y) = request.anchor.point(); - let mut x = anchor_x.saturating_sub(w / 2); - let mut y = anchor_y.saturating_sub(h / 2); - x = clamp_partly_visible( - x, - w, - request.visible_canvas_rect.x, - request.visible_canvas_rect.width, - ); - y = clamp_partly_visible( - y, - h, - request.visible_canvas_rect.y, - request.visible_canvas_rect.height, - ); - (x, y, w, h) -} - -fn clamp_partly_visible(value: i32, size: i32, visible_start: i32, visible_size: i32) -> i32 { - let visible_end = visible_start.saturating_add(visible_size); - let min_value = visible_start.saturating_sub(size.saturating_sub(1)); - let max_value = visible_end.saturating_sub(1); - value.clamp(min_value, max_value) -} diff --git a/src/input/state/core/selection_actions/clipboard/duplicate.rs b/src/input/state/core/selection_actions/clipboard/duplicate.rs new file mode 100644 index 00000000..3197bdad --- /dev/null +++ b/src/input/state/core/selection_actions/clipboard/duplicate.rs @@ -0,0 +1,62 @@ +use super::super::super::base::InputState; +use crate::draw::frame::UndoAction; + +const DUPLICATE_OFFSET: i32 = 12; + +#[allow(dead_code)] +impl InputState { + pub(crate) fn duplicate_selection(&mut self) -> bool { + let ids_len = self.selected_shape_ids().len(); + if ids_len == 0 { + return false; + } + + let mut created = Vec::new(); + let mut new_ids = Vec::new(); + for idx in 0..ids_len { + let id = self.selected_shape_ids()[idx]; + let original = { + let frame = self.boards.active_frame(); + frame.shape(id).cloned() + }; + let Some(shape) = original else { + continue; + }; + if shape.locked { + continue; + } + + let mut cloned_shape = shape.shape.clone(); + Self::translate_shape(&mut cloned_shape, DUPLICATE_OFFSET, DUPLICATE_OFFSET); + let new_id = { + let frame = self.boards.active_frame_mut(); + frame.add_shape(cloned_shape) + }; + + if let Some((index, stored)) = { + let frame = self.boards.active_frame(); + frame + .find_index(new_id) + .and_then(|idx| frame.shape(new_id).map(|s| (idx, s.clone()))) + } { + self.mark_selection_dirty_region(stored.shape.bounding_box()); + self.invalidate_hit_cache_for(new_id); + created.push((index, stored)); + new_ids.push(new_id); + } + } + + if created.is_empty() { + return false; + } + + self.boards.active_frame_mut().push_undo_action( + UndoAction::Create { shapes: created }, + self.undo_stack_limit, + ); + self.mark_session_dirty(); + self.needs_redraw = true; + self.set_selection(new_ids); + true + } +} diff --git a/src/input/state/core/selection_actions/clipboard/image_paste.rs b/src/input/state/core/selection_actions/clipboard/image_paste.rs new file mode 100644 index 00000000..18e79289 --- /dev/null +++ b/src/input/state/core/selection_actions/clipboard/image_paste.rs @@ -0,0 +1,164 @@ +#![allow(dead_code)] // Used by the binary Wayland backend; the library build exports input only. + +use super::super::super::base::{ClipboardPasteRequest, InputState, UiToastKind}; +use crate::draw::frame::UndoAction; +use crate::draw::{EmbeddedImage, Shape}; + +impl InputState { + pub(crate) fn paste_external_image_from_request( + &mut self, + request: &ClipboardPasteRequest, + image: EmbeddedImage, + ) -> bool { + if self.active_clipboard_paste_request_id != Some(request.id) { + log::info!( + "Ignoring external image paste request {} because active request is {:?}", + request.id, + self.active_clipboard_paste_request_id + ); + return false; + } + + let image_mime_type = image.mime_type.clone(); + let image_width = image.width; + let image_height = image.height; + let image_bytes = image.bytes.len(); + let target_active = self.clipboard_request_targets_active_page(request); + let max_shapes = self.max_shapes_per_frame; + let undo_limit = self.undo_stack_limit; + let target = self + .boards + .board_state_by_id_mut(&request.target_board_id) + .filter(|board| board.pages.generation() == request.target_page_generation) + .and_then(|board| board.pages.frame_mut(request.target_page_index)); + + let Some(frame) = target else { + log::warn!( + "External image paste request {} cancelled because target board '{}' page {} generation {} is no longer available", + request.id, + request.target_board_id, + request.target_page_index, + request.target_page_generation + ); + self.set_ui_toast( + UiToastKind::Warning, + "Paste target changed; image paste was cancelled.", + ); + self.trigger_blocked_feedback(); + return false; + }; + + let (x, y, w, h) = image_display_bounds(request, image.width, image.height); + let shape = Shape::Image { + x, + y, + w, + h, + data: image, + }; + let Some(new_id) = frame.try_add_shape_with_id(shape, max_shapes) else { + log::warn!( + "External image paste request {} rejected by shape limit on board '{}' page {} (max_shapes={}, image_bytes={})", + request.id, + request.target_board_id, + request.target_page_index, + max_shapes, + image_bytes + ); + self.set_ui_toast( + UiToastKind::Warning, + "Shape limit reached; image not pasted.", + ); + self.trigger_blocked_feedback(); + return false; + }; + + let Some((index, stored)) = frame + .find_index(new_id) + .and_then(|index| frame.shape(new_id).map(|shape| (index, shape.clone()))) + else { + return false; + }; + let bounds = stored.shape.bounding_box(); + frame.push_undo_action( + UndoAction::Create { + shapes: vec![(index, stored)], + }, + undo_limit, + ); + let undo_entries = frame.undo_stack_len(); + self.mark_session_dirty(); + if target_active { + self.mark_selection_dirty_region(bounds); + self.invalidate_hit_cache_for(new_id); + self.set_selection(vec![new_id]); + self.needs_redraw = true; + } + log::info!( + "Pasted external image shape {} from request {} into board '{}' page {}: target_active={}, mime={}, image={}x{}, bytes={}, display_bounds=({}, {}, {}, {}), undo_entries={}", + new_id, + request.id, + request.target_board_id, + request.target_page_index, + target_active, + image_mime_type, + image_width, + image_height, + image_bytes, + x, + y, + w, + h, + undo_entries + ); + true + } + + pub(super) fn clipboard_request_targets_active_page( + &self, + request: &ClipboardPasteRequest, + ) -> bool { + self.boards.active_board_id() == request.target_board_id + && self.boards.active_page_index() == request.target_page_index + && self.boards.active_page_generation() == request.target_page_generation + } +} + +fn image_display_bounds( + request: &ClipboardPasteRequest, + natural_width: u32, + natural_height: u32, +) -> (i32, i32, i32, i32) { + let natural_width = natural_width.max(1) as f64; + let natural_height = natural_height.max(1) as f64; + let max_width = (request.visible_canvas_rect.width.max(1) as f64 * 0.7).max(1.0); + let max_height = (request.visible_canvas_rect.height.max(1) as f64 * 0.7).max(1.0); + let scale = (max_width / natural_width) + .min(max_height / natural_height) + .min(1.0); + let w = (natural_width * scale).round().max(1.0) as i32; + let h = (natural_height * scale).round().max(1.0) as i32; + let (anchor_x, anchor_y) = request.anchor.point(); + let mut x = anchor_x.saturating_sub(w / 2); + let mut y = anchor_y.saturating_sub(h / 2); + x = clamp_partly_visible( + x, + w, + request.visible_canvas_rect.x, + request.visible_canvas_rect.width, + ); + y = clamp_partly_visible( + y, + h, + request.visible_canvas_rect.y, + request.visible_canvas_rect.height, + ); + (x, y, w, h) +} + +fn clamp_partly_visible(value: i32, size: i32, visible_start: i32, visible_size: i32) -> i32 { + let visible_end = visible_start.saturating_add(visible_size); + let min_value = visible_start.saturating_sub(size.saturating_sub(1)); + let max_value = visible_end.saturating_sub(1); + value.clamp(min_value, max_value) +} diff --git a/src/input/state/core/selection_actions/translation/undo.rs b/src/input/state/core/selection_actions/translation/undo.rs index 16f38dd5..726c75cb 100644 --- a/src/input/state/core/selection_actions/translation/undo.rs +++ b/src/input/state/core/selection_actions/translation/undo.rs @@ -31,7 +31,10 @@ impl InputState { } let undo_action = if actions.len() == 1 { - actions.into_iter().next().unwrap() + let Some(action) = actions.pop() else { + return false; + }; + action } else { UndoAction::Compound { actions } }; diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 932e5ae0..04c638a9 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -1,18 +1,17 @@ use crate::draw::Shape; -use crate::draw::frame::{ShapeSnapshot, UndoAction}; -use crate::draw::shape::{PolygonKind, has_minimum_distinct_points}; +use crate::draw::frame::ShapeSnapshot; use crate::input::tool::ToolPressBehavior; use crate::input::{DragTool, Tool, events::MouseButton}; use std::sync::Arc; -use std::time::Instant; use super::super::core::MenuCommand; -use super::super::core::PolygonClickState; use super::super::{ ContextMenuKind, DrawingState, InputState, interaction::{CanvasPoint, PointerPoints, PointerPress, ScreenPoint, route_pointer_press}, }; -use super::{TEXT_DOUBLE_CLICK_DISTANCE, TEXT_DOUBLE_CLICK_MS}; + +mod panels; +mod polygon; #[derive(Clone, Copy)] struct PressCoords { @@ -107,19 +106,6 @@ impl InputState { self.needs_redraw = true; } - fn is_point_in_context_menu(&self, x: i32, y: i32) -> bool { - if let Some(layout) = self.context_menu_layout() { - let xf = x as f64; - let yf = y as f64; - xf >= layout.origin_x - && xf <= layout.origin_x + layout.width - && yf >= layout.origin_y - && yf <= layout.origin_y + layout.height - } else { - false - } - } - /// Processes a mouse button press event. /// /// # Arguments @@ -383,303 +369,4 @@ impl InputState { } } } - - pub(crate) fn start_building_polygon(&mut self, x: i32, y: i32) { - self.sync_current_settings_for_tool(Tool::FreeformPolygon); - let color = self.color_for_tool(Tool::FreeformPolygon); - let thick = self.thickness_for_tool(Tool::FreeformPolygon); - self.clear_selection(); - self.last_polygon_click = Some(PolygonClickState { - x, - y, - at: Instant::now(), - }); - self.state = DrawingState::BuildingPolygon { - points: vec![(x, y)], - preview: None, - fill: self.fill_enabled, - color, - thick, - }; - self.last_provisional_bounds = None; - self.update_provisional_dirty(x, y); - self.set_ui_toast( - super::super::UiToastKind::Info, - "Click points. Enter/double-click to finish. Backspace undo. Esc cancel.", - ); - self.needs_redraw = true; - } - - fn should_finish_building_polygon_on_click(&self, x: i32, y: i32) -> bool { - let Some(last) = self.last_polygon_click else { - return false; - }; - if Instant::now().duration_since(last.at).as_millis() > TEXT_DOUBLE_CLICK_MS as u128 { - return false; - } - if (x - last.x).abs() > TEXT_DOUBLE_CLICK_DISTANCE - || (y - last.y).abs() > TEXT_DOUBLE_CLICK_DISTANCE - { - return false; - } - let DrawingState::BuildingPolygon { points, .. } = &self.state else { - return false; - }; - has_minimum_distinct_points(points) - } - - pub(crate) fn handle_building_polygon_left_click(&mut self, x: i32, y: i32) { - if self.should_finish_building_polygon_on_click(x, y) { - self.finish_building_polygon(); - } else { - self.append_building_polygon_point(x, y); - } - } - - pub(crate) fn append_building_polygon_point(&mut self, x: i32, y: i32) { - let DrawingState::BuildingPolygon { - points, preview, .. - } = &mut self.state - else { - return; - }; - points.push((x, y)); - *preview = None; - self.last_polygon_click = Some(PolygonClickState { - x, - y, - at: Instant::now(), - }); - self.update_provisional_dirty(x, y); - self.needs_redraw = true; - } - - pub(crate) fn pop_building_polygon_point(&mut self) { - let DrawingState::BuildingPolygon { points, .. } = &mut self.state else { - return; - }; - let _ = points.pop(); - if points.is_empty() { - self.clear_provisional_dirty(); - self.last_polygon_click = None; - self.state = DrawingState::Idle; - } else { - let (x, y) = self.canvas_pointer_position(); - self.last_polygon_click = None; - self.update_provisional_dirty(x, y); - } - self.needs_redraw = true; - } - - pub(crate) fn finish_building_polygon(&mut self) { - let state = std::mem::replace(&mut self.state, DrawingState::Idle); - let DrawingState::BuildingPolygon { - points, - fill, - color, - thick, - .. - } = state - else { - self.state = state; - return; - }; - - self.clear_provisional_dirty(); - self.last_polygon_click = None; - if !has_minimum_distinct_points(&points) { - self.needs_redraw = true; - return; - } - - let shape = Shape::Polygon { - kind: PolygonKind::Freeform, - points, - fill, - color, - thick, - }; - let bounds = shape.bounding_box(); - let addition = { - let frame = self.boards.active_frame_mut(); - frame - .try_add_shape_with_id(shape, self.max_shapes_per_frame) - .and_then(|new_id| { - let index = frame.find_index(new_id)?; - let snapshot = frame.shape(new_id)?.clone(); - frame.push_undo_action( - UndoAction::Create { - shapes: vec![(index, snapshot.clone())], - }, - self.undo_stack_limit, - ); - Some((new_id, snapshot)) - }) - }; - if let Some((new_id, _snapshot)) = addition { - self.invalidate_hit_cache_for(new_id); - self.dirty_tracker.mark_optional_rect(bounds); - self.mark_session_dirty(); - self.record_first_stroke_done_for_onboarding(); - } - self.needs_redraw = true; - } - - pub(in crate::input::state) fn handle_context_menu_press( - &mut self, - screen_x: i32, - screen_y: i32, - ) -> bool { - if !self.is_context_menu_open() { - return false; - } - - self.last_text_click = None; - if self.is_point_in_context_menu(screen_x, screen_y) { - self.update_context_menu_hover_from_pointer(screen_x, screen_y); - } else { - self.close_context_menu(); - self.needs_redraw = true; - } - true - } - - pub(in crate::input::state) fn handle_radial_menu_press( - &mut self, - button: MouseButton, - screen_x: i32, - screen_y: i32, - canvas_x: i32, - canvas_y: i32, - ) -> bool { - if !self.is_radial_menu_open() { - return false; - } - self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); - match button { - MouseButton::Left => { - // Update hover at exact click position before selecting - self.update_radial_menu_hover(screen_x as f64, screen_y as f64); - self.radial_menu_select_hovered(); - } - MouseButton::Right => { - self.close_radial_menu(); - if !self.is_radial_menu_toggle_button(MouseButton::Right) { - // Keep right-click context-menu flow when right button is not the - // configured radial-menu trigger. - self.handle_right_click(screen_x, screen_y, canvas_x, canvas_y); - } - } - MouseButton::Middle => { - self.close_radial_menu(); - } - } - true - } - - pub(in crate::input::state) fn handle_color_picker_press( - &mut self, - button: MouseButton, - x: i32, - y: i32, - ) -> bool { - if !self.is_color_picker_popup_open() { - return false; - } - self.update_pointer_position(x, y); - match button { - MouseButton::Left => { - if let Some(layout) = self.color_picker_popup_layout() { - let fx = x as f64; - let fy = y as f64; - // Start dragging if clicking on gradient - if layout.point_in_gradient(fx, fy) { - self.color_picker_popup_set_dragging(true); - let norm_x = (fx - layout.gradient_x) / layout.gradient_w; - let norm_y = (fy - layout.gradient_y) / layout.gradient_h; - self.color_picker_popup_set_from_gradient(norm_x, norm_y); - self.color_picker_popup_set_hex_editing(false); - } - } - } - MouseButton::Right => { - self.close_color_picker_popup(true); - } - MouseButton::Middle => {} - } - true - } - - pub(in crate::input::state) fn handle_board_picker_press( - &mut self, - button: MouseButton, - x: i32, - y: i32, - ) -> bool { - if !self.is_board_picker_open() { - return false; - } - self.update_pointer_position(x, y); - match button { - MouseButton::Left => { - if self.board_picker_contains_point(x, y) { - if let Some(index) = self.board_picker_page_handle_index_at(x, y) { - self.board_picker_start_page_drag(index); - return true; - } - if let Some(row) = self.board_picker_handle_index_at(x, y) { - self.board_picker_start_drag(row); - return true; - } - if self.board_picker_index_at(x, y).is_some() { - self.update_board_picker_hover_from_pointer(x, y); - } - } else { - self.close_board_picker(); - } - } - MouseButton::Right => { - if self.board_picker_contains_point(x, y) - && let Some(page_index) = self.board_picker_page_index_at(x, y) - && let Some(board_index) = self.board_picker_page_panel_board_index() - { - self.update_pointer_position_synthetic(x, y); - self.open_page_context_menu((x, y), board_index, page_index); - } else { - self.close_board_picker(); - } - } - MouseButton::Middle => {} - } - true - } - - pub(in crate::input::state) fn handle_properties_panel_press( - &mut self, - button: MouseButton, - x: i32, - y: i32, - ) -> bool { - if !self.is_properties_panel_open() { - return false; - } - self.update_pointer_position(x, y); - if self.properties_panel_layout().is_none() { - return true; - } - match button { - MouseButton::Left => { - if let Some(index) = self.properties_panel_index_at(x, y) { - self.set_properties_panel_focus(Some(index)); - } else { - self.close_properties_panel(); - } - } - MouseButton::Right => { - self.close_properties_panel(); - } - MouseButton::Middle => {} - } - true - } } diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs new file mode 100644 index 00000000..7e11811f --- /dev/null +++ b/src/input/state/mouse/press/panels.rs @@ -0,0 +1,176 @@ +use crate::input::events::MouseButton; + +use super::super::super::InputState; + +impl InputState { + fn is_point_in_context_menu(&self, x: i32, y: i32) -> bool { + if let Some(layout) = self.context_menu_layout() { + let xf = x as f64; + let yf = y as f64; + xf >= layout.origin_x + && xf <= layout.origin_x + layout.width + && yf >= layout.origin_y + && yf <= layout.origin_y + layout.height + } else { + false + } + } + + pub(in crate::input::state) fn handle_context_menu_press( + &mut self, + screen_x: i32, + screen_y: i32, + ) -> bool { + if !self.is_context_menu_open() { + return false; + } + + self.last_text_click = None; + if self.is_point_in_context_menu(screen_x, screen_y) { + self.update_context_menu_hover_from_pointer(screen_x, screen_y); + } else { + self.close_context_menu(); + self.needs_redraw = true; + } + true + } + + pub(in crate::input::state) fn handle_radial_menu_press( + &mut self, + button: MouseButton, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + ) -> bool { + if !self.is_radial_menu_open() { + return false; + } + self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); + match button { + MouseButton::Left => { + // Update hover at exact click position before selecting + self.update_radial_menu_hover(screen_x as f64, screen_y as f64); + self.radial_menu_select_hovered(); + } + MouseButton::Right => { + self.close_radial_menu(); + if !self.is_radial_menu_toggle_button(MouseButton::Right) { + // Keep right-click context-menu flow when right button is not the + // configured radial-menu trigger. + self.handle_right_click(screen_x, screen_y, canvas_x, canvas_y); + } + } + MouseButton::Middle => { + self.close_radial_menu(); + } + } + true + } + + pub(in crate::input::state) fn handle_color_picker_press( + &mut self, + button: MouseButton, + x: i32, + y: i32, + ) -> bool { + if !self.is_color_picker_popup_open() { + return false; + } + self.update_pointer_position(x, y); + match button { + MouseButton::Left => { + if let Some(layout) = self.color_picker_popup_layout() { + let fx = x as f64; + let fy = y as f64; + // Start dragging if clicking on gradient + if layout.point_in_gradient(fx, fy) { + self.color_picker_popup_set_dragging(true); + let norm_x = (fx - layout.gradient_x) / layout.gradient_w; + let norm_y = (fy - layout.gradient_y) / layout.gradient_h; + self.color_picker_popup_set_from_gradient(norm_x, norm_y); + self.color_picker_popup_set_hex_editing(false); + } + } + } + MouseButton::Right => { + self.close_color_picker_popup(true); + } + MouseButton::Middle => {} + } + true + } + + pub(in crate::input::state) fn handle_board_picker_press( + &mut self, + button: MouseButton, + x: i32, + y: i32, + ) -> bool { + if !self.is_board_picker_open() { + return false; + } + self.update_pointer_position(x, y); + match button { + MouseButton::Left => { + if self.board_picker_contains_point(x, y) { + if let Some(index) = self.board_picker_page_handle_index_at(x, y) { + self.board_picker_start_page_drag(index); + return true; + } + if let Some(row) = self.board_picker_handle_index_at(x, y) { + self.board_picker_start_drag(row); + return true; + } + if self.board_picker_index_at(x, y).is_some() { + self.update_board_picker_hover_from_pointer(x, y); + } + } else { + self.close_board_picker(); + } + } + MouseButton::Right => { + if self.board_picker_contains_point(x, y) + && let Some(page_index) = self.board_picker_page_index_at(x, y) + && let Some(board_index) = self.board_picker_page_panel_board_index() + { + self.update_pointer_position_synthetic(x, y); + self.open_page_context_menu((x, y), board_index, page_index); + } else { + self.close_board_picker(); + } + } + MouseButton::Middle => {} + } + true + } + + pub(in crate::input::state) fn handle_properties_panel_press( + &mut self, + button: MouseButton, + x: i32, + y: i32, + ) -> bool { + if !self.is_properties_panel_open() { + return false; + } + self.update_pointer_position(x, y); + if self.properties_panel_layout().is_none() { + return true; + } + match button { + MouseButton::Left => { + if let Some(index) = self.properties_panel_index_at(x, y) { + self.set_properties_panel_focus(Some(index)); + } else { + self.close_properties_panel(); + } + } + MouseButton::Right => { + self.close_properties_panel(); + } + MouseButton::Middle => {} + } + true + } +} diff --git a/src/input/state/mouse/press/polygon.rs b/src/input/state/mouse/press/polygon.rs new file mode 100644 index 00000000..8f5460f4 --- /dev/null +++ b/src/input/state/mouse/press/polygon.rs @@ -0,0 +1,152 @@ +use crate::draw::Shape; +use crate::draw::frame::UndoAction; +use crate::draw::shape::{PolygonKind, has_minimum_distinct_points}; +use crate::input::Tool; +use std::time::Instant; + +use super::super::super::core::PolygonClickState; +use super::super::super::{DrawingState, InputState, UiToastKind}; +use super::super::{TEXT_DOUBLE_CLICK_DISTANCE, TEXT_DOUBLE_CLICK_MS}; + +impl InputState { + pub(crate) fn start_building_polygon(&mut self, x: i32, y: i32) { + self.sync_current_settings_for_tool(Tool::FreeformPolygon); + let color = self.color_for_tool(Tool::FreeformPolygon); + let thick = self.thickness_for_tool(Tool::FreeformPolygon); + self.clear_selection(); + self.last_polygon_click = Some(PolygonClickState { + x, + y, + at: Instant::now(), + }); + self.state = DrawingState::BuildingPolygon { + points: vec![(x, y)], + preview: None, + fill: self.fill_enabled, + color, + thick, + }; + self.last_provisional_bounds = None; + self.update_provisional_dirty(x, y); + self.set_ui_toast( + UiToastKind::Info, + "Click points. Enter/double-click to finish. Backspace undo. Esc cancel.", + ); + self.needs_redraw = true; + } + + fn should_finish_building_polygon_on_click(&self, x: i32, y: i32) -> bool { + let Some(last) = self.last_polygon_click else { + return false; + }; + if Instant::now().duration_since(last.at).as_millis() > TEXT_DOUBLE_CLICK_MS as u128 { + return false; + } + if (x - last.x).abs() > TEXT_DOUBLE_CLICK_DISTANCE + || (y - last.y).abs() > TEXT_DOUBLE_CLICK_DISTANCE + { + return false; + } + let DrawingState::BuildingPolygon { points, .. } = &self.state else { + return false; + }; + has_minimum_distinct_points(points) + } + + pub(crate) fn handle_building_polygon_left_click(&mut self, x: i32, y: i32) { + if self.should_finish_building_polygon_on_click(x, y) { + self.finish_building_polygon(); + } else { + self.append_building_polygon_point(x, y); + } + } + + pub(crate) fn append_building_polygon_point(&mut self, x: i32, y: i32) { + let DrawingState::BuildingPolygon { + points, preview, .. + } = &mut self.state + else { + return; + }; + points.push((x, y)); + *preview = None; + self.last_polygon_click = Some(PolygonClickState { + x, + y, + at: Instant::now(), + }); + self.update_provisional_dirty(x, y); + self.needs_redraw = true; + } + + pub(crate) fn pop_building_polygon_point(&mut self) { + let DrawingState::BuildingPolygon { points, .. } = &mut self.state else { + return; + }; + let _ = points.pop(); + if points.is_empty() { + self.clear_provisional_dirty(); + self.last_polygon_click = None; + self.state = DrawingState::Idle; + } else { + let (x, y) = self.canvas_pointer_position(); + self.last_polygon_click = None; + self.update_provisional_dirty(x, y); + } + self.needs_redraw = true; + } + + pub(crate) fn finish_building_polygon(&mut self) { + let state = std::mem::replace(&mut self.state, DrawingState::Idle); + let DrawingState::BuildingPolygon { + points, + fill, + color, + thick, + .. + } = state + else { + self.state = state; + return; + }; + + self.clear_provisional_dirty(); + self.last_polygon_click = None; + if !has_minimum_distinct_points(&points) { + self.needs_redraw = true; + return; + } + + let shape = Shape::Polygon { + kind: PolygonKind::Freeform, + points, + fill, + color, + thick, + }; + let bounds = shape.bounding_box(); + let addition = { + let frame = self.boards.active_frame_mut(); + frame + .try_add_shape_with_id(shape, self.max_shapes_per_frame) + .and_then(|new_id| { + let index = frame.find_index(new_id)?; + let snapshot = frame.shape(new_id)?.clone(); + frame.push_undo_action( + UndoAction::Create { + shapes: vec![(index, snapshot.clone())], + }, + self.undo_stack_limit, + ); + Some((new_id, snapshot)) + }) + }; + if let Some((new_id, _snapshot)) = addition { + self.invalidate_hit_cache_for(new_id); + self.dirty_tracker.mark_optional_rect(bounds); + self.mark_session_dirty(); + self.record_first_stroke_done_for_onboarding(); + } + self.needs_redraw = true; + } +} diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index c35e9019..7ba67386 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -1,7 +1,4 @@ -use crate::draw::shape::{ - PolygonTemplate, bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points, - generated_points, has_minimum_distinct_points, -}; +use crate::draw::shape::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points}; use crate::draw::{ArrowLabel, BlurRectParams, Color, EraserBrush, EraserKind, Shape}; use crate::input::tool::{ EraserMode, Tool, ToolDrawingBehavior, ToolPathKind, ToolPressureBehavior, @@ -10,6 +7,8 @@ use crate::util::{self, Rect}; pub(crate) const PROVISIONAL_POLYGON_DAMAGE_PADDING: i32 = 2; +mod polygon; + /// Immutable inputs needed to turn one completed drag into an app-level outcome. pub(crate) struct ToolStrokeSnapshot { pub(crate) tool: Tool, @@ -330,83 +329,6 @@ impl Tool { }, } } - - pub(crate) fn polygon_template(self) -> Option { - match self.drawing_behavior() { - ToolDrawingBehavior::Polygon(template) => Some(template), - _ => None, - } - } - - pub(crate) fn finish_polygon_stroke( - self, - snapshot: PolygonStrokeSnapshot, - ) -> FinishedToolStroke { - debug_assert_eq!(self, snapshot.tool); - let Some(template) = self.polygon_template() else { - debug_assert!(false, "non-polygon tool cannot finish a polygon stroke"); - return FinishedToolStroke::Noop; - }; - finish_polygon(snapshot, ToolUsage::default(), template) - } - - pub(crate) fn provisional_polygon_stroke( - self, - snapshot: PolygonProvisionalSnapshot, - ) -> ProvisionalToolStroke<'static> { - debug_assert_eq!(self, snapshot.tool); - let Some(template) = self.polygon_template() else { - debug_assert!(false, "non-polygon tool cannot preview a polygon stroke"); - return ProvisionalToolStroke::None; - }; - provisional_polygon(snapshot, template) - } -} - -fn finish_polygon( - snapshot: PolygonStrokeSnapshot, - usage: ToolUsage, - template: PolygonTemplate, -) -> FinishedToolStroke { - let points = generated_points( - template, - snapshot.start, - snapshot.end, - snapshot.regular_sides, - ); - if !has_minimum_distinct_points(&points) { - return FinishedToolStroke::Noop; - } - - FinishedToolStroke::Shape { - shape: Shape::Polygon { - kind: template.kind(snapshot.regular_sides), - points, - fill: snapshot.fill_enabled, - color: snapshot.color, - thick: snapshot.size, - }, - usage, - } -} - -fn provisional_polygon( - snapshot: PolygonProvisionalSnapshot, - template: PolygonTemplate, -) -> ProvisionalToolStroke<'static> { - let points = generated_points( - template, - snapshot.start, - snapshot.current, - snapshot.regular_sides, - ); - ProvisionalToolStroke::Shape(Shape::Polygon { - kind: template.kind(snapshot.regular_sides), - points, - fill: snapshot.fill_enabled, - color: snapshot.color, - thick: snapshot.size, - }) } impl<'a> ProvisionalToolStroke<'a> { diff --git a/src/input/tool/drawing/polygon.rs b/src/input/tool/drawing/polygon.rs new file mode 100644 index 00000000..4d403b68 --- /dev/null +++ b/src/input/tool/drawing/polygon.rs @@ -0,0 +1,87 @@ +use crate::draw::Shape; +use crate::draw::shape::{PolygonTemplate, generated_points, has_minimum_distinct_points}; +use crate::input::tool::{Tool, ToolDrawingBehavior}; + +use super::{ + FinishedToolStroke, PolygonProvisionalSnapshot, PolygonStrokeSnapshot, ProvisionalToolStroke, + ToolUsage, +}; + +impl Tool { + pub(crate) fn polygon_template(self) -> Option { + match self.drawing_behavior() { + ToolDrawingBehavior::Polygon(template) => Some(template), + _ => None, + } + } + + pub(crate) fn finish_polygon_stroke( + self, + snapshot: PolygonStrokeSnapshot, + ) -> FinishedToolStroke { + debug_assert_eq!(self, snapshot.tool); + let Some(template) = self.polygon_template() else { + debug_assert!(false, "non-polygon tool cannot finish a polygon stroke"); + return FinishedToolStroke::Noop; + }; + finish_polygon(snapshot, ToolUsage::default(), template) + } + + pub(crate) fn provisional_polygon_stroke( + self, + snapshot: PolygonProvisionalSnapshot, + ) -> ProvisionalToolStroke<'static> { + debug_assert_eq!(self, snapshot.tool); + let Some(template) = self.polygon_template() else { + debug_assert!(false, "non-polygon tool cannot preview a polygon stroke"); + return ProvisionalToolStroke::None; + }; + provisional_polygon(snapshot, template) + } +} + +fn finish_polygon( + snapshot: PolygonStrokeSnapshot, + usage: ToolUsage, + template: PolygonTemplate, +) -> FinishedToolStroke { + let points = generated_points( + template, + snapshot.start, + snapshot.end, + snapshot.regular_sides, + ); + if !has_minimum_distinct_points(&points) { + return FinishedToolStroke::Noop; + } + + FinishedToolStroke::Shape { + shape: Shape::Polygon { + kind: template.kind(snapshot.regular_sides), + points, + fill: snapshot.fill_enabled, + color: snapshot.color, + thick: snapshot.size, + }, + usage, + } +} + +fn provisional_polygon( + snapshot: PolygonProvisionalSnapshot, + template: PolygonTemplate, +) -> ProvisionalToolStroke<'static> { + let points = generated_points( + template, + snapshot.start, + snapshot.current, + snapshot.regular_sides, + ); + ProvisionalToolStroke::Shape(Shape::Polygon { + kind: template.kind(snapshot.regular_sides), + points, + fill: snapshot.fill_enabled, + color: snapshot.color, + thick: snapshot.size, + }) +} diff --git a/src/lib.rs b/src/lib.rs index fe48e936..9ec0d03e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod canvas_export; pub mod capture; pub mod config; pub mod draw; +pub mod durable_io; pub mod env_vars; #[cfg(feature = "portal")] pub(crate) mod file_uri; diff --git a/src/main.rs b/src/main.rs index c34ac158..82e26bb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod cli; mod config; mod daemon; mod draw; +mod durable_io; mod env_vars; mod file_uri; mod image_decode; diff --git a/src/onboarding.rs b/src/onboarding.rs index fdb83a11..62577751 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -1,3 +1,4 @@ +use crate::durable_io::{AtomicWriteOptions, OverwriteMode, PermissionPolicy, SymlinkPolicy}; use crate::paths::data_dir; use log::warn; use serde::{Deserialize, Serialize}; @@ -249,7 +250,17 @@ impl OnboardingStore { } match toml::to_string_pretty(&self.state) { Ok(contents) => { - if let Err(err) = fs::write(path, contents) { + if let Err(err) = crate::durable_io::write_text_atomic( + path, + &contents, + AtomicWriteOptions { + overwrite: OverwriteMode::Replace, + permissions: PermissionPolicy::PreserveExistingOrMode(0o644), + symlink: SymlinkPolicy::Reject, + sync_file: true, + sync_parent: true, + }, + ) { warn!( "Failed to write onboarding state {}: {}", path.display(), @@ -413,91 +424,4 @@ fn backup_path(path: &Path) -> PathBuf { } #[cfg(test)] -mod tests { - use super::*; - use std::fs; - - #[test] - fn onboarding_defaults_when_missing() { - let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); - let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); - let store = OnboardingStore::load_from_path(path.clone()); - assert!(!store.state().welcome_shown); - assert!(!store.state().toolbar_hint_shown); - assert!(!store.state().first_run_completed); - assert!(store.state().active_step.is_none()); - - store.save(); - assert!(path.exists()); - } - - #[test] - fn onboarding_persists_flags() { - let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); - let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); - let mut store = OnboardingStore::load_from_path(path.clone()); - store.state_mut().welcome_shown = true; - store.state_mut().toolbar_hint_shown = true; - store.state_mut().used_help_overlay = true; - store.save(); - - let reloaded = OnboardingStore::load_from_path(path.clone()); - assert!(reloaded.state().welcome_shown); - assert!(reloaded.state().toolbar_hint_shown); - assert!(reloaded.state().used_help_overlay); - } - - #[test] - fn onboarding_recovers_from_parse_error() { - let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); - let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create onboarding dir"); - } - fs::write(&path, "not = [toml").expect("write invalid toml"); - - let store = OnboardingStore::load_from_path(path.clone()); - assert!(store.state().welcome_shown); - assert!(store.state().first_run_completed); - assert!(path.exists()); - - let backup_found = fs::read_dir(path.parent().expect("parent dir")) - .expect("read onboarding dir") - .filter_map(|entry| entry.ok()) - .any(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with("onboarding.bak") - }); - assert!(backup_found); - - let contents = fs::read_to_string(&path).expect("read recovered file"); - let state: OnboardingState = - toml::from_str(&contents).expect("recovered file should parse"); - assert!(state.welcome_shown); - assert!(state.first_run_completed); - } - - #[test] - fn onboarding_version_bump_saves() { - let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); - let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create onboarding dir"); - } - let seed = "version = 0\nwelcome_shown = true\ntoolbar_hint_shown = false\n"; - fs::write(&path, seed).expect("write seed"); - - let store = OnboardingStore::load_from_path(path.clone()); - assert!(store.state().welcome_shown); - assert_eq!(store.state().version, ONBOARDING_VERSION); - assert!(store.state().first_run_completed); - - let contents = fs::read_to_string(&path).expect("read bumped file"); - let state: OnboardingState = toml::from_str(&contents).expect("bumped file should parse"); - assert_eq!(state.version, ONBOARDING_VERSION); - assert!(state.welcome_shown); - assert!(state.first_run_completed); - } -} +mod tests; diff --git a/src/onboarding/tests.rs b/src/onboarding/tests.rs new file mode 100644 index 00000000..cdae107f --- /dev/null +++ b/src/onboarding/tests.rs @@ -0,0 +1,85 @@ +use super::*; +use std::fs; + +#[test] +fn onboarding_defaults_when_missing() { + let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); + let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); + let store = OnboardingStore::load_from_path(path.clone()); + assert!(!store.state().welcome_shown); + assert!(!store.state().toolbar_hint_shown); + assert!(!store.state().first_run_completed); + assert!(store.state().active_step.is_none()); + + store.save(); + assert!(path.exists()); +} + +#[test] +fn onboarding_persists_flags() { + let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); + let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); + let mut store = OnboardingStore::load_from_path(path.clone()); + store.state_mut().welcome_shown = true; + store.state_mut().toolbar_hint_shown = true; + store.state_mut().used_help_overlay = true; + store.save(); + + let reloaded = OnboardingStore::load_from_path(path.clone()); + assert!(reloaded.state().welcome_shown); + assert!(reloaded.state().toolbar_hint_shown); + assert!(reloaded.state().used_help_overlay); +} + +#[test] +fn onboarding_recovers_from_parse_error() { + let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); + let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create onboarding dir"); + } + fs::write(&path, "not = [toml").expect("write invalid toml"); + + let store = OnboardingStore::load_from_path(path.clone()); + assert!(store.state().welcome_shown); + assert!(store.state().first_run_completed); + assert!(path.exists()); + + let backup_found = fs::read_dir(path.parent().expect("parent dir")) + .expect("read onboarding dir") + .filter_map(|entry| entry.ok()) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("onboarding.bak") + }); + assert!(backup_found); + + let contents = fs::read_to_string(&path).expect("read recovered file"); + let state: OnboardingState = toml::from_str(&contents).expect("recovered file should parse"); + assert!(state.welcome_shown); + assert!(state.first_run_completed); +} + +#[test] +fn onboarding_version_bump_saves() { + let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); + let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create onboarding dir"); + } + let seed = "version = 0\nwelcome_shown = true\ntoolbar_hint_shown = false\n"; + fs::write(&path, seed).expect("write seed"); + + let store = OnboardingStore::load_from_path(path.clone()); + assert!(store.state().welcome_shown); + assert_eq!(store.state().version, ONBOARDING_VERSION); + assert!(store.state().first_run_completed); + + let contents = fs::read_to_string(&path).expect("read bumped file"); + let state: OnboardingState = toml::from_str(&contents).expect("bumped file should parse"); + assert_eq!(state.version, ONBOARDING_VERSION); + assert!(state.welcome_shown); + assert!(state.first_run_completed); +} diff --git a/src/render_profiles.rs b/src/render_profiles.rs index 5725c0aa..3fa1b822 100644 --- a/src/render_profiles.rs +++ b/src/render_profiles.rs @@ -347,249 +347,4 @@ fn premultiply_component(value: u8, alpha: u8) -> u8 { } #[cfg(test)] -mod tests { - use super::*; - use crate::config::RenderColorMappingConfig; - - fn profile(from: &str, to: &str) -> RenderColorProfile { - RenderColorProfile::from_config(&RenderProfileConfig { - id: "print".to_string(), - name: "Print".to_string(), - mappings: vec![RenderColorMappingConfig { - from: from.to_string(), - to: to.to_string(), - }], - }) - .expect("profile") - } - - fn argb(alpha: u8, red: u8, green: u8, blue: u8) -> u32 { - let red = premultiply_component(red, alpha); - let green = premultiply_component(green, alpha); - let blue = premultiply_component(blue, alpha); - (u32::from(alpha) << 24) - | (u32::from(red) << 16) - | (u32::from(green) << 8) - | u32::from(blue) - } - - #[test] - fn parse_hex_rgb_accepts_supported_forms() { - assert_eq!( - parse_hex_rgb("#8B4513"), - Some(Rgb8 { - r: 0x8b, - g: 0x45, - b: 0x13, - }) - ); - assert_eq!( - parse_hex_rgb("0xFFFFFF"), - Some(Rgb8 { - r: 255, - g: 255, - b: 255 - }) - ); - assert_eq!(parse_hex_rgb("000000"), Some(Rgb8 { r: 0, g: 0, b: 0 })); - assert_eq!( - format_hex_rgb(Rgb8 { - r: 0x8b, - g: 0x45, - b: 0x13, - }), - "#8B4513" - ); - } - - #[test] - fn parse_hex_rgb_rejects_invalid_values() { - assert_eq!(parse_hex_rgb("#FFF"), None); - assert_eq!(parse_hex_rgb("#GG0000"), None); - assert_eq!(parse_hex_rgb(""), None); - } - - #[test] - fn remap_preserves_alpha_for_semitransparent_pixels() { - let profile = profile("#808000", "#0000FF"); - let mapped = profile.remap_pixel(argb(128, 128, 128, 0)); - assert_eq!(mapped, argb(128, 0, 0, 255)); - } - - #[test] - fn remap_leaves_unmapped_and_transparent_pixels_unchanged() { - let profile = profile("#000000", "#FFFFFF"); - assert_eq!( - profile.remap_pixel(argb(255, 10, 20, 30)), - argb(255, 10, 20, 30) - ); - assert_eq!(profile.remap_pixel(0), 0); - } - - #[test] - fn remap_argb8888_regions_only_changes_damaged_pixels() { - let profile = profile("#000000", "#FFFFFF"); - let mut data = Vec::new(); - data.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); - data.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); - - profile.remap_argb8888_regions( - &mut data, - 2, - 1, - 8, - &[Rect::new(1, 0, 1, 1).expect("valid rect")], - ); - - assert_eq!( - u32::from_ne_bytes(data[0..4].try_into().unwrap()), - argb(255, 0, 0, 0) - ); - assert_eq!( - u32::from_ne_bytes(data[4..8].try_into().unwrap()), - argb(255, 255, 255, 255) - ); - } - - #[test] - fn remap_argb8888_changed_regions_skips_unchanged_canvas_pixels() { - let profile = profile("#000000", "#FFFFFF"); - let mut baseline = Vec::new(); - baseline.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); - baseline.extend_from_slice(&argb(255, 255, 0, 0).to_ne_bytes()); - let mut data = Vec::new(); - data.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); - data.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); - - profile.remap_argb8888_regions_changed_from( - &mut data, - &baseline, - 2, - 1, - 8, - &[Rect::new(0, 0, 2, 1).expect("valid rect")], - ); - - assert_eq!( - u32::from_ne_bytes(data[0..4].try_into().unwrap()), - argb(255, 0, 0, 0) - ); - assert_eq!( - u32::from_ne_bytes(data[4..8].try_into().unwrap()), - argb(255, 255, 255, 255) - ); - } - - #[test] - fn render_profile_set_cycles_through_profiles_and_off_state() { - fn active_id(set: &RenderProfileSet) -> Option<&str> { - set.active().map(|profile| profile.id.as_str()) - } - - let config = RenderProfilesConfig { - active: Some("first".to_string()), - apply_to_canvas: true, - apply_to_ui: true, - export: RenderProfileExportMode::Off, - export_profile: None, - profiles: vec![ - RenderProfileConfig { - id: "first".to_string(), - name: "First".to_string(), - mappings: Vec::new(), - }, - RenderProfileConfig { - id: "second".to_string(), - name: "Second".to_string(), - mappings: Vec::new(), - }, - ], - }; - let mut set = RenderProfileSet::from_config(&config); - - assert_eq!(active_id(&set), Some("first")); - assert!(set.activate_next()); - assert_eq!(active_id(&set), Some("second")); - assert!(set.activate_next()); - assert_eq!(active_id(&set), None); - assert!(set.activate_previous()); - assert_eq!(active_id(&set), Some("second")); - } - - #[test] - fn render_profile_set_preserves_target_flags() { - let set = RenderProfileSet::from_config(&RenderProfilesConfig { - active: None, - apply_to_canvas: false, - apply_to_ui: true, - export: RenderProfileExportMode::Off, - export_profile: None, - profiles: Vec::new(), - }); - - assert!(!set.applies_to_canvas()); - assert!(set.applies_to_ui()); - } - - #[test] - fn export_profile_resolves_off_active_and_named_profiles() { - let config = RenderProfilesConfig { - active: Some("active".to_string()), - apply_to_canvas: true, - apply_to_ui: true, - export: RenderProfileExportMode::Active, - export_profile: Some("off".to_string()), - profiles: vec![ - RenderProfileConfig { - id: "active".to_string(), - name: "Active".to_string(), - mappings: Vec::new(), - }, - RenderProfileConfig { - id: "off".to_string(), - name: "Off Named Profile".to_string(), - mappings: Vec::new(), - }, - ], - }; - - let mut active = RenderProfileSet::from_config(&config); - assert_eq!( - active.export_profile().as_ref().map(|p| p.id()), - Some("active") - ); - - let mut named_config = config; - named_config.export = RenderProfileExportMode::Profile; - active = RenderProfileSet::from_config(&named_config); - assert_eq!( - active.export_profile().as_ref().map(|p| p.id()), - Some("off") - ); - - named_config.export = RenderProfileExportMode::Off; - active = RenderProfileSet::from_config(&named_config); - assert!(active.export_profile().is_none()); - } - - #[test] - fn config_serializes_profile_collection_as_profiles() { - let config = RenderProfilesConfig { - active: None, - apply_to_canvas: true, - apply_to_ui: true, - export: RenderProfileExportMode::Off, - export_profile: None, - profiles: vec![RenderProfileConfig { - id: "print".to_string(), - name: "Print".to_string(), - mappings: Vec::new(), - }], - }; - - let serialized = toml::to_string(&config).expect("serialize"); - - assert!(serialized.contains("[[profiles]]")); - assert!(!serialized.contains("[[items]]")); - } -} +mod tests; diff --git a/src/render_profiles/tests.rs b/src/render_profiles/tests.rs new file mode 100644 index 00000000..cf63628b --- /dev/null +++ b/src/render_profiles/tests.rs @@ -0,0 +1,241 @@ +use super::*; +use crate::config::RenderColorMappingConfig; + +fn profile(from: &str, to: &str) -> RenderColorProfile { + RenderColorProfile::from_config(&RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: vec![RenderColorMappingConfig { + from: from.to_string(), + to: to.to_string(), + }], + }) + .expect("profile") +} + +fn argb(alpha: u8, red: u8, green: u8, blue: u8) -> u32 { + let red = premultiply_component(red, alpha); + let green = premultiply_component(green, alpha); + let blue = premultiply_component(blue, alpha); + (u32::from(alpha) << 24) | (u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue) +} + +#[test] +fn parse_hex_rgb_accepts_supported_forms() { + assert_eq!( + parse_hex_rgb("#8B4513"), + Some(Rgb8 { + r: 0x8b, + g: 0x45, + b: 0x13, + }) + ); + assert_eq!( + parse_hex_rgb("0xFFFFFF"), + Some(Rgb8 { + r: 255, + g: 255, + b: 255 + }) + ); + assert_eq!(parse_hex_rgb("000000"), Some(Rgb8 { r: 0, g: 0, b: 0 })); + assert_eq!( + format_hex_rgb(Rgb8 { + r: 0x8b, + g: 0x45, + b: 0x13, + }), + "#8B4513" + ); +} + +#[test] +fn parse_hex_rgb_rejects_invalid_values() { + assert_eq!(parse_hex_rgb("#FFF"), None); + assert_eq!(parse_hex_rgb("#GG0000"), None); + assert_eq!(parse_hex_rgb(""), None); +} + +#[test] +fn remap_preserves_alpha_for_semitransparent_pixels() { + let profile = profile("#808000", "#0000FF"); + let mapped = profile.remap_pixel(argb(128, 128, 128, 0)); + assert_eq!(mapped, argb(128, 0, 0, 255)); +} + +#[test] +fn remap_leaves_unmapped_and_transparent_pixels_unchanged() { + let profile = profile("#000000", "#FFFFFF"); + assert_eq!( + profile.remap_pixel(argb(255, 10, 20, 30)), + argb(255, 10, 20, 30) + ); + assert_eq!(profile.remap_pixel(0), 0); +} + +#[test] +fn remap_argb8888_regions_only_changes_damaged_pixels() { + let profile = profile("#000000", "#FFFFFF"); + let mut data = Vec::new(); + data.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); + data.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); + + profile.remap_argb8888_regions( + &mut data, + 2, + 1, + 8, + &[Rect::new(1, 0, 1, 1).expect("valid rect")], + ); + + assert_eq!( + u32::from_ne_bytes(data[0..4].try_into().unwrap()), + argb(255, 0, 0, 0) + ); + assert_eq!( + u32::from_ne_bytes(data[4..8].try_into().unwrap()), + argb(255, 255, 255, 255) + ); +} + +#[test] +fn remap_argb8888_changed_regions_skips_unchanged_canvas_pixels() { + let profile = profile("#000000", "#FFFFFF"); + let mut baseline = Vec::new(); + baseline.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); + baseline.extend_from_slice(&argb(255, 255, 0, 0).to_ne_bytes()); + let mut data = Vec::new(); + data.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); + data.extend_from_slice(&argb(255, 0, 0, 0).to_ne_bytes()); + + profile.remap_argb8888_regions_changed_from( + &mut data, + &baseline, + 2, + 1, + 8, + &[Rect::new(0, 0, 2, 1).expect("valid rect")], + ); + + assert_eq!( + u32::from_ne_bytes(data[0..4].try_into().unwrap()), + argb(255, 0, 0, 0) + ); + assert_eq!( + u32::from_ne_bytes(data[4..8].try_into().unwrap()), + argb(255, 255, 255, 255) + ); +} + +#[test] +fn render_profile_set_cycles_through_profiles_and_off_state() { + fn active_id(set: &RenderProfileSet) -> Option<&str> { + set.active().map(|profile| profile.id.as_str()) + } + + let config = RenderProfilesConfig { + active: Some("first".to_string()), + apply_to_canvas: true, + apply_to_ui: true, + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: vec![ + RenderProfileConfig { + id: "first".to_string(), + name: "First".to_string(), + mappings: Vec::new(), + }, + RenderProfileConfig { + id: "second".to_string(), + name: "Second".to_string(), + mappings: Vec::new(), + }, + ], + }; + let mut set = RenderProfileSet::from_config(&config); + + assert_eq!(active_id(&set), Some("first")); + assert!(set.activate_next()); + assert_eq!(active_id(&set), Some("second")); + assert!(set.activate_next()); + assert_eq!(active_id(&set), None); + assert!(set.activate_previous()); + assert_eq!(active_id(&set), Some("second")); +} + +#[test] +fn render_profile_set_preserves_target_flags() { + let set = RenderProfileSet::from_config(&RenderProfilesConfig { + active: None, + apply_to_canvas: false, + apply_to_ui: true, + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: Vec::new(), + }); + + assert!(!set.applies_to_canvas()); + assert!(set.applies_to_ui()); +} + +#[test] +fn export_profile_resolves_off_active_and_named_profiles() { + let config = RenderProfilesConfig { + active: Some("active".to_string()), + apply_to_canvas: true, + apply_to_ui: true, + export: RenderProfileExportMode::Active, + export_profile: Some("off".to_string()), + profiles: vec![ + RenderProfileConfig { + id: "active".to_string(), + name: "Active".to_string(), + mappings: Vec::new(), + }, + RenderProfileConfig { + id: "off".to_string(), + name: "Off Named Profile".to_string(), + mappings: Vec::new(), + }, + ], + }; + + let mut active = RenderProfileSet::from_config(&config); + assert_eq!( + active.export_profile().as_ref().map(|p| p.id()), + Some("active") + ); + + let mut named_config = config; + named_config.export = RenderProfileExportMode::Profile; + active = RenderProfileSet::from_config(&named_config); + assert_eq!( + active.export_profile().as_ref().map(|p| p.id()), + Some("off") + ); + + named_config.export = RenderProfileExportMode::Off; + active = RenderProfileSet::from_config(&named_config); + assert!(active.export_profile().is_none()); +} + +#[test] +fn config_serializes_profile_collection_as_profiles() { + let config = RenderProfilesConfig { + active: None, + apply_to_canvas: true, + apply_to_ui: true, + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: vec![RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: Vec::new(), + }], + }; + + let serialized = toml::to_string(&config).expect("serialize"); + + assert!(serialized.contains("[[profiles]]")); + assert!(!serialized.contains("[[items]]")); +} diff --git a/src/session/snapshot/load/corrupt.rs b/src/session/snapshot/load/corrupt.rs index 22a5b534..51a4661f 100644 --- a/src/session/snapshot/load/corrupt.rs +++ b/src/session/snapshot/load/corrupt.rs @@ -9,8 +9,18 @@ pub(super) fn backup_corrupt_session(session_path: &Path, options: &SessionOptio } else { options.corrupt_artifact_backup_file_path(session_path) }; - fs::write(&backup_path, &bytes) - .with_context(|| format!("failed to write session backup {}", backup_path.display()))?; + crate::durable_io::write_atomic( + &backup_path, + &bytes, + crate::durable_io::AtomicWriteOptions { + overwrite: crate::durable_io::OverwriteMode::Replace, + permissions: crate::durable_io::PermissionPolicy::PreserveExistingOrMode(0o600), + symlink: crate::durable_io::SymlinkPolicy::Reject, + sync_file: true, + sync_parent: true, + }, + ) + .with_context(|| format!("failed to write session backup {}", backup_path.display()))?; if named_primary { debug!( "Backed up corrupt named session primary {} to {}; leaving the selected primary in place", diff --git a/src/tray_action.rs b/src/tray_action.rs index 1f04a2b0..e677155f 100644 --- a/src/tray_action.rs +++ b/src/tray_action.rs @@ -1,3 +1,4 @@ +use crate::durable_io::{AtomicWriteOptions, OverwriteMode, PermissionPolicy, SymlinkPolicy}; use anyhow::{Context, Result}; use log::warn; use serde::{Deserialize, Serialize}; @@ -81,17 +82,18 @@ pub(crate) fn queue_action(action: TrayAction) -> Result { .with_context(|| format!("failed to create runtime directory {}", dir.display()))?; let path = queued_action_path(&dir); - let file_name = path - .file_name() - .ok_or_else(|| anyhow::anyhow!("{} has no file name", path.display()))? - .to_string_lossy(); - let tmp_path = dir.join(format!(".{}.tmp", file_name)); - fs::write(&tmp_path, action.as_str()) - .with_context(|| format!("failed to write {}", tmp_path.display()))?; - if let Err(err) = fs::rename(&tmp_path, &path) { - let _ = fs::remove_file(&tmp_path); - return Err(err).with_context(|| format!("failed to queue tray action {}", path.display())); - } + crate::durable_io::write_text_atomic( + &path, + action.as_str(), + AtomicWriteOptions { + overwrite: OverwriteMode::CreateNew, + permissions: PermissionPolicy::FixedMode(0o600), + symlink: SymlinkPolicy::Reject, + sync_file: false, + sync_parent: false, + }, + ) + .with_context(|| format!("failed to queue tray action {}", path.display()))?; Ok(path) } diff --git a/src/ui/toolbar/model/settings.rs b/src/ui/toolbar/model/settings.rs index 62ef3134..b3a39bd6 100644 --- a/src/ui/toolbar/model/settings.rs +++ b/src/ui/toolbar/model/settings.rs @@ -11,6 +11,13 @@ use super::super::{ToolbarEvent, ToolbarItemCustomizeGroup, ToolbarSideSection, use super::activation::{ToolbarActivation, ToolbarControlId}; use super::control::{ToolbarIcon, ToolbarTooltip}; +mod helpers; +use helpers::{ + control_visible, customize_buttons, customize_group_contains, customize_groups, + definition_order_group_for_customize, is_section_toggle_id, section_buttons, settings_buttons, + sort_customize_definitions, +}; + #[derive(Debug, Clone)] pub(crate) struct ToolbarSettingsModel { toggles: Vec, @@ -203,251 +210,6 @@ impl ToolbarSettingsModel { } } -fn settings_buttons(snapshot: &ToolbarSnapshot) -> Vec { - vec![ - ToolbarSettingsButton { - id: ToolbarControlId::CustomizeToolbarItems, - label: Cow::Borrowed("Customize toolbar"), - event: ToolbarEvent::SetToolbarItemCustomizationOpen(true), - icon: ToolbarIcon::Visibility, - tooltip: ToolbarTooltip::text("Customize toolbar item visibility"), - }, - ToolbarSettingsButton { - id: ToolbarControlId::ResetToolbarHiddenItems, - label: Cow::Borrowed("Reset hidden"), - event: ToolbarEvent::ResetToolbarItemHiddenOverrides, - icon: ToolbarIcon::Visibility, - tooltip: ToolbarTooltip::text("Restore default hidden items"), - }, - ToolbarSettingsButton { - id: ToolbarControlId::OpenConfigurator, - label: Cow::Borrowed(action_short_label(Action::OpenConfigurator)), - event: ToolbarEvent::OpenConfigurator, - icon: ToolbarIcon::Settings, - tooltip: ToolbarTooltip::Binding { - label: Cow::Borrowed(action_label(Action::OpenConfigurator)), - binding: snapshot - .binding_hints - .binding_for_action(Action::OpenConfigurator) - .map(str::to_string), - }, - }, - ToolbarSettingsButton { - id: ToolbarControlId::OpenConfigFile, - label: Cow::Borrowed("Config file"), - event: ToolbarEvent::OpenConfigFile, - icon: ToolbarIcon::File, - tooltip: ToolbarTooltip::text("Config file"), - }, - ] - .into_iter() - .filter(|button| reset_button_visible(snapshot, button.id)) - .filter(|button| control_visible(snapshot, button.id)) - .collect() -} - -fn section_buttons(snapshot: &ToolbarSnapshot) -> Vec { - vec![ToolbarSettingsButton { - id: ToolbarControlId::ResetToolbarHiddenItems, - label: Cow::Borrowed("Reset hidden"), - event: ToolbarEvent::ResetToolbarItemHiddenOverrides, - icon: ToolbarIcon::Visibility, - tooltip: ToolbarTooltip::text("Restore default hidden items"), - }] - .into_iter() - .filter(|button| reset_button_visible(snapshot, button.id)) - .collect() -} - -fn customize_buttons(snapshot: &ToolbarSnapshot) -> Vec { - let back_event = if snapshot.customize_items_group.is_some() { - ToolbarEvent::SetToolbarItemCustomizationGroup(None) - } else if snapshot.drawer_tab == crate::input::ToolbarDrawerTab::Customize { - ToolbarEvent::SetDrawerTab(crate::input::ToolbarDrawerTab::App) - } else { - ToolbarEvent::SetToolbarItemCustomizationOpen(false) - }; - let mut buttons = vec![ - ToolbarSettingsButton { - id: ToolbarControlId::BackToolbarSettings, - label: Cow::Borrowed("Back"), - event: back_event, - icon: ToolbarIcon::Back, - tooltip: ToolbarTooltip::text("Back to settings"), - }, - ToolbarSettingsButton { - id: ToolbarControlId::ResetToolbarHiddenItems, - label: Cow::Borrowed("Reset hidden"), - event: ToolbarEvent::ResetToolbarItemHiddenOverrides, - icon: ToolbarIcon::Visibility, - tooltip: ToolbarTooltip::text("Restore default hidden items"), - }, - ]; - if let Some(group) = snapshot - .customize_items_group - .and_then(customize_order_group) - .filter(|group| order_is_customized(snapshot, *group)) - { - buttons.push(ToolbarSettingsButton { - id: ToolbarControlId::ResetToolbarItemOrder, - label: Cow::Borrowed("Reset order"), - event: ToolbarEvent::ResetToolbarItemOrder(group), - icon: ToolbarIcon::Back, - tooltip: ToolbarTooltip::text("Restore default order for this group"), - }); - } - buttons - .into_iter() - .filter(|button| reset_button_visible(snapshot, button.id)) - .collect() -} - -fn reset_button_visible(snapshot: &ToolbarSnapshot, id: ToolbarControlId) -> bool { - id != ToolbarControlId::ResetToolbarHiddenItems - || !snapshot.resolved_toolbar_items.hidden.is_empty() -} - -fn is_section_toggle_id(id: ToolbarControlId) -> bool { - matches!( - id, - ToolbarControlId::SettingsPresets - | ToolbarControlId::SettingsActions - | ToolbarControlId::SettingsZoomActions - | ToolbarControlId::SettingsAdvancedActions - | ToolbarControlId::SettingsBoards - | ToolbarControlId::SettingsPages - | ToolbarControlId::SettingsStepControls - ) -} - -fn overlay_item_override_allowed(definition: &ToolbarItemDefinition) -> bool { - definition.group != Some(ToolbarGroupId::Settings) -} - -fn customize_groups() -> Vec { - [ - ToolbarItemCustomizeGroup::TopTools, - ToolbarItemCustomizeGroup::TopControls, - ToolbarItemCustomizeGroup::SideSections, - ToolbarItemCustomizeGroup::Actions, - ToolbarItemCustomizeGroup::Pages, - ToolbarItemCustomizeGroup::Boards, - ToolbarItemCustomizeGroup::Presets, - ToolbarItemCustomizeGroup::ToolOptions, - ToolbarItemCustomizeGroup::Sessions, - ] - .into_iter() - .map(ToolbarSettingsCustomizeGroup::new) - .collect() -} - -fn customize_group_contains( - group: ToolbarItemCustomizeGroup, - definition: &ToolbarItemDefinition, -) -> bool { - if !overlay_item_override_allowed(definition) { - return false; - } - - match group { - ToolbarItemCustomizeGroup::TopTools => { - definition.surface == ToolbarItemSurface::Top - && definition.category == ToolbarItemCategory::Tool - } - ToolbarItemCustomizeGroup::TopControls => { - definition.surface == ToolbarItemSurface::Top - && definition.category != ToolbarItemCategory::Tool - } - ToolbarItemCustomizeGroup::SideSections => { - definition.category == ToolbarItemCategory::Group - } - ToolbarItemCustomizeGroup::Actions => definition.category == ToolbarItemCategory::Action, - ToolbarItemCustomizeGroup::Pages => definition.category == ToolbarItemCategory::Page, - ToolbarItemCustomizeGroup::Boards => definition.category == ToolbarItemCategory::Board, - ToolbarItemCustomizeGroup::Presets => definition.group == Some(ToolbarGroupId::Presets), - ToolbarItemCustomizeGroup::ToolOptions => { - definition.category == ToolbarItemCategory::ToolOption - } - ToolbarItemCustomizeGroup::Sessions => definition.category == ToolbarItemCategory::Session, - } -} - -fn sort_customize_definitions( - snapshot: &ToolbarSnapshot, - group: ToolbarItemCustomizeGroup, - definitions: &mut Vec<&ToolbarItemDefinition>, -) { - let Some(order_group) = customize_order_group(group) else { - return; - }; - definitions.sort_by_key(|definition| { - if overlay_order_group_for_definition(definition) == Some(order_group) { - snapshot - .resolved_toolbar_items - .order - .index_of(order_group, definition.id) - .unwrap_or(usize::MAX) - } else { - usize::MAX - } - }); -} - -fn customize_order_group(group: ToolbarItemCustomizeGroup) -> Option { - match group { - ToolbarItemCustomizeGroup::TopTools => Some(ToolbarItemOrderGroup::TopTools), - ToolbarItemCustomizeGroup::TopControls => Some(ToolbarItemOrderGroup::TopControls), - ToolbarItemCustomizeGroup::SideSections => Some(ToolbarItemOrderGroup::SideSections), - _ => None, - } -} - -fn definition_order_group_for_customize( - group: ToolbarItemCustomizeGroup, - definition: &ToolbarItemDefinition, -) -> Option { - let order_group = customize_order_group(group)?; - (overlay_order_group_for_definition(definition) == Some(order_group)).then_some(order_group) -} - -fn overlay_order_group_for_definition( - definition: &ToolbarItemDefinition, -) -> Option { - toolbar_item_order_group(definition) -} - -fn order_is_customized(snapshot: &ToolbarSnapshot, group: ToolbarItemOrderGroup) -> bool { - let current = snapshot.resolved_toolbar_items.order.ordered_ids(group); - let default_order = ToolbarItemOrderConfig::default().resolved(); - current != default_order.ordered_ids(group) -} - -fn control_visible(snapshot: &ToolbarSnapshot, id: ToolbarControlId) -> bool { - control_item_id(id).is_none_or(|item| !snapshot.toolbar_item_hidden(item)) -} - -fn control_item_id(id: ToolbarControlId) -> Option { - Some(match id { - ToolbarControlId::SettingsContextAwareUi => ids::SIDE_SETTINGS_CONTEXT_AWARE_UI, - ToolbarControlId::SettingsTextControls => ids::SIDE_SETTINGS_TEXT_CONTROLS, - ToolbarControlId::SettingsStatusBar => ids::SIDE_SETTINGS_STATUS_BAR, - ToolbarControlId::SettingsStatusBoardBadge => ids::SIDE_SETTINGS_STATUS_BOARD_BADGE, - ToolbarControlId::SettingsStatusPageBadge => ids::SIDE_SETTINGS_STATUS_PAGE_BADGE, - ToolbarControlId::SettingsFloatingBadgeAlways => ids::SIDE_SETTINGS_FLOATING_BADGE_ALWAYS, - ToolbarControlId::SettingsPresetToasts => ids::SIDE_SETTINGS_PRESET_TOASTS, - ToolbarControlId::SettingsPresets => ids::SIDE_SETTINGS_PRESETS, - ToolbarControlId::SettingsActions => ids::SIDE_SETTINGS_ACTIONS, - ToolbarControlId::SettingsZoomActions => ids::SIDE_SETTINGS_ZOOM_ACTIONS, - ToolbarControlId::SettingsAdvancedActions => ids::SIDE_SETTINGS_ADVANCED_ACTIONS, - ToolbarControlId::SettingsBoards => ids::SIDE_SETTINGS_BOARDS, - ToolbarControlId::SettingsPages => ids::SIDE_SETTINGS_PAGES, - ToolbarControlId::SettingsStepControls => ids::SIDE_SETTINGS_STEP_CONTROLS, - ToolbarControlId::OpenConfigurator => ids::SIDE_SETTINGS_CONFIGURATOR, - ToolbarControlId::OpenConfigFile => ids::SIDE_SETTINGS_CONFIG_FILE, - _ => return None, - }) -} - #[derive(Debug, Clone)] pub(crate) struct ToolbarSettingsCustomizeGroup { pub(crate) label: Cow<'static, str>, diff --git a/src/ui/toolbar/model/settings/helpers.rs b/src/ui/toolbar/model/settings/helpers.rs new file mode 100644 index 00000000..3f703aa4 --- /dev/null +++ b/src/ui/toolbar/model/settings/helpers.rs @@ -0,0 +1,246 @@ +use super::*; + +pub(super) fn settings_buttons(snapshot: &ToolbarSnapshot) -> Vec { + vec![ + ToolbarSettingsButton { + id: ToolbarControlId::CustomizeToolbarItems, + label: Cow::Borrowed("Customize toolbar"), + event: ToolbarEvent::SetToolbarItemCustomizationOpen(true), + icon: ToolbarIcon::Visibility, + tooltip: ToolbarTooltip::text("Customize toolbar item visibility"), + }, + ToolbarSettingsButton { + id: ToolbarControlId::ResetToolbarHiddenItems, + label: Cow::Borrowed("Reset hidden"), + event: ToolbarEvent::ResetToolbarItemHiddenOverrides, + icon: ToolbarIcon::Visibility, + tooltip: ToolbarTooltip::text("Restore default hidden items"), + }, + ToolbarSettingsButton { + id: ToolbarControlId::OpenConfigurator, + label: Cow::Borrowed(action_short_label(Action::OpenConfigurator)), + event: ToolbarEvent::OpenConfigurator, + icon: ToolbarIcon::Settings, + tooltip: ToolbarTooltip::Binding { + label: Cow::Borrowed(action_label(Action::OpenConfigurator)), + binding: snapshot + .binding_hints + .binding_for_action(Action::OpenConfigurator) + .map(str::to_string), + }, + }, + ToolbarSettingsButton { + id: ToolbarControlId::OpenConfigFile, + label: Cow::Borrowed("Config file"), + event: ToolbarEvent::OpenConfigFile, + icon: ToolbarIcon::File, + tooltip: ToolbarTooltip::text("Config file"), + }, + ] + .into_iter() + .filter(|button| reset_button_visible(snapshot, button.id)) + .filter(|button| control_visible(snapshot, button.id)) + .collect() +} + +pub(super) fn section_buttons(snapshot: &ToolbarSnapshot) -> Vec { + vec![ToolbarSettingsButton { + id: ToolbarControlId::ResetToolbarHiddenItems, + label: Cow::Borrowed("Reset hidden"), + event: ToolbarEvent::ResetToolbarItemHiddenOverrides, + icon: ToolbarIcon::Visibility, + tooltip: ToolbarTooltip::text("Restore default hidden items"), + }] + .into_iter() + .filter(|button| reset_button_visible(snapshot, button.id)) + .collect() +} + +pub(super) fn customize_buttons(snapshot: &ToolbarSnapshot) -> Vec { + let back_event = if snapshot.customize_items_group.is_some() { + ToolbarEvent::SetToolbarItemCustomizationGroup(None) + } else if snapshot.drawer_tab == crate::input::ToolbarDrawerTab::Customize { + ToolbarEvent::SetDrawerTab(crate::input::ToolbarDrawerTab::App) + } else { + ToolbarEvent::SetToolbarItemCustomizationOpen(false) + }; + let mut buttons = vec![ + ToolbarSettingsButton { + id: ToolbarControlId::BackToolbarSettings, + label: Cow::Borrowed("Back"), + event: back_event, + icon: ToolbarIcon::Back, + tooltip: ToolbarTooltip::text("Back to settings"), + }, + ToolbarSettingsButton { + id: ToolbarControlId::ResetToolbarHiddenItems, + label: Cow::Borrowed("Reset hidden"), + event: ToolbarEvent::ResetToolbarItemHiddenOverrides, + icon: ToolbarIcon::Visibility, + tooltip: ToolbarTooltip::text("Restore default hidden items"), + }, + ]; + if let Some(group) = snapshot + .customize_items_group + .and_then(customize_order_group) + .filter(|group| order_is_customized(snapshot, *group)) + { + buttons.push(ToolbarSettingsButton { + id: ToolbarControlId::ResetToolbarItemOrder, + label: Cow::Borrowed("Reset order"), + event: ToolbarEvent::ResetToolbarItemOrder(group), + icon: ToolbarIcon::Back, + tooltip: ToolbarTooltip::text("Restore default order for this group"), + }); + } + buttons + .into_iter() + .filter(|button| reset_button_visible(snapshot, button.id)) + .collect() +} + +fn reset_button_visible(snapshot: &ToolbarSnapshot, id: ToolbarControlId) -> bool { + id != ToolbarControlId::ResetToolbarHiddenItems + || !snapshot.resolved_toolbar_items.hidden.is_empty() +} + +pub(super) fn is_section_toggle_id(id: ToolbarControlId) -> bool { + matches!( + id, + ToolbarControlId::SettingsPresets + | ToolbarControlId::SettingsActions + | ToolbarControlId::SettingsZoomActions + | ToolbarControlId::SettingsAdvancedActions + | ToolbarControlId::SettingsBoards + | ToolbarControlId::SettingsPages + | ToolbarControlId::SettingsStepControls + ) +} + +fn overlay_item_override_allowed(definition: &ToolbarItemDefinition) -> bool { + definition.group != Some(ToolbarGroupId::Settings) +} + +pub(super) fn customize_groups() -> Vec { + [ + ToolbarItemCustomizeGroup::TopTools, + ToolbarItemCustomizeGroup::TopControls, + ToolbarItemCustomizeGroup::SideSections, + ToolbarItemCustomizeGroup::Actions, + ToolbarItemCustomizeGroup::Pages, + ToolbarItemCustomizeGroup::Boards, + ToolbarItemCustomizeGroup::Presets, + ToolbarItemCustomizeGroup::ToolOptions, + ToolbarItemCustomizeGroup::Sessions, + ] + .into_iter() + .map(ToolbarSettingsCustomizeGroup::new) + .collect() +} + +pub(super) fn customize_group_contains( + group: ToolbarItemCustomizeGroup, + definition: &ToolbarItemDefinition, +) -> bool { + if !overlay_item_override_allowed(definition) { + return false; + } + + match group { + ToolbarItemCustomizeGroup::TopTools => { + definition.surface == ToolbarItemSurface::Top + && definition.category == ToolbarItemCategory::Tool + } + ToolbarItemCustomizeGroup::TopControls => { + definition.surface == ToolbarItemSurface::Top + && definition.category != ToolbarItemCategory::Tool + } + ToolbarItemCustomizeGroup::SideSections => { + definition.category == ToolbarItemCategory::Group + } + ToolbarItemCustomizeGroup::Actions => definition.category == ToolbarItemCategory::Action, + ToolbarItemCustomizeGroup::Pages => definition.category == ToolbarItemCategory::Page, + ToolbarItemCustomizeGroup::Boards => definition.category == ToolbarItemCategory::Board, + ToolbarItemCustomizeGroup::Presets => definition.group == Some(ToolbarGroupId::Presets), + ToolbarItemCustomizeGroup::ToolOptions => { + definition.category == ToolbarItemCategory::ToolOption + } + ToolbarItemCustomizeGroup::Sessions => definition.category == ToolbarItemCategory::Session, + } +} + +pub(super) fn sort_customize_definitions( + snapshot: &ToolbarSnapshot, + group: ToolbarItemCustomizeGroup, + definitions: &mut Vec<&ToolbarItemDefinition>, +) { + let Some(order_group) = customize_order_group(group) else { + return; + }; + definitions.sort_by_key(|definition| { + if overlay_order_group_for_definition(definition) == Some(order_group) { + snapshot + .resolved_toolbar_items + .order + .index_of(order_group, definition.id) + .unwrap_or(usize::MAX) + } else { + usize::MAX + } + }); +} + +fn customize_order_group(group: ToolbarItemCustomizeGroup) -> Option { + match group { + ToolbarItemCustomizeGroup::TopTools => Some(ToolbarItemOrderGroup::TopTools), + ToolbarItemCustomizeGroup::TopControls => Some(ToolbarItemOrderGroup::TopControls), + ToolbarItemCustomizeGroup::SideSections => Some(ToolbarItemOrderGroup::SideSections), + _ => None, + } +} + +pub(super) fn definition_order_group_for_customize( + group: ToolbarItemCustomizeGroup, + definition: &ToolbarItemDefinition, +) -> Option { + let order_group = customize_order_group(group)?; + (overlay_order_group_for_definition(definition) == Some(order_group)).then_some(order_group) +} + +fn overlay_order_group_for_definition( + definition: &ToolbarItemDefinition, +) -> Option { + toolbar_item_order_group(definition) +} + +fn order_is_customized(snapshot: &ToolbarSnapshot, group: ToolbarItemOrderGroup) -> bool { + let current = snapshot.resolved_toolbar_items.order.ordered_ids(group); + let default_order = ToolbarItemOrderConfig::default().resolved(); + current != default_order.ordered_ids(group) +} + +pub(super) fn control_visible(snapshot: &ToolbarSnapshot, id: ToolbarControlId) -> bool { + control_item_id(id).is_none_or(|item| !snapshot.toolbar_item_hidden(item)) +} + +fn control_item_id(id: ToolbarControlId) -> Option { + Some(match id { + ToolbarControlId::SettingsContextAwareUi => ids::SIDE_SETTINGS_CONTEXT_AWARE_UI, + ToolbarControlId::SettingsTextControls => ids::SIDE_SETTINGS_TEXT_CONTROLS, + ToolbarControlId::SettingsStatusBar => ids::SIDE_SETTINGS_STATUS_BAR, + ToolbarControlId::SettingsStatusBoardBadge => ids::SIDE_SETTINGS_STATUS_BOARD_BADGE, + ToolbarControlId::SettingsStatusPageBadge => ids::SIDE_SETTINGS_STATUS_PAGE_BADGE, + ToolbarControlId::SettingsFloatingBadgeAlways => ids::SIDE_SETTINGS_FLOATING_BADGE_ALWAYS, + ToolbarControlId::SettingsPresetToasts => ids::SIDE_SETTINGS_PRESET_TOASTS, + ToolbarControlId::SettingsPresets => ids::SIDE_SETTINGS_PRESETS, + ToolbarControlId::SettingsActions => ids::SIDE_SETTINGS_ACTIONS, + ToolbarControlId::SettingsZoomActions => ids::SIDE_SETTINGS_ZOOM_ACTIONS, + ToolbarControlId::SettingsAdvancedActions => ids::SIDE_SETTINGS_ADVANCED_ACTIONS, + ToolbarControlId::SettingsBoards => ids::SIDE_SETTINGS_BOARDS, + ToolbarControlId::SettingsPages => ids::SIDE_SETTINGS_PAGES, + ToolbarControlId::SettingsStepControls => ids::SIDE_SETTINGS_STEP_CONTROLS, + ToolbarControlId::OpenConfigurator => ids::SIDE_SETTINGS_CONFIGURATOR, + ToolbarControlId::OpenConfigFile => ids::SIDE_SETTINGS_CONFIG_FILE, + _ => return None, + }) +} diff --git a/src/unix_signals.rs b/src/unix_signals.rs index ff1b6784..208bf2ae 100644 --- a/src/unix_signals.rs +++ b/src/unix_signals.rs @@ -382,174 +382,4 @@ fn close_fd(fd: RawFd) { } #[cfg(test)] -mod tests { - use super::{ - PENDING_SIGNALS, SIGNAL_WRITE_FD, clear_registered_signals, close_fd, - dispatch_pending_signals, errno_location, register_signals, signal_action_flags, - signal_handler, - }; - use std::cell::RefCell; - use std::io; - use std::sync::{Mutex, atomic::Ordering}; - - static TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn signal_handlers_restart_interrupted_syscalls() { - let _guard = TEST_LOCK.lock().unwrap(); - - assert_ne!(signal_action_flags() & libc::SA_RESTART, 0); - } - - #[cfg(any( - target_os = "android", - target_os = "cygwin", - target_os = "dragonfly", - target_os = "emscripten", - target_os = "freebsd", - target_os = "hurd", - target_os = "illumos", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "netbsd", - target_os = "redox", - target_os = "solaris", - target_os = "tvos", - target_os = "visionos", - target_os = "watchos" - ))] - #[test] - fn signal_handler_preserves_errno() { - let _guard = TEST_LOCK.lock().unwrap(); - - clear_registered_signals(); - register_signals(&[libc::SIGTERM]).unwrap(); - SIGNAL_WRITE_FD.store(i32::MAX, Ordering::Release); - set_errno(libc::E2BIG); - - signal_handler(libc::SIGTERM); - - assert_eq!(current_errno(), libc::E2BIG); - SIGNAL_WRITE_FD.store(-1, Ordering::Release); - clear_registered_signals(); - } - - #[test] - fn spawn_listener_restores_installed_handlers_after_partial_failure() { - let _guard = TEST_LOCK.lock().unwrap(); - let signal = libc::SIGWINCH; - let before = current_sigaction(signal).unwrap(); - - let err = super::spawn_listener(&[signal, i32::MAX], |_| {}).unwrap_err(); - - assert_eq!(err.kind(), io::ErrorKind::InvalidInput); - let after = current_sigaction(signal).unwrap(); - assert_eq!(after.sa_sigaction, before.sa_sigaction); - clear_registered_signals(); - SIGNAL_WRITE_FD.store(-1, Ordering::Release); - } - - #[test] - fn pending_signals_are_preserved_when_wakeup_write_is_unavailable() { - let _guard = TEST_LOCK.lock().unwrap(); - - clear_registered_signals(); - register_signals(&[libc::SIGTERM]).unwrap(); - SIGNAL_WRITE_FD.store(-1, Ordering::Release); - - signal_handler(libc::SIGTERM); - - let dispatched = RefCell::new(Vec::new()); - dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal)); - assert_eq!(dispatched.into_inner(), vec![libc::SIGTERM]); - assert_eq!(PENDING_SIGNALS[0].load(Ordering::Acquire), 0); - - clear_registered_signals(); - } - - #[test] - fn pending_signal_counters_preserve_repeated_delivery() { - let _guard = TEST_LOCK.lock().unwrap(); - - clear_registered_signals(); - register_signals(&[libc::SIGUSR1]).unwrap(); - SIGNAL_WRITE_FD.store(-1, Ordering::Release); - - signal_handler(libc::SIGUSR1); - signal_handler(libc::SIGUSR1); - - let dispatched = RefCell::new(Vec::new()); - dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal)); - assert_eq!(dispatched.into_inner(), vec![libc::SIGUSR1, libc::SIGUSR1]); - - clear_registered_signals(); - } - - #[test] - fn pending_state_survives_full_self_pipe() { - let _guard = TEST_LOCK.lock().unwrap(); - - clear_registered_signals(); - register_signals(&[libc::SIGTERM]).unwrap(); - let (read_fd, write_fd) = super::create_pipe().unwrap(); - SIGNAL_WRITE_FD.store(write_fd, Ordering::Release); - - let wakeup = 1u8; - loop { - // SAFETY: `write_fd` is a nonblocking pipe write end and `wakeup` - // points to one readable byte. - let count = unsafe { - libc::write( - write_fd, - (&wakeup as *const u8).cast::(), - std::mem::size_of::(), - ) - }; - if count == 1 { - continue; - } - assert!(count < 0); - let err = std::io::Error::last_os_error(); - assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock); - break; - } - - signal_handler(libc::SIGTERM); - - let dispatched = RefCell::new(Vec::new()); - dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal)); - assert_eq!(dispatched.into_inner(), vec![libc::SIGTERM]); - - SIGNAL_WRITE_FD.store(-1, Ordering::Release); - clear_registered_signals(); - close_fd(read_fd); - close_fd(write_fd); - } - - fn current_errno() -> libc::c_int { - let location = errno_location(); - assert!(!location.is_null()); - // SAFETY: `location` points to the current thread's errno slot. - unsafe { *location } - } - - fn set_errno(value: libc::c_int) { - let location = errno_location(); - assert!(!location.is_null()); - // SAFETY: `location` points to the current thread's errno slot. - unsafe { - *location = value; - } - } - - fn current_sigaction(signal: libc::c_int) -> io::Result { - // SAFETY: Zeroed sigaction is filled by libc when the query succeeds. - let mut action = unsafe { std::mem::zeroed::() }; - // SAFETY: Null new action queries the current handler for `signal`. - if unsafe { libc::sigaction(signal, std::ptr::null(), &mut action) } != 0 { - return Err(io::Error::last_os_error()); - } - Ok(action) - } -} +mod tests; diff --git a/src/unix_signals/tests.rs b/src/unix_signals/tests.rs new file mode 100644 index 00000000..dc9f1ae7 --- /dev/null +++ b/src/unix_signals/tests.rs @@ -0,0 +1,168 @@ +use super::{ + PENDING_SIGNALS, SIGNAL_WRITE_FD, clear_registered_signals, close_fd, dispatch_pending_signals, + errno_location, register_signals, signal_action_flags, signal_handler, +}; +use std::cell::RefCell; +use std::io; +use std::sync::{Mutex, atomic::Ordering}; + +static TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[test] +fn signal_handlers_restart_interrupted_syscalls() { + let _guard = TEST_LOCK.lock().unwrap(); + + assert_ne!(signal_action_flags() & libc::SA_RESTART, 0); +} + +#[cfg(any( + target_os = "android", + target_os = "cygwin", + target_os = "dragonfly", + target_os = "emscripten", + target_os = "freebsd", + target_os = "hurd", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "netbsd", + target_os = "redox", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos" +))] +#[test] +fn signal_handler_preserves_errno() { + let _guard = TEST_LOCK.lock().unwrap(); + + clear_registered_signals(); + register_signals(&[libc::SIGTERM]).unwrap(); + SIGNAL_WRITE_FD.store(i32::MAX, Ordering::Release); + set_errno(libc::E2BIG); + + signal_handler(libc::SIGTERM); + + assert_eq!(current_errno(), libc::E2BIG); + SIGNAL_WRITE_FD.store(-1, Ordering::Release); + clear_registered_signals(); +} + +#[test] +fn spawn_listener_restores_installed_handlers_after_partial_failure() { + let _guard = TEST_LOCK.lock().unwrap(); + let signal = libc::SIGWINCH; + let before = current_sigaction(signal).unwrap(); + + let err = super::spawn_listener(&[signal, i32::MAX], |_| {}).unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + let after = current_sigaction(signal).unwrap(); + assert_eq!(after.sa_sigaction, before.sa_sigaction); + clear_registered_signals(); + SIGNAL_WRITE_FD.store(-1, Ordering::Release); +} + +#[test] +fn pending_signals_are_preserved_when_wakeup_write_is_unavailable() { + let _guard = TEST_LOCK.lock().unwrap(); + + clear_registered_signals(); + register_signals(&[libc::SIGTERM]).unwrap(); + SIGNAL_WRITE_FD.store(-1, Ordering::Release); + + signal_handler(libc::SIGTERM); + + let dispatched = RefCell::new(Vec::new()); + dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal)); + assert_eq!(dispatched.into_inner(), vec![libc::SIGTERM]); + assert_eq!(PENDING_SIGNALS[0].load(Ordering::Acquire), 0); + + clear_registered_signals(); +} + +#[test] +fn pending_signal_counters_preserve_repeated_delivery() { + let _guard = TEST_LOCK.lock().unwrap(); + + clear_registered_signals(); + register_signals(&[libc::SIGUSR1]).unwrap(); + SIGNAL_WRITE_FD.store(-1, Ordering::Release); + + signal_handler(libc::SIGUSR1); + signal_handler(libc::SIGUSR1); + + let dispatched = RefCell::new(Vec::new()); + dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal)); + assert_eq!(dispatched.into_inner(), vec![libc::SIGUSR1, libc::SIGUSR1]); + + clear_registered_signals(); +} + +#[test] +fn pending_state_survives_full_self_pipe() { + let _guard = TEST_LOCK.lock().unwrap(); + + clear_registered_signals(); + register_signals(&[libc::SIGTERM]).unwrap(); + let (read_fd, write_fd) = super::create_pipe().unwrap(); + SIGNAL_WRITE_FD.store(write_fd, Ordering::Release); + + let wakeup = 1u8; + loop { + // SAFETY: `write_fd` is a nonblocking pipe write end and `wakeup` + // points to one readable byte. + let count = unsafe { + libc::write( + write_fd, + (&wakeup as *const u8).cast::(), + std::mem::size_of::(), + ) + }; + if count == 1 { + continue; + } + assert!(count < 0); + let err = std::io::Error::last_os_error(); + assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock); + break; + } + + signal_handler(libc::SIGTERM); + + let dispatched = RefCell::new(Vec::new()); + dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal)); + assert_eq!(dispatched.into_inner(), vec![libc::SIGTERM]); + + SIGNAL_WRITE_FD.store(-1, Ordering::Release); + clear_registered_signals(); + close_fd(read_fd); + close_fd(write_fd); +} + +fn current_errno() -> libc::c_int { + let location = errno_location(); + assert!(!location.is_null()); + // SAFETY: `location` points to the current thread's errno slot. + unsafe { *location } +} + +fn set_errno(value: libc::c_int) { + let location = errno_location(); + assert!(!location.is_null()); + // SAFETY: `location` points to the current thread's errno slot. + unsafe { + *location = value; + } +} + +fn current_sigaction(signal: libc::c_int) -> io::Result { + // SAFETY: Zeroed sigaction is filled by libc when the query succeeds. + let mut action = unsafe { std::mem::zeroed::() }; + // SAFETY: Null new action queries the current handler for `signal`. + if unsafe { libc::sigaction(signal, std::ptr::null(), &mut action) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(action) +} diff --git a/tools/README.md b/tools/README.md index 96e280c6..b0aca68f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -16,6 +16,11 @@ Helper scripts for development, installation, packaging, and release workflows. - Runs `cargo test --workspace` - Usage: `./tools/test.sh` +- **code-health-report.sh** - Report local maintainability metrics + - Reports Rust files over 500 lines, functions over 120 lines, production unwrap/expect/panic/unsafe markers, selected allowances, and direct `fs::write` usage + - Does not fail on reported findings; intended for baseline visibility before adding quality gates + - Usage: `bash tools/code-health-report.sh` + - **reload-daemon.sh** - Restart running daemon - Kills and restarts the daemon to pick up config/code changes - Usage: `./tools/reload-daemon.sh` diff --git a/tools/code-health-report.sh b/tools/code-health-report.sh new file mode 100644 index 00000000..994ada6f --- /dev/null +++ b/tools/code-health-report.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) + +if command -v python3 >/dev/null 2>&1; then + PYTHON=python3 +elif command -v python >/dev/null 2>&1; then + PYTHON=python +else + echo "report=wayscriber-code-health" + echo "status=error" + echo "error=python_not_found" + exit 0 +fi + +set +e +"$PYTHON" - "$REPO_ROOT" <<'PY' +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +repo_root = Path(sys.argv[1]).resolve() + + +def run_git_ls_files() -> tuple[list[Path], str | None, str | None]: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "ls-files", "-co", "--exclude-standard", "--", "*.rs"], + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + return [], f"git_unavailable\t{exc}", None + stderr = result.stderr.strip() + if result.returncode != 0: + return [], f"git_ls_files_failed\t{stderr}", None + warning = f"git_ls_files_stderr\t{stderr}" if stderr else None + files: list[Path] = [] + seen: set[str] = set() + for line in result.stdout.splitlines(): + if not line: + continue + relative_path = Path(line) + if not (repo_root / relative_path).is_file(): + continue + path_key = relative_path.as_posix() + if path_key in seen: + continue + seen.add(path_key) + files.append(relative_path) + return files, None, warning + + +def read_text(relative_path: Path) -> str: + return (repo_root / relative_path).read_text(encoding="utf-8", errors="replace") + + +def physical_line_count(text: str) -> int: + if not text: + return 0 + return text.count("\n") + (0 if text.endswith("\n") else 1) + + +def is_test_path(relative_path: Path) -> bool: + parts = relative_path.as_posix().split("/") + name = relative_path.name + return ( + "tests" in parts + or name in {"tests.rs", "test_helpers.rs", "test_support.rs"} + or name.startswith("test_") + or name.endswith("_tests.rs") + ) + + +def scrub_rust_code(text: str) -> str: + output: list[str] = [] + index = 0 + length = len(text) + state = "code" + block_depth = 0 + raw_hashes = "" + + def blank(char: str) -> str: + return "\n" if char == "\n" else " " + + while index < length: + char = text[index] + next_char = text[index + 1] if index + 1 < length else "" + + if state == "code": + if char == "/" and next_char == "/": + output.extend(" ") + index += 2 + state = "line_comment" + continue + if char == "/" and next_char == "*": + output.extend(" ") + index += 2 + state = "block_comment" + block_depth = 1 + continue + if char == '"': + output.append(" ") + index += 1 + state = "string" + continue + if char == "r" or (char == "b" and next_char == "r"): + raw_start = index + (2 if char == "b" else 1) + hash_end = raw_start + while hash_end < length and text[hash_end] == "#": + hash_end += 1 + if hash_end < length and text[hash_end] == '"': + output.extend(" " * (hash_end - index + 1)) + index = hash_end + 1 + raw_hashes = text[raw_start:hash_end] + state = "raw_string" + continue + output.append(char) + index += 1 + continue + + if state == "line_comment": + output.append(blank(char)) + index += 1 + if char == "\n": + state = "code" + continue + + if state == "block_comment": + if char == "/" and next_char == "*": + output.extend(" ") + index += 2 + block_depth += 1 + continue + if char == "*" and next_char == "/": + output.extend(" ") + index += 2 + block_depth -= 1 + if block_depth == 0: + state = "code" + continue + output.append(blank(char)) + index += 1 + continue + + if state == "string": + if char == "\\" and next_char: + output.append(blank(char)) + output.append(blank(next_char)) + index += 2 + continue + output.append(blank(char)) + index += 1 + if char == '"': + state = "code" + continue + + if state == "raw_string": + output.append(blank(char)) + index += 1 + if char == '"' and text.startswith(raw_hashes, index): + output.extend(" " * len(raw_hashes)) + index += len(raw_hashes) + state = "code" + continue + + return "".join(output) + + +cfg_attr_start_pattern = re.compile(r"#\s*\[\s*cfg\s*\(", re.MULTILINE) + + +def preserve_newlines_as_spaces(text: str) -> str: + return "".join("\n" if char == "\n" else " " for char in text) + + +def find_attribute_end(code: str, start: int) -> int: + index = start + bracket_depth = 0 + while index < len(code): + char = code[index] + if char == "[": + bracket_depth += 1 + elif char == "]": + bracket_depth -= 1 + if bracket_depth == 0: + return index + 1 + index += 1 + return start + + +def skip_attributes_and_whitespace(code: str, start: int) -> int: + index = start + while index < len(code): + while index < len(code) and code[index].isspace(): + index += 1 + if code.startswith("#[", index): + next_index = find_attribute_end(code, index + 1) + if next_index <= index: + return index + index = next_index + continue + return index + return index + + +def is_cfg_test_only_attribute(attribute: str) -> bool: + compact = re.sub(r"\s+", "", attribute) + if compact == "#[cfg(test)]": + return True + if "not(test)" in compact: + return False + return compact.startswith("#[cfg(all(") and re.search(r"\btest\b", attribute) is not None + + +def find_item_end(code: str, start: int) -> int: + paren_depth = 0 + bracket_depth = 0 + index = start + while index < len(code): + char = code[index] + if char == "(": + paren_depth += 1 + elif char == ")" and paren_depth > 0: + paren_depth -= 1 + elif char == "[": + bracket_depth += 1 + elif char == "]" and bracket_depth > 0: + bracket_depth -= 1 + elif char == ";" and paren_depth == 0 and bracket_depth == 0: + return index + 1 + elif char == "{" and paren_depth == 0 and bracket_depth == 0: + body_end = find_matching_brace(code, index) + return len(code) if body_end is None else body_end + 1 + index += 1 + return len(code) + + +def strip_cfg_test_code(code: str) -> str: + chars = list(code) + for match in cfg_attr_start_pattern.finditer(code): + attribute_end = find_attribute_end(code, match.start() + 1) + attribute = code[match.start() : attribute_end] + if not is_cfg_test_only_attribute(attribute): + continue + item_start = skip_attributes_and_whitespace(code, attribute_end) + item_end = find_item_end(code, item_start) + replacement = preserve_newlines_as_spaces(code[match.start() : item_end]) + chars[match.start() : item_end] = replacement + return "".join(chars) + + +fn_name_pattern = re.compile( + r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>{;]*>)?\s*\(", + re.MULTILINE, +) + + +def line_number_at(text: str, index: int) -> int: + return text.count("\n", 0, index) + 1 + + +def find_function_body(code: str, start: int) -> int | None: + paren_depth = 1 + bracket_depth = 0 + index = start + while index < len(code): + char = code[index] + if char == "(": + paren_depth += 1 + elif char == ")" and paren_depth > 0: + paren_depth -= 1 + elif char == "[": + bracket_depth += 1 + elif char == "]" and bracket_depth > 0: + bracket_depth -= 1 + elif char == "{" and paren_depth == 0 and bracket_depth == 0: + return index + elif char == ";" and paren_depth == 0 and bracket_depth == 0: + return None + index += 1 + return None + + +def find_matching_brace(code: str, body_start: int) -> int | None: + depth = 0 + for index in range(body_start, len(code)): + char = code[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + return None + + +def long_functions(relative_path: Path, code: str) -> list[tuple[int, int, str, str]]: + findings: list[tuple[int, int, str, str]] = [] + for match in fn_name_pattern.finditer(code): + body_start = find_function_body(code, match.end()) + if body_start is None: + continue + body_end = find_matching_brace(code, body_start) + if body_end is None: + continue + start_line = line_number_at(code, match.start()) + end_line = line_number_at(code, body_end) + lines = end_line - start_line + 1 + if lines > 120: + findings.append((lines, start_line, relative_path.as_posix(), match.group(1))) + return findings + + +prod_patterns = { + "unwrap": re.compile(r"\.\s*unwrap\s*\("), + "expect": re.compile(r"\.\s*expect\s*\("), + "panic": re.compile(r"\bpanic\s*!"), + "unsafe": re.compile(r"\bunsafe\b"), +} +allow_dead_code_pattern = re.compile(r"#\s*\[\s*allow\s*\([^)]*\bdead_code\b[^)]*\)\s*\]") +allow_unused_imports_pattern = re.compile( + r"#\s*\[\s*allow\s*\([^)]*\bunused_imports\b[^)]*\)\s*\]" +) +direct_fs_write_pattern = re.compile(r"(? 500), reverse=True) +long_function_findings.sort(reverse=True) +direct_fs_write_files.sort() + +report_errors: list[str] = [] +report_warnings: list[str] = [] +if discovery_error: + report_errors.append("discovery") +if discovery_warning: + report_warnings.append("discovery") +if read_errors: + report_errors.append("read") + +print("report=wayscriber-code-health") +if report_errors: + print("status=error") +elif report_warnings: + print("status=warning") +else: + print("status=ok") +if report_errors: + print(f"errors={','.join(report_errors)}") +if report_warnings: + print(f"warnings={','.join(report_warnings)}") +if discovery_error: + error_name, _, error_detail = discovery_error.partition("\t") + print(f"error={error_name}") + if error_detail: + print(f"error_detail={error_detail}") +if discovery_warning: + warning_name, _, warning_detail = discovery_warning.partition("\t") + print(f"warning={warning_name}") + if warning_detail: + print(f"warning_detail={warning_detail}") +print(f"repo_root={repo_root}") +print(f"rust_files={len(rust_files)}") +print(f"rust_physical_lines={total_lines}") +print(f"files_over_500={len(files_over_500)}") +print(f"functions_over_120={len(long_function_findings)}") +print(f"production_unwrap={production_counts['unwrap']}") +print(f"production_expect={production_counts['expect']}") +print(f"production_panic={production_counts['panic']}") +print(f"production_unsafe={production_counts['unsafe']}") +print(f"allow_dead_code={allow_dead_code}") +print(f"allow_unused_imports={allow_unused_imports}") +print(f"direct_fs_write_files={len(direct_fs_write_files)}") +print(f"read_errors={len(read_errors)}") + + +def print_section(name: str, rows: list[str]) -> None: + print() + print(f"{name}:") + if not rows: + print(" none") + return + for row in rows: + print(f" {row}") + + +print_section( + "files_over_500", + [f"{lines}\t{path}" for lines, path in files_over_500], +) +print_section( + "functions_over_120", + [f"{lines}\t{path}:{line}\t{name}" for lines, line, path, name in long_function_findings], +) +print_section("direct_fs_write_files", direct_fs_write_files) +print_section("read_errors", read_errors) +PY +status=$? +set -e + +if [ "$status" -ne 0 ]; then + echo "report=wayscriber-code-health" + echo "status=error" + echo "error=python_report_failed" + echo "python_exit=$status" +fi + +exit 0