diff --git a/configurator/src/app.rs b/configurator/src/app.rs index c7521e39..4d5aad29 100644 --- a/configurator/src/app.rs +++ b/configurator/src/app.rs @@ -10,14 +10,15 @@ use iced::widget::{ scrollable, text, text_input, }; use iced::{Application, Background, Border, Command, Element, Length, Settings, Size}; -use wayscriber::config::Config; +use wayscriber::config::{Config, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN}; use crate::messages::Message; use crate::models::{ BoardModeOption, ColorMode, ColorQuadInput, ColorTripletInput, ConfigDraft, EraserModeOption, - FontStyleOption, FontWeightOption, NamedColorOption, OverrideOption, QuadField, + FontStyleOption, FontWeightOption, NamedColorOption, OverrideOption, PresetEraserKindOption, + PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TabId, TextField, - ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, UiTabId, + ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, UiTabId, }; pub fn run() -> iced::Result { @@ -336,6 +337,107 @@ impl Application for ConfiguratorApp { } self.refresh_dirty_flag(); } + Message::PresetSlotCountChanged(count) => { + self.status = StatusMessage::idle(); + self.draft.presets.slot_count = count; + self.refresh_dirty_flag(); + } + Message::PresetSlotEnabledChanged(slot_index, enabled) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) { + slot.enabled = enabled; + } + self.refresh_dirty_flag(); + } + Message::PresetToolChanged(slot_index, tool) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) { + slot.tool = tool; + } + self.refresh_dirty_flag(); + } + Message::PresetColorModeChanged(slot_index, mode) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) { + slot.color.mode = mode; + if matches!(mode, ColorMode::Named) { + if slot.color.name.trim().is_empty() { + slot.color.selected_named = NamedColorOption::Red; + slot.color.name = slot.color.selected_named.as_value().to_string(); + } else { + slot.color.update_named_from_current(); + } + } + } + self.refresh_dirty_flag(); + } + Message::PresetNamedColorSelected(slot_index, option) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) { + slot.color.selected_named = option; + if option != NamedColorOption::Custom { + slot.color.name = option.as_value().to_string(); + } + } + self.refresh_dirty_flag(); + } + Message::PresetColorComponentChanged(slot_index, component, value) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) + && let Some(entry) = slot.color.rgb.get_mut(component) + { + *entry = value; + } + self.refresh_dirty_flag(); + } + Message::PresetTextChanged(slot_index, field, value) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) { + match field { + PresetTextField::Name => { + slot.name = value; + } + PresetTextField::ColorName => { + slot.color.name = value; + slot.color.update_named_from_current(); + } + PresetTextField::Size => slot.size = value, + PresetTextField::MarkerOpacity => slot.marker_opacity = value, + PresetTextField::FontSize => slot.font_size = value, + PresetTextField::ArrowLength => slot.arrow_length = value, + PresetTextField::ArrowAngle => slot.arrow_angle = value, + } + } + self.refresh_dirty_flag(); + } + Message::PresetToggleOptionChanged(slot_index, field, value) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) { + match field { + PresetToggleField::FillEnabled => slot.fill_enabled = value, + PresetToggleField::TextBackgroundEnabled => { + slot.text_background_enabled = value; + } + PresetToggleField::ArrowHeadAtEnd => slot.arrow_head_at_end = value, + PresetToggleField::ShowStatusBar => slot.show_status_bar = value, + } + } + self.refresh_dirty_flag(); + } + Message::PresetEraserKindChanged(slot_index, value) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) { + slot.eraser_kind = value; + } + self.refresh_dirty_flag(); + } + Message::PresetEraserModeChanged(slot_index, value) => { + self.status = StatusMessage::idle(); + if let Some(slot) = self.draft.presets.slot_mut(slot_index) { + slot.eraser_mode = value; + } + self.refresh_dirty_flag(); + } } Command::none() @@ -432,6 +534,7 @@ impl ConfiguratorApp { let content: Element<'_, Message> = match self.active_tab { TabId::Drawing => self.drawing_tab(), + TabId::Presets => self.presets_tab(), TabId::Arrow => self.arrow_tab(), TabId::History => self.history_tab(), TabId::Performance => self.performance_tab(), @@ -748,6 +851,343 @@ impl ConfiguratorApp { scrollable(column).into() } + fn presets_tab(&self) -> Element<'_, Message> { + let slot_counts: Vec = (PRESET_SLOTS_MIN..=PRESET_SLOTS_MAX).collect(); + let slot_picker = pick_list( + slot_counts, + Some(self.draft.presets.slot_count), + Message::PresetSlotCountChanged, + ) + .width(Length::Fixed(140.0)); + + let slot_count_control = labeled_control( + "Visible slots", + slot_picker.into(), + self.defaults.presets.slot_count.to_string(), + self.draft.presets.slot_count != self.defaults.presets.slot_count, + ); + + let mut column = Column::new() + .spacing(12) + .push(text("Preset Slots").size(20)) + .push(slot_count_control); + + for slot_index in 1..=PRESET_SLOTS_MAX { + column = column.push(self.preset_slot_section(slot_index)); + } + + scrollable(column).into() + } + + fn preset_slot_section(&self, slot_index: usize) -> Element<'_, Message> { + let Some(slot) = self.draft.presets.slot(slot_index) else { + return Space::new(Length::Shrink, Length::Shrink).into(); + }; + let Some(default_slot) = self.defaults.presets.slot(slot_index) else { + return Space::new(Length::Shrink, Length::Shrink).into(); + }; + + let enabled_row = row![ + checkbox("Enabled", slot.enabled) + .on_toggle(move |val| Message::PresetSlotEnabledChanged(slot_index, val)), + default_value_text( + bool_label(default_slot.enabled), + slot.enabled != default_slot.enabled + ), + ] + .spacing(DEFAULT_LABEL_GAP) + .align_items(iced::Alignment::Center); + + let mut section = Column::new() + .spacing(8) + .push(text(format!("Slot {slot_index}")).size(18)) + .push(enabled_row); + + if slot_index > self.draft.presets.slot_count { + section = section.push( + text(format!( + "Hidden (slot count is {})", + self.draft.presets.slot_count + )) + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); + } + + if !slot.enabled { + section = section.push( + text("Slot disabled. Enable to configure.") + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); + return container(section) + .padding(12) + .style(theme::Container::Box) + .into(); + } + + let tool_picker = pick_list(ToolOption::list(), Some(slot.tool), move |opt| { + Message::PresetToolChanged(slot_index, opt) + }) + .width(Length::Fill); + + let header_row = row![ + preset_input( + "Label", + &slot.name, + &default_slot.name, + slot_index, + PresetTextField::Name, + true, + ), + labeled_control( + "Tool", + tool_picker.into(), + default_slot.tool.label(), + slot.tool != default_slot.tool, + ) + ] + .spacing(12); + + let color_mode_picker = Row::new() + .spacing(12) + .push( + button("Named Color") + .style(if slot.color.mode == ColorMode::Named { + theme::Button::Primary + } else { + theme::Button::Secondary + }) + .on_press(Message::PresetColorModeChanged( + slot_index, + ColorMode::Named, + )), + ) + .push( + button("RGB Color") + .style(if slot.color.mode == ColorMode::Rgb { + theme::Button::Primary + } else { + theme::Button::Secondary + }) + .on_press(Message::PresetColorModeChanged(slot_index, ColorMode::Rgb)), + ); + + let color_section: Element<'_, Message> = match slot.color.mode { + ColorMode::Named => { + let picker = pick_list( + NamedColorOption::list(), + Some(slot.color.selected_named), + move |opt| Message::PresetNamedColorSelected(slot_index, opt), + ) + .width(Length::Fixed(160.0)); + + let picker_row = row![picker, color_preview_badge(slot.color.preview_color()),] + .spacing(8) + .align_items(iced::Alignment::Center); + + let mut column = Column::new().spacing(8).push(picker_row); + + if slot.color.selected_named_is_custom() { + column = column.push( + text_input("Custom color name", &slot.color.name) + .on_input(move |value| { + Message::PresetTextChanged( + slot_index, + PresetTextField::ColorName, + value, + ) + }) + .width(Length::Fill), + ); + + if slot.color.preview_color().is_none() && !slot.color.name.trim().is_empty() { + column = column.push( + text("Unknown color name") + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.6, 0.6))), + ); + } + } + + column.into() + } + ColorMode::Rgb => { + let rgb_inputs = row![ + text_input("R (0-255)", &slot.color.rgb[0]).on_input(move |value| { + Message::PresetColorComponentChanged(slot_index, 0, value) + }), + text_input("G (0-255)", &slot.color.rgb[1]).on_input(move |value| { + Message::PresetColorComponentChanged(slot_index, 1, value) + }), + text_input("B (0-255)", &slot.color.rgb[2]).on_input(move |value| { + Message::PresetColorComponentChanged(slot_index, 2, value) + }), + color_preview_badge(slot.color.preview_color()), + ] + .spacing(8) + .align_items(iced::Alignment::Center); + + let mut column = Column::new().spacing(8).push(rgb_inputs); + + if slot.color.preview_color().is_none() + && slot.color.rgb.iter().any(|value| !value.trim().is_empty()) + { + column = column.push( + text("RGB values must be between 0 and 255") + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.6, 0.6))), + ); + } + + column.into() + } + }; + + let color_block = column![ + row![ + text("Color").size(14), + default_value_text( + default_slot.color.summary(), + slot.color != default_slot.color, + ), + ] + .spacing(DEFAULT_LABEL_GAP) + .align_items(iced::Alignment::Center), + color_mode_picker, + color_section + ] + .spacing(8); + + let size_row = row![ + preset_input( + "Size (px)", + &slot.size, + &default_slot.size, + slot_index, + PresetTextField::Size, + false, + ), + preset_input( + "Marker opacity (0.05-0.9)", + &slot.marker_opacity, + &default_slot.marker_opacity, + slot_index, + PresetTextField::MarkerOpacity, + true, + ) + ] + .spacing(12); + + let eraser_row = row![ + labeled_control( + "Eraser kind", + pick_list( + PresetEraserKindOption::list(), + Some(slot.eraser_kind), + move |opt| Message::PresetEraserKindChanged(slot_index, opt), + ) + .width(Length::Fill) + .into(), + default_slot.eraser_kind.label(), + slot.eraser_kind != default_slot.eraser_kind, + ), + labeled_control( + "Eraser mode", + pick_list( + PresetEraserModeOption::list(), + Some(slot.eraser_mode), + move |opt| Message::PresetEraserModeChanged(slot_index, opt), + ) + .width(Length::Fill) + .into(), + default_slot.eraser_mode.label(), + slot.eraser_mode != default_slot.eraser_mode, + ) + ] + .spacing(12); + + let fill_row = row![ + preset_override_control( + "Fill enabled", + slot.fill_enabled, + default_slot.fill_enabled, + slot_index, + PresetToggleField::FillEnabled, + ), + preset_override_control( + "Text background", + slot.text_background_enabled, + default_slot.text_background_enabled, + slot_index, + PresetToggleField::TextBackgroundEnabled, + ) + ] + .spacing(12); + + let font_row = row![ + preset_input( + "Font size (pt)", + &slot.font_size, + &default_slot.font_size, + slot_index, + PresetTextField::FontSize, + true, + ), + preset_input( + "Arrow length (px)", + &slot.arrow_length, + &default_slot.arrow_length, + slot_index, + PresetTextField::ArrowLength, + true, + ) + ] + .spacing(12); + + let arrow_row = row![ + preset_input( + "Arrow angle (deg)", + &slot.arrow_angle, + &default_slot.arrow_angle, + slot_index, + PresetTextField::ArrowAngle, + true, + ), + preset_override_control( + "Arrow head at end", + slot.arrow_head_at_end, + default_slot.arrow_head_at_end, + slot_index, + PresetToggleField::ArrowHeadAtEnd, + ) + ] + .spacing(12); + + let status_row = row![preset_override_control( + "Show status bar", + slot.show_status_bar, + default_slot.show_status_bar, + slot_index, + PresetToggleField::ShowStatusBar, + )]; + + section = section + .push(header_row) + .push(color_block) + .push(size_row) + .push(eraser_row) + .push(fill_row) + .push(font_row) + .push(arrow_row) + .push(status_row); + + container(section) + .padding(12) + .style(theme::Container::Box) + .into() + } + fn arrow_tab(&self) -> Element<'_, Message> { scrollable( column![ @@ -1623,6 +2063,51 @@ fn labeled_control<'a>( .into() } +fn preset_input<'a>( + label: &'static str, + value: &'a str, + default: &'a str, + slot_index: usize, + field: PresetTextField, + show_unset: bool, +) -> Element<'a, Message> { + let changed = value.trim() != default.trim(); + let default_label = if show_unset && default.trim().is_empty() { + "unset".to_string() + } else { + default.trim().to_string() + }; + + column![ + row![ + text(label).size(14), + default_value_text(default_label, changed) + ] + .spacing(DEFAULT_LABEL_GAP) + .align_items(iced::Alignment::Center), + text_input(label, value) + .on_input(move |val| Message::PresetTextChanged(slot_index, field, val)) + ] + .spacing(4) + .width(Length::Fill) + .into() +} + +fn preset_override_control<'a>( + label: &'static str, + value: OverrideOption, + default: OverrideOption, + slot_index: usize, + field: PresetToggleField, +) -> Element<'a, Message> { + let picker = pick_list(OverrideOption::list(), Some(value), move |opt| { + Message::PresetToggleOptionChanged(slot_index, field, opt) + }) + .width(Length::Fill); + + labeled_control(label, picker.into(), default.label(), value != default) +} + fn override_row<'a>(field: ToolbarOverrideField, value: OverrideOption) -> Element<'a, Message> { row![ text(field.label()).size(14), diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index b6f7b1ef..869ebf6e 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -5,9 +5,10 @@ use wayscriber::config::Config; use crate::models::{ BoardModeOption, ColorMode, EraserModeOption, FontStyleOption, FontWeightOption, - KeybindingField, NamedColorOption, OverrideOption, QuadField, SessionCompressionOption, - SessionStorageModeOption, StatusPositionOption, TabId, TextField, ToggleField, - ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, UiTabId, + KeybindingField, NamedColorOption, OverrideOption, PresetEraserKindOption, + PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField, + SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TabId, TextField, + ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, UiTabId, }; #[derive(Debug, Clone)] @@ -37,4 +38,14 @@ pub enum Message { KeybindingChanged(KeybindingField, String), FontStyleOptionSelected(FontStyleOption), FontWeightOptionSelected(FontWeightOption), + PresetSlotCountChanged(usize), + PresetSlotEnabledChanged(usize, bool), + PresetToolChanged(usize, ToolOption), + PresetColorModeChanged(usize, ColorMode), + PresetNamedColorSelected(usize, NamedColorOption), + PresetColorComponentChanged(usize, usize, String), + PresetTextChanged(usize, PresetTextField, String), + PresetToggleOptionChanged(usize, PresetToggleField, OverrideOption), + PresetEraserKindChanged(usize, PresetEraserKindOption), + PresetEraserModeChanged(usize, PresetEraserModeOption), } diff --git a/configurator/src/models/color.rs b/configurator/src/models/color.rs index cd61da83..a56df7ae 100644 --- a/configurator/src/models/color.rs +++ b/configurator/src/models/color.rs @@ -115,6 +115,10 @@ impl ColorInput { } pub fn to_color_spec(&self) -> Result { + self.to_color_spec_with_field("drawing.default_color") + } + + pub fn to_color_spec_with_field(&self, field: &str) -> Result { match self.mode { ColorMode::Named => { let value = if self.selected_named_is_custom() { @@ -125,7 +129,7 @@ impl ColorInput { if value.trim().is_empty() { Err(FormError::new( - "drawing.default_color", + field.to_string(), "Please enter a color name.", )) } else { @@ -135,7 +139,7 @@ impl ColorInput { ColorMode::Rgb => { let mut rgb = [0u8; 3]; for (index, component) in self.rgb.iter().enumerate() { - let field = format!("drawing.default_color[{}]", index); + let field = format!("{field}[{index}]"); let parsed = component.trim().parse::().map_err(|_| { FormError::new(&field, "Expected integer between 0 and 255") })?; diff --git a/configurator/src/models/config.rs b/configurator/src/models/config.rs index 0f2af7e4..71321170 100644 --- a/configurator/src/models/config.rs +++ b/configurator/src/models/config.rs @@ -1,11 +1,16 @@ -use wayscriber::config::{Config, PresetSlotsConfig, ToolbarModeOverride, ToolbarModeOverrides}; +use wayscriber::config::{ + Config, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PresetSlotsConfig, ToolPresetConfig, + ToolbarModeOverride, ToolbarModeOverrides, +}; +use wayscriber::input::Tool; use super::color::{ColorInput, ColorQuadInput, ColorTripletInput}; use super::error::FormError; use super::fields::{ BoardModeOption, EraserModeOption, FontStyleOption, FontWeightOption, OverrideOption, - QuadField, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TextField, - ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, + PresetEraserKindOption, PresetEraserModeOption, QuadField, SessionCompressionOption, + SessionStorageModeOption, StatusPositionOption, TextField, ToggleField, ToolOption, + ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, }; use super::keybindings::KeybindingsDraft; use super::util::{format_float, parse_f64}; @@ -129,11 +134,207 @@ pub struct ConfigDraft { pub tablet_min_thickness: String, pub tablet_max_thickness: String, - pub presets: PresetSlotsConfig, + pub presets: PresetsDraft, pub keybindings: KeybindingsDraft, } +#[derive(Debug, Clone, PartialEq)] +pub struct PresetSlotDraft { + pub enabled: bool, + pub name: String, + pub tool: ToolOption, + pub color: ColorInput, + pub size: String, + pub eraser_kind: PresetEraserKindOption, + pub eraser_mode: PresetEraserModeOption, + pub marker_opacity: String, + pub fill_enabled: OverrideOption, + pub font_size: String, + pub text_background_enabled: OverrideOption, + pub arrow_length: String, + pub arrow_angle: String, + pub arrow_head_at_end: OverrideOption, + pub show_status_bar: OverrideOption, +} + +impl PresetSlotDraft { + fn from_config(preset: Option<&ToolPresetConfig>, defaults: &Config) -> Self { + match preset { + Some(preset) => Self { + enabled: true, + name: preset.name.clone().unwrap_or_default(), + tool: ToolOption::from_tool(preset.tool), + color: ColorInput::from_color(&preset.color), + size: format_float(preset.size), + eraser_kind: PresetEraserKindOption::from_option(preset.eraser_kind), + eraser_mode: PresetEraserModeOption::from_option(preset.eraser_mode), + marker_opacity: preset.marker_opacity.map(format_float).unwrap_or_default(), + fill_enabled: OverrideOption::from_option(preset.fill_enabled), + font_size: preset.font_size.map(format_float).unwrap_or_default(), + text_background_enabled: OverrideOption::from_option( + preset.text_background_enabled, + ), + arrow_length: preset.arrow_length.map(format_float).unwrap_or_default(), + arrow_angle: preset.arrow_angle.map(format_float).unwrap_or_default(), + arrow_head_at_end: OverrideOption::from_option(preset.arrow_head_at_end), + show_status_bar: OverrideOption::from_option(preset.show_status_bar), + }, + None => { + let mut slot = Self::default_from_config(defaults); + slot.enabled = false; + slot + } + } + } + + fn default_from_config(defaults: &Config) -> Self { + Self { + enabled: true, + name: String::new(), + tool: ToolOption::from_tool(Tool::Pen), + color: ColorInput::from_color(&defaults.drawing.default_color), + size: format_float(defaults.drawing.default_thickness), + eraser_kind: PresetEraserKindOption::Default, + eraser_mode: PresetEraserModeOption::Default, + marker_opacity: String::new(), + fill_enabled: OverrideOption::Default, + font_size: String::new(), + text_background_enabled: OverrideOption::Default, + arrow_length: String::new(), + arrow_angle: String::new(), + arrow_head_at_end: OverrideOption::Default, + show_status_bar: OverrideOption::Default, + } + } + + fn to_config( + &self, + slot_index: usize, + errors: &mut Vec, + ) -> Option { + if !self.enabled { + return None; + } + + let name = self.name.trim(); + let name = if name.is_empty() { + None + } else { + Some(name.to_string()) + }; + + let color = match self + .color + .to_color_spec_with_field(&format!("presets.slot_{slot_index}.color")) + { + Ok(color) => Some(color), + Err(err) => { + errors.push(err); + None + } + }; + + let size = parse_required_f64( + &self.size, + format!("presets.slot_{slot_index}.size"), + errors, + ); + + let marker_opacity = parse_optional_f64( + &self.marker_opacity, + format!("presets.slot_{slot_index}.marker_opacity"), + errors, + ); + let font_size = parse_optional_f64( + &self.font_size, + format!("presets.slot_{slot_index}.font_size"), + errors, + ); + let arrow_length = parse_optional_f64( + &self.arrow_length, + format!("presets.slot_{slot_index}.arrow_length"), + errors, + ); + let arrow_angle = parse_optional_f64( + &self.arrow_angle, + format!("presets.slot_{slot_index}.arrow_angle"), + errors, + ); + + let color = color?; + let size = size?; + + Some(ToolPresetConfig { + name, + tool: self.tool.to_tool(), + color, + size, + eraser_kind: self.eraser_kind.to_option(), + eraser_mode: self.eraser_mode.to_option(), + marker_opacity, + fill_enabled: self.fill_enabled.to_option(), + font_size, + text_background_enabled: self.text_background_enabled.to_option(), + arrow_length, + arrow_angle, + arrow_head_at_end: self.arrow_head_at_end.to_option(), + show_status_bar: self.show_status_bar.to_option(), + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PresetsDraft { + pub slot_count: usize, + slots: Vec, +} + +impl PresetsDraft { + pub fn from_config(config: &Config) -> Self { + let slots = (1..=PRESET_SLOTS_MAX) + .map(|slot| PresetSlotDraft::from_config(config.presets.get_slot(slot), config)) + .collect(); + Self { + slot_count: config.presets.slot_count, + slots, + } + } + + pub fn to_config(&self, errors: &mut Vec) -> PresetSlotsConfig { + let mut config = PresetSlotsConfig::default(); + + if !(PRESET_SLOTS_MIN..=PRESET_SLOTS_MAX).contains(&self.slot_count) { + errors.push(FormError::new( + "presets.slot_count", + format!("Expected a value between {PRESET_SLOTS_MIN} and {PRESET_SLOTS_MAX}"), + )); + } + config.slot_count = self.slot_count.clamp(PRESET_SLOTS_MIN, PRESET_SLOTS_MAX); + + for (index, slot) in self.slots.iter().enumerate() { + let slot_index = index + 1; + config.set_slot(slot_index, slot.to_config(slot_index, errors)); + } + + config + } + + pub fn slot(&self, slot: usize) -> Option<&PresetSlotDraft> { + if slot == 0 { + return None; + } + self.slots.get(slot - 1) + } + + pub fn slot_mut(&mut self, slot: usize) -> Option<&mut PresetSlotDraft> { + if slot == 0 { + return None; + } + self.slots.get_mut(slot - 1) + } +} + #[derive(Debug, Clone, PartialEq)] pub struct ToolbarModeOverrideDraft { pub show_presets: OverrideOption, @@ -367,7 +568,7 @@ impl ConfigDraft { tablet_min_thickness: format_float(config.tablet.min_thickness), tablet_max_thickness: format_float(config.tablet.max_thickness), - presets: config.presets.clone(), + presets: PresetsDraft::from_config(config), keybindings: KeybindingsDraft::from_config(&config.keybindings), } @@ -755,7 +956,7 @@ impl ConfigDraft { ); } - config.presets = self.presets.clone(); + config.presets = self.presets.to_config(&mut errors); match self.keybindings.to_config() { Ok(cfg) => config.keybindings = cfg, @@ -1009,6 +1210,35 @@ fn parse_optional_usize_field( } } +fn parse_required_f64(value: &str, field: String, errors: &mut Vec) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + errors.push(FormError::new(field, "Value is required")); + return None; + } + match parse_f64(trimmed) { + Ok(parsed) => Some(parsed), + Err(err) => { + errors.push(FormError::new(field, err)); + None + } + } +} + +fn parse_optional_f64(value: &str, field: String, errors: &mut Vec) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + match parse_f64(trimmed) { + Ok(parsed) => Some(parsed), + Err(err) => { + errors.push(FormError::new(field, err)); + None + } + } +} + fn parse_u64_field(value: &str, field: &'static str, errors: &mut Vec, apply: F) where F: FnOnce(u64), diff --git a/configurator/src/models/fields.rs b/configurator/src/models/fields.rs index ee9e3e84..e226782d 100644 --- a/configurator/src/models/fields.rs +++ b/configurator/src/models/fields.rs @@ -1,7 +1,8 @@ use wayscriber::config::{ SessionCompression, SessionStorageMode, StatusPosition, ToolbarLayoutMode, }; -use wayscriber::input::EraserMode; +use wayscriber::draw::EraserKind; +use wayscriber::input::{EraserMode, Tool}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FontStyleOption { @@ -158,6 +159,169 @@ impl std::fmt::Display for EraserModeOption { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolOption { + Select, + Pen, + Line, + Rect, + Ellipse, + Arrow, + Marker, + Highlight, + Eraser, +} + +impl ToolOption { + pub fn list() -> Vec { + vec![ + Self::Select, + Self::Pen, + Self::Line, + Self::Rect, + Self::Ellipse, + Self::Arrow, + Self::Marker, + Self::Highlight, + Self::Eraser, + ] + } + + pub fn label(&self) -> &'static str { + match self { + Self::Select => "Select", + Self::Pen => "Pen", + Self::Line => "Line", + Self::Rect => "Rectangle", + Self::Ellipse => "Ellipse", + Self::Arrow => "Arrow", + Self::Marker => "Marker", + Self::Highlight => "Highlight", + Self::Eraser => "Eraser", + } + } + + pub fn to_tool(self) -> Tool { + match self { + Self::Select => Tool::Select, + Self::Pen => Tool::Pen, + Self::Line => Tool::Line, + Self::Rect => Tool::Rect, + Self::Ellipse => Tool::Ellipse, + Self::Arrow => Tool::Arrow, + Self::Marker => Tool::Marker, + Self::Highlight => Tool::Highlight, + Self::Eraser => Tool::Eraser, + } + } + + pub fn from_tool(tool: Tool) -> Self { + match tool { + Tool::Select => Self::Select, + Tool::Pen => Self::Pen, + Tool::Line => Self::Line, + Tool::Rect => Self::Rect, + Tool::Ellipse => Self::Ellipse, + Tool::Arrow => Self::Arrow, + Tool::Marker => Self::Marker, + Tool::Highlight => Self::Highlight, + Tool::Eraser => Self::Eraser, + } + } +} + +impl std::fmt::Display for ToolOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresetEraserKindOption { + Default, + Circle, + Rect, +} + +impl PresetEraserKindOption { + pub fn list() -> Vec { + vec![Self::Default, Self::Circle, Self::Rect] + } + + pub fn label(&self) -> &'static str { + match self { + Self::Default => "Default", + Self::Circle => "Circle", + Self::Rect => "Rectangle", + } + } + + pub fn to_option(self) -> Option { + match self { + Self::Default => None, + Self::Circle => Some(EraserKind::Circle), + Self::Rect => Some(EraserKind::Rect), + } + } + + pub fn from_option(value: Option) -> Self { + match value { + None => Self::Default, + Some(EraserKind::Circle) => Self::Circle, + Some(EraserKind::Rect) => Self::Rect, + } + } +} + +impl std::fmt::Display for PresetEraserKindOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresetEraserModeOption { + Default, + Brush, + Stroke, +} + +impl PresetEraserModeOption { + pub fn list() -> Vec { + vec![Self::Default, Self::Brush, Self::Stroke] + } + + pub fn label(&self) -> &'static str { + match self { + Self::Default => "Default", + Self::Brush => "Brush", + Self::Stroke => "Stroke", + } + } + + pub fn to_option(self) -> Option { + match self { + Self::Default => None, + Self::Brush => Some(EraserMode::Brush), + Self::Stroke => Some(EraserMode::Stroke), + } + } + + pub fn from_option(value: Option) -> Self { + match value { + None => Self::Default, + Some(EraserMode::Brush) => Self::Brush, + Some(EraserMode::Stroke) => Self::Stroke, + } + } +} + +impl std::fmt::Display for PresetEraserModeOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StatusPositionOption { TopLeft, @@ -416,6 +580,14 @@ pub enum ToggleField { TabletPressureEnabled, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresetToggleField { + FillEnabled, + TextBackgroundEnabled, + ArrowHeadAtEnd, + ShowStatusBar, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TextField { DrawingColorName, @@ -466,6 +638,17 @@ pub enum TextField { TabletMaxThickness, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresetTextField { + Name, + ColorName, + Size, + MarkerOpacity, + FontSize, + ArrowLength, + ArrowAngle, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TripletField { DrawingColorRgb, diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index ae062214..7ff91043 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -10,8 +10,9 @@ pub use color::{ColorMode, ColorQuadInput, ColorTripletInput, NamedColorOption}; pub use config::ConfigDraft; pub use fields::{ BoardModeOption, EraserModeOption, FontStyleOption, FontWeightOption, OverrideOption, - QuadField, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TextField, - ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, + PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField, + SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TextField, + ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, }; pub use keybindings::KeybindingField; pub use tab::{TabId, UiTabId}; diff --git a/configurator/src/models/tab.rs b/configurator/src/models/tab.rs index b22a91fc..8d479611 100644 --- a/configurator/src/models/tab.rs +++ b/configurator/src/models/tab.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TabId { Drawing, + Presets, Arrow, History, Performance, @@ -13,8 +14,9 @@ pub enum TabId { } impl TabId { - pub const ALL: [TabId; 10] = [ + pub const ALL: [TabId; 11] = [ TabId::Drawing, + TabId::Presets, TabId::Ui, TabId::Board, TabId::Performance, @@ -29,6 +31,7 @@ impl TabId { pub fn title(&self) -> &'static str { match self { TabId::Drawing => "Drawing", + TabId::Presets => "Presets", TabId::Arrow => "Arrow", TabId::History => "History", TabId::Performance => "Performance",