From a27559336458bf3c5426b68e8bd7bcb507427bbc Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Mon, 25 May 2026 13:44:04 +0200
Subject: [PATCH] feat: add render color profiles with canvas and UI targets
---
README.md | 2 +
config.example.toml | 30 ++
.../models/keybindings/field/config/read.rs | 3 +
.../models/keybindings/field/config/write.rs | 3 +
.../src/models/keybindings/field/labels.rs | 6 +
.../src/models/keybindings/field/list.rs | 3 +
.../src/models/keybindings/field/mod.rs | 3 +
.../src/models/keybindings/field/tab.rs | 3 +
docs/CONFIG.md | 45 ++
.../wayland/backend/state_init/input_state.rs | 3 +
src/backend/wayland/state/data.rs | 3 +
.../wayland/state/render/canvas/mod.rs | 3 -
src/backend/wayland/state/render/mod.rs | 68 ++-
src/backend/wayland/state/render/ui.rs | 12 +-
.../wayland/state/toolbar/visibility/sync.rs | 4 +-
src/backend/wayland/toolbar/main/render.rs | 11 +-
.../wayland/toolbar/surfaces/render.rs | 15 +
src/config/action_meta/entries/ui.rs | 33 ++
src/config/core.rs | 8 +-
src/config/keybindings/actions.rs | 3 +
src/config/keybindings/config/map/ui.rs | 6 +
.../keybindings/config/types/bindings/ui.rs | 12 +
src/config/keybindings/defaults/ui.rs | 12 +
src/config/mod.rs | 7 +-
src/config/tests/schema.rs | 1 +
src/config/tests/validate.rs | 73 +++
src/config/types/mod.rs | 2 +
src/config/types/render_profiles.rs | 63 +++
src/config/validate/mod.rs | 2 +
src/config/validate/render_profiles.rs | 93 ++++
src/input/state/actions/action_ui.rs | 31 ++
src/input/state/core/base/state/init.rs | 1 +
src/input/state/core/base/state/structs.rs | 3 +
src/input/state/core/utility/mod.rs | 1 +
.../state/core/utility/render_profiles.rs | 106 ++++
src/input/state/interaction/actions.rs | 3 +
src/lib.rs | 1 +
src/main.rs | 1 +
src/render_profiles.rs | 503 ++++++++++++++++++
src/ui/toolbar/snapshot/build.rs | 1 +
src/ui/toolbar/snapshot/types.rs | 2 +
41 files changed, 1172 insertions(+), 13 deletions(-)
create mode 100644 src/config/types/render_profiles.rs
create mode 100644 src/config/validate/render_profiles.rs
create mode 100644 src/input/state/core/utility/render_profiles.rs
create mode 100644 src/render_profiles.rs
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,
}