diff --git a/README.md b/README.md index 17605266..0dc6151a 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ For distro-specific package details, see [Installation](#installation). For keyb - Selection: Alt-drag, V tool, properties panel - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, palettes, size via hotkeys or scroll +- Render color profiles for print/projector/light-theme preview - Radial menu at cursor (Middle-click): quick tool/color selection + scroll size adjust ### Boards @@ -915,6 +916,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project structure, - [x] Highlighter & eraser tools - [x] Additional shapes (filled shapes) - [x] Color picker +- [x] Render color profiles - [x] Zoom (ZoomIt-style controls) - [x] Presets (tool/color/size slots) - [x] Sticky notes diff --git a/config.example.toml b/config.example.toml index a9b60c95..5eb429d4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -140,6 +140,11 @@ toggle_light_mode = ["Ctrl+Shift+L"] # or `--light-draw-on` / `--light-draw-off` from a compositor shortcut. toggle_light_mode_drawing = [] +# Optional render color profile preview controls +render_profile_next = [] +render_profile_previous = [] +render_profile_off = [] + # Toggle fill for rectangle/ellipse toggle_fill = [] @@ -606,6 +611,31 @@ auto_adjust_pen = true # default_pen_color = { rgb = [0.29, 0.23, 0.18] } # auto_adjust_pen = true +# ═══════════════════════════════════════════════════════════════════════════════ +# RENDER COLOR PROFILES +# ═══════════════════════════════════════════════════════════════════════════════ + +[render_profiles] +# Optional profile id to preview on startup +# active = "print" +# Apply profiles to board backgrounds, annotations, and canvas-space previews +apply_to_canvas = true +# Apply profiles to screen-space UI chrome, toolbars, popups, and status text +apply_to_ui = true + +# Render profiles remap final rendered pixels by exact RGB match. Alpha is +# preserved, and colors not listed here are unchanged. +# +# [[render_profiles.items]] +# id = "print" +# name = "Print" +# mappings = [ +# { from = "#000000", to = "#FFFFFF" }, +# { from = "#FFFFFF", to = "#000000" }, +# { from = "#FFFF00", to = "#8B4513" }, +# { from = "#00FF00", to = "#006400" }, +# ] + # ═══════════════════════════════════════════════════════════════════════════════ # SESSION PERSISTENCE # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs index 8eaed2a4..a5ee3797 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -82,6 +82,9 @@ impl KeybindingField { Self::ToggleClickHighlight => &config.ui.toggle_click_highlight, Self::ToggleToolbar => &config.ui.toggle_toolbar, Self::TogglePresenterMode => &config.ui.toggle_presenter_mode, + Self::RenderProfileNext => &config.ui.render_profile_next, + Self::RenderProfilePrevious => &config.ui.render_profile_previous, + Self::RenderProfileOff => &config.ui.render_profile_off, Self::ToggleFill => &config.ui.toggle_fill, Self::ToggleHighlightTool => &config.tools.toggle_highlight_tool, Self::ToggleSelectionProperties => &config.ui.toggle_selection_properties, diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs index 8d47e6e4..a4020383 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -83,6 +83,9 @@ impl KeybindingField { Self::ToggleClickHighlight => config.ui.toggle_click_highlight = value, Self::ToggleToolbar => config.ui.toggle_toolbar = value, Self::TogglePresenterMode => config.ui.toggle_presenter_mode = value, + Self::RenderProfileNext => config.ui.render_profile_next = value, + Self::RenderProfilePrevious => config.ui.render_profile_previous = value, + Self::RenderProfileOff => config.ui.render_profile_off = value, Self::ToggleFill => config.ui.toggle_fill = value, Self::ToggleHighlightTool => config.tools.toggle_highlight_tool = value, Self::ToggleSelectionProperties => config.ui.toggle_selection_properties = value, diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs index 7534e883..f529a6c7 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -77,6 +77,9 @@ impl KeybindingField { Self::ToggleClickHighlight => "Toggle click highlight", Self::ToggleToolbar => "Toggle toolbar", Self::TogglePresenterMode => "Toggle presenter mode", + Self::RenderProfileNext => "Render profile: next", + Self::RenderProfilePrevious => "Render profile: previous", + Self::RenderProfileOff => "Render profile: off", Self::ToggleFill => "Toggle fill", Self::ToggleHighlightTool => "Toggle highlight tool", Self::ToggleSelectionProperties => "Toggle selection properties", @@ -203,6 +206,9 @@ impl KeybindingField { Self::ToggleClickHighlight => "toggle_click_highlight", Self::ToggleToolbar => "toggle_toolbar", Self::TogglePresenterMode => "toggle_presenter_mode", + Self::RenderProfileNext => "render_profile_next", + Self::RenderProfilePrevious => "render_profile_previous", + Self::RenderProfileOff => "render_profile_off", Self::ToggleFill => "toggle_fill", Self::ToggleHighlightTool => "toggle_highlight_tool", Self::ToggleSelectionProperties => "toggle_selection_properties", diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index a495113f..10757e7c 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -77,6 +77,9 @@ impl KeybindingField { Self::ToggleClickHighlight, Self::ToggleToolbar, Self::TogglePresenterMode, + Self::RenderProfileNext, + Self::RenderProfilePrevious, + Self::RenderProfileOff, Self::ToggleFill, Self::ToggleHighlightTool, Self::ToggleSelectionProperties, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index 6ae4c14d..c2738245 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -64,6 +64,9 @@ pub enum KeybindingField { ToggleClickHighlight, ToggleToolbar, TogglePresenterMode, + RenderProfileNext, + RenderProfilePrevious, + RenderProfileOff, ToggleFill, ToggleHighlightTool, ToggleSelectionProperties, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index b9ff2be2..978e6de9 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -90,6 +90,9 @@ impl KeybindingField { | Self::ToggleClickHighlight | Self::ToggleToolbar | Self::TogglePresenterMode + | Self::RenderProfileNext + | Self::RenderProfilePrevious + | Self::RenderProfileOff | Self::ToggleSelectionProperties | Self::OpenContextMenu | Self::ToggleCommandPalette => KeybindingsTabId::UiModes, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index fe1e27db..84550f72 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -670,6 +670,46 @@ wayscriber --daemon --mode transparent This section is still recognized for backward compatibility. If `[boards]` is missing, wayscriber will synthesize boards from `[board]`. New configurations should prefer `[boards]`. +### `[render_profiles]` - Render Color Profiles + +Render profiles preview an alternate final color mapping without changing saved shapes or board +data. They are useful for print, projectors, grayscale-ish previews, and light/dark sharing +workflows. + +```toml +[render_profiles] +# Optional profile id to preview on startup +# active = "print" +apply_to_canvas = true +apply_to_ui = true + +[[render_profiles.items]] +id = "print" +name = "Print" +mappings = [ + { from = "#000000", to = "#FFFFFF" }, + { from = "#FFFFFF", to = "#000000" }, + { from = "#FFFF00", to = "#8B4513" }, + { from = "#00FF00", to = "#006400" }, +] +``` + +**Behavior:** +- `id` is the stable identifier used by `active` and runtime profile switching. +- `apply_to_canvas` controls board backgrounds, annotations, and canvas-space editor previews such as selections, hover rings, provisional strokes, text-edit previews, and click highlights. +- `apply_to_ui` controls screen-space Wayscriber UI chrome, status text, popups, command palette, and toolbars. +- `mappings` use exact RGB matches. Accepted input forms are `#RRGGBB`, `RRGGBB`, and `0xRRGGBB`; validation normalizes to `#RRGGBB`. +- Pixel alpha is preserved. Unmapped colors are unchanged. +- With both targets enabled, profiles apply to Wayscriber-rendered pixels: annotations, board backgrounds, UI chrome, toolbars, popups, embedded images, and frozen/zoom backgrounds when Wayscriber paints them. +- Set `apply_to_ui = false` to preview remapped canvas content while keeping screen-space UI text and controls in the normal theme. +- Profiles do not recolor the compositor-owned live desktop seen through a transparent overlay. +- Screenshot capture, PDF export, and saved annotation export are not profile-aware in this version. + +**Runtime actions:** +- `render_profile_next` +- `render_profile_previous` +- `render_profile_off` + ### `[capture]` - Screenshot Capture Configures how screenshots are stored and shared. @@ -909,6 +949,11 @@ toggle_light_mode = ["Ctrl+Shift+L"] # `wayscriber --light-draw-toggle`, `--light-draw-on`, or `--light-draw-off`. toggle_light_mode_drawing = [] +# Optional render color profile preview controls +render_profile_next = [] +render_profile_previous = [] +render_profile_off = [] + # Toggle click highlight (visual mouse halo) toggle_click_highlight = ["Ctrl+Shift+H"] diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index ae1437d0..3cf944e0 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -44,6 +44,9 @@ pub(super) fn build_input_state(config: &Config) -> InputState { ); input_state.set_action_bindings(action_bindings); input_state.set_drag_tool_bindings(build_drag_tool_bindings(config)); + input_state.set_render_profiles(crate::render_profiles::RenderProfileSet::from_config( + &config.render_profiles, + )); input_state.set_hit_test_tolerance(config.drawing.hit_test_tolerance); input_state.set_hit_test_threshold(config.drawing.hit_test_linear_threshold); diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index 256eb8ab..6e89a317 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -97,6 +97,8 @@ pub struct StateData { pub(super) xdg_close_guard_until: Option, /// Explicit compositor close request received for xdg fallback window. pub(super) xdg_explicit_close_requested: bool, + /// Reused pre-UI pixel snapshot for render-profile UI-only remapping. + pub(super) render_profile_ui_baseline: Vec, } impl StateData { @@ -162,6 +164,7 @@ impl StateData { suppress_focus_exit_until: None, xdg_close_guard_until: None, xdg_explicit_close_requested: false, + render_profile_ui_baseline: Vec::new(), } } } diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index 6b29edb0..c0e7dc81 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -14,7 +14,6 @@ impl WaylandState { scale: i32, phys_width: u32, phys_height: u32, - render_ui: bool, now: Instant, damage_world: &[crate::util::Rect], ) -> Result<()> { @@ -192,8 +191,6 @@ impl WaylandState { let _ = ctx.restore(); } - self.render_ui_layers(ctx, width, height, render_ui); - let _ = ctx.restore(); Ok(()) diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index 1406afa3..020dbe0c 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -128,6 +128,12 @@ impl WaylandState { } else { damage_screen.clone() }; + let scaled_damage = scale_damage_regions(damage_screen.clone(), scale); + let active_render_profile = self.input_state.active_render_profile().cloned(); + let remap_canvas = self.input_state.active_canvas_render_profile().is_some(); + let remap_ui = self.input_state.active_ui_render_profile().is_some(); + let stride = (phys_width * 4) as i32; + let canvas_len = phys_height as usize * stride as usize; // SAFETY: This unsafe block creates a Cairo surface from raw memory buffer. // Safety invariants that must be maintained: @@ -184,18 +190,76 @@ impl WaylandState { scale, phys_width, phys_height, - render_ui, now, &damage_world, )?; } + let mut has_ui_baseline = false; + if let Some(profile) = active_render_profile.as_ref() { + if remap_canvas && !remap_ui { + cairo_surface.flush(); + // SAFETY: `canvas_ptr` points to the SlotPool memory for the buffer created above. + // Cairo has flushed all pending writes, so the rendered canvas pixels can be + // rewritten before UI is drawn on top. + let canvas = + unsafe { std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, canvas_len) }; + profile.remap_argb8888_regions( + canvas, + phys_width as i32, + phys_height as i32, + stride, + &scaled_damage, + ); + cairo_surface.mark_dirty(); + } else if !remap_canvas && remap_ui && render_ui { + cairo_surface.flush(); + // SAFETY: `canvas_ptr` points to the SlotPool memory for the buffer created above. + // Cairo has flushed all pending writes, so we can snapshot the canvas-only pixels in + // a reusable scratch buffer before drawing UI and later remap only bytes changed by + // the UI pass. + let canvas = + unsafe { std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, canvas_len) }; + self.data.render_profile_ui_baseline.resize(canvas_len, 0); + self.data.render_profile_ui_baseline.copy_from_slice(canvas); + has_ui_baseline = true; + } + } + + self.render_ui_layer(&ctx, width, height, scale, render_ui); + // Flush Cairo debug!("Flushing Cairo surface"); cairo_surface.flush(); drop(ctx); drop(cairo_surface); + if let Some(profile) = active_render_profile.as_ref() { + // SAFETY: `canvas_ptr` points to the SlotPool memory for the buffer created above. + // Cairo has been flushed and dropped, and the buffer has not been attached yet, so this + // is the only active mutable access to the rendered pixel bytes. + let canvas = + unsafe { std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, canvas_len) }; + if remap_canvas && remap_ui { + profile.remap_argb8888_regions( + canvas, + phys_width as i32, + phys_height as i32, + stride, + &scaled_damage, + ); + } else if !remap_canvas && remap_ui && has_ui_baseline { + profile.remap_argb8888_regions_changed_from( + canvas, + &self.data.render_profile_ui_baseline, + phys_width as i32, + phys_height as i32, + stride, + &scaled_damage, + ); + } + } + let draw_duration = draw_start.elapsed(); if draw_duration > std::time::Duration::from_millis(2) { debug!("Cairo draw took {:?}", draw_duration); @@ -214,8 +278,6 @@ impl WaylandState { // Damage logic moved to top of function (add_regions and take_buffer_damage). // We now use the computed screen-space damage for clipping and compositor hints. - let scaled_damage = scale_damage_regions(damage_screen.clone(), scale); - if debug_damage_logging_enabled() { debug!( "Damage (scaled): count={}, {}", diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 5dc29ce9..7c758f27 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -2,13 +2,23 @@ use super::tool_preview::{draw_stylus_hover_cursor, draw_tool_preview}; use super::*; impl WaylandState { - pub(super) fn render_ui_layers( + pub(super) fn render_ui_layer( &mut self, ctx: &cairo::Context, width: u32, height: u32, + scale: i32, render_ui: bool, ) { + let _ = ctx.save(); + if scale > 1 { + ctx.scale(scale as f64, scale as f64); + } + self.render_ui_layers(ctx, width, height, render_ui); + let _ = ctx.restore(); + } + + fn render_ui_layers(&mut self, ctx: &cairo::Context, width: u32, height: u32, render_ui: bool) { if render_ui { if self.input_state.show_tool_preview && self.has_cursor_focus() diff --git a/src/backend/wayland/state/toolbar/visibility/sync.rs b/src/backend/wayland/state/toolbar/visibility/sync.rs index 1a9e5c66..880f64ea 100644 --- a/src/backend/wayland/state/toolbar/visibility/sync.rs +++ b/src/backend/wayland/state/toolbar/visibility/sync.rs @@ -198,7 +198,9 @@ impl WaylandState { } // No hover tracking yet; pass None. Can be updated when we record pointer positions per surface. - self.toolbar.render(&self.shm, snapshot, None); + let render_profile = self.input_state.active_ui_render_profile().cloned(); + self.toolbar + .render(&self.shm, snapshot, None, render_profile.as_ref()); } pub(in crate::backend::wayland) fn render_layer_toolbars_if_needed(&mut self) { diff --git a/src/backend/wayland/toolbar/main/render.rs b/src/backend/wayland/toolbar/main/render.rs index 95cb1319..0b6014c9 100644 --- a/src/backend/wayland/toolbar/main/render.rs +++ b/src/backend/wayland/toolbar/main/render.rs @@ -1,10 +1,17 @@ use smithay_client_toolkit::shm::Shm; use super::structs::ToolbarSurfaceManager; +use crate::render_profiles::RenderColorProfile; use crate::ui::toolbar::ToolbarSnapshot; impl ToolbarSurfaceManager { - pub fn render(&mut self, shm: &Shm, snapshot: &ToolbarSnapshot, hover: Option<(f64, f64)>) { + pub fn render( + &mut self, + shm: &Shm, + snapshot: &ToolbarSnapshot, + hover: Option<(f64, f64)>, + render_profile: Option<&RenderColorProfile>, + ) { // Render top toolbar if visible if self.is_top_visible() { self.top.set_ui_scale(snapshot.toolbar_scale); @@ -15,6 +22,7 @@ impl ToolbarSurfaceManager { snapshot, top_hover, top_hover_start, + render_profile, |ctx, w, h, snap, hits, hov, hov_start| { crate::backend::wayland::toolbar::render_top_strip( ctx, w, h, snap, hits, hov, hov_start, @@ -35,6 +43,7 @@ impl ToolbarSurfaceManager { snapshot, side_hover, side_hover_start, + render_profile, |ctx, w, h, snap, hits, hov, hov_start| { crate::backend::wayland::toolbar::render_side_palette( ctx, w, h, snap, hits, hov, hov_start, diff --git a/src/backend/wayland/toolbar/surfaces/render.rs b/src/backend/wayland/toolbar/surfaces/render.rs index bb2c47e9..1245a42f 100644 --- a/src/backend/wayland/toolbar/surfaces/render.rs +++ b/src/backend/wayland/toolbar/surfaces/render.rs @@ -9,6 +9,7 @@ use smithay_client_toolkit::{ use super::structs::ToolbarSurface; use crate::backend::wayland::toolbar::hit::HitRegion; +use crate::render_profiles::RenderColorProfile; use crate::ui::toolbar::ToolbarSnapshot; impl ToolbarSurface { @@ -19,6 +20,7 @@ impl ToolbarSurface { snapshot: &ToolbarSnapshot, hover: Option<(f64, f64)>, hover_start: Option, + render_profile: Option<&RenderColorProfile>, render_fn: F, ) -> Result<()> where @@ -127,6 +129,19 @@ impl ToolbarSurface { } surface.flush(); + drop(ctx); + drop(surface); + if let Some(profile) = render_profile + && let Some(full) = crate::util::Rect::new(0, 0, phys_w as i32, phys_h as i32) + { + profile.remap_argb8888_regions( + canvas, + phys_w as i32, + phys_h as i32, + (phys_w * 4) as i32, + &[full], + ); + } if let Some(layer) = self.layer_surface.as_ref() { let wl_surface = layer.wl_surface(); diff --git a/src/config/action_meta/entries/ui.rs b/src/config/action_meta/entries/ui.rs index 39fa2823..edb867ae 100644 --- a/src/config/action_meta/entries/ui.rs +++ b/src/config/action_meta/entries/ui.rs @@ -73,6 +73,39 @@ pub const ENTRIES: &[ActionMeta] = &[ false, &["passthrough draw", "quick draw"] ), + meta!( + RenderProfileNext, + "Next Render Profile", + Some("Next Profile"), + "Switch to the next render color profile", + UI, + true, + true, + false, + &["color profile", "print profile", "export theme"] + ), + meta!( + RenderProfilePrevious, + "Previous Render Profile", + Some("Prev Profile"), + "Switch to the previous render color profile", + UI, + true, + true, + false, + &["color profile", "print profile", "export theme"] + ), + meta!( + RenderProfileOff, + "Render Profile Off", + None, + "Disable render color profile preview", + UI, + true, + true, + false, + &["color profile off", "normal colors", "export theme off"] + ), meta!( ToggleClickHighlight, "Click Highlight", diff --git a/src/config/core.rs b/src/config/core.rs index 90dc8f94..a4f3e89c 100644 --- a/src/config/core.rs +++ b/src/config/core.rs @@ -3,7 +3,8 @@ use super::keybindings::KeybindingsConfig; use super::types::TabletInputConfig; use super::types::{ ArrowConfig, BoardConfig, BoardsConfig, CaptureConfig, DrawingConfig, HistoryConfig, - PerformanceConfig, PresenterModeConfig, PresetSlotsConfig, SessionConfig, UiConfig, + PerformanceConfig, PresenterModeConfig, PresetSlotsConfig, RenderProfilesConfig, SessionConfig, + UiConfig, }; use serde::{Deserialize, Serialize}; @@ -68,6 +69,10 @@ pub struct Config { #[serde(default)] pub presenter_mode: PresenterModeConfig, + /// Final-render color profile mappings. + #[serde(default)] + pub render_profiles: RenderProfilesConfig, + /// Multi-board settings (preferred over legacy [board] section) #[serde(default, skip_serializing_if = "Option::is_none")] pub boards: Option, @@ -104,6 +109,7 @@ impl Default for Config { performance: PerformanceConfig::default(), ui: UiConfig::default(), presenter_mode: PresenterModeConfig::default(), + render_profiles: RenderProfilesConfig::default(), boards: Some(BoardsConfig::default()), board: BoardConfig::default(), keybindings: KeybindingsConfig::default(), diff --git a/src/config/keybindings/actions.rs b/src/config/keybindings/actions.rs index 7abd34a9..580e5304 100644 --- a/src/config/keybindings/actions.rs +++ b/src/config/keybindings/actions.rs @@ -110,6 +110,9 @@ pub enum Action { TogglePresenterMode, ToggleLightMode, ToggleLightModeDrawing, + RenderProfileNext, + RenderProfilePrevious, + RenderProfileOff, ToggleHighlightTool, ToggleFill, ToggleRadialMenu, diff --git a/src/config/keybindings/config/map/ui.rs b/src/config/keybindings/config/map/ui.rs index 77fa999e..2c8de327 100644 --- a/src/config/keybindings/config/map/ui.rs +++ b/src/config/keybindings/config/map/ui.rs @@ -18,6 +18,12 @@ impl KeybindingsConfig { &self.ui.toggle_light_mode_drawing, Action::ToggleLightModeDrawing, )?; + inserter.insert_all(&self.ui.render_profile_next, Action::RenderProfileNext)?; + inserter.insert_all( + &self.ui.render_profile_previous, + Action::RenderProfilePrevious, + )?; + inserter.insert_all(&self.ui.render_profile_off, Action::RenderProfileOff)?; inserter.insert_all(&self.ui.toggle_fill, Action::ToggleFill)?; inserter.insert_all(&self.ui.toggle_radial_menu, Action::ToggleRadialMenu)?; inserter.insert_all( diff --git a/src/config/keybindings/config/types/bindings/ui.rs b/src/config/keybindings/config/types/bindings/ui.rs index b6a3a5e4..5ed60d7a 100644 --- a/src/config/keybindings/config/types/bindings/ui.rs +++ b/src/config/keybindings/config/types/bindings/ui.rs @@ -29,6 +29,15 @@ pub struct UiKeybindingsConfig { #[serde(default = "default_toggle_light_mode_drawing")] pub toggle_light_mode_drawing: Vec, + #[serde(default = "default_render_profile_next")] + pub render_profile_next: Vec, + + #[serde(default = "default_render_profile_previous")] + pub render_profile_previous: Vec, + + #[serde(default = "default_render_profile_off")] + pub render_profile_off: Vec, + #[serde(default = "default_toggle_fill")] pub toggle_fill: Vec, @@ -59,6 +68,9 @@ impl Default for UiKeybindingsConfig { toggle_presenter_mode: default_toggle_presenter_mode(), toggle_light_mode: default_toggle_light_mode(), toggle_light_mode_drawing: default_toggle_light_mode_drawing(), + render_profile_next: default_render_profile_next(), + render_profile_previous: default_render_profile_previous(), + render_profile_off: default_render_profile_off(), toggle_fill: default_toggle_fill(), toggle_radial_menu: default_toggle_radial_menu(), toggle_selection_properties: default_toggle_selection_properties(), diff --git a/src/config/keybindings/defaults/ui.rs b/src/config/keybindings/defaults/ui.rs index 5c3a8e18..c3b01184 100644 --- a/src/config/keybindings/defaults/ui.rs +++ b/src/config/keybindings/defaults/ui.rs @@ -30,6 +30,18 @@ pub(crate) fn default_toggle_light_mode_drawing() -> Vec { Vec::new() } +pub(crate) fn default_render_profile_next() -> Vec { + Vec::new() +} + +pub(crate) fn default_render_profile_previous() -> Vec { + Vec::new() +} + +pub(crate) fn default_render_profile_off() -> Vec { + Vec::new() +} + pub(crate) fn default_toggle_fill() -> Vec { Vec::new() } diff --git a/src/config/mod.rs b/src/config/mod.rs index 5e0415df..a1f99b82 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -43,9 +43,10 @@ pub use types::{ BoardsConfig, CaptureConfig, ClickHighlightConfig, DragButtonConfig, DrawingConfig, HelpOverlayStyle, HistoryConfig, MouseDragToolsConfig, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PerformanceConfig, PresenterModeConfig, PresenterToolBehavior, PresetSlotsConfig, - PresetToolSettingConfig, PresetToolStatesConfig, SessionCompression, SessionConfig, - SessionStorageMode, StatusBarStyle, ToolPresetConfig, ToolbarConfig, ToolbarLayoutMode, - ToolbarModeOverride, ToolbarModeOverrides, UiConfig, + PresetToolSettingConfig, PresetToolStatesConfig, RenderColorMappingConfig, RenderProfileConfig, + RenderProfilesConfig, SessionCompression, SessionConfig, SessionStorageMode, StatusBarStyle, + ToolPresetConfig, ToolbarConfig, ToolbarLayoutMode, ToolbarModeOverride, ToolbarModeOverrides, + UiConfig, }; // Re-export for public API (unused internally but part of public interface) diff --git a/src/config/tests/schema.rs b/src/config/tests/schema.rs index 82236ff6..816ac502 100644 --- a/src/config/tests/schema.rs +++ b/src/config/tests/schema.rs @@ -14,6 +14,7 @@ fn json_schema_includes_expected_sections() { "arrow", "performance", "ui", + "render_profiles", "boards", "board", "keybindings", diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index db339ac8..9a20c115 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -135,6 +135,79 @@ fn validate_boards_uses_boundary_id_normalization() { ); } +#[test] +fn validate_render_profiles_normalizes_ids_and_mappings() { + let mut config = Config { + render_profiles: RenderProfilesConfig { + active: Some(" PRINT ".to_string()), + apply_to_canvas: true, + apply_to_ui: true, + items: vec![ + RenderProfileConfig { + id: " Print ".to_string(), + name: " Print Friendly ".to_string(), + mappings: vec![ + RenderColorMappingConfig { + from: "#000000".to_string(), + to: "FFFFFF".to_string(), + }, + RenderColorMappingConfig { + from: "#000000".to_string(), + to: "#111111".to_string(), + }, + RenderColorMappingConfig { + from: "#GGGGGG".to_string(), + to: "#222222".to_string(), + }, + ], + }, + RenderProfileConfig { + id: "print".to_string(), + name: " ".to_string(), + mappings: Vec::new(), + }, + ], + }, + ..Config::default() + }; + + config.validate_and_clamp(); + + assert_eq!(config.render_profiles.active.as_deref(), Some("print")); + assert_eq!(config.render_profiles.items[0].id, "print"); + assert_eq!(config.render_profiles.items[0].name, "Print Friendly"); + assert_eq!(config.render_profiles.items[1].id, "print-2"); + assert_eq!(config.render_profiles.items[1].name, "Profile 2"); + assert_eq!( + config.render_profiles.items[0].mappings, + vec![RenderColorMappingConfig { + from: "#000000".to_string(), + to: "#111111".to_string(), + }] + ); +} + +#[test] +fn validate_render_profiles_disables_missing_active_profile() { + let mut config = Config { + render_profiles: RenderProfilesConfig { + active: Some("missing".to_string()), + apply_to_canvas: true, + apply_to_ui: true, + items: vec![RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: Vec::new(), + }], + }, + ..Config::default() + }; + + config.validate_and_clamp(); + + assert_eq!(config.render_profiles.active, None); +} + #[test] fn validate_clamps_history_delays() { let mut config = Config::default(); diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs index 41bdb250..59cccd03 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -12,6 +12,7 @@ mod history; mod performance; mod presenter_mode; mod presets; +mod render_profiles; mod session; mod status_bar; #[cfg(tablet)] @@ -34,6 +35,7 @@ pub use presets::{ PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PresetSlotsConfig, PresetToolSettingConfig, PresetToolStatesConfig, ToolPresetConfig, }; +pub use render_profiles::{RenderColorMappingConfig, RenderProfileConfig, RenderProfilesConfig}; pub use session::{SessionCompression, SessionConfig, SessionStorageMode}; pub use status_bar::StatusBarStyle; #[cfg(tablet)] diff --git a/src/config/types/render_profiles.rs b/src/config/types/render_profiles.rs new file mode 100644 index 00000000..98a2899d --- /dev/null +++ b/src/config/types/render_profiles.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; + +/// Configurable final-render color profiles. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenderProfilesConfig { + /// Profile id to enable when the overlay starts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, + + /// Apply the active profile to board backgrounds and annotation pixels. + #[serde(default = "default_render_profile_target_enabled")] + pub apply_to_canvas: bool, + + /// Apply the active profile to Wayscriber UI chrome, toolbars, popups, and status text. + #[serde(default = "default_render_profile_target_enabled")] + pub apply_to_ui: bool, + + /// Available render color profiles. + #[serde(default)] + pub items: Vec, +} + +impl Default for RenderProfilesConfig { + fn default() -> Self { + Self { + active: None, + apply_to_canvas: true, + apply_to_ui: true, + items: Vec::new(), + } + } +} + +fn default_render_profile_target_enabled() -> bool { + true +} + +/// A named set of exact RGB color mappings applied to rendered pixels. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenderProfileConfig { + /// Stable profile id used by config and runtime switching. + pub id: String, + + /// Human-friendly display name. + pub name: String, + + /// Exact RGB mappings for this profile. Pixel alpha is preserved. + #[serde(default)] + pub mappings: Vec, +} + +/// One exact source-to-target RGB color mapping. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RenderColorMappingConfig { + /// Source color as #RRGGBB, RRGGBB, or 0xRRGGBB. + pub from: String, + + /// Target color as #RRGGBB, RRGGBB, or 0xRRGGBB. + pub to: String, +} diff --git a/src/config/validate/mod.rs b/src/config/validate/mod.rs index 4b34b5c4..fcf5c471 100644 --- a/src/config/validate/mod.rs +++ b/src/config/validate/mod.rs @@ -9,6 +9,7 @@ mod history; mod keybindings; mod performance; mod presets; +mod render_profiles; mod session; #[cfg(tablet)] mod tablet; @@ -39,6 +40,7 @@ impl Config { self.validate_boards(); self.validate_board(); self.validate_ui(); + self.validate_render_profiles(); self.validate_keybindings(); self.validate_session(); } diff --git a/src/config/validate/render_profiles.rs b/src/config/validate/render_profiles.rs new file mode 100644 index 00000000..f146ebe2 --- /dev/null +++ b/src/config/validate/render_profiles.rs @@ -0,0 +1,93 @@ +use std::collections::HashSet; + +use super::Config; +use crate::render_profiles::{format_hex_rgb, normalize_profile_id, parse_hex_rgb}; +use log::warn; + +impl Config { + pub(super) fn validate_render_profiles(&mut self) { + let mut seen_ids = HashSet::new(); + for (index, profile) in self.render_profiles.items.iter_mut().enumerate() { + let raw_id = profile.id.clone(); + let mut id = normalize_profile_id(&raw_id); + if id.is_empty() { + id = format!("profile-{}", index + 1); + warn!("Render profile id was empty; using '{}'", id); + } else if id != raw_id.trim() { + warn!("Render profile id '{}' normalized to '{}'", raw_id, id); + } + + let base = id.clone(); + let mut suffix = 2; + while seen_ids.contains(&id) { + id = format!("{base}-{suffix}"); + suffix += 1; + } + if id != base { + warn!("Render profile id '{}' deduplicated to '{}'", base, id); + } + seen_ids.insert(id.clone()); + profile.id = id; + + if profile.name.trim().is_empty() { + profile.name = format!("Profile {}", index + 1); + warn!( + "Render profile '{}' had empty name; using '{}'", + profile.id, profile.name + ); + } else { + profile.name = profile.name.trim().to_string(); + } + + let mut seen_sources = HashSet::new(); + let mut normalized = Vec::with_capacity(profile.mappings.len()); + for mapping in profile.mappings.iter().rev() { + let Some(from) = parse_hex_rgb(&mapping.from) else { + warn!( + "Render profile '{}' has invalid source color '{}'; dropping mapping", + profile.id, mapping.from + ); + continue; + }; + let Some(to) = parse_hex_rgb(&mapping.to) else { + warn!( + "Render profile '{}' has invalid target color '{}'; dropping mapping", + profile.id, mapping.to + ); + continue; + }; + if !seen_sources.insert(from) { + warn!( + "Render profile '{}' has duplicate source color {}; keeping the last mapping", + profile.id, + format_hex_rgb(from) + ); + continue; + } + normalized.push(crate::config::RenderColorMappingConfig { + from: format_hex_rgb(from), + to: format_hex_rgb(to), + }); + } + normalized.reverse(); + profile.mappings = normalized; + } + + if let Some(active) = self.render_profiles.active.as_mut() { + *active = normalize_profile_id(active); + if active.is_empty() + || !self + .render_profiles + .items + .iter() + .any(|profile| profile.id == *active) + { + warn!( + "Active render profile '{}' not found; starting with render profiles off", + active + ); + self.render_profiles.active = None; + } + } + } +} diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs index 4407d00c..dea6c041 100644 --- a/src/input/state/actions/action_ui.rs +++ b/src/input/state/actions/action_ui.rs @@ -76,6 +76,37 @@ impl InputState { ); true } + Action::RenderProfileNext => { + let changed = self.activate_next_render_profile(); + if changed { + info!( + "Render profile {}", + self.active_render_profile() + .map(|profile| profile.name()) + .unwrap_or("off") + ); + } + true + } + Action::RenderProfilePrevious => { + let changed = self.activate_previous_render_profile(); + if changed { + info!( + "Render profile {}", + self.active_render_profile() + .map(|profile| profile.name()) + .unwrap_or("off") + ); + } + true + } + Action::RenderProfileOff => { + let changed = self.deactivate_render_profile(); + if changed { + info!("Render profile off"); + } + true + } Action::ToggleRadialMenu => { if self.is_radial_menu_open() { self.close_radial_menu(); diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index 0771a1e9..bc89394e 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -124,6 +124,7 @@ impl InputState { show_floating_badge_always: false, presenter_mode: false, presenter_mode_config, + render_profiles: crate::render_profiles::RenderProfileSet::default(), presenter_restore: None, light_mode: false, light_mode_drawing: false, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index c146da83..438a0af4 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -31,6 +31,7 @@ use crate::input::{ modifiers::{DragToolBindings, Modifiers}, tool::{EraserMode, PerToolDrawingSettings, Tool}, }; +use crate::render_profiles::RenderProfileSet; use crate::session::SessionOptions; use crate::util::Rect; use std::collections::HashMap; @@ -162,6 +163,8 @@ pub struct InputState { pub presenter_mode: bool, /// Presenter mode behavior configuration pub presenter_mode_config: PresenterModeConfig, + /// Configured render color profiles and active preview state. + pub(crate) render_profiles: RenderProfileSet, /// Previous UI state to restore after presenter mode exits pub(crate) presenter_restore: Option, /// Whether passthrough light mode is currently enabled diff --git a/src/input/state/core/utility/mod.rs b/src/input/state/core/utility/mod.rs index cc10f54a..dbc4d368 100644 --- a/src/input/state/core/utility/mod.rs +++ b/src/input/state/core/utility/mod.rs @@ -8,6 +8,7 @@ mod launcher; mod light_mode; mod pending; mod presenter_mode; +mod render_profiles; mod step_markers; mod toasts; diff --git a/src/input/state/core/utility/render_profiles.rs b/src/input/state/core/utility/render_profiles.rs new file mode 100644 index 00000000..89e5ea43 --- /dev/null +++ b/src/input/state/core/utility/render_profiles.rs @@ -0,0 +1,106 @@ +use crate::render_profiles::{RenderColorProfile, RenderProfileSet}; + +use super::super::InputState; + +impl InputState { + #[allow(dead_code)] // Used by the binary Wayland backend; the library target has no backend entrypoint. + pub(crate) fn set_render_profiles(&mut self, render_profiles: RenderProfileSet) { + self.render_profiles = render_profiles; + } + + pub fn active_render_profile(&self) -> Option<&RenderColorProfile> { + self.render_profiles.active() + } + + pub fn active_canvas_render_profile(&self) -> Option<&RenderColorProfile> { + if self.render_profiles.applies_to_canvas() { + self.render_profiles.active() + } else { + None + } + } + + pub fn active_ui_render_profile(&self) -> Option<&RenderColorProfile> { + if self.render_profiles.applies_to_ui() { + self.render_profiles.active() + } else { + None + } + } + + pub fn render_profile_generation(&self) -> u64 { + self.render_profiles.generation() + } + + pub(crate) fn activate_next_render_profile(&mut self) -> bool { + self.activate_render_profile_with(|profiles| profiles.activate_next()) + } + + pub(crate) fn activate_previous_render_profile(&mut self) -> bool { + self.activate_render_profile_with(|profiles| profiles.activate_previous()) + } + + pub(crate) fn deactivate_render_profile(&mut self) -> bool { + self.activate_render_profile_with(|profiles| profiles.deactivate()) + } + + fn activate_render_profile_with( + &mut self, + update: impl FnOnce(&mut RenderProfileSet) -> bool, + ) -> bool { + if self.render_profiles.is_empty() { + return false; + } + let changed = update(&mut self.render_profiles); + if changed { + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + changed + } +} + +#[cfg(test)] +mod tests { + use crate::config::{RenderProfileConfig, RenderProfilesConfig}; + use crate::input::state::test_support::make_test_input_state; + use crate::render_profiles::RenderProfileSet; + + #[test] + fn render_profile_actions_cycle_and_mark_redraw() { + let mut state = make_test_input_state(); + state.set_render_profiles(RenderProfileSet::from_config(&RenderProfilesConfig { + active: None, + apply_to_canvas: true, + apply_to_ui: true, + items: vec![RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: Vec::new(), + }], + })); + state.needs_redraw = false; + let generation = state.render_profile_generation(); + + assert!(state.activate_next_render_profile()); + assert_eq!( + state.active_render_profile().map(|profile| profile.name()), + Some("Print") + ); + assert!(state.needs_redraw); + assert_ne!(state.render_profile_generation(), generation); + + state.needs_redraw = false; + assert!(state.deactivate_render_profile()); + assert!(state.active_render_profile().is_none()); + assert!(state.needs_redraw); + } + + #[test] + fn render_profile_actions_noop_when_unconfigured() { + let mut state = make_test_input_state(); + assert!(!state.activate_next_render_profile()); + assert!(!state.activate_previous_render_profile()); + assert!(!state.deactivate_render_profile()); + } +} diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index 4e204fac..fb9d7eb3 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -88,6 +88,9 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::TogglePresenterMode | Action::ToggleLightMode | Action::ToggleLightModeDrawing + | Action::RenderProfileNext + | Action::RenderProfilePrevious + | Action::RenderProfileOff | Action::ToggleRadialMenu | Action::ToggleSelectionProperties | Action::OpenContextMenu diff --git a/src/lib.rs b/src/lib.rs index 151ccfa7..8b7c63b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub(crate) mod image_decode; pub mod input; mod label_format; pub mod paths; +pub mod render_profiles; pub mod runtime_capabilities; pub mod session; pub mod shortcut_hint; diff --git a/src/main.rs b/src/main.rs index 3c3c7e7e..a2cf99f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod logger; mod notification; mod onboarding; mod paths; +mod render_profiles; mod session; mod session_override; #[cfg(test)] diff --git a/src/render_profiles.rs b/src/render_profiles.rs new file mode 100644 index 00000000..2c1700d2 --- /dev/null +++ b/src/render_profiles.rs @@ -0,0 +1,503 @@ +//! Runtime support for final-render color profile remapping. + +use std::collections::HashMap; + +use crate::config::{RenderProfileConfig, RenderProfilesConfig}; +use crate::util::Rect; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Rgb8 { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb8 { + fn key(self) -> u32 { + (u32::from(self.r) << 16) | (u32::from(self.g) << 8) | u32::from(self.b) + } +} + +#[derive(Clone, Debug)] +pub struct RenderColorProfile { + id: String, + name: String, + mappings: HashMap, +} + +impl RenderColorProfile { + pub fn name(&self) -> &str { + &self.name + } + + pub fn is_empty(&self) -> bool { + self.mappings.is_empty() + } + + fn from_config(config: &RenderProfileConfig) -> Option { + let mut mappings = HashMap::with_capacity(config.mappings.len()); + for mapping in &config.mappings { + let Some(from) = parse_hex_rgb(&mapping.from) else { + continue; + }; + let Some(to) = parse_hex_rgb(&mapping.to) else { + continue; + }; + mappings.insert(from.key(), to); + } + + (!config.id.trim().is_empty()).then(|| Self { + id: normalize_profile_id(&config.id), + name: if config.name.trim().is_empty() { + normalize_profile_id(&config.id) + } else { + config.name.trim().to_string() + }, + mappings, + }) + } + + fn remap_pixel(&self, pixel: u32) -> u32 { + let alpha = ((pixel >> 24) & 0xff) as u8; + if alpha == 0 { + return pixel; + } + + let red = unpremultiply_component(((pixel >> 16) & 0xff) as u8, alpha); + let green = unpremultiply_component(((pixel >> 8) & 0xff) as u8, alpha); + let blue = unpremultiply_component((pixel & 0xff) as u8, alpha); + let Some(target) = self.mappings.get( + &Rgb8 { + r: red, + g: green, + b: blue, + } + .key(), + ) else { + return pixel; + }; + + let premul_red = premultiply_component(target.r, alpha); + let premul_green = premultiply_component(target.g, alpha); + let premul_blue = premultiply_component(target.b, alpha); + (u32::from(alpha) << 24) + | (u32::from(premul_red) << 16) + | (u32::from(premul_green) << 8) + | u32::from(premul_blue) + } + + pub fn remap_argb8888_regions( + &self, + data: &mut [u8], + width: i32, + height: i32, + stride: i32, + regions: &[Rect], + ) { + if self.is_empty() || width <= 0 || height <= 0 || stride < width.saturating_mul(4) { + return; + } + + let stride = stride as usize; + for region in regions { + let x0 = region.x.max(0).min(width); + let y0 = region.y.max(0).min(height); + let x1 = region.x.saturating_add(region.width).max(0).min(width); + let y1 = region.y.saturating_add(region.height).max(0).min(height); + if x1 <= x0 || y1 <= y0 { + continue; + } + + for y in y0..y1 { + let row_start = y as usize * stride; + for x in x0..x1 { + let offset = row_start + x as usize * 4; + if offset + 4 > data.len() { + return; + } + let pixel = u32::from_ne_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + let mapped = self.remap_pixel(pixel); + if mapped != pixel { + data[offset..offset + 4].copy_from_slice(&mapped.to_ne_bytes()); + } + } + } + } + } + + pub fn remap_argb8888_regions_changed_from( + &self, + data: &mut [u8], + baseline: &[u8], + width: i32, + height: i32, + stride: i32, + regions: &[Rect], + ) { + if self.is_empty() + || width <= 0 + || height <= 0 + || stride < width.saturating_mul(4) + || baseline.len() < data.len() + { + return; + } + + let stride = stride as usize; + for region in regions { + let x0 = region.x.max(0).min(width); + let y0 = region.y.max(0).min(height); + let x1 = region.x.saturating_add(region.width).max(0).min(width); + let y1 = region.y.saturating_add(region.height).max(0).min(height); + if x1 <= x0 || y1 <= y0 { + continue; + } + + for y in y0..y1 { + let row_start = y as usize * stride; + for x in x0..x1 { + let offset = row_start + x as usize * 4; + if offset + 4 > data.len() { + return; + } + if data[offset..offset + 4] == baseline[offset..offset + 4] { + continue; + } + let pixel = u32::from_ne_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + let mapped = self.remap_pixel(pixel); + if mapped != pixel { + data[offset..offset + 4].copy_from_slice(&mapped.to_ne_bytes()); + } + } + } + } + } +} + +#[derive(Clone, Debug)] +pub struct RenderProfileSet { + profiles: Vec, + active_index: Option, + apply_to_canvas: bool, + apply_to_ui: bool, + generation: u64, +} + +impl Default for RenderProfileSet { + fn default() -> Self { + Self { + profiles: Vec::new(), + active_index: None, + apply_to_canvas: true, + apply_to_ui: true, + generation: 0, + } + } +} + +impl RenderProfileSet { + pub fn from_config(config: &RenderProfilesConfig) -> Self { + let profiles: Vec<_> = config + .items + .iter() + .filter_map(RenderColorProfile::from_config) + .collect(); + let active_index = config.active.as_ref().and_then(|active| { + let active = normalize_profile_id(active); + profiles.iter().position(|profile| profile.id == active) + }); + Self { + profiles, + active_index, + apply_to_canvas: config.apply_to_canvas, + apply_to_ui: config.apply_to_ui, + generation: 0, + } + } + + pub fn active(&self) -> Option<&RenderColorProfile> { + self.active_index.and_then(|index| self.profiles.get(index)) + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn applies_to_canvas(&self) -> bool { + self.apply_to_canvas + } + + pub fn applies_to_ui(&self) -> bool { + self.apply_to_ui + } + + pub fn is_empty(&self) -> bool { + self.profiles.is_empty() + } + + pub fn activate_next(&mut self) -> bool { + if self.profiles.is_empty() { + return false; + } + let next = match self.active_index { + None => Some(0), + Some(index) if index + 1 < self.profiles.len() => Some(index + 1), + Some(_) => None, + }; + self.set_active_index(next) + } + + pub fn activate_previous(&mut self) -> bool { + if self.profiles.is_empty() { + return false; + } + let previous = match self.active_index { + None => Some(self.profiles.len() - 1), + Some(0) => None, + Some(index) => Some(index - 1), + }; + self.set_active_index(previous) + } + + pub fn deactivate(&mut self) -> bool { + self.set_active_index(None) + } + + fn set_active_index(&mut self, active_index: Option) -> bool { + if self.active_index == active_index { + return false; + } + self.active_index = active_index; + self.generation = self.generation.wrapping_add(1); + true + } +} + +pub fn normalize_profile_id(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +pub fn parse_hex_rgb(value: &str) -> Option { + let trimmed = value.trim(); + let hex = trimmed + .strip_prefix('#') + .or_else(|| trimmed.strip_prefix("0x")) + .or_else(|| trimmed.strip_prefix("0X")) + .unwrap_or(trimmed); + if hex.len() != 6 || !hex.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Rgb8 { r, g, b }) +} + +pub fn format_hex_rgb(color: Rgb8) -> String { + format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b) +} + +fn unpremultiply_component(value: u8, alpha: u8) -> u8 { + if alpha == 255 { + value + } else { + ((u32::from(value) * 255 + u32::from(alpha) / 2) / u32::from(alpha)).min(255) as u8 + } +} + +fn premultiply_component(value: u8, alpha: u8) -> u8 { + ((u32::from(value) * u32::from(alpha) + 127) / 255).min(255) as 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, + items: 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, + items: Vec::new(), + }); + + assert!(!set.applies_to_canvas()); + assert!(set.applies_to_ui()); + } +} diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index 4bb253e2..081c7004 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -184,6 +184,7 @@ impl ToolbarSnapshot { binding_hints, show_drawer_hint, is_transparent: state.board_is_transparent(), + render_profile_generation: state.render_profile_generation(), } } } diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index 56d596b6..c76e2f76 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -302,4 +302,6 @@ pub struct ToolbarSnapshot { pub show_drawer_hint: bool, /// Whether the current board is the transparent overlay pub is_transparent: bool, + /// Changes whenever final-render color profile preview changes. + pub render_profile_generation: u64, }