diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 44feba2..d60e4c9 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -587,6 +587,10 @@ pub struct ToolState { pub brush_color_variant: bool, /// Magnitude of the per-dab color variation (0.0..1.0). pub brush_variant_amount: f32, + /// True when the brush color should cycle through the hue over stroke distance. + pub brush_cyclic_color: bool, + /// Speed of the hue cycle along the stroke (0.01..5.0). + pub brush_cyclic_speed: f32, /// Timestamp of the last update() call for frame timing. pub last_update_instant: std::time::Instant, /// Imported brush presets from ABR/KPP files. @@ -621,6 +625,8 @@ impl Default for ToolState { brush_spacing: 1.0, brush_color_variant: false, brush_variant_amount: 0.0, + brush_cyclic_color: false, + brush_cyclic_speed: 0.5, last_update_instant: std::time::Instant::now(), imported_brushes: Vec::new(), active_brush_preset: None, @@ -778,6 +784,8 @@ pub enum Message { BrushSpacingChanged(f32), BrushColorVariantToggled(bool), BrushVariantAmountChanged(f32), + BrushCyclicColorToggled(bool), + BrushCyclicSpeedChanged(f32), BrushImportAbr, BrushImportAbrFile(Result, String>), ResetToolDefaults, @@ -1319,6 +1327,7 @@ fn build_brush_tip(state: &ToolState) -> BrushTip { } } + /// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`. /// /// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with @@ -2564,7 +2573,7 @@ impl HcieIcedApp { /// Apply brush parameters from tool state to the engine. /// /// Called before starting a stroke to ensure the engine uses - /// the current brush size, opacity, hardness, and style. + /// the current brush size, opacity, hardness, style, and cyclic color. fn apply_brush_params(&mut self) { let mut tip = self .tool_state @@ -2577,6 +2586,15 @@ impl HcieIcedApp { tip.opacity = self.tool_state.brush_opacity; tip.hardness = self.tool_state.brush_hardness; tip.spacing = self.tool_state.brush_spacing; + // Cyclic color is handled by engine state, not the tip, but preview rendering + // also reads the tip's time dynamics. Synchronize both here. + tip.time_enabled = self.tool_state.brush_cyclic_color; + tip.time_size_start = 1.0; + tip.time_size_end = 1.0; + tip.time_opacity_start = 1.0; + tip.time_opacity_end = 1.0; + tip.time_angle_start = 0.0; + tip.time_angle_end = 0.0; match self.tool_state.active_tool { Tool::Pen => { tip.style = BrushStyle::Round; @@ -2592,7 +2610,10 @@ impl HcieIcedApp { } _ => {} } - self.documents[self.active_doc].engine.set_brush_tip(tip); + let engine = &mut self.documents[self.active_doc].engine; + engine.set_brush_tip(tip); + engine.set_cyclic_color(self.tool_state.brush_cyclic_color); + engine.set_cyclic_speed(self.tool_state.brush_cyclic_speed); } /// Push the current SVG editor model into the active vector shape so the @@ -4955,6 +4976,22 @@ impl HcieIcedApp { self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } + Message::BrushCyclicColorToggled(enabled) => { + self.tool_state.brush_cyclic_color = enabled; + self.documents[self.active_doc] + .engine + .set_cyclic_color(enabled); + self.settings.update_from_tool_state(&self.tool_state); + let _ = self.settings.save(); + } + Message::BrushCyclicSpeedChanged(speed) => { + self.tool_state.brush_cyclic_speed = speed; + self.documents[self.active_doc] + .engine + .set_cyclic_speed(speed); + self.settings.update_from_tool_state(&self.tool_state); + let _ = self.settings.save(); + } Message::SelectionToleranceChanged(tol) => { self.settings.tool_settings.magic_wand_tolerance = tol as f32; self.settings.tool_settings.flood_fill_tolerance = tol as f32; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index bea421e..e3f866e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -184,6 +184,8 @@ pub fn panel_body<'a>( app.tool_state.brush_hardness, app.tool_state.brush_color_variant, app.tool_state.brush_variant_amount, + app.tool_state.brush_cyclic_color, + app.tool_state.brush_cyclic_speed, app.brush_category, &app.tool_state.imported_brushes, app.tool_state.active_brush_preset.as_ref(), @@ -236,6 +238,10 @@ pub fn panel_body<'a>( app.tool_state.brush_size, app.tool_state.brush_opacity, app.tool_state.brush_hardness, + app.tool_state.brush_color_variant, + app.tool_state.brush_variant_amount, + app.tool_state.brush_cyclic_color, + app.tool_state.brush_cyclic_speed, doc.selection_rect, doc.vector_draw, colors, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs index 3211a69..21137c6 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs @@ -290,6 +290,7 @@ struct PreviewKey { size: u8, hardness: u8, opacity: u8, + cyclic: bool, r: u8, g: u8, b: u8, @@ -508,12 +509,20 @@ fn build_tip_from_state( size: f32, opacity: f32, hardness: f32, + cyclic_color: bool, ) -> hcie_engine_api::BrushTip { hcie_engine_api::BrushTip { style, size, opacity, hardness, + time_enabled: cyclic_color, + time_size_start: 1.0, + time_size_end: 1.0, + time_opacity_start: 1.0, + time_opacity_end: 1.0, + time_angle_start: 0.0, + time_angle_end: 0.0, ..hcie_engine_api::BrushTip::default() } } @@ -527,6 +536,7 @@ fn preview_key( size: f32, opacity: f32, hardness: f32, + cyclic: bool, fg_color: [u8; 4], ) -> PreviewKey { PreviewKey { @@ -534,6 +544,7 @@ fn preview_key( size: size.clamp(1.0, 255.0) as u8, hardness: (hardness * 100.0).clamp(0.0, 100.0) as u8, opacity: (opacity * 100.0).clamp(0.0, 100.0) as u8, + cyclic, r: fg_color[0], g: fg_color[1], b: fg_color[2], @@ -562,13 +573,14 @@ fn get_live_stroke_preview( size: f32, opacity: f32, hardness: f32, + cyclic_color: bool, fg_color: [u8; 4], ) -> iced::widget::image::Handle { // Clamp the rendered brush size so a 110 px soft brush does not fill the // entire 40 px preview well with a solid blob. The preview shows texture and // stroke character; the slider still reports the real painting size. let preview_size = size.clamp(4.0, LIVE_PREVIEW_BRUSH_SIZE); - let key = preview_key(style, preview_size, opacity, hardness, fg_color); + let key = preview_key(style, preview_size, opacity, hardness, cyclic_color, fg_color); let cache = LIVE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut cache = cache .lock() @@ -576,7 +588,7 @@ fn get_live_stroke_preview( if let Some(handle) = cache.get(&key) { return handle.clone(); } - let tip = build_tip_from_state(style, preview_size, opacity, hardness); + let tip = build_tip_from_state(style, preview_size, opacity, hardness, cyclic_color); let pixels = render_stroke_preview(LIVE_PREVIEW_IMG_SIZE, LIVE_PREVIEW_IMG_SIZE, &tip, fg_color); let handle = iced::widget::image::Handle::from_rgba( @@ -588,6 +600,11 @@ fn get_live_stroke_preview( handle } +/// Clamp a cyclic speed value to the engine's valid range. +fn clamp_cyclic_speed(speed: f32) -> f32 { + speed.clamp(0.01, 5.0) +} + /// Get or generate a realistic stroke preview for a built-in brush style. /// /// **Purpose:** Provides the thumbnails shown in the built-in style grid. The @@ -637,6 +654,8 @@ pub fn view( current_hardness: f32, brush_color_variant: bool, brush_variant_amount: f32, + brush_cyclic_color: bool, + brush_cyclic_speed: f32, active_category: BrushCategory, imported_presets: &[hcie_engine_api::BrushPreset], active_brush_preset: Option<&hcie_engine_api::BrushPreset>, @@ -1021,6 +1040,16 @@ pub fn view( colors, Message::BrushVariantAmountChanged, ); + let cyclic_speed_slider = plain_slider( + "Cyclic Speed", + clamp_cyclic_speed(brush_cyclic_speed), + 0.01..=5.0, + 0.01, + "x", + 2, + colors, + Message::BrushCyclicSpeedChanged, + ); // Compact color-variant toggle: an empty 12 px checkbox plus an 8 px label so // the control matches the rest of the compact panel typography. @@ -1033,6 +1062,15 @@ pub fn view( .spacing(4) .align_y(iced::Alignment::Center); + let cyclic_color_check = row![ + checkbox("", brush_cyclic_color) + .on_toggle(Message::BrushCyclicColorToggled) + .size(12), + text("Cyclic color").size(LABEL_SIZE), + ] + .spacing(4) + .align_y(iced::Alignment::Center); + // Live preview — show a realistic engine-rendered stroke using the active // size, opacity, hardness, and foreground color. The preview is // parameter-keyed and cached so it updates in real time without @@ -1042,6 +1080,7 @@ pub fn view( current_size, current_opacity, current_hardness, + brush_cyclic_color, fg_color, ); let live_preview = container( @@ -1091,6 +1130,8 @@ pub fn view( hardness_slider, color_variant_check, variant_slider, + cyclic_color_check, + cyclic_speed_slider, ] .spacing(ITEM_SPACING) .padding(3); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs index 191e3b2..2402955 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs @@ -93,6 +93,10 @@ pub fn view<'a>( brush_size: f32, brush_opacity: f32, brush_hardness: f32, + brush_color_variant: bool, + brush_variant_amount: f32, + brush_cyclic_color: bool, + brush_cyclic_speed: f32, selection_rect: Option<(f32, f32, f32, f32)>, vector_draw: Option<((f32, f32), (f32, f32))>, colors: ThemeColors, @@ -134,12 +138,25 @@ pub fn view<'a>( // Build tool/shape-specific section let tool_section: Element<'a, Message> = match active_tool { Tool::Pen | Tool::Brush | Tool::Eraser => { - brush_properties(brush_size, brush_opacity, brush_hardness, colors) + brush_properties( + brush_size, + brush_opacity, + brush_hardness, + brush_color_variant, + brush_variant_amount, + brush_cyclic_color, + brush_cyclic_speed, + colors, + ) } Tool::Spray => spray_properties( brush_size, brush_opacity, brush_hardness, + brush_color_variant, + brush_variant_amount, + brush_cyclic_color, + brush_cyclic_speed, settings_spray_particle_size, settings_spray_density, colors, @@ -511,11 +528,20 @@ fn vector_tool_properties<'a>( col.into() } +/// Shared cyclic speed clamp used by brush and spray property sections. +fn clamp_cyclic_speed(speed: f32) -> f32 { + speed.clamp(0.01, 5.0) +} + /// Brush tool properties (Pen, Brush, Eraser). fn brush_properties<'a>( size: f32, opacity: f32, hardness: f32, + color_variant: bool, + variant_amount: f32, + cyclic_color: bool, + cyclic_speed: f32, colors: ThemeColors, ) -> Element<'a, Message> { column![ @@ -529,6 +555,40 @@ fn brush_properties<'a>( plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| { Message::BrushHardnessChanged(v) },), + row![ + checkbox("Color Variant", color_variant) + .on_toggle(Message::BrushColorVariantToggled) + .size(11), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + plain_slider( + "Variant Amount", + variant_amount, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::BrushVariantAmountChanged, + ), + row![ + checkbox("Cyclic Color", cyclic_color) + .on_toggle(Message::BrushCyclicColorToggled) + .size(11), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + plain_slider( + "Cyclic Speed", + clamp_cyclic_speed(cyclic_speed), + 0.01..=5.0, + 0.01, + "x", + 2, + colors, + Message::BrushCyclicSpeedChanged, + ), ] .spacing(4) .into() @@ -539,6 +599,10 @@ fn spray_properties<'a>( size: f32, opacity: f32, hardness: f32, + color_variant: bool, + variant_amount: f32, + cyclic_color: bool, + cyclic_speed: f32, particle_size: f32, density: u32, colors: ThemeColors, @@ -574,6 +638,40 @@ fn spray_properties<'a>( plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| { Message::BrushHardnessChanged(v) },), + row![ + checkbox("Color Variant", color_variant) + .on_toggle(Message::BrushColorVariantToggled) + .size(11), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + plain_slider( + "Variant Amount", + variant_amount, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::BrushVariantAmountChanged, + ), + row![ + checkbox("Cyclic Color", cyclic_color) + .on_toggle(Message::BrushCyclicColorToggled) + .size(11), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + plain_slider( + "Cyclic Speed", + clamp_cyclic_speed(cyclic_speed), + 0.01..=5.0, + 0.01, + "x", + 2, + colors, + Message::BrushCyclicSpeedChanged, + ), ] .spacing(4) .into() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs index 22e60b4..10aa0fa 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs @@ -134,6 +134,17 @@ fn brush_settings( |v| Message::BrushVariantAmountChanged(v / 100.0), c ), + checkbox("Cyclic Color", ts.brush_cyclic_color) + .on_toggle(Message::BrushCyclicColorToggled) + .size(11), + prop_slider( + "Cyclic Speed", + ts.brush_cyclic_speed.clamp(0.01, 5.0), + 0.01, + 5.0, + Message::BrushCyclicSpeedChanged, + c + ), ] .spacing(4) } @@ -186,6 +197,28 @@ fn spray_settings( |v| Message::BrushHardnessChanged(v / 100.0), c ), + checkbox("Color Variant", ts.brush_color_variant) + .on_toggle(Message::BrushColorVariantToggled) + .size(11), + prop_slider( + "Variant Amount", + ts.brush_variant_amount * 100.0, + 0.0, + 100.0, + |v| Message::BrushVariantAmountChanged(v / 100.0), + c + ), + checkbox("Cyclic Color", ts.brush_cyclic_color) + .on_toggle(Message::BrushCyclicColorToggled) + .size(11), + prop_slider( + "Cyclic Speed", + ts.brush_cyclic_speed.clamp(0.01, 5.0), + 0.01, + 5.0, + Message::BrushCyclicSpeedChanged, + c + ), ] .spacing(4) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs index de23ed7..5c2094c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs @@ -59,6 +59,12 @@ pub struct ToolSettings { /// Magnitude of the per-dab color variation (0.0..1.0). #[serde(default)] pub brush_variant_amount: f32, + /// True when the brush color should cycle through the hue over stroke distance. + #[serde(default)] + pub brush_cyclic_color: bool, + /// Speed of the hue cycle along the stroke (0.01..5.0). + #[serde(default)] + pub brush_cyclic_speed: f32, pub eraser_size: f32, pub eraser_opacity: f32, pub pen_size: f32, @@ -192,6 +198,8 @@ impl Default for ToolSettings { brush_spacing: 1.0, brush_color_variant: false, brush_variant_amount: 0.0, + brush_cyclic_color: false, + brush_cyclic_speed: 0.5, eraser_size: 20.0, eraser_opacity: 1.0, pen_size: 2.0, @@ -344,6 +352,8 @@ impl AppSettings { self.tool_settings.brush_hardness = tool_state.brush_hardness; self.tool_settings.brush_color_variant = tool_state.brush_color_variant; self.tool_settings.brush_variant_amount = tool_state.brush_variant_amount; + self.tool_settings.brush_cyclic_color = tool_state.brush_cyclic_color; + self.tool_settings.brush_cyclic_speed = tool_state.brush_cyclic_speed; self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool); } @@ -354,6 +364,8 @@ impl AppSettings { tool_state.brush_hardness = self.tool_settings.brush_hardness; tool_state.brush_color_variant = self.tool_settings.brush_color_variant; tool_state.brush_variant_amount = self.tool_settings.brush_variant_amount; + tool_state.brush_cyclic_color = self.tool_settings.brush_cyclic_color; + tool_state.brush_cyclic_speed = self.tool_settings.brush_cyclic_speed; } }