diff --git a/config.example.toml b/config.example.toml index 5eb429d4..e7da0ea8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -182,6 +182,9 @@ capture_clipboard_selection = ["Ctrl+Shift+C"] capture_file_selection = ["Ctrl+Shift+S"] capture_clipboard_region = ["Ctrl+6"] capture_file_region = ["Ctrl+Alt+6"] +export_canvas_file = [] +export_canvas_clipboard = [] +export_canvas_clipboard_and_file = [] # Open the most recent capture folder open_capture_folder = ["Ctrl+Alt+O"] @@ -622,11 +625,16 @@ auto_adjust_pen = true apply_to_canvas = true # Apply profiles to screen-space UI chrome, toolbars, popups, and status text apply_to_ui = true +# Accepted values: "off", "active", "profile". +# Explicit canvas PNG export applies this selector to persisted canvas content. +export = "off" +# Used only when export = "profile". +# export_profile = "print" # Render profiles remap final rendered pixels by exact RGB match. Alpha is # preserved, and colors not listed here are unchanged. # -# [[render_profiles.items]] +# [[render_profiles.profiles]] # id = "print" # name = "Print" # mappings = [ diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index 95af8ebb..b4e96822 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -6,8 +6,8 @@ use wayscriber::config::{Config, PRESET_SLOTS_MAX}; use crate::messages::Message; use crate::models::{ - ColorPickerId, ConfigDraft, DaemonRuntimeStatus, DesktopEnvironment, KeybindingsTabId, TabId, - ToolbarLayoutModeOption, UiTabId, + ColorPickerId, ConfigDraft, DaemonRuntimeStatus, DesktopEnvironment, DragMouseButton, + KeybindingsTabId, TabId, ToolbarLayoutModeOption, UiTabId, }; use super::daemon_setup::load_daemon_runtime_status; @@ -24,6 +24,7 @@ pub(crate) struct ConfiguratorApp { pub(crate) active_tab: TabId, pub(crate) active_ui_tab: UiTabId, pub(crate) active_keybindings_tab: KeybindingsTabId, + pub(crate) active_drawing_drag_button: Option, pub(crate) preset_collapsed: Vec, pub(crate) boards_collapsed: Vec, pub(crate) color_picker_open: Option, @@ -97,6 +98,7 @@ impl ConfiguratorApp { active_tab: TabId::Daemon, active_ui_tab: UiTabId::Toolbar, active_keybindings_tab: KeybindingsTabId::General, + active_drawing_drag_button: None, preset_collapsed: vec![false; PRESET_SLOTS_MAX], boards_collapsed: vec![false; boards_len], color_picker_open: None, diff --git a/configurator/src/app/update/color_picker.rs b/configurator/src/app/update/color_picker.rs index 7e202a84..50a1d31b 100644 --- a/configurator/src/app/update/color_picker.rs +++ b/configurator/src/app/update/color_picker.rs @@ -86,8 +86,27 @@ impl ConfiguratorApp { } } + pub(super) fn sync_render_profile_color_picker_hex(&mut self) { + for profile_index in 0..self.draft.render_profiles.profiles.len() { + let mapping_len = self.draft.render_profiles.profiles[profile_index] + .mappings + .len(); + for mapping_index in 0..mapping_len { + self.sync_color_picker_hex_for_id(ColorPickerId::RenderProfileMappingFrom( + profile_index, + mapping_index, + )); + self.sync_color_picker_hex_for_id(ColorPickerId::RenderProfileMappingTo( + profile_index, + mapping_index, + )); + } + } + } + pub(crate) fn sync_all_color_picker_hex(&mut self) { self.sync_board_color_picker_hex(); + self.sync_render_profile_color_picker_hex(); for id in [ ColorPickerId::DrawingColor, ColorPickerId::StatusBarBg, @@ -130,6 +149,23 @@ impl ConfiguratorApp { } } } + ColorPickerId::RenderProfileMappingFrom(profile_index, mapping_index) + | ColorPickerId::RenderProfileMappingTo(profile_index, mapping_index) => { + if let Some(mapping) = self + .draft + .render_profiles + .profiles + .get_mut(profile_index) + .and_then(|profile| profile.mappings.get_mut(mapping_index)) + { + let hex = hex_from_rgb(rgb); + match id { + ColorPickerId::RenderProfileMappingFrom(_, _) => mapping.from = hex, + ColorPickerId::RenderProfileMappingTo(_, _) => mapping.to = hex, + _ => {} + } + } + } ColorPickerId::StatusBarBg => { self.apply_quad_rgb(QuadField::StatusBarBg, values, alpha); } @@ -184,6 +220,21 @@ impl ConfiguratorApp { None, ) }), + ColorPickerId::RenderProfileMappingFrom(profile_index, mapping_index) + | ColorPickerId::RenderProfileMappingTo(profile_index, mapping_index) => { + let mapping = self + .draft + .render_profiles + .profiles + .get(profile_index) + .and_then(|profile| profile.mappings.get(mapping_index))?; + let value = match id { + ColorPickerId::RenderProfileMappingFrom(_, _) => &mapping.from, + ColorPickerId::RenderProfileMappingTo(_, _) => &mapping.to, + _ => return None, + }; + parse_hex(value).map(|(rgb, _)| (rgb, None)) + } ColorPickerId::StatusBarBg => { let values = parse_quad_values(&self.draft.status_bar_bg_color.components); Some(([values[0], values[1], values[2]], Some(values[3]))) diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index 61a53740..335fc824 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -4,6 +4,7 @@ mod config; mod daemon; mod fields; mod presets; +mod render_profiles; mod tabs; use iced::Task; @@ -52,6 +53,9 @@ impl ConfiguratorApp { Message::ColorModeChanged(mode) => self.handle_color_mode_changed(mode), Message::NamedColorSelected(option) => self.handle_named_color_selected(option), Message::EraserModeChanged(option) => self.handle_eraser_mode_changed(option), + Message::DrawingDragMappingSectionToggled(button) => { + self.handle_drawing_drag_mapping_section_toggled(button) + } Message::DrawingMouseDragToolChanged(button, field, option) => { self.handle_drawing_mouse_drag_tool_changed(button, field, option) } @@ -93,6 +97,36 @@ impl ConfiguratorApp { Message::BoardsItemToggleChanged(index, field, value) => { self.handle_boards_item_toggle_changed(index, field, value) } + Message::RenderProfileAdd => self.handle_render_profile_add(), + Message::RenderProfileRemove(index) => self.handle_render_profile_remove(index), + Message::RenderProfileDuplicate(index) => self.handle_render_profile_duplicate(index), + Message::RenderProfileTextChanged(index, field, value) => { + self.handle_render_profile_text_changed(index, field, value) + } + Message::RenderProfileActiveChanged(value) => { + self.handle_render_profile_active_changed(value) + } + Message::RenderProfileExportChanged(value) => { + self.handle_render_profile_export_changed(value) + } + Message::RenderProfileExportProfileChanged(value) => { + self.handle_render_profile_export_profile_changed(value) + } + Message::RenderProfileApplyCanvasChanged(value) => { + self.handle_render_profile_apply_canvas_changed(value) + } + Message::RenderProfileApplyUiChanged(value) => { + self.handle_render_profile_apply_ui_changed(value) + } + Message::RenderProfileMappingAdd(index) => { + self.handle_render_profile_mapping_add(index) + } + Message::RenderProfileMappingRemove(profile, mapping) => { + self.handle_render_profile_mapping_remove(profile, mapping) + } + Message::RenderProfileMappingColorChanged(profile, mapping, side, value) => { + self.handle_render_profile_mapping_color_changed(profile, mapping, side, value) + } Message::SessionStorageModeChanged(option) => { self.handle_session_storage_mode_changed(option) } diff --git a/configurator/src/app/update/render_profiles.rs b/configurator/src/app/update/render_profiles.rs new file mode 100644 index 00000000..da84f2c8 --- /dev/null +++ b/configurator/src/app/update/render_profiles.rs @@ -0,0 +1,220 @@ +use iced::Task; + +use crate::messages::Message; +use crate::models::{ + ColorPickerId, RenderProfileExportOption, RenderProfileMappingDraft, RenderProfileMappingSide, + RenderProfileTextField, +}; + +use super::super::state::{ConfiguratorApp, StatusMessage}; + +impl ConfiguratorApp { + pub(super) fn handle_render_profile_add(&mut self) -> Task { + self.status = StatusMessage::idle(); + let profile = self.draft.render_profiles.new_profile(); + self.draft.render_profiles.profiles.push(profile); + self.sync_render_profile_color_picker_hex(); + self.refresh_dirty_flag(); + Task::none() + } + + pub(super) fn handle_render_profile_remove(&mut self, index: usize) -> Task { + self.status = StatusMessage::idle(); + if index < self.draft.render_profiles.profiles.len() { + self.draft.render_profiles.profiles.remove(index); + self.clear_render_profile_color_pickers(); + self.draft.render_profiles.ensure_selections_exist(); + self.refresh_dirty_flag(); + } + Task::none() + } + + pub(super) fn handle_render_profile_duplicate(&mut self, index: usize) -> Task { + self.status = StatusMessage::idle(); + if let Some(profile) = self.draft.render_profiles.duplicate_profile(index) { + self.draft + .render_profiles + .profiles + .insert(index + 1, profile); + self.clear_render_profile_color_pickers(); + self.refresh_dirty_flag(); + } + Task::none() + } + + pub(super) fn handle_render_profile_text_changed( + &mut self, + index: usize, + field: RenderProfileTextField, + value: String, + ) -> Task { + self.status = StatusMessage::idle(); + let old_id = self + .draft + .render_profiles + .profile_ids() + .get(index) + .cloned() + .unwrap_or_default(); + if let Some(profile) = self.draft.render_profiles.profiles.get_mut(index) { + match field { + RenderProfileTextField::Id => { + let next_id = wayscriber::render_profiles::normalize_profile_id(&value); + profile.id = value; + if self.draft.render_profiles.active == old_id { + self.draft.render_profiles.active = next_id.clone(); + } + if self.draft.render_profiles.export_profile == old_id { + self.draft.render_profiles.export_profile = next_id; + } + } + RenderProfileTextField::Name => profile.name = value, + } + } + self.draft.render_profiles.ensure_selections_exist(); + self.refresh_dirty_flag(); + Task::none() + } + + pub(super) fn handle_render_profile_active_changed(&mut self, value: String) -> Task { + self.status = StatusMessage::idle(); + self.draft.render_profiles.active = value; + self.refresh_dirty_flag(); + Task::none() + } + + pub(super) fn handle_render_profile_export_changed( + &mut self, + value: RenderProfileExportOption, + ) -> Task { + self.status = StatusMessage::idle(); + self.draft.render_profiles.export = value; + if value == RenderProfileExportOption::Profile + && self.draft.render_profiles.export_profile.is_empty() + && let Some(first) = self.draft.render_profiles.profile_ids().first() + { + self.draft.render_profiles.export_profile = first.clone(); + } + self.refresh_dirty_flag(); + Task::none() + } + + pub(super) fn handle_render_profile_export_profile_changed( + &mut self, + value: String, + ) -> Task { + self.status = StatusMessage::idle(); + self.draft.render_profiles.export_profile = value; + self.refresh_dirty_flag(); + Task::none() + } + + pub(super) fn handle_render_profile_apply_canvas_changed( + &mut self, + value: bool, + ) -> Task { + self.status = StatusMessage::idle(); + self.draft.render_profiles.apply_to_canvas = value; + self.refresh_dirty_flag(); + Task::none() + } + + pub(super) fn handle_render_profile_apply_ui_changed(&mut self, value: bool) -> Task { + self.status = StatusMessage::idle(); + self.draft.render_profiles.apply_to_ui = value; + self.refresh_dirty_flag(); + Task::none() + } + + pub(super) fn handle_render_profile_mapping_add( + &mut self, + profile_index: usize, + ) -> Task { + self.status = StatusMessage::idle(); + if let Some(profile) = self.draft.render_profiles.profiles.get_mut(profile_index) { + profile.mappings.push(RenderProfileMappingDraft { + from: "#000000".to_string(), + to: "#FFFFFF".to_string(), + }); + self.sync_render_profile_color_picker_hex(); + self.refresh_dirty_flag(); + } + Task::none() + } + + pub(super) fn handle_render_profile_mapping_remove( + &mut self, + profile_index: usize, + mapping_index: usize, + ) -> Task { + self.status = StatusMessage::idle(); + if let Some(profile) = self.draft.render_profiles.profiles.get_mut(profile_index) + && mapping_index < profile.mappings.len() + { + profile.mappings.remove(mapping_index); + self.clear_render_profile_color_pickers(); + self.refresh_dirty_flag(); + } + Task::none() + } + + pub(super) fn handle_render_profile_mapping_color_changed( + &mut self, + profile_index: usize, + mapping_index: usize, + side: RenderProfileMappingSide, + value: String, + ) -> Task { + self.status = StatusMessage::idle(); + if let Some(mapping) = self + .draft + .render_profiles + .profiles + .get_mut(profile_index) + .and_then(|profile| profile.mappings.get_mut(mapping_index)) + { + let picker_id = match side { + RenderProfileMappingSide::From => { + ColorPickerId::RenderProfileMappingFrom(profile_index, mapping_index) + } + RenderProfileMappingSide::To => { + ColorPickerId::RenderProfileMappingTo(profile_index, mapping_index) + } + }; + self.color_picker_hex.insert(picker_id, value.clone()); + match side { + RenderProfileMappingSide::From => mapping.from = value, + RenderProfileMappingSide::To => mapping.to = value, + } + self.refresh_dirty_flag(); + } + Task::none() + } + + fn clear_render_profile_color_pickers(&mut self) { + if matches!( + self.color_picker_open, + Some( + ColorPickerId::RenderProfileMappingFrom(_, _) + | ColorPickerId::RenderProfileMappingTo(_, _) + ) + ) { + self.color_picker_open = None; + } + self.color_picker_hex.retain(|id, _| { + !matches!( + id, + ColorPickerId::RenderProfileMappingFrom(_, _) + | ColorPickerId::RenderProfileMappingTo(_, _) + ) + }); + self.color_picker_advanced.retain(|id| { + !matches!( + id, + ColorPickerId::RenderProfileMappingFrom(_, _) + | ColorPickerId::RenderProfileMappingTo(_, _) + ) + }); + self.sync_render_profile_color_picker_hex(); + } +} diff --git a/configurator/src/app/update/tabs.rs b/configurator/src/app/update/tabs.rs index d945c10d..b8e497b7 100644 --- a/configurator/src/app/update/tabs.rs +++ b/configurator/src/app/update/tabs.rs @@ -1,7 +1,7 @@ use iced::Task; use crate::messages::Message; -use crate::models::{KeybindingsTabId, TabId, UiTabId}; +use crate::models::{DragMouseButton, KeybindingsTabId, TabId, UiTabId}; use super::super::state::ConfiguratorApp; @@ -23,4 +23,16 @@ impl ConfiguratorApp { self.active_keybindings_tab = tab; Task::none() } + + pub(super) fn handle_drawing_drag_mapping_section_toggled( + &mut self, + button: DragMouseButton, + ) -> Task { + self.active_drawing_drag_button = if self.active_drawing_drag_button == Some(button) { + None + } else { + Some(button) + }; + Task::none() + } } diff --git a/configurator/src/app/view/drawing/mod.rs b/configurator/src/app/view/drawing/mod.rs index 095e952e..61cd005e 100644 --- a/configurator/src/app/view/drawing/mod.rs +++ b/configurator/src/app/view/drawing/mod.rs @@ -1,7 +1,7 @@ mod color; mod font; -use iced::widget::{column, pick_list, row, scrollable, text}; +use iced::widget::{button, column, pick_list, row, scrollable, text}; use iced::{Element, Length}; use crate::messages::Message; @@ -14,6 +14,7 @@ use wayscriber::config::DragButtonConfig; use self::color::drawing_color_block; use self::font::font_controls; use super::super::state::ConfiguratorApp; +use super::theme; use super::widgets::{ labeled_control, labeled_input_with_feedback, toggle_row, validate_f64_range, validate_usize_min, validate_usize_range, @@ -26,35 +27,6 @@ impl ConfiguratorApp { Some(self.draft.drawing_default_eraser_mode), Message::EraserModeChanged, ); - let drag_button_controls = - |button: DragMouseButton, current: &DragButtonConfig, defaults: &DragButtonConfig| { - column![ - text(button.label()).size(14), - row![ - drag_binding_control(button, DragToolField::Drag, current, defaults,), - drag_binding_control(button, DragToolField::ShiftDrag, current, defaults,) - ] - .spacing(12), - row![ - drag_binding_control(button, DragToolField::CtrlDrag, current, defaults,), - drag_binding_control( - button, - DragToolField::CtrlShiftDrag, - current, - defaults, - ) - ] - .spacing(12), - row![drag_binding_control( - button, - DragToolField::TabDrag, - current, - defaults, - )] - .spacing(12) - ] - .spacing(8) - }; let column = column![ text("Drawing Defaults").size(20), @@ -99,22 +71,7 @@ impl ConfiguratorApp { ) ] .spacing(12), - text("Drag Tool Mapping").size(16), - drag_button_controls( - DragMouseButton::Left, - &self.draft.drawing_drag_tools.left, - &self.defaults.drawing_drag_tools.left, - ), - drag_button_controls( - DragMouseButton::Right, - &self.draft.drawing_drag_tools.right, - &self.defaults.drawing_drag_tools.right, - ), - drag_button_controls( - DragMouseButton::Middle, - &self.draft.drawing_drag_tools.middle, - &self.defaults.drawing_drag_tools.middle, - ), + self.drag_mapping_block(), row![ labeled_input_with_feedback( "Marker opacity (0.05-0.9)", @@ -172,6 +129,78 @@ impl ConfiguratorApp { scrollable(column).into() } + + fn drag_mapping_block(&self) -> Element<'_, Message> { + let section_button = |mouse_button: DragMouseButton| { + button(mouse_button.label()) + .style(if self.active_drawing_drag_button == Some(mouse_button) { + theme::Button::Primary + } else { + theme::Button::Secondary + }) + .on_press(Message::DrawingDragMappingSectionToggled(mouse_button)) + }; + + let mut column = column![ + text("Drag Tool Mapping").size(16), + row![ + section_button(DragMouseButton::Left), + section_button(DragMouseButton::Right), + section_button(DragMouseButton::Middle), + ] + .spacing(8) + ] + .spacing(8); + + if let Some(mouse_button) = self.active_drawing_drag_button { + let (current, defaults) = match mouse_button { + DragMouseButton::Left => ( + &self.draft.drawing_drag_tools.left, + &self.defaults.drawing_drag_tools.left, + ), + DragMouseButton::Right => ( + &self.draft.drawing_drag_tools.right, + &self.defaults.drawing_drag_tools.right, + ), + DragMouseButton::Middle => ( + &self.draft.drawing_drag_tools.middle, + &self.defaults.drawing_drag_tools.middle, + ), + }; + + column = column.push(drag_button_controls(mouse_button, current, defaults)); + } + + column.into() + } +} + +fn drag_button_controls<'a>( + button: DragMouseButton, + current: &DragButtonConfig, + defaults: &DragButtonConfig, +) -> Element<'a, Message> { + column![ + row![ + drag_binding_control(button, DragToolField::Drag, current, defaults,), + drag_binding_control(button, DragToolField::ShiftDrag, current, defaults,) + ] + .spacing(12), + row![ + drag_binding_control(button, DragToolField::CtrlDrag, current, defaults,), + drag_binding_control(button, DragToolField::CtrlShiftDrag, current, defaults,) + ] + .spacing(12), + row![drag_binding_control( + button, + DragToolField::TabDrag, + current, + defaults, + )] + .spacing(12) + ] + .spacing(8) + .into() } fn drag_binding_control<'a>( diff --git a/configurator/src/app/view/mod.rs b/configurator/src/app/view/mod.rs index 983dbe70..983de97d 100644 --- a/configurator/src/app/view/mod.rs +++ b/configurator/src/app/view/mod.rs @@ -7,6 +7,7 @@ mod history; mod keybindings; mod performance; mod presets; +mod render_profiles; mod session; #[cfg(feature = "tablet-input")] mod tablet; @@ -144,6 +145,7 @@ impl ConfiguratorApp { TabId::Performance => self.performance_tab(), TabId::Ui => self.ui_tab(), TabId::Boards => self.boards_tab(), + TabId::RenderProfiles => self.render_profiles_tab(), TabId::Capture => self.capture_tab(), TabId::Daemon => self.daemon_tab(), TabId::Session => self.session_tab(), diff --git a/configurator/src/app/view/render_profiles.rs b/configurator/src/app/view/render_profiles.rs new file mode 100644 index 00000000..b3add7b3 --- /dev/null +++ b/configurator/src/app/view/render_profiles.rs @@ -0,0 +1,245 @@ +use iced::widget::{ + button, checkbox, column, container, pick_list, row, scrollable, text, text_input, +}; +use iced::{Alignment, Element, Length}; + +use crate::app::view::theme; +use crate::messages::Message; +use crate::models::color::rgb_to_hsv; +use crate::models::{ + ColorPickerId, RenderProfileExportOption, RenderProfileMappingSide, + RenderProfileSelectionOption, RenderProfileTextField, +}; +use wayscriber::render_profiles::parse_hex_rgb; + +use super::super::state::ConfiguratorApp; +use super::widgets::{color_preview_badge, labeled_control, picker_panel}; + +impl ConfiguratorApp { + pub(super) fn render_profiles_tab(&self) -> Element<'_, Message> { + let profiles = &self.draft.render_profiles; + let profile_ids = profiles.profile_ids(); + let active_selection = + RenderProfileSelectionOption::from_active(&profiles.active, &profile_ids); + let export_selection = (!profiles.export_profile.is_empty() + && profile_ids.contains(&profiles.export_profile)) + .then_some(profiles.export_profile.clone()); + + let active_picker = pick_list( + RenderProfileSelectionOption::list(&profile_ids), + Some(active_selection), + |selection| Message::RenderProfileActiveChanged(selection.profile_id()), + ) + .width(Length::Fill); + + let export_picker = pick_list( + RenderProfileExportOption::list(), + Some(profiles.export), + Message::RenderProfileExportChanged, + ) + .width(Length::Fill); + + let mut content = column![ + text("Render Profiles").size(20), + row![ + checkbox(profiles.apply_to_canvas) + .label("Preview canvas") + .on_toggle(Message::RenderProfileApplyCanvasChanged), + checkbox(profiles.apply_to_ui) + .label("Preview UI") + .on_toggle(Message::RenderProfileApplyUiChanged), + ] + .spacing(16) + .align_y(Alignment::Center), + labeled_control( + "Startup profile", + active_picker.into(), + self.defaults.render_profiles.active.clone(), + profiles.active != self.defaults.render_profiles.active, + ), + labeled_control( + "Canvas export profile", + export_picker.into(), + self.defaults.render_profiles.export.label().to_string(), + profiles.export != self.defaults.render_profiles.export, + ), + ] + .spacing(12); + + if profiles.export == RenderProfileExportOption::Profile { + let picker = pick_list( + profile_ids, + export_selection, + Message::RenderProfileExportProfileChanged, + ) + .width(Length::Fill); + content = content.push(labeled_control( + "Named export profile", + picker.into(), + self.defaults.render_profiles.export_profile.clone(), + profiles.export_profile != self.defaults.render_profiles.export_profile, + )); + } + + content = content.push(button("Add profile").on_press(Message::RenderProfileAdd)); + + for index in 0..profiles.profiles.len() { + content = content.push(self.render_profile_section(index)); + } + + scrollable(content).into() + } + + fn render_profile_section(&self, profile_index: usize) -> Element<'_, Message> { + let profile = &self.draft.render_profiles.profiles[profile_index]; + let header = row![ + text(if profile.name.trim().is_empty() { + "Profile" + } else { + profile.name.trim() + }) + .size(16), + button("Duplicate").on_press(Message::RenderProfileDuplicate(profile_index)), + button("Delete") + .style(theme::Button::Warning) + .on_press(Message::RenderProfileRemove(profile_index)), + ] + .spacing(8) + .align_y(Alignment::Center); + + let mut mappings = column![].spacing(8); + for mapping_index in 0..profile.mappings.len() { + mappings = mappings.push(self.render_profile_mapping_row(profile_index, mapping_index)); + } + + container( + column![ + header, + row![ + text_input("id", &profile.id) + .on_input(move |value| Message::RenderProfileTextChanged( + profile_index, + RenderProfileTextField::Id, + value + )) + .width(Length::FillPortion(1)), + text_input("name", &profile.name) + .on_input(move |value| Message::RenderProfileTextChanged( + profile_index, + RenderProfileTextField::Name, + value + )) + .width(Length::FillPortion(2)), + ] + .spacing(8), + mappings, + button("Add mapping").on_press(Message::RenderProfileMappingAdd(profile_index)), + ] + .spacing(10), + ) + .padding(12) + .style(theme::Container::Box) + .into() + } + + fn render_profile_mapping_row( + &self, + profile_index: usize, + mapping_index: usize, + ) -> Element<'_, Message> { + let mapping = &self.draft.render_profiles.profiles[profile_index].mappings[mapping_index]; + let from = self.render_profile_color_control( + "From", + ColorPickerId::RenderProfileMappingFrom(profile_index, mapping_index), + &mapping.from, + profile_index, + mapping_index, + RenderProfileMappingSide::From, + ); + let to = self.render_profile_color_control( + "To", + ColorPickerId::RenderProfileMappingTo(profile_index, mapping_index), + &mapping.to, + profile_index, + mapping_index, + RenderProfileMappingSide::To, + ); + + column![ + row![ + from, + text("->").size(16), + to, + button("Remove").on_press(Message::RenderProfileMappingRemove( + profile_index, + mapping_index + )), + ] + .spacing(8) + .align_y(Alignment::Center), + ] + .spacing(6) + .into() + } + + fn render_profile_color_control<'a>( + &'a self, + label: &'static str, + id: ColorPickerId, + value: &'a str, + profile_index: usize, + mapping_index: usize, + side: RenderProfileMappingSide, + ) -> Element<'a, Message> { + let hex_value = self + .color_picker_hex + .get(&id) + .map(|value| value.as_str()) + .unwrap_or(value); + let rgb = render_profile_rgb(hex_value).unwrap_or([0.0, 0.0, 0.0]); + let preview = render_profile_rgb(hex_value) + .map(|rgb| iced::Color::from_rgb(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32)); + let (hue, saturation, value_slider) = rgb_to_hsv(rgb); + let picker = if self.color_picker_open == Some(id) { + picker_panel(id, hue, saturation, value_slider, rgb, None) + } else { + column![].into() + }; + + column![ + row![ + text(label).size(12), + color_preview_badge(preview), + text_input("#RRGGBB", hex_value) + .on_input(move |value| Message::RenderProfileMappingColorChanged( + profile_index, + mapping_index, + side, + value + )) + .width(Length::Fixed(120.0)), + button(if self.color_picker_open == Some(id) { + "Hide" + } else { + "Pick" + }) + .on_press(Message::ColorPickerToggled(id)), + ] + .spacing(6) + .align_y(Alignment::Center), + picker, + ] + .spacing(6) + .width(Length::FillPortion(1)) + .into() + } +} + +fn render_profile_rgb(value: &str) -> Option<[f64; 3]> { + let color = parse_hex_rgb(value)?; + Some([ + f64::from(color.r) / 255.0, + f64::from(color.g) / 255.0, + f64::from(color.b) / 255.0, + ]) +} diff --git a/configurator/src/app/view/widgets/color_picker.rs b/configurator/src/app/view/widgets/color_picker.rs index 947fc4fb..4b884ae8 100644 --- a/configurator/src/app/view/widgets/color_picker.rs +++ b/configurator/src/app/view/widgets/color_picker.rs @@ -1,9 +1,12 @@ +mod gradient; mod inputs; mod panel; +pub(in crate::app::view) use gradient::color_gradient; pub(in crate::app::view) use inputs::{ color_quad_picker, color_rgb255_picker, color_triplet_picker, }; +pub(in crate::app::view) use panel::picker_panel; use crate::models::ColorPickerId; diff --git a/configurator/src/app/view/widgets/color_picker/gradient.rs b/configurator/src/app/view/widgets/color_picker/gradient.rs new file mode 100644 index 00000000..0494038f --- /dev/null +++ b/configurator/src/app/view/widgets/color_picker/gradient.rs @@ -0,0 +1,95 @@ +use iced::border::Radius; +use iced::widget::{Space, button, column, container, row}; +use iced::{Background, Border, Color, Element, Length, Shadow}; + +use crate::app::view::theme; +use crate::messages::Message; +use crate::models::color::hsv_to_rgb; +use crate::models::{ColorPickerId, ColorPickerValue}; + +const GRADIENT_COLUMNS: usize = 18; +const GRADIENT_ROWS: usize = 6; +const CELL_HEIGHT: f32 = 15.0; + +pub(in crate::app::view) fn color_gradient<'a>( + id: ColorPickerId, + hue: f64, + saturation: f64, + value: f64, + alpha: Option, +) -> Element<'a, Message> { + let selected_col = selected_index(hue, GRADIENT_COLUMNS); + let selected_row = selected_index(1.0 - value, GRADIENT_ROWS); + + (0..GRADIENT_ROWS) + .fold(column![].spacing(2), |column, row_index| { + let row_value = 1.0 - normalized_step(row_index, GRADIENT_ROWS); + let row = (0..GRADIENT_COLUMNS).fold(row![].spacing(2), |row, col_index| { + let cell_hue = normalized_step(col_index, GRADIENT_COLUMNS); + let rgb = hsv_to_rgb(cell_hue, saturation, row_value); + let selected = row_index == selected_row && col_index == selected_col; + + row.push( + button(color_cell(rgb, selected)) + .padding(0) + .width(Length::FillPortion(1)) + .height(Length::Fixed(CELL_HEIGHT)) + .style(theme::Button::Subtle) + .on_press(Message::ColorPickerChanged( + id, + ColorPickerValue { rgb, alpha }, + )), + ) + }); + column.push(row) + }) + .into() +} + +fn color_cell<'a>(rgb: [f64; 3], selected: bool) -> Element<'a, Message> { + container( + Space::new() + .width(Length::Fill) + .height(Length::Fixed(CELL_HEIGHT)), + ) + .style(cell_style(rgb, selected)) + .into() +} + +fn cell_style(rgb: [f64; 3], selected: bool) -> impl Fn(&iced::Theme) -> container::Style { + move |_theme| container::Style { + background: Some(Background::Color(Color::from_rgb( + rgb[0] as f32, + rgb[1] as f32, + rgb[2] as f32, + ))), + text_color: None, + border: Border { + color: if selected { + Color::from_rgba(1.0, 1.0, 1.0, 0.95) + } else { + Color::from_rgba(0.0, 0.0, 0.0, 0.22) + }, + width: if selected { 2.0 } else { 1.0 }, + radius: Radius::from(2.0), + }, + shadow: Shadow::default(), + snap: true, + } +} + +fn normalized_step(index: usize, count: usize) -> f64 { + if count <= 1 { + 0.0 + } else { + index as f64 / (count - 1) as f64 + } +} + +fn selected_index(value: f64, count: usize) -> usize { + if count <= 1 { + 0 + } else { + (value.clamp(0.0, 1.0) * (count - 1) as f64).round() as usize + } +} diff --git a/configurator/src/app/view/widgets/color_picker/panel.rs b/configurator/src/app/view/widgets/color_picker/panel.rs index b6db93b1..e2e9370e 100644 --- a/configurator/src/app/view/widgets/color_picker/panel.rs +++ b/configurator/src/app/view/widgets/color_picker/panel.rs @@ -1,14 +1,32 @@ use crate::app::view::theme; -use iced::widget::{column, container, row, slider, text}; -use iced::{Alignment, Element, Length}; +use iced::border::Radius; +use iced::widget::{Space, button, column, container, row, slider, text}; +use iced::{Alignment, Background, Border, Color, Element, Length, Shadow}; use crate::messages::Message; -use crate::models::color::hsv_to_rgb; +use crate::models::color::{hex_from_rgb, hsv_to_rgb}; use crate::models::{ColorPickerId, ColorPickerValue}; +use super::color_gradient; + const COLOR_SLIDER_STEP: f32 = 0.001; +const PRESET_SWATCH_SIZE: f32 = 24.0; +const PRESET_SWATCH_GAP: f32 = 6.0; +const PRESET_COLORS: [(&str, [f64; 3]); 11] = [ + ("Red", [1.0, 0.0, 0.0]), + ("Green", [0.0, 1.0, 0.0]), + ("Blue", [0.0, 0.0, 1.0]), + ("Yellow", [1.0, 1.0, 0.0]), + ("White", [1.0, 1.0, 1.0]), + ("Black", [0.0, 0.0, 0.0]), + ("Orange", [1.0, 0.5, 0.0]), + ("Pink", [1.0, 0.0, 1.0]), + ("Cyan", [0.0, 1.0, 1.0]), + ("Purple", [0.6, 0.4, 0.8]), + ("Gray", [0.4, 0.4, 0.4]), +]; -pub(super) fn picker_panel<'a>( +pub(in crate::app::view) fn picker_panel<'a>( id: ColorPickerId, hue: f64, saturation: f64, @@ -16,18 +34,6 @@ pub(super) fn picker_panel<'a>( rgb: [f64; 3], alpha: Option, ) -> Element<'a, Message> { - let hue_slider = slider(0.0..=1.0, hue as f32, move |val| { - Message::ColorPickerChanged( - id, - ColorPickerValue { - rgb: hsv_to_rgb(val as f64, saturation, value), - alpha, - }, - ) - }) - .step(COLOR_SLIDER_STEP) - .width(Length::Fill); - let saturation_slider = slider(0.0..=1.0, saturation as f32, move |val| { Message::ColorPickerChanged( id, @@ -40,28 +46,19 @@ pub(super) fn picker_panel<'a>( .step(COLOR_SLIDER_STEP) .width(Length::Fill); - let value_slider = slider(0.0..=1.0, value as f32, move |val| { - Message::ColorPickerChanged( - id, - ColorPickerValue { - rgb: hsv_to_rgb(hue, saturation, val as f64), - alpha, - }, - ) - }) - .step(COLOR_SLIDER_STEP) - .width(Length::Fill); - let mut column = column![ - row![text("Hue").size(12), hue_slider] - .spacing(8) - .align_y(Alignment::Center), + color_gradient(id, hue, saturation, value, alpha), + row![ + color_swatch(rgb, false), + text(hex_from_rgb(rgb)).size(12), + Space::new().width(Length::Fill), + ] + .spacing(8) + .align_y(Alignment::Center), + preset_palette(id, rgb, alpha), row![text("Saturation").size(12), saturation_slider] .spacing(8) .align_y(Alignment::Center), - row![text("Value").size(12), value_slider] - .spacing(8) - .align_y(Alignment::Center), ] .spacing(8); @@ -86,7 +83,75 @@ pub(super) fn picker_panel<'a>( } container(column) - .padding(8) + .padding(10) .style(theme::Container::Box) .into() } + +fn preset_palette<'a>( + id: ColorPickerId, + current_rgb: [f64; 3], + alpha: Option, +) -> Element<'a, Message> { + PRESET_COLORS + .chunks(6) + .fold(column![].spacing(PRESET_SWATCH_GAP), |column, chunk| { + let row = chunk.iter().fold( + row![].spacing(PRESET_SWATCH_GAP).align_y(Alignment::Center), + |row, (_label, rgb)| { + row.push( + button(color_swatch(*rgb, colors_approx_equal(*rgb, current_rgb))) + .padding(0) + .on_press(Message::ColorPickerChanged( + id, + ColorPickerValue { rgb: *rgb, alpha }, + )), + ) + }, + ); + column.push(row) + }) + .into() +} + +fn color_swatch<'a>(rgb: [f64; 3], selected: bool) -> Element<'a, Message> { + container( + Space::new() + .width(Length::Fixed(PRESET_SWATCH_SIZE)) + .height(Length::Fixed(PRESET_SWATCH_SIZE)), + ) + .style(swatch_style(rgb, selected)) + .into() +} + +fn swatch_style(rgb: [f64; 3], selected: bool) -> impl Fn(&iced::Theme) -> container::Style { + move |_theme| { + let color = Color::from_rgb(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32); + let luminance = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + let border_color = if selected { + Color::from_rgb(0.95, 0.95, 0.95) + } else if luminance < 0.3 { + Color::from_rgba(0.65, 0.68, 0.72, 0.85) + } else { + Color::from_rgba(0.12, 0.13, 0.15, 0.75) + }; + + container::Style { + background: Some(Background::Color(color)), + text_color: None, + border: Border { + color: border_color, + width: if selected { 2.0 } else { 1.0 }, + radius: Radius::from(4.0), + }, + shadow: Shadow::default(), + snap: true, + } + } +} + +fn colors_approx_equal(left: [f64; 3], right: [f64; 3]) -> bool { + left.iter() + .zip(right.iter()) + .all(|(left, right)| (left - right).abs() <= 0.01) +} diff --git a/configurator/src/app/view/widgets/mod.rs b/configurator/src/app/view/widgets/mod.rs index fa2d0e78..7b99d646 100644 --- a/configurator/src/app/view/widgets/mod.rs +++ b/configurator/src/app/view/widgets/mod.rs @@ -6,9 +6,9 @@ mod labels; mod validation; pub(super) use color_picker::{ - ColorPickerUi, color_quad_picker, color_rgb255_picker, color_triplet_picker, + ColorPickerUi, color_quad_picker, color_rgb255_picker, color_triplet_picker, picker_panel, }; -pub(super) use colors::color_preview_labeled; +pub(super) use colors::{color_preview_badge, color_preview_labeled}; pub(super) use constants::{ BUFFER_PICKER_WIDTH, COLOR_PICKER_WIDTH, DEFAULT_LABEL_GAP, LABEL_COLUMN_WIDTH, SMALL_PICKER_WIDTH, diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 64cd18b6..c020502d 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -9,7 +9,8 @@ use crate::models::{ DragMouseButton, DragToolField, DragToolOption, EraserModeOption, FontStyleOption, FontWeightOption, KeybindingField, KeybindingsTabId, NamedColorOption, OverrideOption, PresenterToolBehaviorOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, - PresetToggleField, QuadField, SessionCompressionOption, SessionStorageModeOption, + PresetToggleField, QuadField, RenderProfileExportOption, RenderProfileMappingSide, + RenderProfileTextField, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TabId, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, UiTabId, }; @@ -43,6 +44,7 @@ pub enum Message { ColorModeChanged(ColorMode), NamedColorSelected(NamedColorOption), EraserModeChanged(EraserModeOption), + DrawingDragMappingSectionToggled(DragMouseButton), DrawingMouseDragToolChanged(DragMouseButton, DragToolField, DragToolOption), DrawingMouseDragColorChanged(DragMouseButton, DragToolField, DragColorOption), StatusPositionChanged(StatusPositionOption), @@ -62,6 +64,18 @@ pub enum Message { BoardsDefaultPenEnabledChanged(usize, bool), BoardsDefaultPenColorChanged(usize, usize, String), BoardsItemToggleChanged(usize, BoardItemToggleField, bool), + RenderProfileAdd, + RenderProfileRemove(usize), + RenderProfileDuplicate(usize), + RenderProfileTextChanged(usize, RenderProfileTextField, String), + RenderProfileActiveChanged(String), + RenderProfileExportChanged(RenderProfileExportOption), + RenderProfileExportProfileChanged(String), + RenderProfileApplyCanvasChanged(bool), + RenderProfileApplyUiChanged(bool), + RenderProfileMappingAdd(usize), + RenderProfileMappingRemove(usize, usize), + RenderProfileMappingColorChanged(usize, usize, RenderProfileMappingSide, String), SessionStorageModeChanged(SessionStorageModeOption), SessionCompressionChanged(SessionCompressionOption), PresenterToolBehaviorChanged(PresenterToolBehaviorOption), diff --git a/configurator/src/models/color_picker.rs b/configurator/src/models/color_picker.rs index 9df92095..5ece78d2 100644 --- a/configurator/src/models/color_picker.rs +++ b/configurator/src/models/color_picker.rs @@ -3,6 +3,8 @@ pub enum ColorPickerId { DrawingColor, BoardBackground(usize), BoardPen(usize), + RenderProfileMappingFrom(usize, usize), + RenderProfileMappingTo(usize, usize), StatusBarBg, StatusBarText, HighlightFill, diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index b57c6129..42b55df6 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -12,6 +12,7 @@ use super::super::super::keybindings::KeybindingsDraft; use super::super::super::util::format_float; use super::super::boards::BoardsDraft; use super::super::presets::PresetsDraft; +use super::super::render_profiles::RenderProfilesDraft; use super::super::toolbar_overrides::ToolbarModeOverridesDraft; use super::ConfigDraft; use wayscriber::config::{Config, XdgFocusLossBehavior}; @@ -160,6 +161,8 @@ impl ConfigDraft { boards: BoardsDraft::from_config(config), + render_profiles: RenderProfilesDraft::from_config(config), + capture_enabled: config.capture.enabled, capture_save_directory: config.capture.save_directory.clone(), capture_filename_template: config.capture.filename_template.clone(), diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index caef2c06..f1873e87 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -11,6 +11,7 @@ use super::super::fields::{PressureThicknessEditModeOption, PressureThicknessEnt use super::super::keybindings::KeybindingsDraft; use super::boards::BoardsDraft; use super::presets::PresetsDraft; +use super::render_profiles::RenderProfilesDraft; use super::toolbar_overrides::ToolbarModeOverridesDraft; use wayscriber::config::MouseDragToolsConfig; @@ -128,6 +129,8 @@ pub struct ConfigDraft { pub boards: BoardsDraft, + pub render_profiles: RenderProfilesDraft, + pub capture_enabled: bool, pub capture_save_directory: String, pub capture_filename_template: String, diff --git a/configurator/src/models/config/mod.rs b/configurator/src/models/config/mod.rs index 320151b3..09b7f60f 100644 --- a/configurator/src/models/config/mod.rs +++ b/configurator/src/models/config/mod.rs @@ -2,6 +2,7 @@ mod boards; mod draft; mod parse; mod presets; +mod render_profiles; mod setters; mod to_config; mod toolbar_overrides; @@ -11,3 +12,7 @@ mod tests; pub use boards::{BoardBackgroundOption, BoardItemTextField, BoardItemToggleField}; pub use draft::ConfigDraft; +pub use render_profiles::{ + RenderProfileExportOption, RenderProfileMappingDraft, RenderProfileMappingSide, + RenderProfileSelectionOption, RenderProfileTextField, +}; diff --git a/configurator/src/models/config/render_profiles.rs b/configurator/src/models/config/render_profiles.rs new file mode 100644 index 00000000..1d62a263 --- /dev/null +++ b/configurator/src/models/config/render_profiles.rs @@ -0,0 +1,285 @@ +use wayscriber::config::{ + Config, RenderColorMappingConfig, RenderProfileConfig, RenderProfileExportMode, +}; +use wayscriber::render_profiles::{format_hex_rgb, normalize_profile_id, parse_hex_rgb}; + +use super::super::error::FormError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderProfileTextField { + Id, + Name, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RenderProfileMappingSide { + From, + To, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderProfileExportOption { + Off, + Active, + Profile, +} + +impl RenderProfileExportOption { + pub fn list() -> Vec { + vec![Self::Off, Self::Active, Self::Profile] + } + + pub fn label(self) -> &'static str { + match self { + Self::Off => "Off", + Self::Active => "Active profile", + Self::Profile => "Named profile", + } + } + + fn from_mode(mode: RenderProfileExportMode) -> Self { + match mode { + RenderProfileExportMode::Off => Self::Off, + RenderProfileExportMode::Active => Self::Active, + RenderProfileExportMode::Profile => Self::Profile, + } + } + + fn to_mode(self) -> RenderProfileExportMode { + match self { + Self::Off => RenderProfileExportMode::Off, + Self::Active => RenderProfileExportMode::Active, + Self::Profile => RenderProfileExportMode::Profile, + } + } +} + +impl std::fmt::Display for RenderProfileExportOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RenderProfileSelectionOption { + Off, + Profile(String), +} + +impl RenderProfileSelectionOption { + pub fn list(profile_ids: &[String]) -> Vec { + std::iter::once(Self::Off) + .chain(profile_ids.iter().cloned().map(Self::Profile)) + .collect() + } + + pub fn from_active(value: &str, profile_ids: &[String]) -> Self { + if !value.is_empty() && profile_ids.iter().any(|id| id == value) { + Self::Profile(value.to_string()) + } else { + Self::Off + } + } + + pub fn profile_id(self) -> String { + match self { + Self::Off => String::new(), + Self::Profile(id) => id, + } + } +} + +impl std::fmt::Display for RenderProfileSelectionOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Off => f.write_str("Off"), + Self::Profile(id) => f.write_str(id), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderProfileMappingDraft { + pub from: String, + pub to: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderProfileDraft { + pub id: String, + pub name: String, + pub mappings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderProfilesDraft { + pub active: String, + pub apply_to_canvas: bool, + pub apply_to_ui: bool, + pub export: RenderProfileExportOption, + pub export_profile: String, + pub profiles: Vec, +} + +impl RenderProfilesDraft { + pub fn from_config(config: &Config) -> Self { + Self { + active: config.render_profiles.active.clone().unwrap_or_default(), + apply_to_canvas: config.render_profiles.apply_to_canvas, + apply_to_ui: config.render_profiles.apply_to_ui, + export: RenderProfileExportOption::from_mode(config.render_profiles.export), + export_profile: config + .render_profiles + .export_profile + .clone() + .unwrap_or_default(), + profiles: config + .render_profiles + .profiles + .iter() + .map(|profile| RenderProfileDraft { + id: profile.id.clone(), + name: profile.name.clone(), + mappings: profile + .mappings + .iter() + .map(|mapping| RenderProfileMappingDraft { + from: mapping.from.clone(), + to: mapping.to.clone(), + }) + .collect(), + }) + .collect(), + } + } + + pub fn apply_to_config(&self, config: &mut Config, errors: &mut Vec) { + config.render_profiles.active = non_empty_normalized(&self.active); + config.render_profiles.apply_to_canvas = self.apply_to_canvas; + config.render_profiles.apply_to_ui = self.apply_to_ui; + config.render_profiles.export = self.export.to_mode(); + config.render_profiles.export_profile = non_empty_normalized(&self.export_profile); + config.render_profiles.profiles = self + .profiles + .iter() + .enumerate() + .map(|(profile_index, profile)| RenderProfileConfig { + id: profile.id.clone(), + name: profile.name.clone(), + mappings: profile + .mappings + .iter() + .enumerate() + .filter_map(|(mapping_index, mapping)| { + let from = normalized_hex( + &mapping.from, + profile_index, + mapping_index, + RenderProfileMappingSide::From, + errors, + )?; + let to = normalized_hex( + &mapping.to, + profile_index, + mapping_index, + RenderProfileMappingSide::To, + errors, + )?; + Some(RenderColorMappingConfig { from, to }) + }) + .collect(), + }) + .collect(); + } + + pub fn profile_ids(&self) -> Vec { + self.profiles + .iter() + .enumerate() + .map(|(index, profile)| effective_profile_id(&profile.id, index)) + .collect() + } + + pub fn ensure_selections_exist(&mut self) { + let ids = self.profile_ids(); + if !self.active.is_empty() && !ids.contains(&self.active) { + self.active.clear(); + } + if !self.export_profile.is_empty() && !ids.contains(&self.export_profile) { + self.export_profile.clear(); + if self.export == RenderProfileExportOption::Profile { + self.export = RenderProfileExportOption::Off; + } + } + } + + pub fn new_profile(&self) -> RenderProfileDraft { + let next = self.profiles.len() + 1; + RenderProfileDraft { + id: self.next_profile_id(), + name: format!("Profile {next}"), + mappings: vec![RenderProfileMappingDraft { + from: "#000000".to_string(), + to: "#FFFFFF".to_string(), + }], + } + } + + pub fn duplicate_profile(&self, index: usize) -> Option { + let mut duplicate = self.profiles.get(index)?.clone(); + duplicate.id = self.next_profile_id(); + if !duplicate.name.trim().is_empty() { + duplicate.name = format!("{} Copy", duplicate.name.trim()); + } + Some(duplicate) + } + + fn next_profile_id(&self) -> String { + let existing = self.profile_ids(); + let mut index = self.profiles.len() + 1; + loop { + let id = format!("profile-{index}"); + if !existing.contains(&id) { + return id; + } + index += 1; + } + } +} + +fn effective_profile_id(value: &str, index: usize) -> String { + let normalized = normalize_profile_id(value); + if normalized.is_empty() { + format!("profile-{}", index + 1) + } else { + normalized + } +} + +fn non_empty_normalized(value: &str) -> Option { + let normalized = normalize_profile_id(value); + (!normalized.is_empty()).then_some(normalized) +} + +fn normalized_hex( + value: &str, + profile_index: usize, + mapping_index: usize, + side: RenderProfileMappingSide, + errors: &mut Vec, +) -> Option { + let field = format!( + "render_profiles.profiles[{profile_index}].mappings[{mapping_index}].{}", + match side { + RenderProfileMappingSide::From => "from", + RenderProfileMappingSide::To => "to", + } + ); + match parse_hex_rgb(value) { + Some(color) => Some(format_hex_rgb(color)), + None => { + errors.push(FormError::new(field, "Expected #RRGGBB hex color")); + None + } + } +} diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 7e7117a1..f219448e 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -4,9 +4,10 @@ use super::super::fields::{ SessionStorageModeOption, TextField, ToggleField, ToolOption, TripletField, }; use super::super::{ColorMode, NamedColorOption}; -use super::ConfigDraft; +use super::{ConfigDraft, RenderProfileSelectionOption}; use wayscriber::config::{ - ColorSpec, Config, PresetToolStatesConfig, ToolPresetConfig, XdgFocusLossBehavior, + ColorSpec, Config, PresetToolStatesConfig, RenderColorMappingConfig, RenderProfileConfig, + RenderProfileExportMode, ToolPresetConfig, XdgFocusLossBehavior, }; use wayscriber::input::{DragTool, PerToolDrawingSettings, Tool}; @@ -58,6 +59,96 @@ fn config_draft_round_trips_light_mode_click_highlight_policy() { assert!(!round_trip.ui.click_highlight.force_in_light_mode); } +#[test] +fn config_draft_round_trips_render_profiles() { + let mut config = Config::default(); + config.render_profiles.active = Some("print".to_string()); + config.render_profiles.apply_to_canvas = true; + config.render_profiles.apply_to_ui = false; + config.render_profiles.export = RenderProfileExportMode::Profile; + config.render_profiles.export_profile = Some("export".to_string()); + config.render_profiles.profiles = vec![ + RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: vec![RenderColorMappingConfig { + from: "#000000".to_string(), + to: "#FFFFFF".to_string(), + }], + }, + RenderProfileConfig { + id: "export".to_string(), + name: "Export".to_string(), + mappings: vec![RenderColorMappingConfig { + from: "#FF0000".to_string(), + to: "#00FF00".to_string(), + }], + }, + ]; + + let draft = ConfigDraft::from_config(&config); + let round_trip = draft + .to_config(&config) + .expect("expected render profiles to round trip"); + + assert_eq!(round_trip.render_profiles.active.as_deref(), Some("print")); + assert!(!round_trip.render_profiles.apply_to_ui); + assert_eq!( + round_trip.render_profiles.export, + RenderProfileExportMode::Profile + ); + assert_eq!( + round_trip.render_profiles.export_profile.as_deref(), + Some("export") + ); + assert_eq!(round_trip.render_profiles.profiles.len(), 2); + assert_eq!( + round_trip.render_profiles.profiles[0].mappings[0].from, + "#000000" + ); +} + +#[test] +fn render_profile_selection_options_include_selectable_off() { + let ids = vec!["print".to_string(), "projector".to_string()]; + + assert_eq!( + RenderProfileSelectionOption::list(&ids), + vec![ + RenderProfileSelectionOption::Off, + RenderProfileSelectionOption::Profile("print".to_string()), + RenderProfileSelectionOption::Profile("projector".to_string()), + ] + ); + assert_eq!( + RenderProfileSelectionOption::from_active("print", &ids), + RenderProfileSelectionOption::Profile("print".to_string()) + ); + assert_eq!( + RenderProfileSelectionOption::from_active("missing", &ids), + RenderProfileSelectionOption::Off + ); + assert_eq!(RenderProfileSelectionOption::Off.profile_id(), ""); +} + +#[test] +fn config_draft_reports_invalid_render_profile_hex() { + let mut draft = ConfigDraft::from_config(&Config::default()); + let mut profile = draft.render_profiles.new_profile(); + profile.mappings[0].from = "#GGGGGG".to_string(); + draft.render_profiles.profiles.push(profile); + + let errors = draft + .to_config(&Config::default()) + .expect_err("expected invalid hex"); + + assert!( + errors + .iter() + .any(|error| error.field.contains("mappings[0].from")) + ); +} + #[test] fn setters_update_draft_state() { let mut draft = ConfigDraft::from_config(&Config::default()); diff --git a/configurator/src/models/config/to_config/mod.rs b/configurator/src/models/config/to_config/mod.rs index 219fee5a..ddabfa86 100644 --- a/configurator/src/models/config/to_config/mod.rs +++ b/configurator/src/models/config/to_config/mod.rs @@ -25,6 +25,8 @@ impl ConfigDraft { self.apply_ui(&mut config, &mut errors); self.apply_presenter_mode(&mut config); self.apply_boards(&mut config, &mut errors); + self.render_profiles + .apply_to_config(&mut config, &mut errors); self.apply_capture(&mut config, &mut errors); self.apply_session(&mut config, &mut errors); self.apply_tablet(&mut config, &mut errors); diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs index a5ee3797..d4d553ad 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -108,6 +108,9 @@ impl KeybindingField { Self::CaptureFileSelection => &config.capture.capture_file_selection, Self::CaptureClipboardRegion => &config.capture.capture_clipboard_region, Self::CaptureFileRegion => &config.capture.capture_file_region, + Self::ExportCanvasFile => &config.capture.export_canvas_file, + Self::ExportCanvasClipboard => &config.capture.export_canvas_clipboard, + Self::ExportCanvasClipboardAndFile => &config.capture.export_canvas_clipboard_and_file, Self::OpenCaptureFolder => &config.capture.open_capture_folder, Self::ToggleFrozenMode => &config.zoom.toggle_frozen_mode, Self::ZoomIn => &config.zoom.zoom_in, diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs index a4020383..4c335f79 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -109,6 +109,11 @@ impl KeybindingField { Self::CaptureFileSelection => config.capture.capture_file_selection = value, Self::CaptureClipboardRegion => config.capture.capture_clipboard_region = value, Self::CaptureFileRegion => config.capture.capture_file_region = value, + Self::ExportCanvasFile => config.capture.export_canvas_file = value, + Self::ExportCanvasClipboard => config.capture.export_canvas_clipboard = value, + Self::ExportCanvasClipboardAndFile => { + config.capture.export_canvas_clipboard_and_file = value; + } Self::OpenCaptureFolder => config.capture.open_capture_folder = value, Self::ToggleFrozenMode => config.zoom.toggle_frozen_mode = value, Self::ZoomIn => config.zoom.zoom_in = value, diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs index f529a6c7..90195412 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -103,6 +103,9 @@ impl KeybindingField { Self::CaptureFileSelection => "File selection", Self::CaptureClipboardRegion => "Clipboard region", Self::CaptureFileRegion => "File region", + Self::ExportCanvasFile => "Export canvas: file", + Self::ExportCanvasClipboard => "Export canvas: clipboard", + Self::ExportCanvasClipboardAndFile => "Export canvas: clipboard and file", Self::OpenCaptureFolder => "Open capture folder", Self::ToggleFrozenMode => "Toggle freeze", Self::ZoomIn => "Zoom in", @@ -232,6 +235,9 @@ impl KeybindingField { Self::CaptureFileSelection => "capture_file_selection", Self::CaptureClipboardRegion => "capture_clipboard_region", Self::CaptureFileRegion => "capture_file_region", + Self::ExportCanvasFile => "export_canvas_file", + Self::ExportCanvasClipboard => "export_canvas_clipboard", + Self::ExportCanvasClipboardAndFile => "export_canvas_clipboard_and_file", Self::OpenCaptureFolder => "open_capture_folder", Self::ToggleFrozenMode => "toggle_frozen_mode", Self::ZoomIn => "zoom_in", diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index 10757e7c..0ad35b62 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -103,6 +103,9 @@ impl KeybindingField { Self::CaptureFileSelection, Self::CaptureClipboardRegion, Self::CaptureFileRegion, + Self::ExportCanvasFile, + Self::ExportCanvasClipboard, + Self::ExportCanvasClipboardAndFile, Self::OpenCaptureFolder, Self::ToggleFrozenMode, Self::ZoomIn, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index c2738245..efe4ea67 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -90,6 +90,9 @@ pub enum KeybindingField { CaptureFileSelection, CaptureClipboardRegion, CaptureFileRegion, + ExportCanvasFile, + ExportCanvasClipboard, + ExportCanvasClipboardAndFile, OpenCaptureFolder, ToggleFrozenMode, ZoomIn, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index 978e6de9..9802b1bc 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -105,6 +105,9 @@ impl KeybindingField { | Self::CaptureFileSelection | Self::CaptureClipboardRegion | Self::CaptureFileRegion + | Self::ExportCanvasFile + | Self::ExportCanvasClipboard + | Self::ExportCanvasClipboardAndFile | Self::OpenCaptureFolder | Self::ToggleFrozenMode | Self::ZoomIn diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index fa660247..14ad7634 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -10,7 +10,11 @@ pub mod util; pub use color::{ColorMode, ColorQuadInput, ColorTripletInput, NamedColorOption}; pub use color_picker::{ColorPickerId, ColorPickerValue}; -pub use config::{BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ConfigDraft}; +pub use config::{ + BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ConfigDraft, + RenderProfileExportOption, RenderProfileMappingDraft, RenderProfileMappingSide, + RenderProfileSelectionOption, RenderProfileTextField, +}; pub use daemon::{ DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DesktopEnvironment, LightShortcutApplyCapability, ShortcutApplyCapability, ShortcutBackend, diff --git a/configurator/src/models/tab.rs b/configurator/src/models/tab.rs index 108cfd72..fc85cca9 100644 --- a/configurator/src/models/tab.rs +++ b/configurator/src/models/tab.rs @@ -7,6 +7,7 @@ pub enum TabId { Performance, Ui, Boards, + RenderProfiles, Capture, Daemon, Session, @@ -17,12 +18,13 @@ pub enum TabId { impl TabId { #[cfg(feature = "tablet-input")] - pub const ALL: [TabId; 12] = [ + pub const ALL: [TabId; 13] = [ TabId::Daemon, TabId::Drawing, TabId::Presets, TabId::Ui, TabId::Boards, + TabId::RenderProfiles, TabId::Performance, TabId::History, TabId::Capture, @@ -33,12 +35,13 @@ impl TabId { ]; #[cfg(not(feature = "tablet-input"))] - pub const ALL: [TabId; 11] = [ + pub const ALL: [TabId; 12] = [ TabId::Daemon, TabId::Drawing, TabId::Presets, TabId::Ui, TabId::Boards, + TabId::RenderProfiles, TabId::Performance, TabId::History, TabId::Capture, @@ -56,6 +59,7 @@ impl TabId { TabId::Performance => "Performance", TabId::Ui => "UI", TabId::Boards => "Boards", + TabId::RenderProfiles => "Render Profiles", TabId::Capture => "Capture", TabId::Daemon => "Background Mode", TabId::Session => "Session", diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 84550f72..c6340f7c 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -682,8 +682,10 @@ workflows. # active = "print" apply_to_canvas = true apply_to_ui = true +export = "off" +# export_profile = "print" -[[render_profiles.items]] +[[render_profiles.profiles]] id = "print" name = "Print" mappings = [ @@ -696,20 +698,31 @@ mappings = [ **Behavior:** - `id` is the stable identifier used by `active` and runtime profile switching. +- Profile entries are stored under `profiles`. - `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. +- `export` controls explicit canvas PNG export remapping: `off`, `active`, or `profile`. +- `export_profile` is used only when `export = "profile"`. - `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. +- Explicit canvas PNG export applies its resolved export profile to persisted Wayscriber canvas content only, uses the current panned board viewport, respects output scale, and excludes frozen/zoom desktop pixels. +- Explicit canvas export and its clipboard-failure fallback save PNG data as `.png`; screenshot clipboard fallback still uses `[capture].format`. +- `[capture].enabled` disables compositor screenshot capture actions, not explicit canvas export actions. +- PDF export is out of scope. **Runtime actions:** - `render_profile_next` - `render_profile_previous` - `render_profile_off` +**Canvas export actions:** +- `export_canvas_file` +- `export_canvas_clipboard` +- `export_canvas_clipboard_and_file` + ### `[capture]` - Screenshot Capture Configures how screenshots are stored and shared. @@ -994,6 +1007,9 @@ capture_clipboard_selection = ["Ctrl+Shift+C"] capture_file_selection = ["Ctrl+Shift+S"] capture_clipboard_region = ["Ctrl+6"] capture_file_region = ["Ctrl+Alt+6"] +export_canvas_file = [] +export_canvas_clipboard = [] +export_canvas_clipboard_and_file = [] # Open the most recent capture folder open_capture_folder = ["Ctrl+Alt+O"] diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 3f9fc168..9c1efa94 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -72,7 +72,7 @@ Daemon mode therefore provides a persistent background service that reacts to us 1. **Keyboard events (`handlers/keyboard.rs`)** - Translate Wayland keysyms to internal `Key`. - Call `InputState::on_key_press` / `on_key_release`. - - After processing a key press, check `InputState::take_pending_capture_action` to trigger captures. + - Key presses can enqueue backend output work; the event loop drains `InputState::take_pending_backend_action`. 2. **Mouse events (`handlers/pointer.rs`)** - Update `current_mouse_x/y`. @@ -103,22 +103,24 @@ The result is a predictable pipeline: Wayland → handlers → `InputState` → | `mod.rs` | Public exports and shared submodules. | | `manager.rs` | `CaptureManager` – owns channel, status, tokio task. | | `dependencies.rs` | Trait definitions (`CaptureSource`, `CaptureFileSaver`, `CaptureClipboard`) and default implementations. | -| `pipeline.rs` | `perform_capture` and `CaptureRequest` definition. | +| `pipeline.rs` | `perform_capture`, `deliver_image`, and capture/image-delivery request definitions. | | `sources/` | Strategies for acquiring image bytes: Hyprland fast-path (`hyprland.rs`), portal fallback (`portal.rs`), and URI reader/cleanup (`reader.rs`). | | `clipboard.rs`, `file.rs`, `portal.rs` | Support code reused by the pipeline. | | `tests.rs` | Unit tests for the manager/pipeline, plus mocks. | **Runtime flow:** -1. `InputState::handle_action` sets `pending_capture_action`. -2. Keyboard handler sees the pending action, calls `WaylandState::handle_capture_action`. -3. `WaylandState::handle_capture_action` builds a `CaptureRequest` (type + destination + save config) and calls `CaptureManager::request_capture`. -4. `CaptureManager`’s tokio task receives the request, updates status, and calls `perform_capture`. -5. `perform_capture`: +1. `InputState::handle_action` sets `pending_backend_action` for screenshot capture and canvas export actions. +2. The Wayland event loop centrally drains the pending backend action, so keybindings, command-palette Return, and command-palette mouse clicks share the same dispatch path. +3. Screenshot actions call `WaylandState::handle_capture_action`; explicit canvas PNG export actions call `WaylandState::handle_canvas_export_action`. +4. `WaylandState::handle_capture_action` builds a `CaptureRequest` (type + destination + save config) and calls `CaptureManager::request_capture`. +5. Canvas export snapshots persisted board content in the current panned viewport, renders PNG bytes, and calls `CaptureManager::request_image_delivery`. +6. `CaptureManager`’s tokio task receives the request, updates status, and calls `perform_capture` or `deliver_image`. +7. `perform_capture`: - Calls the configured `CaptureSource` (default: `sources::capture_image` with Hyprland→portal fallback). - Optionally saves via `CaptureFileSaver`. - Optionally copies to clipboard via `CaptureClipboard`. - Returns `CaptureResult` used for desktop notifications. -6. `WaylandState` polls `CaptureManager::try_take_result()` to restore the overlay and emit notifications once capture completes. +8. `WaylandState` polls `CaptureManager::try_take_result()` to restore the overlay and emit notifications once capture completes. Notifications are sent via `notification::send_notification_async`, keeping all UI feedback on the event loop thread. diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index ee86dc66..78610c09 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -6,7 +6,7 @@ use super::super::helpers::friendly_capture_error; use crate::capture::CaptureOutcome; use crate::capture::file::{FileSaveConfig, expand_tilde}; use crate::config::Action; -use crate::input::state::UiToastKind; +use crate::input::state::{PendingBackendAction, UiToastKind}; use crate::notification; pub(super) fn poll_portal_captures(state: &mut WaylandState) { @@ -32,6 +32,12 @@ pub(super) fn handle_pending_actions( state.drain_clipboard_requests(); handle_frozen_toggle(state); + if let Some(action) = state.input_state.take_pending_backend_action() { + match action { + PendingBackendAction::Screenshot(action) => state.handle_capture_action(action), + PendingBackendAction::CanvasExport(action) => state.handle_canvas_export_action(action), + } + } if let Some(action) = state.input_state.take_pending_output_focus_action() { state.handle_output_focus_action(qh, action); } @@ -100,14 +106,18 @@ fn handle_capture_results(state: &mut WaylandState) { let mut message_parts = Vec::new(); if let Some(ref path) = result.saved_path { - info!("Screenshot saved to: {}", path.display()); + info!( + "{} saved to: {}", + result.operation.saved_log_label(), + path.display() + ); if let Some(filename) = path.file_name() { message_parts.push(format!("Saved as {}", filename.to_string_lossy())); } } if result.copied_to_clipboard { - info!("Screenshot copied to clipboard"); + info!("{} copied to clipboard", result.operation.saved_log_label()); message_parts.push("Copied to clipboard".to_string()); } @@ -122,27 +132,31 @@ fn handle_capture_results(state: &mut WaylandState) { warn!("Clipboard copy failed, offering save-to-file fallback"); // Build save config from user preferences for fallback save - let save_config = FileSaveConfig { + let mut save_config = FileSaveConfig { save_directory: expand_tilde(&state.config.capture.save_directory), filename_template: state.config.capture.filename_template.clone(), format: state.config.capture.format.clone(), }; + if let Some(format) = result.fallback_format_override.as_ref() { + save_config.format = format.extension.clone(); + } // Pass exit_after_capture so we can exit after successful fallback save state.input_state.set_clipboard_fallback( result.image_data.clone(), save_config, + result.operation, exit_after_capture, ); state.input_state.set_ui_toast_with_action( UiToastKind::Error, - "Clipboard failed", + result.operation.fallback_toast(), "Save to file", Action::SavePendingToFile, ); notification::send_notification_async( &state.tokio_handle, - "Screenshot Clipboard Failed".to_string(), + result.operation.clipboard_failure_title().to_string(), "Could not copy to clipboard. Use overlay to save to file.".to_string(), Some("dialog-warning".to_string()), ); @@ -150,7 +164,14 @@ fn handle_capture_results(state: &mut WaylandState) { } else { // Send normal notification. let notification_body = if message_parts.is_empty() { - "Screenshot captured".to_string() + match result.operation { + crate::capture::ImageOperationKind::Screenshot => { + "Screenshot captured".to_string() + } + crate::capture::ImageOperationKind::CanvasExport => { + "Canvas exported".to_string() + } + } } else { message_parts.join(" - ") }; @@ -170,7 +191,7 @@ fn handle_capture_results(state: &mut WaylandState) { notification::send_notification_async( &state.tokio_handle, - "Screenshot Captured".to_string(), + result.operation.success_title().to_string(), notification_body, Some("camera-photo".to_string()), ); @@ -179,23 +200,28 @@ fn handle_capture_results(state: &mut WaylandState) { should_exit = exit_after_capture; } } - CaptureOutcome::Failed(error) => { - let friendly_error = friendly_capture_error(&error); + CaptureOutcome::Failed { operation, message } => { + let friendly_error = + if matches!(operation, crate::capture::ImageOperationKind::Screenshot) { + friendly_capture_error(&message) + } else { + message.clone() + }; - warn!("Screenshot capture failed: {}", error); + warn!("{} failed: {}", operation.saved_log_label(), message); state .input_state .set_ui_toast(UiToastKind::Error, friendly_error.clone()); notification::send_notification_async( &state.tokio_handle, - "Screenshot Failed".to_string(), + operation.failure_title().to_string(), friendly_error, Some("dialog-error".to_string()), ); } - CaptureOutcome::Cancelled(reason) => { - info!("Capture cancelled: {}", reason); + CaptureOutcome::Cancelled { operation, reason } => { + info!("{} cancelled: {}", operation.saved_log_label(), reason); } } if should_exit { diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 1933033c..2dd82bd4 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -167,9 +167,6 @@ impl KeyboardHandler for WaylandState { self.apply_input_key(key); - if let Some(action) = self.input_state.take_pending_capture_action() { - self.handle_capture_action(action); - } if let Some(action) = self.input_state.take_pending_zoom_action() { self.handle_zoom_action(action); } diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index e4093a62..e978c235 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -44,8 +44,13 @@ use wayland_protocols::wp::{ use crate::input::tablet::TabletSettings; use crate::{ backend::ExitAfterCaptureMode, + canvas_export::{ + BoardExportSnapshot, CanvasExportBackdropSnapshot, CanvasExportSnapshot, + CanvasExportViewport, render_canvas_png, + }, capture::{ - CaptureDestination, CaptureManager, + CaptureDestination, CaptureManager, ImageDeliveryRequest, ImageFormatMetadata, + ImageOperationKind, file::{FileSaveConfig, expand_tilde}, types::CaptureType, }, diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index 6352a364..4d4cd870 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -147,6 +147,102 @@ impl WaylandState { self.capture.queue_preflight(request); } + pub(in crate::backend::wayland) fn handle_canvas_export_action(&mut self, action: Action) { + if self.capture.is_in_progress() { + log::warn!( + "Canvas export action {:?} requested while another image operation is running; ignoring", + action + ); + return; + } + + let destination = match action { + Action::ExportCanvasFile => CaptureDestination::FileOnly, + Action::ExportCanvasClipboard => CaptureDestination::ClipboardOnly, + Action::ExportCanvasClipboardAndFile => CaptureDestination::ClipboardAndFile, + _ => { + log::error!( + "Non-canvas-export action passed to handle_canvas_export_action: {:?}", + action + ); + return; + } + }; + + let snapshot = self.canvas_export_snapshot(); + let rendered = match render_canvas_png(&snapshot) { + Ok(rendered) => rendered, + Err(err) => { + let message = ImageOperationKind::CanvasExport.format_error(&err); + log::error!("Canvas export failed: {}", message); + self.input_state + .set_ui_toast(crate::input::state::UiToastKind::Error, message); + return; + } + }; + + let save_config = if matches!(destination, CaptureDestination::ClipboardOnly) { + None + } else { + Some(FileSaveConfig { + save_directory: expand_tilde(&self.config.capture.save_directory), + filename_template: self.config.capture.filename_template.clone(), + format: rendered.format.extension.clone(), + }) + }; + + let exit_on_success = self.should_exit_after_capture(destination); + self.capture.set_exit_on_success(exit_on_success); + self.capture.mark_in_progress(); + + let request = ImageDeliveryRequest { + image: rendered, + destination, + save_config, + operation: ImageOperationKind::CanvasExport, + fallback_format_override: Some(ImageFormatMetadata::png()), + }; + + if let Err(err) = self.capture.manager_mut().request_image_delivery(request) { + log::error!("Failed to request canvas export delivery: {}", err); + self.capture.clear_in_progress(); + self.capture.clear_exit_on_success(); + self.input_state.set_ui_toast( + crate::input::state::UiToastKind::Error, + format!("Canvas export failed: {err}"), + ); + } + } + + fn canvas_export_snapshot(&self) -> CanvasExportSnapshot { + let (origin_x, origin_y) = self.board_view_offset(); + CanvasExportSnapshot { + viewport: CanvasExportViewport { + logical_width: self.surface.width(), + logical_height: self.surface.height(), + scale: self.surface.scale(), + origin_x: origin_x.round() as i32, + origin_y: origin_y.round() as i32, + }, + backdrop: match self.input_state.boards.active_background() { + crate::input::BoardBackground::Transparent => { + CanvasExportBackdropSnapshot::Transparent + } + crate::input::BoardBackground::Solid(color) => { + CanvasExportBackdropSnapshot::Solid(*color) + } + }, + board: BoardExportSnapshot { + frame: self + .input_state + .boards + .active_frame() + .clone_without_history(), + }, + render_profile: self.input_state.export_render_profile(), + } + } + pub(in crate::backend::wayland) fn begin_pending_capture(&mut self, request: CaptureRequest) { log::info!("Requesting {:?} capture", request.capture_type); if let Err(e) = self.capture.manager_mut().request_capture( diff --git a/src/canvas_export.rs b/src/canvas_export.rs new file mode 100644 index 00000000..da18b90a --- /dev/null +++ b/src/canvas_export.rs @@ -0,0 +1,606 @@ +use std::sync::Arc; + +use crate::capture::{CaptureError, ImageFormatMetadata, RenderedImage}; +use crate::draw::{ + BlurRectParams, Color, EraserReplayContext, Frame, Shape, render_blur_rect, + render_eraser_stroke, render_shape, +}; +use crate::render_profiles::RenderColorProfile; +use crate::util::Rect; + +#[derive(Debug, Clone)] +pub struct CanvasExportSnapshot { + pub viewport: CanvasExportViewport, + pub backdrop: CanvasExportBackdropSnapshot, + pub board: BoardExportSnapshot, + pub render_profile: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CanvasExportViewport { + pub logical_width: u32, + pub logical_height: u32, + pub scale: i32, + pub origin_x: i32, + pub origin_y: i32, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] // Persisted image backdrops are currently exercised by export tests. +pub enum CanvasExportBackdropSnapshot { + Transparent, + Solid(Color), + PersistedImage { + data: Arc<[u8]>, + width: i32, + height: i32, + stride: i32, + logical_to_image_scale_x: f64, + logical_to_image_scale_y: f64, + }, +} + +#[derive(Debug, Clone)] +pub struct BoardExportSnapshot { + pub frame: Frame, +} + +pub fn render_canvas_png(snapshot: &CanvasExportSnapshot) -> Result { + let surface = render_canvas_surface(snapshot)?; + let mut bytes = Vec::new(); + surface + .write_to_png(&mut bytes) + .map_err(|err| CaptureError::ImageError(format!("Failed to encode canvas PNG: {err}")))?; + + Ok(RenderedImage { + bytes, + format: ImageFormatMetadata::png(), + width: snapshot + .viewport + .logical_width + .saturating_mul(snapshot.viewport.scale.max(1) as u32), + height: snapshot + .viewport + .logical_height + .saturating_mul(snapshot.viewport.scale.max(1) as u32), + }) +} + +fn render_canvas_surface( + snapshot: &CanvasExportSnapshot, +) -> Result { + let viewport = snapshot.viewport; + let scale = viewport.scale.max(1); + let physical_width = viewport.logical_width.saturating_mul(scale as u32); + let physical_height = viewport.logical_height.saturating_mul(scale as u32); + if physical_width == 0 || physical_height == 0 { + return Err(CaptureError::ImageError( + "Canvas export requires a configured non-empty surface".to_string(), + )); + } + + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + physical_width as i32, + physical_height as i32, + ) + .map_err(|err| CaptureError::ImageError(format!("Failed to create canvas surface: {err}")))?; + { + let ctx = cairo::Context::new(&surface).map_err(|err| { + CaptureError::ImageError(format!("Failed to create canvas context: {err}")) + })?; + + let backdrop = ExportBackdrop::new(&snapshot.backdrop)?; + draw_canvas_snapshot(&ctx, snapshot, &backdrop, scale); + } + + if let Some(profile) = snapshot.render_profile.as_ref() { + surface.flush(); + { + let width = surface.width(); + let height = surface.height(); + let stride = surface.stride(); + let mut data = surface.data().map_err(|err| { + CaptureError::ImageError(format!("Failed to access canvas pixels: {err}")) + })?; + profile.remap_argb8888_regions( + &mut data, + width, + height, + stride, + &[Rect { + x: 0, + y: 0, + width, + height, + }], + ); + } + surface.mark_dirty(); + } + + Ok(surface) +} + +fn draw_canvas_snapshot( + ctx: &cairo::Context, + snapshot: &CanvasExportSnapshot, + backdrop: &ExportBackdrop, + scale: i32, +) { + let _ = ctx.save(); + if scale > 1 { + ctx.scale(scale as f64, scale as f64); + } + ctx.translate( + -(snapshot.viewport.origin_x as f64), + -(snapshot.viewport.origin_y as f64), + ); + + backdrop.paint(ctx); + let replay_ctx = backdrop.replay_context(); + for drawn_shape in &snapshot.board.frame.shapes { + match &drawn_shape.shape { + Shape::EraserStroke { points, brush } => { + render_eraser_stroke(ctx, points, brush, &replay_ctx); + } + Shape::BlurRect { + x, + y, + w, + h, + strength, + } => render_blur_rect( + ctx, + BlurRectParams { + x: *x, + y: *y, + w: *w, + h: *h, + strength: *strength, + cacheable: false, + }, + &replay_ctx, + ), + other => render_shape(ctx, other), + } + } + + let _ = ctx.restore(); +} + +struct ExportBackdrop { + surface: Option, + pattern: Option, + bg_color: Option, + logical_to_image_scale_x: f64, + logical_to_image_scale_y: f64, +} + +impl ExportBackdrop { + fn new(snapshot: &CanvasExportBackdropSnapshot) -> Result { + match snapshot { + CanvasExportBackdropSnapshot::Transparent => Ok(Self { + surface: None, + pattern: None, + bg_color: None, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + }), + CanvasExportBackdropSnapshot::Solid(color) => Ok(Self { + surface: None, + pattern: None, + bg_color: Some(*color), + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + }), + CanvasExportBackdropSnapshot::PersistedImage { + data, + width, + height, + stride, + logical_to_image_scale_x, + logical_to_image_scale_y, + } => { + validate_persisted_image_backdrop(data.len(), *width, *height, *stride)?; + + // SAFETY: dimensions and stride have been checked, and the Arc-backed + // byte slice covers every row Cairo may read for this temporary surface. + // The surface is owned by ExportBackdrop and dropped before the snapshot. + let surface = unsafe { + cairo::ImageSurface::create_for_data_unsafe( + data.as_ptr() as *mut u8, + cairo::Format::ARgb32, + *width, + *height, + *stride, + ) + } + .map_err(|err| { + CaptureError::ImageError(format!("Failed to create export backdrop: {err}")) + })?; + let pattern = cairo::SurfacePattern::create(&surface); + pattern.set_extend(cairo::Extend::Pad); + let mut matrix = cairo::Matrix::identity(); + matrix.scale( + logical_to_image_scale_x.max(f64::MIN_POSITIVE), + logical_to_image_scale_y.max(f64::MIN_POSITIVE), + ); + pattern.set_matrix(matrix); + Ok(Self { + surface: Some(surface), + pattern: Some(pattern), + bg_color: None, + logical_to_image_scale_x: *logical_to_image_scale_x, + logical_to_image_scale_y: *logical_to_image_scale_y, + }) + } + } + } + + fn paint(&self, ctx: &cairo::Context) { + if let Some(color) = self.bg_color { + ctx.set_source_rgba(color.r, color.g, color.b, color.a); + let _ = ctx.paint(); + return; + } + + let Some(surface) = self.surface.as_ref() else { + return; + }; + let _ = ctx.save(); + ctx.scale( + 1.0 / self.logical_to_image_scale_x.max(f64::MIN_POSITIVE), + 1.0 / self.logical_to_image_scale_y.max(f64::MIN_POSITIVE), + ); + if ctx.set_source_surface(surface, 0.0, 0.0).is_ok() { + let _ = ctx.paint(); + } + let _ = ctx.restore(); + } + + fn replay_context(&self) -> EraserReplayContext<'_> { + EraserReplayContext { + pattern: self.pattern.as_ref().map(|p| p as &cairo::Pattern), + surface: self.surface.as_ref(), + backdrop_cache_key: self.surface.as_ref().map(|_| 1), + bg_color: self.bg_color, + logical_to_image_scale_x: self.logical_to_image_scale_x, + logical_to_image_scale_y: self.logical_to_image_scale_y, + } + } +} + +fn validate_persisted_image_backdrop( + data_len: usize, + width: i32, + height: i32, + stride: i32, +) -> Result<(), CaptureError> { + if width <= 0 || height <= 0 { + return Err(CaptureError::ImageError(format!( + "Invalid export backdrop dimensions: {width}x{height}" + ))); + } + if stride <= 0 { + return Err(CaptureError::ImageError(format!( + "Invalid export backdrop stride: {stride}" + ))); + } + + let width = width as usize; + let height = height as usize; + let stride = stride as usize; + let min_stride = width.checked_mul(4).ok_or_else(|| { + CaptureError::ImageError("Export backdrop width is too large".to_string()) + })?; + if stride < min_stride { + return Err(CaptureError::ImageError(format!( + "Export backdrop stride {stride} is too small for width {width}" + ))); + } + + let required_len = stride.checked_mul(height).ok_or_else(|| { + CaptureError::ImageError("Export backdrop buffer size overflow".to_string()) + })?; + if data_len < required_len { + return Err(CaptureError::ImageError(format!( + "Export backdrop buffer is too small: {data_len} bytes for {required_len} required" + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{RenderColorMappingConfig, RenderProfileConfig}; + use crate::draw::{BLACK, RED, Shape, WHITE}; + + fn snapshot(frame: Frame, viewport: CanvasExportViewport) -> CanvasExportSnapshot { + CanvasExportSnapshot { + viewport, + backdrop: CanvasExportBackdropSnapshot::Transparent, + board: BoardExportSnapshot { frame }, + render_profile: None, + } + } + + fn pixel(surface: &mut cairo::ImageSurface, x: i32, y: i32) -> u32 { + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().expect("surface data"); + let offset = y as usize * stride + x as usize * 4; + u32::from_ne_bytes(data[offset..offset + 4].try_into().expect("pixel")) + } + + #[test] + fn export_uses_current_viewport_origin() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 20, + y: 10, + w: 8, + h: 8, + fill: true, + color: RED, + thick: 1.0, + }); + let mut surface = render_canvas_surface(&snapshot( + frame, + CanvasExportViewport { + logical_width: 20, + logical_height: 20, + scale: 1, + origin_x: 20, + origin_y: 10, + }, + )) + .expect("surface"); + + assert_ne!(pixel(&mut surface, 3, 3), 0); + } + + #[test] + fn export_scale_creates_physical_surface_and_scales_geometry() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 1, + y: 1, + w: 4, + h: 4, + fill: true, + color: RED, + thick: 1.0, + }); + let mut surface = render_canvas_surface(&snapshot( + frame, + CanvasExportViewport { + logical_width: 10, + logical_height: 10, + scale: 2, + origin_x: 0, + origin_y: 0, + }, + )) + .expect("surface"); + + assert_eq!(surface.width(), 20); + assert_eq!(surface.height(), 20); + assert_ne!(pixel(&mut surface, 4, 4), 0); + assert_eq!(pixel(&mut surface, 0, 0), 0); + } + + #[test] + fn export_applies_cloned_profile_to_pixels() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 0, + y: 0, + w: 6, + h: 6, + fill: true, + color: BLACK, + thick: 1.0, + }); + let profile = RenderColorProfile::from_config(&RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: vec![RenderColorMappingConfig { + from: "#000000".to_string(), + to: "#FFFFFF".to_string(), + }], + }) + .expect("profile"); + let mut export = snapshot( + frame, + CanvasExportViewport { + logical_width: 8, + logical_height: 8, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + ); + export.render_profile = Some(profile); + + let mut surface = render_canvas_surface(&export).expect("surface"); + + assert_eq!(pixel(&mut surface, 2, 2), 0xffffffff); + } + + #[test] + fn export_replays_eraser_on_solid_background() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 0, + y: 0, + w: 12, + h: 12, + fill: true, + color: RED, + thick: 1.0, + }); + frame.add_shape(Shape::EraserStroke { + points: vec![(6, 6)], + brush: crate::draw::EraserBrush { + size: 6.0, + kind: crate::draw::EraserKind::Circle, + }, + }); + let mut export = snapshot( + frame, + CanvasExportViewport { + logical_width: 14, + logical_height: 14, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + ); + export.backdrop = CanvasExportBackdropSnapshot::Solid(WHITE); + let mut surface = render_canvas_surface(&export).expect("surface"); + + assert_eq!(pixel(&mut surface, 6, 6), 0xffffffff); + } + + #[test] + fn export_replays_eraser_on_transparent_background() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 0, + y: 0, + w: 12, + h: 12, + fill: true, + color: RED, + thick: 1.0, + }); + frame.add_shape(Shape::EraserStroke { + points: vec![(6, 6)], + brush: crate::draw::EraserBrush { + size: 6.0, + kind: crate::draw::EraserKind::Circle, + }, + }); + let mut surface = render_canvas_surface(&snapshot( + frame, + CanvasExportViewport { + logical_width: 14, + logical_height: 14, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + )) + .expect("surface"); + + assert_eq!(pixel(&mut surface, 6, 6), 0); + } + + #[test] + fn export_blur_uses_placeholder_without_persisted_backdrop() { + let mut frame = Frame::new(); + frame.add_shape(Shape::BlurRect { + x: 2, + y: 2, + w: 8, + h: 8, + strength: 12.0, + }); + let mut surface = render_canvas_surface(&snapshot( + frame, + CanvasExportViewport { + logical_width: 14, + logical_height: 14, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + )) + .expect("surface"); + + assert_ne!(pixel(&mut surface, 5, 5), 0); + } + + #[test] + fn export_blur_replays_against_persisted_image_backdrop() { + let width = 16; + let height = 16; + let stride = width * 4; + let mut data = vec![0u8; (stride * height) as usize]; + for y in 0..height { + for x in 0..width { + let offset = (y * stride + x * 4) as usize; + let red = if x < 8 { 255 } else { 0 }; + let blue = if x < 8 { 0 } else { 255 }; + data[offset..offset + 4].copy_from_slice( + &(0xff000000u32 | ((red as u32) << 16) | blue as u32).to_ne_bytes(), + ); + } + } + let mut frame = Frame::new(); + frame.add_shape(Shape::BlurRect { + x: 4, + y: 4, + w: 8, + h: 8, + strength: 12.0, + }); + let mut export = snapshot( + frame, + CanvasExportViewport { + logical_width: width as u32, + logical_height: height as u32, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + ); + export.backdrop = CanvasExportBackdropSnapshot::PersistedImage { + data: Arc::from(data), + width, + height, + stride, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + }; + let mut surface = render_canvas_surface(&export).expect("surface"); + + assert_ne!(pixel(&mut surface, 6, 6), 0); + assert_ne!(pixel(&mut surface, 6, 6), pixel(&mut surface, 1, 1)); + } + + #[test] + fn export_rejects_invalid_persisted_image_backdrop_buffer() { + let mut export = snapshot( + Frame::new(), + CanvasExportViewport { + logical_width: 4, + logical_height: 4, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + ); + export.backdrop = CanvasExportBackdropSnapshot::PersistedImage { + data: Arc::from(vec![0u8; 8]), + width: 4, + height: 4, + stride: 16, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + }; + + let err = match render_canvas_surface(&export) { + Ok(_) => panic!("short backdrop must fail"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("buffer is too small"), + "unexpected error: {err}" + ); + } +} diff --git a/src/capture/manager.rs b/src/capture/manager.rs index dbde711d..fedd82e2 100644 --- a/src/capture/manager.rs +++ b/src/capture/manager.rs @@ -5,8 +5,11 @@ use tokio::sync::{Mutex, mpsc}; use crate::capture::{ dependencies::CaptureDependencies, file::FileSaveConfig, - pipeline::{CaptureRequest, perform_capture}, - types::{CaptureDestination, CaptureError, CaptureOutcome, CaptureStatus, CaptureType}, + pipeline::{CaptureManagerRequest, CaptureRequest, deliver_image, perform_capture}, + types::{ + CaptureDestination, CaptureError, CaptureOutcome, CaptureStatus, CaptureType, + ImageDeliveryRequest, + }, }; /// Shared state for managing async capture operations. @@ -15,7 +18,7 @@ use crate::capture::{ #[derive(Clone)] pub struct CaptureManager { /// Channel for sending capture requests. - request_tx: mpsc::UnboundedSender, + request_tx: mpsc::UnboundedSender, /// Shared status of the current capture operation. status: Arc>, /// Shared result of the last capture (if any). @@ -39,7 +42,7 @@ impl CaptureManager { runtime_handle: &tokio::runtime::Handle, dependencies: CaptureDependencies, ) -> Self { - let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); + let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); let status = Arc::new(Mutex::new(CaptureStatus::Idle)); let last_result = Arc::new(Mutex::new(None)); let dependencies = Arc::new(dependencies); @@ -51,28 +54,41 @@ impl CaptureManager { // Spawn background task to handle capture requests runtime_handle.spawn(async move { while let Some(request) = request_rx.recv().await { - log::debug!("Processing capture request: {:?}", request.capture_type); + log::debug!("Processing capture manager request: {:?}", request); + let operation = request.operation(); // Update status *status_clone.lock().await = CaptureStatus::AwaitingPermission; - // Perform capture - match perform_capture(request, deps_clone.clone()).await { + let outcome = match request { + CaptureManagerRequest::Capture(request) => { + perform_capture(request, deps_clone.clone()).await + } + CaptureManagerRequest::DeliverImage(request) => { + deliver_image(request, deps_clone.clone()).await + } + }; + + match outcome { Ok(result) => { - log::info!("Capture successful: {:?}", result.saved_path); + log::info!("Image operation successful: {:?}", result.saved_path); *status_clone.lock().await = CaptureStatus::Success; *result_clone.lock().await = Some(CaptureOutcome::Success(result)); } Err(CaptureError::Cancelled(reason)) => { - log::info!("Capture cancelled: {}", reason); + log::info!("Image operation cancelled: {}", reason); *status_clone.lock().await = CaptureStatus::Cancelled(reason.clone()); - *result_clone.lock().await = Some(CaptureOutcome::Cancelled(reason)); + *result_clone.lock().await = + Some(CaptureOutcome::Cancelled { operation, reason }); } Err(e) => { - let error_message = e.to_string(); - log::error!("Capture failed: {}", error_message); + let error_message = operation.format_error(&e); + log::error!("Image operation failed: {}", error_message); *status_clone.lock().await = CaptureStatus::Failed(error_message.clone()); - *result_clone.lock().await = Some(CaptureOutcome::Failed(error_message)); + *result_clone.lock().await = Some(CaptureOutcome::Failed { + operation, + message: error_message, + }); } } } @@ -107,7 +123,18 @@ impl CaptureManager { }; self.request_tx - .send(request) + .send(CaptureManagerRequest::Capture(request)) + .map_err(|_| CaptureError::ImageError("Capture manager not running".to_string()))?; + + Ok(()) + } + + pub fn request_image_delivery( + &self, + request: ImageDeliveryRequest, + ) -> Result<(), CaptureError> { + self.request_tx + .send(CaptureManagerRequest::DeliverImage(request)) .map_err(|_| CaptureError::ImageError("Capture manager not running".to_string()))?; Ok(()) @@ -141,7 +168,7 @@ impl CaptureManager { #[cfg(test)] impl CaptureManager { pub(crate) fn with_closed_channel_for_test() -> Self { - let (tx, rx) = mpsc::unbounded_channel::(); + let (tx, rx) = mpsc::unbounded_channel::(); drop(rx); Self { request_tx: tx, diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 7556ff19..60c29935 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -26,4 +26,5 @@ pub(crate) use pipeline::CaptureRequest; #[allow(unused_imports)] pub use types::{ CaptureDestination, CaptureError, CaptureOutcome, CaptureResult, CaptureStatus, CaptureType, + ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedImage, }; diff --git a/src/capture/pipeline.rs b/src/capture/pipeline.rs index 2541eb29..6ccb57cd 100644 --- a/src/capture/pipeline.rs +++ b/src/capture/pipeline.rs @@ -3,7 +3,10 @@ use std::{fmt, path::PathBuf, sync::Arc}; use crate::capture::{ dependencies::{CaptureClipboard, CaptureDependencies, CaptureFileSaver}, file::FileSaveConfig, - types::{CaptureDestination, CaptureError, CaptureResult, CaptureType}, + types::{ + CaptureDestination, CaptureError, CaptureResult, CaptureType, ImageDeliveryRequest, + ImageOperationKind, + }, }; use tokio::task; @@ -30,6 +33,37 @@ impl fmt::Debug for CaptureRequest { } } +#[derive(Clone)] +pub(crate) enum CaptureManagerRequest { + Capture(CaptureRequest), + DeliverImage(ImageDeliveryRequest), +} + +impl CaptureManagerRequest { + pub(crate) fn operation(&self) -> ImageOperationKind { + match self { + Self::Capture(_) => ImageOperationKind::Screenshot, + Self::DeliverImage(request) => request.operation, + } + } +} + +impl fmt::Debug for CaptureManagerRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Capture(request) => f.debug_tuple("Capture").field(request).finish(), + Self::DeliverImage(request) => f + .debug_struct("DeliverImage") + .field("destination", &request.destination) + .field("operation", &request.operation) + .field("width", &request.image.width) + .field("height", &request.image.height) + .field("format", &request.image.format) + .finish(), + } + } +} + pub(crate) async fn perform_capture( request: CaptureRequest, dependencies: Arc, @@ -123,6 +157,84 @@ pub(crate) async fn perform_capture( Ok(CaptureResult { image_data, + operation: ImageOperationKind::Screenshot, + fallback_format_override: None, + saved_path, + copied_to_clipboard, + }) +} + +pub(crate) async fn deliver_image( + request: ImageDeliveryRequest, + dependencies: Arc, +) -> Result { + log::info!( + "Starting image delivery: {:?} {}x{} {} bytes", + request.operation, + request.image.width, + request.image.height, + request.image.bytes.len() + ); + + let image_data = request.image.bytes; + let save_config = request.save_config.map(|mut config| { + config.format = request.image.format.extension.clone(); + config + }); + + let mut save_error = None; + let saved_path = match request.destination { + CaptureDestination::FileOnly => { + if let Some(config) = + save_config.filter(|config| !config.save_directory.as_os_str().is_empty()) + { + Some(save_image(Arc::clone(&dependencies.saver), image_data.clone(), config).await?) + } else { + None + } + } + CaptureDestination::ClipboardAndFile => { + if let Some(config) = + save_config.filter(|config| !config.save_directory.as_os_str().is_empty()) + { + match save_image(Arc::clone(&dependencies.saver), image_data.clone(), config).await + { + Ok(path) => Some(path), + Err(err) => { + log::warn!("Failed to save delivered image: {}", err); + save_error = Some(err); + None + } + } + } else { + None + } + } + CaptureDestination::ClipboardOnly => None, + }; + + let copied_to_clipboard = match request.destination { + CaptureDestination::ClipboardOnly | CaptureDestination::ClipboardAndFile => { + log::info!( + "Attempting to copy delivered image {} bytes to clipboard", + image_data.len() + ); + copy_to_clipboard(Arc::clone(&dependencies.clipboard), image_data.clone()).await + } + CaptureDestination::FileOnly => false, + }; + + if matches!(request.destination, CaptureDestination::ClipboardAndFile) + && !copied_to_clipboard + && let Some(save_error) = save_error + { + return Err(save_error); + } + + Ok(CaptureResult { + image_data, + operation: request.operation, + fallback_format_override: request.fallback_format_override, saved_path, copied_to_clipboard, }) diff --git a/src/capture/tests/manager.rs b/src/capture/tests/manager.rs index 93b7e909..9ba30b4c 100644 --- a/src/capture/tests/manager.rs +++ b/src/capture/tests/manager.rs @@ -6,6 +6,7 @@ use std::{ use tokio::time::{Duration, sleep}; use crate::capture::{ + ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedImage, dependencies::CaptureDependencies, file::FileSaveConfig, manager::CaptureManager, @@ -14,6 +15,25 @@ use crate::capture::{ use super::fixtures::{MockClipboard, MockSaver, MockSource}; +async fn wait_for_manager_outcome(manager: &CaptureManager) -> Option { + for _ in 0..10 { + if let Some(result) = manager.try_take_result() { + return Some(result); + } + sleep(Duration::from_millis(20)).await; + } + None +} + +fn rendered_png(bytes: Vec) -> RenderedImage { + RenderedImage { + bytes, + format: ImageFormatMetadata::png(), + width: 1, + height: 1, + } +} + #[tokio::test] async fn test_capture_manager_creation() { let manager = CaptureManager::new(&tokio::runtime::Handle::current()); @@ -54,15 +74,7 @@ async fn test_capture_manager_with_dependencies() { ) .unwrap(); - // Wait for background thread to finish - let mut outcome = None; - for _ in 0..10 { - if let Some(result) = manager.try_take_result() { - outcome = Some(result); - break; - } - sleep(Duration::from_millis(20)).await; - } + let outcome = wait_for_manager_outcome(&manager).await; match outcome { Some(CaptureOutcome::Success(result)) => { @@ -123,18 +135,10 @@ async fn capture_manager_records_failure_status() { ) .unwrap(); - // wait for failure outcome - let mut outcome = None; - for _ in 0..10 { - if let Some(result) = manager.try_take_result() { - outcome = Some(result); - break; - } - sleep(Duration::from_millis(20)).await; - } + let outcome = wait_for_manager_outcome(&manager).await; match outcome { - Some(CaptureOutcome::Failed(msg)) => { + Some(CaptureOutcome::Failed { message: msg, .. }) => { assert!( msg.contains("save failed"), "unexpected failure message: {msg}" @@ -148,3 +152,175 @@ async fn capture_manager_records_failure_status() { CaptureStatus::Failed(_) )); } + +#[tokio::test] +async fn request_image_delivery_queues_manager_backed_path() { + let captured_types = Arc::new(Mutex::new(Vec::new())); + let source = MockSource { + data: vec![99], + error: Arc::new(Mutex::new(None)), + captured_types: captured_types.clone(), + }; + let saver = MockSaver { + should_fail: false, + path: PathBuf::from("/tmp/canvas-delivery.png"), + calls: Arc::new(Mutex::new(0)), + }; + let saver_handle = saver.clone(); + let clipboard = MockClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + }; + let deps = CaptureDependencies { + source: Arc::new(source), + saver: Arc::new(saver), + clipboard: Arc::new(clipboard), + }; + let manager = CaptureManager::with_dependencies(&tokio::runtime::Handle::current(), deps); + + manager + .request_image_delivery(ImageDeliveryRequest { + image: RenderedImage { + bytes: vec![1, 2, 3], + format: ImageFormatMetadata::png(), + width: 1, + height: 1, + }, + destination: CaptureDestination::FileOnly, + save_config: Some(FileSaveConfig { + format: "jpg".to_string(), + ..FileSaveConfig::default() + }), + operation: ImageOperationKind::CanvasExport, + fallback_format_override: Some(ImageFormatMetadata::png()), + }) + .unwrap(); + + let outcome = wait_for_manager_outcome(&manager).await; + + match outcome { + Some(CaptureOutcome::Success(result)) => { + assert_eq!(result.operation, ImageOperationKind::CanvasExport); + assert_eq!(result.image_data, vec![1, 2, 3]); + assert_eq!( + result.saved_path, + Some(PathBuf::from("/tmp/canvas-delivery.png")) + ); + } + other => panic!("Expected success outcome, got {other:?}"), + } + assert_eq!(*saver_handle.calls.lock().unwrap(), 1); + assert!(captured_types.lock().unwrap().is_empty()); + assert_eq!(manager.get_status().await, CaptureStatus::Success); +} + +#[tokio::test] +async fn request_image_delivery_records_canvas_save_failure() { + let captured_types = Arc::new(Mutex::new(Vec::new())); + let source = MockSource { + data: vec![99], + error: Arc::new(Mutex::new(None)), + captured_types: captured_types.clone(), + }; + let saver = MockSaver { + should_fail: true, + path: PathBuf::from("/tmp/canvas-delivery.png"), + calls: Arc::new(Mutex::new(0)), + }; + let saver_handle = saver.clone(); + let clipboard = MockClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + }; + let deps = CaptureDependencies { + source: Arc::new(source), + saver: Arc::new(saver), + clipboard: Arc::new(clipboard), + }; + let manager = CaptureManager::with_dependencies(&tokio::runtime::Handle::current(), deps); + + manager + .request_image_delivery(ImageDeliveryRequest { + image: rendered_png(vec![1, 2, 3]), + destination: CaptureDestination::FileOnly, + save_config: Some(FileSaveConfig::default()), + operation: ImageOperationKind::CanvasExport, + fallback_format_override: Some(ImageFormatMetadata::png()), + }) + .unwrap(); + + let outcome = wait_for_manager_outcome(&manager).await; + + match outcome { + Some(CaptureOutcome::Failed { operation, message }) => { + assert_eq!(operation, ImageOperationKind::CanvasExport); + assert!( + message.contains("Failed to save canvas export"), + "unexpected failure message: {message}" + ); + assert!( + !message.to_lowercase().contains("screenshot"), + "canvas export failure should not mention screenshot: {message}" + ); + } + other => panic!("Expected failure outcome, got {other:?}"), + } + assert_eq!(*saver_handle.calls.lock().unwrap(), 1); + assert!(captured_types.lock().unwrap().is_empty()); + assert!(matches!( + manager.get_status().await, + CaptureStatus::Failed(ref message) + if message.contains("Failed to save canvas export") + && !message.to_lowercase().contains("screenshot") + )); +} + +#[tokio::test] +async fn request_image_delivery_preserves_clipboard_success_when_file_fails() { + let captured_types = Arc::new(Mutex::new(Vec::new())); + let source = MockSource { + data: vec![99], + error: Arc::new(Mutex::new(None)), + captured_types: captured_types.clone(), + }; + let saver = MockSaver { + should_fail: true, + path: PathBuf::from("/tmp/canvas-delivery.png"), + calls: Arc::new(Mutex::new(0)), + }; + let clipboard_calls = Arc::new(Mutex::new(0)); + let clipboard = MockClipboard { + should_fail: false, + calls: clipboard_calls.clone(), + }; + let deps = CaptureDependencies { + source: Arc::new(source), + saver: Arc::new(saver), + clipboard: Arc::new(clipboard), + }; + let manager = CaptureManager::with_dependencies(&tokio::runtime::Handle::current(), deps); + + manager + .request_image_delivery(ImageDeliveryRequest { + image: rendered_png(vec![1, 2, 3]), + destination: CaptureDestination::ClipboardAndFile, + save_config: Some(FileSaveConfig::default()), + operation: ImageOperationKind::CanvasExport, + fallback_format_override: Some(ImageFormatMetadata::png()), + }) + .unwrap(); + + let outcome = wait_for_manager_outcome(&manager).await; + + match outcome { + Some(CaptureOutcome::Success(result)) => { + assert_eq!(result.operation, ImageOperationKind::CanvasExport); + assert!(result.saved_path.is_none()); + assert!(result.copied_to_clipboard); + } + other => panic!("Expected success outcome, got {other:?}"), + } + assert_eq!(*clipboard_calls.lock().unwrap(), 1); + assert!(captured_types.lock().unwrap().is_empty()); + assert_eq!(manager.get_status().await, CaptureStatus::Success); +} diff --git a/src/capture/tests/perform_capture.rs b/src/capture/tests/perform_capture.rs index 3d6c1253..75763cdb 100644 --- a/src/capture/tests/perform_capture.rs +++ b/src/capture/tests/perform_capture.rs @@ -4,14 +4,69 @@ use std::{ }; use crate::capture::{ - dependencies::CaptureDependencies, + dependencies::{CaptureClipboard, CaptureDependencies, CaptureFileSaver}, file::FileSaveConfig, - pipeline::{CaptureRequest, perform_capture}, - types::{CaptureDestination, CaptureError, CaptureType}, + pipeline::{CaptureRequest, deliver_image, perform_capture}, + types::{ + CaptureDestination, CaptureError, CaptureType, ImageDeliveryRequest, ImageFormatMetadata, + ImageOperationKind, RenderedImage, + }, }; use super::fixtures::{MockClipboard, MockSaver, MockSource}; +#[derive(Clone)] +struct RecordingSaver { + should_fail: bool, + path: PathBuf, + calls: Arc>, + configs: Arc>>, +} + +impl CaptureFileSaver for RecordingSaver { + fn save(&self, _image_data: &[u8], config: &FileSaveConfig) -> Result { + *self.calls.lock().unwrap() += 1; + self.configs.lock().unwrap().push(config.clone()); + if self.should_fail { + Err(CaptureError::SaveError(std::io::Error::other( + "save failed", + ))) + } else { + Ok(self.path.clone()) + } + } +} + +#[derive(Clone)] +struct RecordingClipboard { + should_fail: bool, + calls: Arc>, + copied: Arc>>>, +} + +impl CaptureClipboard for RecordingClipboard { + fn copy(&self, image_data: &[u8]) -> Result<(), CaptureError> { + *self.calls.lock().unwrap() += 1; + self.copied.lock().unwrap().push(image_data.to_vec()); + if self.should_fail { + Err(CaptureError::ClipboardError( + "clipboard failure".to_string(), + )) + } else { + Ok(()) + } + } +} + +fn rendered_png(bytes: Vec) -> RenderedImage { + RenderedImage { + bytes, + format: ImageFormatMetadata::png(), + width: 2, + height: 1, + } +} + #[tokio::test] async fn test_perform_capture_clipboard_only_success() { let source = MockSource { @@ -44,12 +99,169 @@ async fn test_perform_capture_clipboard_only_success() { let result = perform_capture(request, Arc::new(deps.clone())) .await .unwrap(); + assert_eq!(result.operation, ImageOperationKind::Screenshot); + assert!(result.fallback_format_override.is_none()); assert!(result.saved_path.is_none()); assert!(result.copied_to_clipboard); assert_eq!(*clipboard_handle.calls.lock().unwrap(), 1); assert_eq!(*saver_handle.calls.lock().unwrap(), 0); } +#[tokio::test] +async fn deliver_image_file_only_saves_rendered_format_extension() { + let configs = Arc::new(Mutex::new(Vec::new())); + let saver = RecordingSaver { + should_fail: false, + path: PathBuf::from("/tmp/canvas.png"), + calls: Arc::new(Mutex::new(0)), + configs: configs.clone(), + }; + let clipboard = RecordingClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + copied: Arc::new(Mutex::new(Vec::new())), + }; + let deps = CaptureDependencies { + source: Arc::new(MockSource { + data: Vec::new(), + error: Arc::new(Mutex::new(None)), + captured_types: Arc::new(Mutex::new(Vec::new())), + }), + saver: Arc::new(saver.clone()), + clipboard: Arc::new(clipboard), + }; + let request = ImageDeliveryRequest { + image: rendered_png(vec![137, 80, 78, 71]), + destination: CaptureDestination::FileOnly, + save_config: Some(FileSaveConfig { + format: "jpg".to_string(), + ..FileSaveConfig::default() + }), + operation: ImageOperationKind::CanvasExport, + fallback_format_override: Some(ImageFormatMetadata::png()), + }; + + let result = deliver_image(request, Arc::new(deps)).await.unwrap(); + + assert_eq!(result.operation, ImageOperationKind::CanvasExport); + assert_eq!( + result.fallback_format_override, + Some(ImageFormatMetadata::png()) + ); + assert_eq!(*saver.calls.lock().unwrap(), 1); + assert_eq!(configs.lock().unwrap()[0].format, "png"); + assert_eq!(result.saved_path, Some(PathBuf::from("/tmp/canvas.png"))); +} + +#[tokio::test] +async fn deliver_image_clipboard_only_copies_png_bytes() { + let copied = Arc::new(Mutex::new(Vec::new())); + let clipboard = RecordingClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + copied: copied.clone(), + }; + let deps = CaptureDependencies { + source: Arc::new(MockSource { + data: Vec::new(), + error: Arc::new(Mutex::new(None)), + captured_types: Arc::new(Mutex::new(Vec::new())), + }), + saver: Arc::new(RecordingSaver { + should_fail: false, + path: PathBuf::from("/tmp/unused.png"), + calls: Arc::new(Mutex::new(0)), + configs: Arc::new(Mutex::new(Vec::new())), + }), + clipboard: Arc::new(clipboard.clone()), + }; + let bytes = vec![1, 2, 3, 4]; + let request = ImageDeliveryRequest { + image: rendered_png(bytes.clone()), + destination: CaptureDestination::ClipboardOnly, + save_config: None, + operation: ImageOperationKind::CanvasExport, + fallback_format_override: Some(ImageFormatMetadata::png()), + }; + + let result = deliver_image(request, Arc::new(deps)).await.unwrap(); + + assert!(result.copied_to_clipboard); + assert_eq!(*clipboard.calls.lock().unwrap(), 1); + assert_eq!(copied.lock().unwrap()[0], bytes); +} + +#[tokio::test] +async fn deliver_image_clipboard_and_file_keeps_file_success_when_clipboard_fails() { + let saver = RecordingSaver { + should_fail: false, + path: PathBuf::from("/tmp/partial.png"), + calls: Arc::new(Mutex::new(0)), + configs: Arc::new(Mutex::new(Vec::new())), + }; + let deps = CaptureDependencies { + source: Arc::new(MockSource { + data: Vec::new(), + error: Arc::new(Mutex::new(None)), + captured_types: Arc::new(Mutex::new(Vec::new())), + }), + saver: Arc::new(saver), + clipboard: Arc::new(RecordingClipboard { + should_fail: true, + calls: Arc::new(Mutex::new(0)), + copied: Arc::new(Mutex::new(Vec::new())), + }), + }; + let request = ImageDeliveryRequest { + image: rendered_png(vec![1, 2, 3]), + destination: CaptureDestination::ClipboardAndFile, + save_config: Some(FileSaveConfig::default()), + operation: ImageOperationKind::CanvasExport, + fallback_format_override: Some(ImageFormatMetadata::png()), + }; + + let result = deliver_image(request, Arc::new(deps)).await.unwrap(); + + assert_eq!(result.saved_path, Some(PathBuf::from("/tmp/partial.png"))); + assert!(!result.copied_to_clipboard); +} + +#[tokio::test] +async fn deliver_image_clipboard_and_file_keeps_clipboard_success_when_file_fails() { + let clipboard = RecordingClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + copied: Arc::new(Mutex::new(Vec::new())), + }; + let deps = CaptureDependencies { + source: Arc::new(MockSource { + data: Vec::new(), + error: Arc::new(Mutex::new(None)), + captured_types: Arc::new(Mutex::new(Vec::new())), + }), + saver: Arc::new(RecordingSaver { + should_fail: true, + path: PathBuf::from("/tmp/partial.png"), + calls: Arc::new(Mutex::new(0)), + configs: Arc::new(Mutex::new(Vec::new())), + }), + clipboard: Arc::new(clipboard.clone()), + }; + let request = ImageDeliveryRequest { + image: rendered_png(vec![1, 2, 3]), + destination: CaptureDestination::ClipboardAndFile, + save_config: Some(FileSaveConfig::default()), + operation: ImageOperationKind::CanvasExport, + fallback_format_override: Some(ImageFormatMetadata::png()), + }; + + let result = deliver_image(request, Arc::new(deps)).await.unwrap(); + + assert!(result.saved_path.is_none()); + assert!(result.copied_to_clipboard); + assert_eq!(*clipboard.calls.lock().unwrap(), 1); +} + #[tokio::test] async fn test_perform_capture_file_only_success() { let source = MockSource { diff --git a/src/capture/types.rs b/src/capture/types.rs index 239d4c94..e1096928 100644 --- a/src/capture/types.rs +++ b/src/capture/types.rs @@ -3,6 +3,99 @@ use std::fmt; use std::path::PathBuf; +/// User-facing operation kind for image delivery and status labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageOperationKind { + Screenshot, + CanvasExport, +} + +impl ImageOperationKind { + pub fn success_title(self) -> &'static str { + match self { + Self::Screenshot => "Screenshot Captured", + Self::CanvasExport => "Canvas exported", + } + } + + pub fn failure_title(self) -> &'static str { + match self { + Self::Screenshot => "Screenshot Failed", + Self::CanvasExport => "Canvas export failed", + } + } + + pub fn clipboard_failure_title(self) -> &'static str { + match self { + Self::Screenshot => "Screenshot Clipboard Failed", + Self::CanvasExport => "Canvas clipboard failed", + } + } + + pub fn fallback_toast(self) -> &'static str { + match self { + Self::Screenshot => "Clipboard failed", + Self::CanvasExport => "Canvas clipboard failed", + } + } + + pub fn saved_log_label(self) -> &'static str { + match self { + Self::Screenshot => "Screenshot", + Self::CanvasExport => "Canvas export", + } + } + + pub fn format_error(self, err: &CaptureError) -> String { + match self { + Self::Screenshot => err.to_string(), + Self::CanvasExport => match err { + CaptureError::SaveError(err) => { + format!("Failed to save canvas export: {err}") + } + CaptureError::ClipboardError(err) => { + format!("Canvas export clipboard operation failed: {err}") + } + CaptureError::ImageError(err) => format!("Canvas export failed: {err}"), + CaptureError::Cancelled(reason) => format!("Canvas export cancelled: {reason}"), + other => other.to_string(), + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageFormatMetadata { + pub extension: String, + pub mime_type: String, +} + +impl ImageFormatMetadata { + pub fn png() -> Self { + Self { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct RenderedImage { + pub bytes: Vec, + pub format: ImageFormatMetadata, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone)] +pub struct ImageDeliveryRequest { + pub image: RenderedImage, + pub destination: CaptureDestination, + pub save_config: Option, + pub operation: ImageOperationKind, + pub fallback_format_override: Option, +} + /// Type of screenshot capture to perform. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CaptureType { @@ -26,6 +119,8 @@ pub struct CaptureResult { /// Raw image data (PNG format). #[allow(dead_code)] // Will be used in Phase 2 for annotation compositing pub image_data: Vec, + pub operation: ImageOperationKind, + pub fallback_format_override: Option, /// Path where the image was saved (if saved). pub saved_path: Option, /// Whether the image was copied to clipboard. @@ -37,8 +132,14 @@ pub struct CaptureResult { #[derive(Debug, Clone)] pub enum CaptureOutcome { Success(CaptureResult), - Failed(String), - Cancelled(String), + Failed { + operation: ImageOperationKind, + message: String, + }, + Cancelled { + operation: ImageOperationKind, + reason: String, + }, } /// Where the captured image should be delivered. diff --git a/src/config/action_meta/entries/capture.rs b/src/config/action_meta/entries/capture.rs index 62b5b627..595e7d29 100644 --- a/src/config/action_meta/entries/capture.rs +++ b/src/config/action_meta/entries/capture.rs @@ -91,6 +91,45 @@ pub const ENTRIES: &[ActionMeta] = &[ false, false ), + meta!( + ExportCanvasFile, + "Export Canvas to File", + Some("Canvas to File"), + "Export persisted canvas as PNG", + Capture, + true, + true, + false, + &["save board", "export board", "export canvas", "png"] + ), + meta!( + ExportCanvasClipboard, + "Export Canvas to Clipboard", + Some("Canvas to Clipboard"), + "Copy persisted canvas PNG to clipboard", + Capture, + true, + true, + false, + &["export board", "export canvas", "png", "clipboard"] + ), + meta!( + ExportCanvasClipboardAndFile, + "Export Canvas to Clipboard and File", + Some("Canvas to Clipboard and File"), + "Copy persisted canvas PNG to clipboard and save it", + Capture, + true, + true, + false, + &[ + "save board", + "export board", + "export canvas", + "png", + "clipboard" + ] + ), meta!( OpenCaptureFolder, "Open Capture Folder", diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index abf6fa20..f2d580d3 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -80,6 +80,9 @@ const HELP_ACTIONS: &[Action] = &[ Action::ToggleFrozenMode, Action::CaptureClipboardFull, Action::CaptureFileFull, + Action::ExportCanvasFile, + Action::ExportCanvasClipboard, + Action::ExportCanvasClipboardAndFile, Action::CaptureClipboardSelection, Action::CaptureFileSelection, Action::CaptureActiveWindow, @@ -185,6 +188,9 @@ const PALETTE_ACTIONS: &[Action] = &[ Action::SetColorBlack, Action::CaptureClipboardFull, Action::CaptureFileFull, + Action::ExportCanvasFile, + Action::ExportCanvasClipboard, + Action::ExportCanvasClipboardAndFile, Action::OpenCaptureFolder, Action::ToggleFrozenMode, Action::ZoomIn, diff --git a/src/config/keybindings/actions.rs b/src/config/keybindings/actions.rs index 580e5304..3ea63eee 100644 --- a/src/config/keybindings/actions.rs +++ b/src/config/keybindings/actions.rs @@ -145,6 +145,9 @@ pub enum Action { CaptureFileSelection, CaptureClipboardRegion, CaptureFileRegion, + ExportCanvasFile, + ExportCanvasClipboard, + ExportCanvasClipboardAndFile, OpenCaptureFolder, ToggleFrozenMode, ZoomIn, diff --git a/src/config/keybindings/config/map/capture.rs b/src/config/keybindings/config/map/capture.rs index cd743172..c2810295 100644 --- a/src/config/keybindings/config/map/capture.rs +++ b/src/config/keybindings/config/map/capture.rs @@ -31,6 +31,15 @@ impl KeybindingsConfig { Action::CaptureClipboardRegion, )?; inserter.insert_all(&self.capture.capture_file_region, Action::CaptureFileRegion)?; + inserter.insert_all(&self.capture.export_canvas_file, Action::ExportCanvasFile)?; + inserter.insert_all( + &self.capture.export_canvas_clipboard, + Action::ExportCanvasClipboard, + )?; + inserter.insert_all( + &self.capture.export_canvas_clipboard_and_file, + Action::ExportCanvasClipboardAndFile, + )?; inserter.insert_all(&self.capture.open_capture_folder, Action::OpenCaptureFolder)?; Ok(()) } diff --git a/src/config/keybindings/config/types/bindings/capture.rs b/src/config/keybindings/config/types/bindings/capture.rs index fd9cda78..194fd34d 100644 --- a/src/config/keybindings/config/types/bindings/capture.rs +++ b/src/config/keybindings/config/types/bindings/capture.rs @@ -32,6 +32,15 @@ pub struct CaptureKeybindingsConfig { #[serde(default = "default_capture_file_region")] pub capture_file_region: Vec, + #[serde(default = "default_export_canvas_file")] + pub export_canvas_file: Vec, + + #[serde(default = "default_export_canvas_clipboard")] + pub export_canvas_clipboard: Vec, + + #[serde(default = "default_export_canvas_clipboard_and_file")] + pub export_canvas_clipboard_and_file: Vec, + #[serde(default = "default_open_capture_folder")] pub open_capture_folder: Vec, } @@ -48,6 +57,9 @@ impl Default for CaptureKeybindingsConfig { capture_file_selection: default_capture_file_selection(), capture_clipboard_region: default_capture_clipboard_region(), capture_file_region: default_capture_file_region(), + export_canvas_file: default_export_canvas_file(), + export_canvas_clipboard: default_export_canvas_clipboard(), + export_canvas_clipboard_and_file: default_export_canvas_clipboard_and_file(), open_capture_folder: default_open_capture_folder(), } } diff --git a/src/config/keybindings/defaults/capture.rs b/src/config/keybindings/defaults/capture.rs index 5edbf57e..d518ab2a 100644 --- a/src/config/keybindings/defaults/capture.rs +++ b/src/config/keybindings/defaults/capture.rs @@ -34,6 +34,18 @@ pub(crate) fn default_capture_file_region() -> Vec { vec!["Ctrl+Alt+6".to_string()] } +pub(crate) fn default_export_canvas_file() -> Vec { + Vec::new() +} + +pub(crate) fn default_export_canvas_clipboard() -> Vec { + Vec::new() +} + +pub(crate) fn default_export_canvas_clipboard_and_file() -> Vec { + Vec::new() +} + pub(crate) fn default_open_capture_folder() -> Vec { vec!["Ctrl+Alt+O".to_string()] } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 41378b9a..9de5e654 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -247,3 +247,53 @@ fn test_build_action_bindings_reports_duplicate_keybindings() { assert!(err_msg.contains("Duplicate keybinding")); assert!(err_msg.contains("Ctrl+Z")); } + +#[test] +fn build_action_map_includes_canvas_export_bindings() { + let mut config = KeybindingsConfig::default(); + config.capture.export_canvas_file = vec!["Ctrl+Alt+Shift+F".to_string()]; + config.capture.export_canvas_clipboard = vec!["Ctrl+Alt+Shift+C".to_string()]; + config.capture.export_canvas_clipboard_and_file = vec!["Ctrl+Alt+Shift+B".to_string()]; + + let map = config.build_action_map().unwrap(); + + assert_eq!( + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+F").unwrap()), + Some(&Action::ExportCanvasFile) + ); + assert_eq!( + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+C").unwrap()), + Some(&Action::ExportCanvasClipboard) + ); + assert_eq!( + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+B").unwrap()), + Some(&Action::ExportCanvasClipboardAndFile) + ); +} + +#[test] +fn canvas_export_actions_deserialize_from_config_names() { + #[derive(serde::Deserialize)] + struct ActionFixture { + action: Action, + } + + assert_eq!( + toml::from_str::("action = \"export_canvas_file\"") + .unwrap() + .action, + Action::ExportCanvasFile + ); + assert_eq!( + toml::from_str::("action = \"export_canvas_clipboard\"") + .unwrap() + .action, + Action::ExportCanvasClipboard + ); + assert_eq!( + toml::from_str::("action = \"export_canvas_clipboard_and_file\"") + .unwrap() + .action, + Action::ExportCanvasClipboardAndFile + ); +} diff --git a/src/config/mod.rs b/src/config/mod.rs index a1f99b82..d66901ff 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -44,9 +44,9 @@ pub use types::{ HelpOverlayStyle, HistoryConfig, MouseDragToolsConfig, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PerformanceConfig, PresenterModeConfig, PresenterToolBehavior, PresetSlotsConfig, PresetToolSettingConfig, PresetToolStatesConfig, RenderColorMappingConfig, RenderProfileConfig, - RenderProfilesConfig, SessionCompression, SessionConfig, SessionStorageMode, StatusBarStyle, - ToolPresetConfig, ToolbarConfig, ToolbarLayoutMode, ToolbarModeOverride, ToolbarModeOverrides, - UiConfig, + RenderProfileExportMode, 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/validate.rs b/src/config/tests/validate.rs index 9a20c115..e6ff1305 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -142,7 +142,9 @@ fn validate_render_profiles_normalizes_ids_and_mappings() { active: Some(" PRINT ".to_string()), apply_to_canvas: true, apply_to_ui: true, - items: vec![ + export: RenderProfileExportMode::Profile, + export_profile: Some(" off ".to_string()), + profiles: vec![ RenderProfileConfig { id: " Print ".to_string(), name: " Print Friendly ".to_string(), @@ -162,7 +164,7 @@ fn validate_render_profiles_normalizes_ids_and_mappings() { ], }, RenderProfileConfig { - id: "print".to_string(), + id: "off".to_string(), name: " ".to_string(), mappings: Vec::new(), }, @@ -174,12 +176,20 @@ fn validate_render_profiles_normalizes_ids_and_mappings() { 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, + config.render_profiles.export, + RenderProfileExportMode::Profile + ); + assert_eq!( + config.render_profiles.export_profile.as_deref(), + Some("off") + ); + assert_eq!(config.render_profiles.profiles[0].id, "print"); + assert_eq!(config.render_profiles.profiles[0].name, "Print Friendly"); + assert_eq!(config.render_profiles.profiles[1].id, "off"); + assert_eq!(config.render_profiles.profiles[1].name, "Profile 2"); + assert_eq!( + config.render_profiles.profiles[0].mappings, vec![RenderColorMappingConfig { from: "#000000".to_string(), to: "#111111".to_string(), @@ -194,7 +204,9 @@ fn validate_render_profiles_disables_missing_active_profile() { active: Some("missing".to_string()), apply_to_canvas: true, apply_to_ui: true, - items: vec![RenderProfileConfig { + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: vec![RenderProfileConfig { id: "print".to_string(), name: "Print".to_string(), mappings: Vec::new(), @@ -208,6 +220,60 @@ fn validate_render_profiles_disables_missing_active_profile() { assert_eq!(config.render_profiles.active, None); } +#[test] +fn validate_render_profiles_disables_missing_export_profile() { + let mut config = Config { + render_profiles: RenderProfilesConfig { + active: None, + apply_to_canvas: true, + apply_to_ui: true, + export: RenderProfileExportMode::Profile, + export_profile: Some("missing".to_string()), + profiles: vec![RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: Vec::new(), + }], + }, + ..Config::default() + }; + + config.validate_and_clamp(); + + assert_eq!(config.render_profiles.export, RenderProfileExportMode::Off); + assert_eq!(config.render_profiles.export_profile, None); +} + +#[test] +fn validate_render_profiles_ignores_stale_export_profile_for_active_export() { + let mut config = Config { + render_profiles: RenderProfilesConfig { + active: Some("print".to_string()), + apply_to_canvas: true, + apply_to_ui: true, + export: RenderProfileExportMode::Active, + export_profile: Some("missing".to_string()), + profiles: vec![RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: Vec::new(), + }], + }, + ..Config::default() + }; + + config.validate_and_clamp(); + + assert_eq!( + config.render_profiles.export, + RenderProfileExportMode::Active + ); + assert_eq!( + config.render_profiles.export_profile.as_deref(), + Some("missing") + ); +} + #[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 59cccd03..7f0c95a3 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -35,7 +35,9 @@ pub use presets::{ PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PresetSlotsConfig, PresetToolSettingConfig, PresetToolStatesConfig, ToolPresetConfig, }; -pub use render_profiles::{RenderColorMappingConfig, RenderProfileConfig, RenderProfilesConfig}; +pub use render_profiles::{ + RenderColorMappingConfig, RenderProfileConfig, RenderProfileExportMode, 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 index 98a2899d..fc411042 100644 --- a/src/config/types/render_profiles.rs +++ b/src/config/types/render_profiles.rs @@ -1,5 +1,16 @@ use serde::{Deserialize, Serialize}; +/// Which render profile, if any, applies to explicit canvas PNG export. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RenderProfileExportMode { + #[default] + Off, + Active, + Profile, +} + /// Configurable final-render color profiles. #[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] #[derive(Debug, Clone, Serialize, Deserialize)] @@ -16,9 +27,17 @@ pub struct RenderProfilesConfig { #[serde(default = "default_render_profile_target_enabled")] pub apply_to_ui: bool, - /// Available render color profiles. + /// Export profile selector for explicit Wayscriber canvas PNG export. #[serde(default)] - pub items: Vec, + pub export: RenderProfileExportMode, + + /// Named profile id used when `export = "profile"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub export_profile: Option, + + /// Available render color profiles. + #[serde(default, alias = "items")] + pub profiles: Vec, } impl Default for RenderProfilesConfig { @@ -27,7 +46,9 @@ impl Default for RenderProfilesConfig { active: None, apply_to_canvas: true, apply_to_ui: true, - items: Vec::new(), + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: Vec::new(), } } } diff --git a/src/config/validate/render_profiles.rs b/src/config/validate/render_profiles.rs index f146ebe2..f91fa3b1 100644 --- a/src/config/validate/render_profiles.rs +++ b/src/config/validate/render_profiles.rs @@ -1,13 +1,14 @@ use std::collections::HashSet; use super::Config; +use crate::config::RenderProfileExportMode; 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() { + for (index, profile) in self.render_profiles.profiles.iter_mut().enumerate() { let raw_id = profile.id.clone(); let mut id = normalize_profile_id(&raw_id); if id.is_empty() { @@ -78,7 +79,7 @@ impl Config { if active.is_empty() || !self .render_profiles - .items + .profiles .iter() .any(|profile| profile.id == *active) { @@ -89,5 +90,38 @@ impl Config { self.render_profiles.active = None; } } + + if matches!( + self.render_profiles.export, + RenderProfileExportMode::Profile + ) { + if let Some(export_profile) = self.render_profiles.export_profile.as_mut() { + *export_profile = normalize_profile_id(export_profile); + if export_profile.is_empty() + || !self + .render_profiles + .profiles + .iter() + .any(|profile| profile.id == *export_profile) + { + warn!( + "Export render profile '{}' not found; disabling canvas export remapping", + export_profile + ); + self.render_profiles.export = RenderProfileExportMode::Off; + self.render_profiles.export_profile = None; + } + } else { + warn!( + "render_profiles.export is profile but export_profile is empty; disabling export remapping" + ); + self.render_profiles.export = RenderProfileExportMode::Off; + } + } else if let Some(export_profile) = self.render_profiles.export_profile.as_mut() { + *export_profile = normalize_profile_id(export_profile); + if export_profile.is_empty() { + self.render_profiles.export_profile = None; + } + } } } diff --git a/src/input/state/actions/action_capture_zoom.rs b/src/input/state/actions/action_capture_zoom.rs index 4860b84f..af603808 100644 --- a/src/input/state/actions/action_capture_zoom.rs +++ b/src/input/state/actions/action_capture_zoom.rs @@ -1,7 +1,7 @@ use crate::config::Action; use crate::input::{OutputFocusAction, ZoomAction}; -use super::super::InputState; +use super::super::{InputState, PendingBackendAction}; impl InputState { pub(in crate::input::state) fn handle_capture_zoom_action(&mut self, action: Action) -> bool { @@ -19,7 +19,18 @@ impl InputState { // since they require access to CaptureManager // Store the action for later retrieval log::debug!("Capture action {:?} pending for backend", action); - self.set_pending_capture_action(action); + self.set_pending_backend_action(PendingBackendAction::Screenshot(action)); + + // Clear modifiers to prevent them from being "stuck" after capture + // (portal dialog causes key releases to be missed or focus to flicker) + self.reset_modifiers(); + true + } + Action::ExportCanvasFile + | Action::ExportCanvasClipboard + | Action::ExportCanvasClipboardAndFile => { + log::debug!("Canvas export action {:?} pending for backend", action); + self.set_pending_backend_action(PendingBackendAction::CanvasExport(action)); // Clear modifiers to prevent them from being "stuck" after capture // (portal dialog causes key releases to be missed or focus to flicker) diff --git a/src/input/state/core/base/mod.rs b/src/input/state/core/base/mod.rs index 78c6e0cd..b5e431ee 100644 --- a/src/input/state/core/base/mod.rs +++ b/src/input/state/core/base/mod.rs @@ -13,8 +13,8 @@ pub use types::{ }; pub(crate) use types::{ BlockedActionFeedback, BoardPickerClickState, ClipboardFingerprint, ClipboardPasteRequest, - DelayedHistory, HistoryMode, PasteAnchor, PendingBoardDelete, PendingClipboardFallback, - PendingPageDelete, PendingSelectionClipboardPublish, PresetFeedbackState, - SelectionPublishState, TextClickState, TextEditEntryFeedback, ToastAction, UiToastState, - WayscriberClipboardSelection, + DelayedHistory, HistoryMode, PasteAnchor, PendingBackendAction, PendingBoardDelete, + PendingClipboardFallback, PendingPageDelete, PendingSelectionClipboardPublish, + PresetFeedbackState, SelectionPublishState, TextClickState, TextEditEntryFeedback, ToastAction, + UiToastState, WayscriberClipboardSelection, }; diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index bc89394e..4084e192 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -156,7 +156,7 @@ impl InputState { last_text_preview_bounds: None, action_map, action_bindings: HashMap::new(), - pending_capture_action: None, + pending_backend_action: None, pending_output_focus_action: None, pending_zoom_action: None, pending_onboarding_usage: PendingOnboardingUsage::default(), diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index 438a0af4..9c2bcc22 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -12,11 +12,12 @@ use super::super::super::{ }; use super::super::types::{ BlockedActionFeedback, BoardPickerClickState, ClipboardPasteRequest, CompositorCapabilities, - DelayedHistory, DrawingState, OutputFocusAction, PendingBoardDelete, PendingClipboardFallback, - PendingOnboardingUsage, PendingPageDelete, PendingSelectionClipboardPublish, PresetAction, - PresetFeedbackState, PressureThicknessEditMode, PressureThicknessEntryMode, SelectionAxis, - SelectionPublishState, StatusChangeHighlight, TextClickState, TextEditEntryFeedback, - TextInputMode, ToolbarDrawerTab, UiToastState, ZoomAction, + DelayedHistory, DrawingState, OutputFocusAction, PendingBackendAction, PendingBoardDelete, + PendingClipboardFallback, PendingOnboardingUsage, PendingPageDelete, + PendingSelectionClipboardPublish, PresetAction, PresetFeedbackState, PressureThicknessEditMode, + PressureThicknessEntryMode, SelectionAxis, SelectionPublishState, StatusChangeHighlight, + TextClickState, TextEditEntryFeedback, TextInputMode, ToolbarDrawerTab, UiToastState, + ZoomAction, }; use crate::config::{ Action, BoardsConfig, KeyBinding, PresenterModeConfig, RadialMenuMouseBinding, ToolPresetConfig, @@ -227,8 +228,8 @@ pub struct InputState { pub(in crate::input::state::core) action_map: HashMap, /// Ordered keybindings per action (as configured) pub(in crate::input::state::core) action_bindings: HashMap>, - /// Pending capture action (to be handled by WaylandState) - pub(in crate::input::state::core) pending_capture_action: Option, + /// Pending backend output action (to be handled by WaylandState) + pub(in crate::input::state::core) pending_backend_action: Option, /// Pending output focus action (to be handled by WaylandState) pub(in crate::input::state::core) pending_output_focus_action: Option, /// Pending zoom action (to be handled by WaylandState) diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 72fb0578..799fa9e6 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -13,8 +13,8 @@ pub const PAGE_UNDO_EXPIRE_MS: u64 = 30_000; #[allow(dead_code)] pub const STATUS_CHANGE_HIGHLIGHT_MS: u64 = 300; -use crate::capture::file::FileSaveConfig; -use crate::config::ToolPresetConfig; +use crate::capture::{ImageOperationKind, file::FileSaveConfig}; +use crate::config::{Action, ToolPresetConfig}; use crate::draw::frame::ShapeSnapshot; use crate::draw::{Shape, ShapeId}; use crate::input::tool::Tool; @@ -307,10 +307,17 @@ pub(crate) struct BlockedActionFeedback { pub(crate) struct PendingClipboardFallback { pub image_data: Vec, pub save_config: FileSaveConfig, + pub operation: ImageOperationKind, /// Whether to exit after successful fallback save (from exit-after-capture mode). pub exit_after_save: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingBackendAction { + Screenshot(Action), + CanvasExport(Action), +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct WayscriberClipboardSelection { pub schema_version: u32, diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index 2d580165..77b5a61c 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -274,6 +274,27 @@ mod tests { ); } + #[test] + fn return_key_sets_pending_canvas_export_backend_action() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "export canvas clipboard".to_string(); + let selected = state.selected_command().expect("selected command"); + assert_eq!( + selected.action, + crate::config::keybindings::Action::ExportCanvasClipboard + ); + + assert!(state.handle_command_palette_key(crate::input::Key::Return)); + + assert_eq!( + state.take_pending_backend_action(), + Some(crate::input::state::PendingBackendAction::CanvasExport( + crate::config::keybindings::Action::ExportCanvasClipboard + )) + ); + } + #[test] fn clicking_outside_palette_closes_it() { let mut state = make_state(); @@ -341,6 +362,31 @@ mod tests { assert_eq!(toast.message, selected.label); } + #[test] + fn clicking_visible_canvas_export_item_sets_pending_backend_action() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "save board".to_string(); + state.command_palette_selected = 0; + let filtered = state.filtered_commands(); + assert_eq!( + filtered.first().expect("selected command").action, + crate::config::keybindings::Action::ExportCanvasFile + ); + let geometry = state.command_palette_geometry(1920, 1000, filtered.len()); + let x = (geometry.x + geometry.inner_x + 4.0) as i32; + let y = (geometry.y + geometry.items_top + COMMAND_PALETTE_ITEM_HEIGHT * 0.5) as i32; + + assert!(state.handle_command_palette_click(x, y, 1920, 1000)); + + assert_eq!( + state.take_pending_backend_action(), + Some(crate::input::state::PendingBackendAction::CanvasExport( + crate::config::keybindings::Action::ExportCanvasFile + )) + ); + } + #[test] fn cursor_hint_rejects_strip_below_clamped_panel_height() { let mut state = make_state(); diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 7c6429c1..445211cc 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -28,8 +28,8 @@ pub use base::{ }; pub(crate) use base::{BoardPickerClickState, TextClickState}; pub(crate) use base::{ - ClipboardFingerprint, ClipboardPasteRequest, PasteAnchor, PendingSelectionClipboardPublish, - SelectionPublishState, WayscriberClipboardSelection, + ClipboardFingerprint, ClipboardPasteRequest, PasteAnchor, PendingBackendAction, + PendingSelectionClipboardPublish, SelectionPublishState, WayscriberClipboardSelection, }; pub use board_picker::{BoardPickerCursorHint, BoardPickerLayout}; pub use color_picker_popup::{ diff --git a/src/input/state/core/utility/pending.rs b/src/input/state/core/utility/pending.rs index ca66fcb0..ba79c2d3 100644 --- a/src/input/state/core/utility/pending.rs +++ b/src/input/state/core/utility/pending.rs @@ -1,19 +1,20 @@ use super::super::base::{ ClipboardFingerprint, ClipboardPasteRequest, InputState, OutputFocusAction, - PendingSelectionClipboardPublish, PresetAction, SelectionPublishState, ZoomAction, + PendingBackendAction, PendingSelectionClipboardPublish, PresetAction, SelectionPublishState, + ZoomAction, }; -use crate::config::{Action, BoardsConfig}; +use crate::config::BoardsConfig; #[allow(dead_code)] impl InputState { - /// Takes and clears any pending capture action. - pub fn take_pending_capture_action(&mut self) -> Option { - self.pending_capture_action.take() + /// Takes and clears any pending backend output action. + pub fn take_pending_backend_action(&mut self) -> Option { + self.pending_backend_action.take() } - /// Stores a capture action for retrieval by the backend. - pub(crate) fn set_pending_capture_action(&mut self, action: Action) { - self.pending_capture_action = Some(action); + /// Stores a backend output action for retrieval by the backend. + pub(crate) fn set_pending_backend_action(&mut self, action: PendingBackendAction) { + self.pending_backend_action = Some(action); } /// Stores an output focus action for retrieval by the backend. @@ -90,7 +91,7 @@ impl InputState { #[cfg(test)] mod tests { use super::*; - use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::config::{Action, BoardsConfig, KeybindingsConfig, PresenterModeConfig}; use crate::draw::{Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode}; @@ -135,15 +136,15 @@ mod tests { } #[test] - fn pending_capture_action_is_taken_once() { + fn pending_backend_action_is_taken_once() { let mut state = make_state(); - state.set_pending_capture_action(Action::CaptureFileFull); + state.set_pending_backend_action(PendingBackendAction::Screenshot(Action::CaptureFileFull)); assert_eq!( - state.take_pending_capture_action(), - Some(Action::CaptureFileFull) + state.take_pending_backend_action(), + Some(PendingBackendAction::Screenshot(Action::CaptureFileFull)) ); - assert_eq!(state.take_pending_capture_action(), None); + assert_eq!(state.take_pending_backend_action(), None); } #[test] diff --git a/src/input/state/core/utility/render_profiles.rs b/src/input/state/core/utility/render_profiles.rs index 89e5ea43..73e01ba9 100644 --- a/src/input/state/core/utility/render_profiles.rs +++ b/src/input/state/core/utility/render_profiles.rs @@ -28,6 +28,11 @@ impl InputState { } } + #[allow(dead_code)] // Used by the Wayland backend; the lib crate doesn't compile backend modules. + pub(crate) fn export_render_profile(&self) -> Option { + self.render_profiles.export_profile() + } + pub fn render_profile_generation(&self) -> u64 { self.render_profiles.generation() } @@ -62,7 +67,7 @@ impl InputState { #[cfg(test)] mod tests { - use crate::config::{RenderProfileConfig, RenderProfilesConfig}; + use crate::config::{RenderProfileConfig, RenderProfileExportMode, RenderProfilesConfig}; use crate::input::state::test_support::make_test_input_state; use crate::render_profiles::RenderProfileSet; @@ -73,7 +78,9 @@ mod tests { active: None, apply_to_canvas: true, apply_to_ui: true, - items: vec![RenderProfileConfig { + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: vec![RenderProfileConfig { id: "print".to_string(), name: "Print".to_string(), mappings: Vec::new(), diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index 2cda2a5d..b82dd886 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -2,7 +2,10 @@ use super::super::base::{ BLOCKED_ACTION_DURATION_MS, BlockedActionFeedback, InputState, PendingClipboardFallback, TEXT_EDIT_ENTRY_DURATION_MS, ToastAction, UI_TOAST_DURATION_MS, UiToastKind, UiToastState, }; -use crate::capture::file::{FileSaveConfig, save_screenshot}; +use crate::capture::{ + ImageOperationKind, + file::{FileSaveConfig, save_screenshot}, +}; use crate::config::keybindings::Action; use std::path::Path; use std::time::{Duration, Instant}; @@ -184,11 +187,13 @@ impl InputState { &mut self, image_data: Vec, save_config: FileSaveConfig, + operation: ImageOperationKind, exit_after_save: bool, ) { self.pending_clipboard_fallback = Some(PendingClipboardFallback { image_data, save_config, + operation, exit_after_save, }); } @@ -205,7 +210,11 @@ impl InputState { match save_screenshot(&fallback.image_data, &fallback.save_config) { Ok(path) => { - log::info!("Saved pending screenshot to: {}", path.display()); + log::info!( + "Saved pending {} to: {}", + fallback.operation.saved_log_label(), + path.display() + ); self.last_capture_path = Some(path.clone()); if let Some(filename) = path.file_name() { self.set_ui_toast( @@ -213,7 +222,13 @@ impl InputState { format!("Saved to {}", filename.to_string_lossy()), ); } else { - self.set_ui_toast(UiToastKind::Info, "Screenshot saved"); + self.set_ui_toast( + UiToastKind::Info, + match fallback.operation { + ImageOperationKind::Screenshot => "Screenshot saved", + ImageOperationKind::CanvasExport => "Canvas exported", + }, + ); } // Exit if exit-after-capture was originally enabled if fallback.exit_after_save { @@ -221,12 +236,17 @@ impl InputState { } } Err(err) => { - log::error!("Failed to save pending screenshot: {}", err); + let message = fallback.operation.format_error(&err); + log::error!( + "Failed to save pending {}: {}", + fallback.operation.saved_log_label(), + message + ); // Restore fallback so user can retry self.pending_clipboard_fallback = Some(fallback); self.set_ui_toast_with_action( UiToastKind::Error, - format!("Save failed: {}", err), + format!("Save failed: {message}"), "Retry", Action::SavePendingToFile, ); @@ -393,6 +413,42 @@ mod tests { assert!(state.blocked_action_feedback.is_some()); } + #[test] + fn canvas_clipboard_fallback_retry_failure_uses_canvas_wording() { + let mut state = make_state(); + let temp = crate::test_temp::tempdir().expect("tempdir"); + let not_a_directory = temp.path().join("not-a-directory"); + std::fs::write(¬_a_directory, b"file").expect("test fixture file"); + + state.set_clipboard_fallback( + vec![1, 2, 3], + FileSaveConfig { + save_directory: not_a_directory, + filename_template: "canvas_fallback".to_string(), + format: "png".to_string(), + }, + ImageOperationKind::CanvasExport, + false, + ); + + state.save_pending_clipboard_to_file(); + + let toast = state.ui_toast.as_ref().expect("error toast"); + assert_eq!(toast.kind, UiToastKind::Error); + assert!( + toast.message.contains("Failed to save canvas export"), + "unexpected toast: {}", + toast.message + ); + assert!( + !toast.message.to_lowercase().contains("screenshot"), + "canvas fallback failure should not mention screenshot: {}", + toast.message + ); + assert!(state.pending_clipboard_fallback.is_some()); + assert!(state.blocked_action_feedback.is_some()); + } + #[test] fn advance_text_edit_entry_feedback_clears_expired_feedback() { let mut state = make_state(); diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index fb9d7eb3..94c6b4cc 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -116,6 +116,9 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::CaptureFileSelection | Action::CaptureClipboardRegion | Action::CaptureFileRegion + | Action::ExportCanvasFile + | Action::ExportCanvasClipboard + | Action::ExportCanvasClipboardAndFile | Action::ToggleFrozenMode | Action::ZoomIn | Action::ZoomOut diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index cc004d42..bb2210bb 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -33,8 +33,8 @@ pub(crate) use core::{ }; #[allow(unused_imports)] pub(crate) use core::{ - ClipboardFingerprint, ClipboardPasteRequest, PasteAnchor, PendingSelectionClipboardPublish, - SelectionPublishState, WayscriberClipboardSelection, + ClipboardFingerprint, ClipboardPasteRequest, PasteAnchor, PendingBackendAction, + PendingSelectionClipboardPublish, SelectionPublishState, WayscriberClipboardSelection, }; pub use highlight::ClickHighlightSettings; diff --git a/src/input/state/tests/text_input/actions.rs b/src/input/state/tests/text_input/actions.rs index 956760d0..5ca97d75 100644 --- a/src/input/state/tests/text_input/actions.rs +++ b/src/input/state/tests/text_input/actions.rs @@ -48,8 +48,24 @@ fn capture_action_sets_pending_and_clears_modifiers() { assert!(!state.modifiers.alt); assert_eq!( - state.take_pending_capture_action(), - Some(Action::CaptureClipboardFull) + state.take_pending_backend_action(), + Some(PendingBackendAction::Screenshot( + Action::CaptureClipboardFull + )) + ); + assert!(state.take_pending_backend_action().is_none()); +} + +#[test] +fn canvas_export_action_sets_pending_backend_action() { + let mut state = create_test_input_state(); + + state.handle_action(Action::ExportCanvasClipboard); + + assert_eq!( + state.take_pending_backend_action(), + Some(PendingBackendAction::CanvasExport( + Action::ExportCanvasClipboard + )) ); - assert!(state.take_pending_capture_action().is_none()); } diff --git a/src/lib.rs b/src/lib.rs index 8b7c63b4..69a44ca4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub(crate) mod base64; pub mod build_info; +pub mod canvas_export; pub mod capture; pub mod config; pub mod draw; diff --git a/src/main.rs b/src/main.rs index a2cf99f2..b3040181 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod app_id; mod backend; mod base64; mod build_info; +mod canvas_export; mod capture; mod cli; mod config; diff --git a/src/render_profiles.rs b/src/render_profiles.rs index 2c1700d2..5725c0aa 100644 --- a/src/render_profiles.rs +++ b/src/render_profiles.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; -use crate::config::{RenderProfileConfig, RenderProfilesConfig}; +use crate::config::{RenderProfileConfig, RenderProfileExportMode, RenderProfilesConfig}; use crate::util::Rect; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -26,6 +26,11 @@ pub struct RenderColorProfile { } impl RenderColorProfile { + #[allow(dead_code)] // Used by tests and consumers of the library API. + pub fn id(&self) -> &str { + &self.id + } + pub fn name(&self) -> &str { &self.name } @@ -34,7 +39,7 @@ impl RenderColorProfile { self.mappings.is_empty() } - fn from_config(config: &RenderProfileConfig) -> Option { + pub(crate) 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 { @@ -188,6 +193,8 @@ impl RenderColorProfile { pub struct RenderProfileSet { profiles: Vec, active_index: Option, + export_mode: RenderProfileExportMode, + export_profile_index: Option, apply_to_canvas: bool, apply_to_ui: bool, generation: u64, @@ -198,6 +205,8 @@ impl Default for RenderProfileSet { Self { profiles: Vec::new(), active_index: None, + export_mode: RenderProfileExportMode::Off, + export_profile_index: None, apply_to_canvas: true, apply_to_ui: true, generation: 0, @@ -208,7 +217,7 @@ impl Default for RenderProfileSet { impl RenderProfileSet { pub fn from_config(config: &RenderProfilesConfig) -> Self { let profiles: Vec<_> = config - .items + .profiles .iter() .filter_map(RenderColorProfile::from_config) .collect(); @@ -216,9 +225,15 @@ impl RenderProfileSet { let active = normalize_profile_id(active); profiles.iter().position(|profile| profile.id == active) }); + let export_profile_index = config.export_profile.as_ref().and_then(|profile_id| { + let profile_id = normalize_profile_id(profile_id); + profiles.iter().position(|profile| profile.id == profile_id) + }); Self { profiles, active_index, + export_mode: config.export, + export_profile_index, apply_to_canvas: config.apply_to_canvas, apply_to_ui: config.apply_to_ui, generation: 0, @@ -229,6 +244,17 @@ impl RenderProfileSet { self.active_index.and_then(|index| self.profiles.get(index)) } + pub fn export_profile(&self) -> Option { + match self.export_mode { + RenderProfileExportMode::Off => None, + RenderProfileExportMode::Active => self.active().cloned(), + RenderProfileExportMode::Profile => self + .export_profile_index + .and_then(|index| self.profiles.get(index)) + .cloned(), + } + } + pub fn generation(&self) -> u64 { self.generation } @@ -464,7 +490,9 @@ mod tests { active: Some("first".to_string()), apply_to_canvas: true, apply_to_ui: true, - items: vec![ + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: vec![ RenderProfileConfig { id: "first".to_string(), name: "First".to_string(), @@ -494,10 +522,74 @@ mod tests { active: None, apply_to_canvas: false, apply_to_ui: true, - items: Vec::new(), + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: Vec::new(), }); assert!(!set.applies_to_canvas()); assert!(set.applies_to_ui()); } + + #[test] + fn export_profile_resolves_off_active_and_named_profiles() { + let config = RenderProfilesConfig { + active: Some("active".to_string()), + apply_to_canvas: true, + apply_to_ui: true, + export: RenderProfileExportMode::Active, + export_profile: Some("off".to_string()), + profiles: vec![ + RenderProfileConfig { + id: "active".to_string(), + name: "Active".to_string(), + mappings: Vec::new(), + }, + RenderProfileConfig { + id: "off".to_string(), + name: "Off Named Profile".to_string(), + mappings: Vec::new(), + }, + ], + }; + + let mut active = RenderProfileSet::from_config(&config); + assert_eq!( + active.export_profile().as_ref().map(|p| p.id()), + Some("active") + ); + + let mut named_config = config; + named_config.export = RenderProfileExportMode::Profile; + active = RenderProfileSet::from_config(&named_config); + assert_eq!( + active.export_profile().as_ref().map(|p| p.id()), + Some("off") + ); + + named_config.export = RenderProfileExportMode::Off; + active = RenderProfileSet::from_config(&named_config); + assert!(active.export_profile().is_none()); + } + + #[test] + fn config_serializes_profile_collection_as_profiles() { + let config = RenderProfilesConfig { + active: None, + apply_to_canvas: true, + apply_to_ui: true, + export: RenderProfileExportMode::Off, + export_profile: None, + profiles: vec![RenderProfileConfig { + id: "print".to_string(), + name: "Print".to_string(), + mappings: Vec::new(), + }], + }; + + let serialized = toml::to_string(&config).expect("serialize"); + + assert!(serialized.contains("[[profiles]]")); + assert!(!serialized.contains("[[items]]")); + } } diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index 4129356c..af4db7ab 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -384,9 +384,9 @@ pub(super) fn build_main_sections( icon: Some(toolbar_icons::draw_icon_undo), }; - let screenshots = (!context_filter || capture_enabled).then(|| Section { - title: "Screenshots", - rows: vec![ + let mut screenshot_rows = Vec::new(); + if !context_filter || capture_enabled { + screenshot_rows.extend([ row( binding_or_fallback(bindings, Action::CaptureClipboardFull, NOT_BOUND_LABEL), "Full screen → clipboard", @@ -411,11 +411,33 @@ pub(super) fn build_main_sections( binding_or_fallback(bindings, Action::CaptureSelection, NOT_BOUND_LABEL), "Selection (capture defaults)", ), - row( - binding_or_fallback(bindings, Action::OpenCaptureFolder, NOT_BOUND_LABEL), - action_label(Action::OpenCaptureFolder), + ]); + } + screenshot_rows.extend([ + row( + binding_or_fallback(bindings, Action::ExportCanvasClipboard, NOT_BOUND_LABEL), + action_label(Action::ExportCanvasClipboard), + ), + row( + binding_or_fallback(bindings, Action::ExportCanvasFile, NOT_BOUND_LABEL), + action_label(Action::ExportCanvasFile), + ), + row( + binding_or_fallback( + bindings, + Action::ExportCanvasClipboardAndFile, + NOT_BOUND_LABEL, ), - ], + action_label(Action::ExportCanvasClipboardAndFile), + ), + row( + binding_or_fallback(bindings, Action::OpenCaptureFolder, NOT_BOUND_LABEL), + action_label(Action::OpenCaptureFolder), + ), + ]); + let screenshots = Some(Section { + title: "Screenshots & Export", + rows: screenshot_rows, badges: Vec::new(), icon: Some(toolbar_icons::draw_icon_save), }); diff --git a/src/ui/help_overlay/sections/tests.rs b/src/ui/help_overlay/sections/tests.rs index 8bc283ee..dcf319c7 100644 --- a/src/ui/help_overlay/sections/tests.rs +++ b/src/ui/help_overlay/sections/tests.rs @@ -31,3 +31,30 @@ fn gesture_hints_remain_present() { ); } } + +#[test] +fn canvas_export_rows_remain_visible_when_capture_context_is_disabled() { + let bindings = HelpOverlayBindings::default(); + let sections = build_section_sets(&bindings, false, true, true, false).all; + let rows: Vec<&str> = sections + .iter() + .flat_map(|section| section.rows.iter()) + .map(|row| row.action) + .collect(); + + for action in [ + Action::ExportCanvasClipboard, + Action::ExportCanvasFile, + Action::ExportCanvasClipboardAndFile, + ] { + assert!( + rows.contains(&action_label(action)), + "Missing canvas export help row for {}", + action_label(action) + ); + } + assert!( + !rows.contains(&"Full screen → clipboard"), + "Screenshot rows should stay hidden when capture context is disabled" + ); +}