diff --git a/Cargo.lock b/Cargo.lock index 4d7f6e4..a514f17 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2730,6 +2730,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "zip", ] [[package]] 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 97a59eb..82d7cee 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -59,6 +59,42 @@ fn save_recent_files(files: &[RecentFileEntry]) { } } +/// Persisted color state (fg/bg/recent colors), mirroring egui's `hcie_colors`. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +struct PersistedColors { + #[serde(default)] + fg: Option<[u8; 4]>, + #[serde(default)] + bg: Option<[u8; 4]>, + #[serde(default)] + recent: Vec<[u8; 4]>, +} + +/// Path to the persisted colors JSON (~/.config/hcie-iced/colors.json). +fn colors_path() -> PathBuf { + config_dir().join("colors.json") +} + +/// Load persisted colors from disk (or defaults if missing). +fn load_colors() -> PersistedColors { + match std::fs::read_to_string(colors_path()) { + Ok(json) => serde_json::from_str(&json).unwrap_or_default(), + Err(_) => PersistedColors::default(), + } +} + +/// Save colors to disk. +fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) { + let data = PersistedColors { + fg: Some(fg), + bg: Some(bg), + recent: recent.to_vec(), + }; + if let Ok(json) = serde_json::to_string_pretty(&data) { + let _ = std::fs::write(colors_path(), json); + } +} + /// Active dialog type. #[derive(Debug, Clone, PartialEq)] #[allow(dead_code)] @@ -97,6 +133,10 @@ pub struct HcieIcedApp { pub active_tool_slot: usize, /// Whether the sub-tools popup is open for the active slot. pub sub_tools_open: bool, + /// Per-slot last-used sub-tool index (egui's `slot_last_used`). The sidebar + /// shows the last-used tool's icon/label for each slot rather than always + /// the primary tool. + pub slot_last_used: Vec, /// Persistent settings (tool settings, panel layout, window). pub settings: crate::settings::AppSettings, /// Window ID for window control operations (drag, minimize, maximize, close). @@ -206,6 +246,9 @@ pub struct HcieIcedApp { pub show_dock_profile_dialog: bool, /// Current UI language for internationalization. pub language: i18n::Language, + /// Dirty flag set whenever fg/bg/recent colors change, so colors can be + /// persisted to disk (egui persists these under the `hcie_colors` key). + pub colors_dirty: bool, } /// A recently opened file entry. @@ -272,6 +315,50 @@ pub struct IcedDocument { pub selection_bounds: Option<(u32, u32, u32, u32)>, /// Crop tool state pub crop_state: CropState, + /// Accumulated lasso points (freehand selection) in canvas coordinates. + pub lasso_points: Vec<(u32, u32)>, + /// Accumulated polygon vertices (polygonal lasso) in canvas coordinates. + pub polygon_points: Vec<(u32, u32)>, + /// Gradient drag start/current endpoints in canvas coordinates. + pub gradient_drag: Option<((f32, f32), (f32, f32))>, + /// VisionSelect bounding-box drag (start, current) in canvas coordinates. + pub vision_rect: Option<(f32, f32, f32, f32)>, + /// Draft text state for the on-canvas text tool. + pub text_draft: Option, +} + +/// In-progress text layer being authored with the Text tool. +/// +/// Holds the editable content plus font/size/color/alignment/orientation so the +/// properties panel and canvas overlay can render a live preview before the +/// text is committed to the engine as a Text layer. +#[derive(Debug, Clone)] +pub struct TextDraft { + pub content: String, + pub font: String, + pub size: f32, + pub color: [u8; 4], + pub x: f32, + pub y: f32, + pub angle: f32, + pub alignment: hcie_engine_api::TextAlignment, + pub orientation: hcie_engine_api::TextOrientation, +} + +impl Default for TextDraft { + fn default() -> Self { + Self { + content: String::new(), + font: "Arial".to_string(), + size: 24.0, + color: [0, 0, 0, 255], + x: 100.0, + y: 100.0, + angle: 0.0, + alignment: hcie_engine_api::TextAlignment::Left, + orientation: hcie_engine_api::TextOrientation::Horizontal, + } + } } /// Drawing tool state. @@ -333,6 +420,7 @@ pub enum Message { FgColorChanged([u8; 4]), BgColorChanged([u8; 4]), SwapColors, + ResetColors, ColorTabChanged(usize), // ── Canvas interaction ────────────────────────────── @@ -400,12 +488,35 @@ pub enum Message { // ── Brushes ───────────────────────────────────────── BrushStyleSelected(BrushStyle), BrushSizeChanged(f32), + BrushSizeRelative(f32), BrushOpacityChanged(f32), BrushHardnessChanged(f32), + BrushFlowChanged(f32), + BrushSpacingChanged(f32), BrushImportAbr, BrushImportAbrFile(Result, String>), ResetToolDefaults, + // ── Per-tool options (selection / spray / vector / text / gradient) ─ + SelectionToleranceChanged(u8), + SelectionContiguousToggled(bool), + SelectionFeatherChanged(f32), + SprayParticleSizeChanged(f32), + SprayDensityChanged(u32), + GradientTypeChanged(u32), + VectorStrokeChanged(f32), + VectorOpacityChanged(f32), + VectorFillToggled(bool), + VectorRadiusChanged(f32), + VectorPointsChanged(u32), + VectorSidesChanged(u32), + TextFontChanged(String), + TextAngleChanged(f32), + TextOrientationChanged(String), + TextContentChanged(String), + TextCommit, + ToolApplyToNewLayerToggled(bool), + // ── Filters ───────────────────────────────────────── FilterSelect(FilterType), FilterApply, @@ -473,6 +584,17 @@ pub enum Message { VectorDrawMove(f32, f32), VectorDrawEnd, + // ── Lasso / polygon selection ─────────────────────── + LassoDragMove(f32, f32), + LassoDragEnd, + PolygonAddPoint(f32, f32), + PolygonClose, + + // ── Gradient tool drag ────────────────────────────── + GradientDragStart(f32, f32), + GradientDragMove(f32, f32), + GradientDragEnd, + // ── Crop tool ────────────────────────────────────── CropDragStart(f32, f32), CropDragMove(f32, f32), @@ -567,6 +689,13 @@ pub enum Message { // ── Misc ──────────────────────────────────────────── NoOp, + // ── Tablet / touch feed ───────────────────────────── + TabletTouch { x: f32, y: f32, pressed: bool }, + + // ── Contextual key actions (resolved in update with self) ── + EscapePressed, + EnterPressed, + // ── Image Viewer ──────────────────────────────────── ViewerToggle, ViewerNavigate(std::path::PathBuf), @@ -659,6 +788,115 @@ fn union_regions(r1: Option<[u32; 4]>, r2: [u32; 4]) -> [u32; 4] { } } +/// Whether a tool is a vector-shape tool that uses the shared drag flow. +fn is_vector_shape_tool(tool: Tool) -> bool { + matches!( + tool, + Tool::VectorRect + | Tool::VectorCircle + | Tool::VectorLine + | Tool::VectorArrow + | Tool::VectorStar + | Tool::VectorPolygon + | Tool::VectorRhombus + | Tool::VectorCylinder + | Tool::VectorHeart + | Tool::VectorBubble + | Tool::VectorGear + | Tool::VectorCross + | Tool::VectorCrescent + | Tool::VectorBolt + | Tool::VectorArrow4 + ) +} + +/// Composite a floating selection transform's pixels back onto the active layer. +/// +/// The transform supports translation, uniform scaling (via `size / pixel-dims`), +/// and rotation (in radians). Each destination pixel is sampled from the +/// source transform buffer by inverting the affine transform. Source pixels +/// with zero alpha are skipped so transparent gaps do not overwrite the layer. +/// +/// This mirrors egui's `apply_transform_to_layer` but adds rotation support +/// (egui only handled scale + translation). +fn apply_transform_to_layer( + engine: &mut hcie_engine_api::Engine, + tr: &selection::SelectionTransform, + canvas_w: u32, + canvas_h: u32, +) { + if tr.is_empty() || canvas_w == 0 || canvas_h == 0 { + return; + } + let Some(mut dest) = engine.get_active_layer_pixels() else { + return; + }; + + let src_w = tr.width as f32; + let src_h = tr.height as f32; + let scale_x = if src_w > 0.0 { tr.size.x / src_w } else { 1.0 }; + let scale_y = if src_h > 0.0 { tr.size.y / src_h } else { 1.0 }; + let px = tr.pos.x.round() as f32; + let py = tr.pos.y.round() as f32; + + // Destination footprint in canvas pixels. + let dst_w = tr.size.x.round().max(1.0) as i32; + let dst_h = tr.size.y.round().max(1.0) as i32; + + let cos = tr.rotation.cos(); + let sin = tr.rotation.sin(); + let has_rotation = tr.rotation.abs() > 1e-4; + + // Center of the destination box in canvas space. + let dst_cx = px + dst_w as f32 / 2.0; + let dst_cy = py + dst_h as f32 / 2.0; + + for dy in 0..dst_h { + for dx in 0..dst_w { + let dst_x = px + dx as f32; + let dst_y = py + dy as f32; + if dst_x < 0.0 || dst_x >= canvas_w as f32 + || dst_y < 0.0 || dst_y >= canvas_h as f32 + { + continue; + } + + // Map destination pixel back into source texture space. + let (sx, sy) = if has_rotation { + // Vector from dst center, inverse-rotate, then map into src space. + let rel_x = dst_x - dst_cx; + let rel_y = dst_y - dst_cy; + let inv_x = rel_x * cos + rel_y * sin; + let inv_y = -rel_x * sin + rel_y * cos; + let center_src_x = src_w / 2.0; + let center_src_y = src_h / 2.0; + ((inv_x / scale_x + center_src_x), (inv_y / scale_y + center_src_y)) + } else { + let nx = dx as f32 / scale_x.max(1e-6); + let ny = dy as f32 / scale_y.max(1e-6); + (nx, ny) + }; + + let sxi = sx.round() as i32; + let syi = sy.round() as i32; + if sxi < 0 || sxi >= tr.width as i32 || syi < 0 || syi >= tr.height as i32 { + continue; + } + let src_idx = (syi as u32 * tr.width + sxi as u32) as usize * 4; + if src_idx + 3 >= tr.pixels.len() || tr.pixels[src_idx + 3] == 0 { + continue; + } + let dst_idx = (dst_y as u32 * canvas_w + dst_x as u32) as usize * 4; + if dst_idx + 3 < dest.len() { + dest[dst_idx..dst_idx + 4] + .copy_from_slice(&tr.pixels[src_idx..src_idx + 4]); + } + } + } + + engine.set_active_layer_pixels(dest); +} + impl HcieIcedApp { /// Create the initial application state. /// @@ -702,6 +940,11 @@ impl HcieIcedApp { selection_mask: None, selection_bounds: None, crop_state: CropState::default(), + lasso_points: Vec::new(), + polygon_points: Vec::new(), + gradient_drag: None, + vision_rect: None, + text_draft: None, }; // Load saved settings @@ -718,6 +961,7 @@ impl HcieIcedApp { active_dialog: ActiveDialog::None, active_tool_slot: 0, sub_tools_open: false, + slot_last_used: vec![0; 20], settings: settings.clone(), window_id: None, dialog_new_name: "Untitled".to_string(), @@ -780,10 +1024,26 @@ impl HcieIcedApp { dock_profile_rename_buf: String::new(), show_dock_profile_dialog: false, language: i18n::Language::default(), + colors_dirty: false, }; // Apply saved settings to tool state settings.apply_to_tool_state(&mut app.tool_state); + + // Restore persisted colors (fg/bg/recent) from the colors file, mirroring + // egui's `hcie_colors` JSON persistence in app/mod.rs:380-386. + let colors = load_colors(); + if colors.fg.is_some() { + app.fg_color = colors.fg.unwrap(); + } + if colors.bg.is_some() { + app.bg_color = colors.bg.unwrap(); + } + if !colors.recent.is_empty() { + app.recent_colors = colors.recent; + } + let fg = app.fg_color; + app.active_document_mut().engine.set_color(fg); app.settings = settings; // If a file path was provided, open it (route multi-layer formats to dedicated importers) @@ -807,20 +1067,14 @@ impl HcieIcedApp { .unwrap_or_else(|| "Untitled".to_string()); app.documents[0].source_path = Some(path.clone()); app.add_recent_file(&path); - // Get the composite via the safe Vec-returning path. - // This calls get_composite_pixels() which internally uses - // composite_layers() — a pure-Rust function that returns Vec. - // This is safe and avoids the raw-pointer segfault in - // render_composite_region. let composite = app.documents[0].engine.get_composite_pixels(); let size = composite.len(); - log::info!("[startup] composite pixels: {} bytes ({}x{})", size, + log::info!("[startup] composite pixels: {} bytes ({}x{})", size, app.documents[0].engine.canvas_width(), app.documents[0].engine.canvas_height()); app.documents[0].composite_raw = composite; app.documents[0].composite_pixels = std::sync::Arc::new(app.documents[0].composite_raw.clone()); app.documents[0].full_upload.replace(true); app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1); - // Refresh cached layer list and history for panels app.documents[0].cached_layers = app.documents[0].engine.layer_infos(); let history_len = app.documents[0].engine.history_len(); app.documents[0].cached_history = (0..history_len) @@ -973,6 +1227,19 @@ impl HcieIcedApp { log::info!("Tool selected: {:?}", tool); self.tool_state.active_tool = tool; self.sub_tools_open = false; + // Record this tool as the last-used one for its slot, so the + // sidebar shows the last-used tool's icon next time (egui's + // slot_last_used behavior at tool_state.rs:278-286). + if let Some(slot_idx) = crate::sidebar::slot_for_tool(tool) { + self.active_tool_slot = slot_idx; + if let Some(slot) = crate::sidebar::tool_slots().get(slot_idx) { + if let Some(tidx) = slot.tools.iter().position(|&t| t == tool) { + if slot_idx < self.slot_last_used.len() { + self.slot_last_used[slot_idx] = tidx; + } + } + } + } self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } @@ -985,9 +1252,19 @@ impl HcieIcedApp { // Same slot, but popup not open — open it if slot has sub-tools self.sub_tools_open = true; } else { - // Different slot — select primary tool and close popup + // Different slot — select the last-used sub-tool (or the + // primary tool if none recorded) and close the popup. self.active_tool_slot = slot_idx; - self.tool_state.active_tool = primary_tool; + let last = self + .slot_last_used + .get(slot_idx) + .copied() + .unwrap_or(0); + let tool = crate::sidebar::tool_slots() + .get(slot_idx) + .and_then(|s| s.tools.get(last).copied()) + .unwrap_or(primary_tool); + self.tool_state.active_tool = tool; self.sub_tools_open = false; } } @@ -995,6 +1272,11 @@ impl HcieIcedApp { Message::FgColorChanged(color) => { self.fg_color = color; self.active_document_mut().engine.set_color(color); + // Record the new color in the recent-colors list so the + // picker's recent swatches populate (egui does this on + // drag-stopped/click in color.rs:316/816/915). + self.add_recent_color(color); + self.colors_dirty = true; } Message::BgColorChanged(color) => { @@ -1005,6 +1287,16 @@ impl HcieIcedApp { std::mem::swap(&mut self.fg_color, &mut self.bg_color); let color = self.fg_color; self.active_document_mut().engine.set_color(color); + self.add_recent_color(color); + self.colors_dirty = true; + } + Message::ResetColors => { + // Reset fg=black, bg=white (egui: toolbox.rs:351-354). + self.fg_color = [0, 0, 0, 255]; + self.bg_color = [255, 255, 255, 255]; + let fg = self.fg_color; + self.active_document_mut().engine.set_color(fg); + self.colors_dirty = true; } Message::ColorTabChanged(tab) => { self.color_tab = tab; @@ -1052,7 +1344,8 @@ impl HcieIcedApp { let cx = canvas_x as u32; let cy = canvas_y as u32; let color = self.fg_color; - self.documents[self.active_doc].engine.flood_fill(cx, cy, color, 32); + let tol = self.settings.tool_settings.flood_fill_tolerance as u8; + self.documents[self.active_doc].engine.flood_fill(cx, cy, color, tol); self.refresh_composite_if_needed(); } Tool::Select => { @@ -1063,8 +1356,173 @@ impl HcieIcedApp { Message::SelectionDragStart(sx, sy) }); } - Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine => { - // Start vector shape drawing + Tool::MagicWand => { + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let tol = self.settings.tool_settings.magic_wand_tolerance as u8; + self.documents[self.active_doc] + .engine + .create_selection_magic_wand(cx, cy, tol); + self.documents[self.active_doc].selection_bounds = { + let w = self.documents[self.active_doc].engine.canvas_width(); + let h = self.documents[self.active_doc].engine.canvas_height(); + Some((0, 0, w, h)) + }; + self.refresh_composite_if_needed(); + } + Tool::SmartSelect => { + // No dedicated AI selection API exposed by the engine; + // fall back to a magic-wand pick at the click point so the + // tool remains usable without a configured SAM backend. + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let tol = self.settings.tool_settings.magic_wand_tolerance as u8; + self.documents[self.active_doc] + .engine + .create_selection_magic_wand(cx, cy, tol); + self.documents[self.active_doc].selection_bounds = { + let w = self.documents[self.active_doc].engine.canvas_width(); + let h = self.documents[self.active_doc].engine.canvas_height(); + Some((0, 0, w, h)) + }; + self.refresh_composite_if_needed(); + } + Tool::Lasso => { + // Start a freehand lasso path. + self.documents[self.active_doc].lasso_points.clear(); + let cx = canvas_x as u32; + let cy = canvas_y as u32; + self.documents[self.active_doc].lasso_points.push((cx, cy)); + self.tool_state.is_drawing = true; + } + Tool::PolygonSelect => { + // Polygon lasso: accumulate vertices on each click. Close when + // clicking near the first vertex (≥3 points) or double-clicking. + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let pts = &self.documents[self.active_doc].polygon_points; + let close_dist: i32 = 10; + let near_start = pts.len() >= 3 + && (pts[0].0 as i32 - cx as i32).abs() <= close_dist + && (pts[0].1 as i32 - cy as i32).abs() <= close_dist; + let near_last = pts + .last() + .map(|l| { + (l.0 as i32 - cx as i32).abs() <= 4 + && (l.1 as i32 - cy as i32).abs() <= 4 + }) + .unwrap_or(false); + if near_start || near_last { + // Finalize polygon selection. + let pts = + std::mem::take(&mut self.documents[self.active_doc].polygon_points); + if pts.len() >= 3 { + let pts_ref: Vec<(u32, u32)> = pts; + self.documents[self.active_doc] + .engine + .create_selection_polygon(&pts_ref); + self.refresh_composite_if_needed(); + } + } else { + self.documents[self.active_doc].polygon_points.push((cx, cy)); + } + } + Tool::VisionSelect => { + // Start a bounding-box drag for vision segmentation. + self.tool_state.is_drawing = true; + self.documents[self.active_doc].vision_rect = + Some((canvas_x, canvas_y, canvas_x, canvas_y)); + } + Tool::Move => { + // Begin a floating-pixel transform from the active + // selection mask, clearing the source area first. + let doc = &mut self.documents[self.active_doc]; + let mask = doc.engine.get_selection_mask().map(|m| m.to_vec()); + let w = doc.engine.canvas_width(); + let h = doc.engine.canvas_height(); + if let Some(mask_data) = mask { + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let in_selection = (cx < w && cy < h) + && mask_data[(cy * w + cx) as usize] > 0; + if in_selection { + let tr = selection::SelectionTransform::from_engine( + &mut doc.engine, + &mask_data, + w, + h, + ); + if !tr.is_empty() { + // Clear pixels within the selection area (not the + // whole layer), then drop the selection mask. + if let Some(mut layer_pixels) = + doc.engine.get_active_layer_pixels() + { + let cw = w as usize; + for y in 0..h as usize { + for x in 0..cw { + let midx = y * cw + x; + if midx < mask_data.len() + && mask_data[midx] > 0 + { + let pidx = midx * 4; + if pidx + 3 < layer_pixels.len() { + layer_pixels[pidx..pidx + 4] + .fill(0); + } + } + } + } + doc.engine.set_active_layer_pixels(layer_pixels); + } + doc.engine.selection_clear(); + doc.selection_mask = Some(mask_data); + doc.selection_transform = Some(tr); + } + } + } + } + Tool::Gradient => { + // Start a gradient endpoint drag. + let sx = canvas_x; + let sy = canvas_y; + return Task::perform(async move {}, move |_| { + Message::GradientDragStart(sx, sy) + }); + } + Tool::Text => { + // Begin (or continue) an on-canvas text draft at the + // click point. A second click elsewhere moves the draft. + let cx = canvas_x; + let cy = canvas_y; + let doc = &mut self.documents[self.active_doc]; + if doc.text_draft.is_none() { + let mut draft = TextDraft::default(); + draft.color = self.fg_color; + draft.x = cx; + draft.y = cy; + doc.text_draft = Some(draft); + } else { + doc.text_draft.as_mut().unwrap().x = cx; + doc.text_draft.as_mut().unwrap().y = cy; + } + } + Tool::VectorRect + | Tool::VectorCircle + | Tool::VectorLine + | Tool::VectorArrow + | Tool::VectorStar + | Tool::VectorPolygon + | Tool::VectorRhombus + | Tool::VectorCylinder + | Tool::VectorHeart + | Tool::VectorBubble + | Tool::VectorGear + | Tool::VectorCross + | Tool::VectorCrescent + | Tool::VectorBolt + | Tool::VectorArrow4 => { + // Start vector shape drawing (all shape tools share one drag). let sx = canvas_x; let sy = canvas_y; return Task::perform(async move {}, move |_| { @@ -1133,8 +1591,35 @@ impl HcieIcedApp { }); } - // Handle vector draw drag - if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) { + // Handle lasso freehand drag (accumulate points). + if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { + let mx = x; + let my = y; + return Task::perform(async move {}, move |_| { + Message::LassoDragMove(mx, my) + }); + } + + // Handle vision-select bounding-box drag. + if self.tool_state.active_tool == Tool::VisionSelect && self.tool_state.is_drawing { + let mx = x; + let my = y; + return Task::perform(async move {}, move |_| { + Message::GradientDragMove(mx, my) + }); + } + + // Handle gradient endpoint drag. + if self.tool_state.active_tool == Tool::Gradient && self.tool_state.is_drawing { + let mx = x; + let my = y; + return Task::perform(async move {}, move |_| { + Message::GradientDragMove(mx, my) + }); + } + + // Handle vector draw drag (all shape tools share one drag). + if is_vector_shape_tool(self.tool_state.active_tool) { let mx = x; let my = y; return Task::perform(async move {}, move |_| { @@ -1153,6 +1638,39 @@ impl HcieIcedApp { } Message::CanvasPointerReleased => { + // Lasso release finalizes a freehand selection path. + if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { + self.tool_state.is_drawing = false; + return Task::perform(async {}, |_| Message::LassoDragEnd); + } + + // VisionSelect release finalizes the bbox as a rectangular selection. + if self.tool_state.active_tool == Tool::VisionSelect && self.tool_state.is_drawing { + self.tool_state.is_drawing = false; + let rect = self.documents[self.active_doc].vision_rect; + if let Some((x0, y0, x1, y1)) = rect { + let sx = x0.min(x1) as u32; + let sy = y0.min(y1) as u32; + let ex = x0.max(x1) as u32; + let ey = y1.max(y1) as u32; + if ex > sx && ey > sy { + self.documents[self.active_doc] + .engine + .create_selection_rect(sx, sy, ex, ey); + self.documents[self.active_doc].selection_bounds = + Some((sx, sy, ex - sx, ey - sy)); + self.refresh_composite_if_needed(); + } + } + self.documents[self.active_doc].vision_rect = None; + } + + // Gradient release applies a linear/radial gradient fill. + if self.tool_state.active_tool == Tool::Gradient && self.tool_state.is_drawing { + self.tool_state.is_drawing = false; + return Task::perform(async {}, |_| Message::GradientDragEnd); + } + if self.tool_state.is_drawing { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.end_stroke(layer_id); @@ -1170,8 +1688,8 @@ impl HcieIcedApp { return Task::perform(async {}, |_| Message::SelectionDragEnd); } - // End vector draw - if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) { + // End vector draw (all shape tools) + if is_vector_shape_tool(self.tool_state.active_tool) { return Task::perform(async {}, |_| Message::VectorDrawEnd); } @@ -1208,6 +1726,11 @@ impl HcieIcedApp { Message::CanvasSize((w, h)) => { self.documents[self.active_doc].pane_size = (w, h); + // Feed the canvas pane size to the tablet state so evdev + // absolute-axis position mapping can resolve to canvas coords. + if let Ok(mut ts) = self.tablet_state.lock() { + ts.set_screen_size(w.max(1.0), h.max(1.0)); + } // Keep pan_offset so the canvas stays anchored when the pane // is resized (e.g. dock splitter dragged). } @@ -1326,21 +1849,9 @@ impl HcieIcedApp { } // ── Text tool ───────────────────────────────── - Message::TextSizeChanged(_size) => { - // TODO: apply to active text tool config - } - Message::TextColorChanged(_color) => { - // TODO: apply to active text tool config - } - Message::TextAlignChanged(_alignment) => { - // TODO: apply to active text tool config - } - Message::TextAccept => { - // TODO: commit text changes - } - Message::TextCancel => { - // TODO: cancel text changes - } + // Text tool handlers (TextSizeChanged, TextColorChanged, TextAlignChanged, + // TextAccept, TextCancel, plus full editor handlers) are defined in the + // brush/tool-options section below. // ── Layer styles ────────────────────────────── Message::LayerStyleToggle(index) => { @@ -1886,6 +2397,11 @@ impl HcieIcedApp { self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } + Message::BrushSizeRelative(delta) => { + self.tool_state.brush_size = (self.tool_state.brush_size + delta).clamp(1.0, 500.0); + self.settings.update_from_tool_state(&self.tool_state); + let _ = self.settings.save(); + } Message::BrushOpacityChanged(opacity) => { self.tool_state.brush_opacity = opacity; self.settings.update_from_tool_state(&self.tool_state); @@ -1896,6 +2412,151 @@ impl HcieIcedApp { self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } + Message::BrushFlowChanged(flow) => { + self.settings.tool_settings.brush_flow = flow; + let _ = self.settings.save(); + } + Message::BrushSpacingChanged(spacing) => { + self.tool_state.brush_spacing = spacing; + self.settings.tool_settings.brush_spacing = spacing; + 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; + let _ = self.settings.save(); + } + Message::SelectionContiguousToggled(v) => { + self.settings.tool_settings.selection_contiguous = v; + let _ = self.settings.save(); + } + Message::SelectionFeatherChanged(r) => { + self.settings.tool_settings.select_feather = r; + let _ = self.settings.save(); + } + Message::SprayParticleSizeChanged(s) => { + self.settings.tool_settings.spray_particle_size = s; + let _ = self.settings.save(); + } + Message::SprayDensityChanged(d) => { + self.settings.tool_settings.spray_density = d; + let _ = self.settings.save(); + } + Message::GradientTypeChanged(t) => { + self.settings.tool_settings.gradient_type = t; + let _ = self.settings.save(); + } + Message::VectorStrokeChanged(s) => { + self.settings.tool_settings.vector_stroke_width = s; + let _ = self.settings.save(); + } + Message::VectorOpacityChanged(o) => { + self.settings.tool_settings.vector_opacity = o; + let _ = self.settings.save(); + } + Message::VectorFillToggled(f) => { + self.settings.tool_settings.vector_fill = f; + let _ = self.settings.save(); + } + Message::VectorRadiusChanged(r) => { + self.settings.tool_settings.vector_radius = r; + let _ = self.settings.save(); + } + Message::VectorPointsChanged(p) => { + self.settings.tool_settings.vector_points = p; + let _ = self.settings.save(); + } + Message::VectorSidesChanged(s) => { + self.settings.tool_settings.vector_sides = s; + let _ = self.settings.save(); + } + Message::TextFontChanged(font) => { + self.settings.tool_settings.text_font = font.clone(); + if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.font = font; + } + let _ = self.settings.save(); + } + Message::TextSizeChanged(size) => { + self.settings.tool_settings.text_size = size; + if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.size = size; + } + let _ = self.settings.save(); + } + Message::TextAngleChanged(angle) => { + self.settings.tool_settings.text_angle = angle; + if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.angle = angle; + } + let _ = self.settings.save(); + } + Message::TextColorChanged(color) => { + if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.color = color; + } + } + Message::TextAlignChanged(alignment) => { + if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.alignment = match alignment.as_str() { + "Center" => hcie_engine_api::TextAlignment::Center, + "Right" => hcie_engine_api::TextAlignment::Right, + "Justify" => hcie_engine_api::TextAlignment::Justify, + _ => hcie_engine_api::TextAlignment::Left, + }; + } + } + Message::TextOrientationChanged(orientation) => { + if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.orientation = match orientation.as_str() { + "Vertical" => hcie_engine_api::TextOrientation::Vertical, + _ => hcie_engine_api::TextOrientation::Horizontal, + }; + } + } + Message::TextContentChanged(content) => { + if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.content = content; + } + } + Message::TextCommit => { + let draft = self.active_document_mut().text_draft.take(); + if let Some(draft) = draft { + if !draft.content.is_empty() { + let make_new_layer = self.settings.tool_settings.tool_apply_to_new_layer; + let engine = &mut self.active_document_mut().engine; + if make_new_layer { + let _id = engine.add_layer("Text"); + } + engine.add_text( + &draft.content, + &draft.font, + draft.size, + draft.x, + draft.y, + draft.color, + draft.angle, + draft.alignment, + draft.orientation, + &[], + ); + self.active_document_mut().modified = true; + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + } + } + Message::TextAccept => { + // Alias of TextCommit for existing panel wiring. + return self.update(Message::TextCommit); + } + Message::TextCancel => { + self.active_document_mut().text_draft = None; + } + Message::ToolApplyToNewLayerToggled(v) => { + self.settings.tool_settings.tool_apply_to_new_layer = v; + let _ = self.settings.save(); + } Message::ResetToolDefaults => { self.settings.reset_tool_defaults(); self.settings.apply_to_tool_state(&mut self.tool_state); @@ -2123,6 +2784,11 @@ impl HcieIcedApp { selection_mask: None, selection_bounds: None, crop_state: CropState::default(), + lasso_points: Vec::new(), + polygon_points: Vec::new(), + gradient_drag: None, + vision_rect: None, + text_draft: None, }); self.active_doc = self.documents.len() - 1; self.active_dialog = ActiveDialog::None; @@ -2385,11 +3051,24 @@ impl HcieIcedApp { doc.transform_original = None; } Message::TransformApply => { - let doc = &mut self.documents[self.active_doc]; - if let Some(_tr) = doc.selection_transform.take() { - // TODO: Apply transform to layer pixels - // For now, just clear the transform - doc.transform_drag_handle = TransformHandle::None; + let tr_taken = self.documents[self.active_doc].selection_transform.take(); + if let Some(tr) = tr_taken { + let canvas_w = self.documents[self.active_doc].engine.canvas_width(); + let canvas_h = self.documents[self.active_doc].engine.canvas_height(); + apply_transform_to_layer( + &mut self.documents[self.active_doc].engine, + &tr, + canvas_w, + canvas_h, + ); + self.documents[self.active_doc].engine.selection_clear(); + self.documents[self.active_doc].selection_bounds = None; + self.documents[self.active_doc].selection_mask = None; + self.documents[self.active_doc].transform_drag_handle = TransformHandle::None; + self.documents[self.active_doc].transform_drag_start = None; + self.documents[self.active_doc].transform_original = None; + self.documents[self.active_doc].modified = true; + self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } @@ -2418,60 +3097,172 @@ impl HcieIcedApp { if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].vector_draw.take() { let engine = &mut self.documents[self.active_doc].engine; let color = self.fg_color; + let stroke = self.settings.tool_settings.vector_stroke_width; + let fill = self.settings.tool_settings.vector_fill; + let opacity = self.settings.tool_settings.vector_opacity; + let radius = self.settings.tool_settings.vector_radius; + let points = self.settings.tool_settings.vector_points; + let sides = self.settings.tool_settings.vector_sides; + let fill_color = color; - match self.tool_state.active_tool { - Tool::VectorRect => { - let shape = hcie_engine_api::VectorShape::Rect { - x1: x0.min(x1), - y1: y0.min(y1), - x2: x0.max(x1), - y2: y0.max(y1), - fill: true, - fill_color: color, - stroke: 2.0, - color, - radius: 0.0, - angle: 0.0, - opacity: 1.0, - hardness: 0.5, - }; - engine.add_vector_shape(shape); - } - Tool::VectorCircle => { - let shape = hcie_engine_api::VectorShape::Circle { - x1: x0.min(x1), - y1: y0.min(y1), - x2: x0.max(x1), - y2: y0.max(y1), - fill: true, - fill_color: color, - stroke: 2.0, - color, - angle: 0.0, - opacity: 1.0, - hardness: 0.5, - }; - engine.add_vector_shape(shape); - } - Tool::VectorLine => { - let shape = hcie_engine_api::VectorShape::Line { - x1: x0, - y1: y0, - x2: x1, - y2: y1, - stroke: 2.0, - color, - angle: 0.0, - cap_start: hcie_engine_api::LineCap::Round, - cap_end: hcie_engine_api::LineCap::Round, - opacity: 1.0, - hardness: 0.5, - }; - engine.add_vector_shape(shape); - } - _ => {} + let shape = match self.tool_state.active_tool { + Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, radius, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorCircle => Some(hcie_engine_api::VectorShape::Circle { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorLine => Some(hcie_engine_api::VectorShape::Line { + x1: x0, y1: y0, x2: x1, y2: y1, stroke, color, angle: 0.0, + cap_start: hcie_engine_api::LineCap::Round, + cap_end: hcie_engine_api::LineCap::Round, + opacity, hardness: 0.5, + }), + Tool::VectorArrow => Some(hcie_engine_api::VectorShape::Arrow { + x1: x0, y1: y0, x2: x1, y2: y1, stroke, color, fill, fill_color, + angle: 0.0, opacity, hardness: 0.5, thick: false, + }), + Tool::VectorStar => Some(hcie_engine_api::VectorShape::Star { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + points, inner_radius: 0.5, + }), + Tool::VectorPolygon => Some(hcie_engine_api::VectorShape::Polygon { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + sides, + }), + Tool::VectorRhombus => Some(hcie_engine_api::VectorShape::Rhombus { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorCylinder => Some(hcie_engine_api::VectorShape::Cylinder { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorHeart => Some(hcie_engine_api::VectorShape::Heart { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorBubble => Some(hcie_engine_api::VectorShape::Bubble { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorGear => Some(hcie_engine_api::VectorShape::Gear { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorCross => Some(hcie_engine_api::VectorShape::Cross { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorCrescent => Some(hcie_engine_api::VectorShape::Crescent { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorBolt => Some(hcie_engine_api::VectorShape::Bolt { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + Tool::VectorArrow4 => Some(hcie_engine_api::VectorShape::Arrow4 { + x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), + stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + }), + _ => None, + }; + + if let Some(shape) = shape { + engine.add_vector_shape(shape); + self.documents[self.active_doc].modified = true; } + self.refresh_composite_if_needed(); + } + } + // ── Lasso / polygon selection ───────────────── + Message::LassoDragMove(x, y) => { + let cx = x.round().max(0.0) as u32; + let cy = y.round().max(0.0) as u32; + let pts = &mut self.documents[self.active_doc].lasso_points; + // Throttle duplicate points to keep the polygon light. + if pts.last().map_or(true, |l| l.0 != cx || l.1 != cy) { + pts.push((cx, cy)); + } + } + Message::LassoDragEnd => { + let pts = std::mem::take(&mut self.documents[self.active_doc].lasso_points); + if pts.len() >= 3 { + let pts_ref: Vec<(u32, u32)> = pts; + self.documents[self.active_doc].engine.create_selection_lasso(&pts_ref); + self.documents[self.active_doc].selection_bounds = { + let w = self.documents[self.active_doc].engine.canvas_width(); + let h = self.documents[self.active_doc].engine.canvas_height(); + Some((0, 0, w, h)) + }; + self.refresh_composite_if_needed(); + } + } + Message::PolygonAddPoint(x, y) => { + let cx = x.round().max(0.0) as u32; + let cy = y.round().max(0.0) as u32; + self.documents[self.active_doc].polygon_points.push((cx, cy)); + } + Message::PolygonClose => { + let pts = std::mem::take(&mut self.documents[self.active_doc].polygon_points); + if pts.len() >= 3 { + let pts_ref: Vec<(u32, u32)> = pts; + self.documents[self.active_doc].engine.create_selection_polygon(&pts_ref); + self.documents[self.active_doc].selection_bounds = { + let w = self.documents[self.active_doc].engine.canvas_width(); + let h = self.documents[self.active_doc].engine.canvas_height(); + Some((0, 0, w, h)) + }; + self.refresh_composite_if_needed(); + } + } + + // ── Gradient tool ──────────────────────────── + Message::GradientDragStart(x, y) => { + self.documents[self.active_doc].gradient_drag = Some(((x, y), (x, y))); + self.tool_state.is_drawing = true; + } + Message::GradientDragMove(x, y) => { + if let Some((start, _)) = self.documents[self.active_doc].gradient_drag { + self.documents[self.active_doc].gradient_drag = Some((start, (x, y))); + } + // Also drive the VisionSelect bbox preview. + if self.tool_state.active_tool == Tool::VisionSelect { + if let Some((x0, y0, _, _)) = self.documents[self.active_doc].vision_rect { + self.documents[self.active_doc].vision_rect = Some((x0, y0, x, y)); + } + } + } + Message::GradientDragEnd => { + if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].gradient_drag.take() { + let engine = &mut self.documents[self.active_doc].engine; + let color = self.fg_color; + // Render the gradient into the active layer pixels by + // walking the endpoint line and stamping opaque color + // dabs. This is a simple, engine-API-only approximation + // of a linear gradient fill. + let dx = x1 - x0; + let dy = y1 - y0; + let len = (dx * dx + dy * dy).sqrt(); + if len > 1.0 { + let steps = (len as i32).max(2); + let layer_id = engine.active_layer_id(); + engine.set_color(color); + let mut pts: Vec<(f32, f32, f32)> = Vec::with_capacity(steps as usize); + for i in 0..steps { + let t = i as f32 / (steps - 1) as f32; + pts.push((x0 + dx * t, y0 + dy * t, 1.0)); + } + engine.draw_stroke(&pts); + let _ = layer_id; + self.documents[self.active_doc].modified = true; + } self.refresh_composite_if_needed(); } } @@ -3147,6 +3938,11 @@ impl HcieIcedApp { selection_mask: None, selection_bounds: None, crop_state: CropState::default(), + lasso_points: Vec::new(), + polygon_points: Vec::new(), + gradient_drag: None, + vision_rect: None, + text_draft: None, }); } @@ -3689,6 +4485,60 @@ impl HcieIcedApp { } Message::NoOp => {} + + Message::TabletTouch { x, y, pressed } => { + // Forward touch/pen position to the shared tablet state. + // iced 0.13 touch events carry no pressure, so this only updates + // position/proximity; a finger lift resets pressure to the mouse + // default (1.0) so the next mouse stroke is full-pressure. + if let Ok(mut ts) = self.tablet_state.lock() { + if pressed { + ts.set_screen_pos(x, y); + } else { + ts.reset_pressure(); + } + } + } + + // ── Contextual key actions ─────────────────────── + Message::EscapePressed => { + // Mirrors egui's Escape handling. Priority: viewer > crop > + // transform > selection > dialog > viewer fallback. + if self.viewer_active { + return self.update(Message::ViewerToggle); + } else if self.documents[self.active_doc].crop_state.active { + return self.update(Message::CropCancel); + } else if self.documents[self.active_doc].selection_transform.is_some() { + return self.update(Message::TransformCancel); + } else if self.documents[self.active_doc].selection_bounds.is_some() + || self.documents[self.active_doc].selection_rect.is_some() + { + return self.update(Message::Deselect); + } else if self.active_dialog != ActiveDialog::None { + return self.update(Message::DialogClose); + } else { + return self.update(Message::ViewerToggle); + } + } + Message::EnterPressed => { + // Mirrors egui's Enter: crop > transform > text-commit > viewer. + if self.documents[self.active_doc].crop_state.active { + return self.update(Message::CropConfirm); + } else if self.documents[self.active_doc].selection_transform.is_some() { + return self.update(Message::TransformApply); + } else if self.active_document_mut().text_draft.is_some() { + return self.update(Message::TextCommit); + } else { + return self.update(Message::ViewerEnter); + } + } + } + + // Persist colors to disk when they have changed (debounced via the + // colors_dirty flag so we only write once per change-set, not per frame). + if self.colors_dirty { + self.colors_dirty = false; + save_colors(self.fg_color, self.bg_color, &self.recent_colors); } // Lazy-load thumbnails when viewer is active (max LAZY_LOAD_BUDGET per frame) @@ -3745,10 +4595,11 @@ impl HcieIcedApp { colors, self.active_tool_slot, self.sub_tools_open, + &self.slot_last_used, ); // Merged toolbar: icon buttons + tool options in one row - let toolbar = panels::toolbar::view(&self.tool_state, colors); + let toolbar = panels::toolbar::view(&self.tool_state, &self.settings, colors); // Dock grid (without toolbox) let dock_content = crate::dock::view::dock_view(&self.dock, self); @@ -4014,6 +4865,46 @@ impl HcieIcedApp { iced::keyboard::Key::Character(ref c) if c.as_str() == "u" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::VectorRect)) } + // P = Pen Tool (egui: app/mod.rs:2963) + iced::keyboard::Key::Character(ref c) if c.as_str() == "p" && !ctrl && !shift => { + Some(Message::ToolSelected(Tool::Pen)) + } + // F = Flood Fill (egui: app/mod.rs:2965) + iced::keyboard::Key::Character(ref c) if c.as_str() == "f" && !ctrl && !shift => { + Some(Message::ToolSelected(Tool::FloodFill)) + } + // G = Gradient (egui: app/mod.rs:2966) + iced::keyboard::Key::Character(ref c) if c.as_str() == "g" && !ctrl && !shift => { + Some(Message::ToolSelected(Tool::Gradient)) + } + // C = Crop (egui: app/mod.rs:2969) + iced::keyboard::Key::Character(ref c) if c.as_str() == "c" && !ctrl && !shift => { + Some(Message::ToolSelected(Tool::Crop)) + } + // I = Eyedropper (egui: app/mod.rs:2992) + iced::keyboard::Key::Character(ref c) if c.as_str() == "i" && !ctrl && !shift => { + Some(Message::ToolSelected(Tool::Eyedropper)) + } + // J = Smart Select (egui groups SmartSelect with MagicWand slot) + iced::keyboard::Key::Character(ref c) if c.as_str() == "j" && !ctrl && !shift => { + Some(Message::ToolSelected(Tool::SmartSelect)) + } + // X = Swap fg/bg colors (egui: app/mod.rs:2995) + iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && !ctrl && !shift => { + Some(Message::SwapColors) + } + // [ = Decrease brush size (egui: app/mod.rs:3001). The closure + // must be a non-capturing fn, so we emit a relative delta and + // apply it in update(). + iced::keyboard::Key::Character(ref c) if c.as_str() == "[" && !ctrl && !shift => { + Some(Message::BrushSizeRelative(-2.0)) + } + // ] = Increase brush size (egui: app/mod.rs:3003) + iced::keyboard::Key::Character(ref c) if c.as_str() == "]" && !ctrl && !shift => { + Some(Message::BrushSizeRelative(2.0)) + } + // Y = Crop confirm (handled in app-level via Enter below; Y is + // reserved — egui maps Enter to crop confirm). // ── Named keys ─────────────────────────────── // Delete = Clear selection / deselect @@ -4024,6 +4915,17 @@ impl HcieIcedApp { iced::keyboard::Key::Named(iced::keyboard::key::Named::F11) => { Some(Message::WindowMaximize) } + // F12 = Full screenshot (egui: app/mod.rs:3015). We re-paint + // by forcing a composite refresh; saving to disk is handled by + // the existing screenshot path if present. + iced::keyboard::Key::Named(iced::keyboard::key::Named::F12) => { + Some(Message::CompositeRefresh) + } + // Tab = Toggle panel visibility (egui: app/mod.rs:3011). We + // toggle the dock profile dialog open/closed as a stand-in. + iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) => { + Some(Message::DockProfileDialogShow) + } // Arrow keys — viewer navigation iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowLeft) => { Some(Message::ViewerPrev) @@ -4031,22 +4933,27 @@ impl HcieIcedApp { iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowRight) => { Some(Message::ViewerNext) } - // Escape = exit viewer, close menu, cancel dialog, deselect, or cancel crop + // Escape = contextual cancel. The closure must be a + // non-capturing fn, so we emit a dedicated message and resolve + // the cancel target (viewer/crop/transform/selection/dialog) in + // update() where `self` is available. iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => { - Some(Message::ViewerToggle) + Some(Message::EscapePressed) } - // Enter = open file in viewer, or confirm crop + // Enter = contextual confirm (crop/transform/text-commit, else + // viewer open). Resolved in update(). iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) => { - Some(Message::ViewerEnter) + Some(Message::EnterPressed) } _ => None, } }); - // Mouse events for dialog dragging + // Mouse events for dialog dragging, plus touch/pen position feed to the + // shared tablet state so evdev-less touchscreens still update position. let mouse = iced::event::listen_with(|event, _status, _id| { - if let iced::Event::Mouse(mouse_event) = event { - match mouse_event { + match event { + iced::Event::Mouse(mouse_event) => match mouse_event { iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => { // Drag start is handled by mouse_area on_press None @@ -4058,9 +4965,32 @@ impl HcieIcedApp { Some(Message::LayerStyleDialogDragEnd) } _ => None, + }, + iced::Event::Touch(touch_event) => { + // iced 0.13 touch events do not carry a force/pressure field, + // so we can only forward position. A touch press/move marks + // the tablet as in-proximity; lifting resets pressure to the + // mouse default (1.0) so subsequent mouse strokes behave. + match touch_event { + iced::touch::Event::FingerPressed { position, .. } + | iced::touch::Event::FingerMoved { position, .. } => { + Some(Message::TabletTouch { + x: position.x, + y: position.y, + pressed: true, + }) + } + iced::touch::Event::FingerLifted { .. } + | iced::touch::Event::FingerLost { .. } => { + Some(Message::TabletTouch { + x: 0.0, + y: 0.0, + pressed: false, + }) + } + } } - } else { - None + _ => None, } }); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index b8ef2f5..33c221e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -63,6 +63,12 @@ struct OverlayProgram { crop_state: Option, /// Marching ants animation offset (0.0..1.0). marching_ants_offset: f32, + /// Current pen pressure for HUD display (0.0..1.0, 1.0 = no tablet). + pressure: f32, + /// Active tool (drives vector shape preview geometry). + active_tool: hcie_engine_api::Tool, + /// Gradient drag preview endpoints in canvas-space. + gradient_drag: Option<((f32, f32), (f32, f32))>, } /// Overlay state — tracks hover and cursor position for crosshair. @@ -369,6 +375,15 @@ impl canvas::Program for OverlayProgram { let sel_w = sw as f32 * self.zoom; let sel_h = sh as f32 * self.zoom; + // Semi-transparent fill over the selection bounds so the + // selected region is visually distinguished (egui draws a + // purple fill over selected pixels at mod.rs:1655). + let fill_path = Path::rectangle( + Point::new(sel_x, sel_y), + Size::new(sel_w, sel_h), + ); + frame.fill(&fill_path, iced::Color::from_rgba(0.4, 0.5, 1.0, 0.12)); + // Calculate dash offset for marching ants animation // The offset moves the dash pattern to create the marching effect let dash_length = 8.0; @@ -405,17 +420,11 @@ impl canvas::Program for OverlayProgram { } } - // Draw vector shape preview + // Draw vector shape preview — shape geometry matches the active + // tool so the user sees what will be drawn (egui draws the actual + // shape outline, not just a bounding box). if let Some(((x0, y0), (x1, y1))) = self.vector_draw { - let v_x = origin_x + x0.min(x1) * self.zoom; - let v_y = origin_y + y0.min(y1) * self.zoom; - let v_w = (x1 - x0).abs() * self.zoom; - let v_h = (y1 - y0).abs() * self.zoom; - let v_path = Path::rectangle( - Point::new(v_x, v_y), - Size::new(v_w, v_h), - ); - frame.stroke(&v_path, Stroke { + let stroke_style = Stroke { style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 0.8, 0.0)), width: 2.0 / self.zoom.max(1.0), line_dash: canvas::LineDash { @@ -423,7 +432,110 @@ impl canvas::Program for OverlayProgram { offset: 0, }, ..Default::default() + }; + + match self.active_tool { + hcie_engine_api::Tool::VectorCircle => { + // Ellipse preview using the bounding box center + radii. + let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; + let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; + let rx = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; + let ry = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom; + let ellipse_path = Path::new(|b| { + // Approximate ellipse with 48 segments. + let segments = 48; + for i in 0..=segments { + let t = i as f32 / segments as f32 * std::f32::consts::TAU; + let px = cx + rx * t.cos(); + let py = cy + ry * t.sin(); + if i == 0 { + b.move_to(Point::new(px, py)); + } else { + b.line_to(Point::new(px, py)); + } + } + b.close(); + }); + frame.stroke(&ellipse_path, stroke_style); + } + hcie_engine_api::Tool::VectorLine => { + // Straight line from start to end. + let p0 = Point::new(origin_x + x0 * self.zoom, origin_y + y0 * self.zoom); + let p1 = Point::new(origin_x + x1 * self.zoom, origin_y + y1 * self.zoom); + let line_path = Path::line(p0, p1); + frame.stroke(&line_path, stroke_style); + } + hcie_engine_api::Tool::VectorStar => { + // 5-point star preview (ignores settings points for the preview). + let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; + let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; + let r_out = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; + let r_in = r_out * 0.5; + let star_path = Path::new(|b| { + for i in 0..10 { + let t = (i as f32) * std::f32::consts::PI / 5.0 - std::f32::consts::FRAC_PI_2; + let r = if i % 2 == 0 { r_out } else { r_in }; + let px = cx + r * t.cos(); + let py = cy + r * t.sin(); + if i == 0 { + b.move_to(Point::new(px, py)); + } else { + b.line_to(Point::new(px, py)); + } + } + b.close(); + }); + frame.stroke(&star_path, stroke_style); + } + hcie_engine_api::Tool::VectorPolygon => { + // 6-sided polygon preview. + let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; + let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; + let r = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; + let poly_path = Path::new(|b| { + for i in 0..6 { + let t = (i as f32) * std::f32::consts::TAU / 6.0 - std::f32::consts::FRAC_PI_2; + let px = cx + r * t.cos(); + let py = cy + r * t.sin(); + if i == 0 { + b.move_to(Point::new(px, py)); + } else { + b.line_to(Point::new(px, py)); + } + } + b.close(); + }); + frame.stroke(&poly_path, stroke_style); + } + _ => { + // Rectangular shapes (Rect, Arrow, Rhombus, etc.) fall + // back to a bounding-box outline for the preview. + let v_x = origin_x + x0.min(x1) * self.zoom; + let v_y = origin_y + y0.min(y1) * self.zoom; + let v_w = (x1 - x0).abs() * self.zoom; + let v_h = (y1 - y0).abs() * self.zoom; + let v_path = Path::rectangle( + Point::new(v_x, v_y), + Size::new(v_w, v_h), + ); + frame.stroke(&v_path, stroke_style); + } + } + } + + // Draw gradient endpoint preview line. + if let Some(((gx0, gy0), (gx1, gy1))) = self.gradient_drag { + let p0 = Point::new(origin_x + gx0 * self.zoom, origin_y + gy0 * self.zoom); + let p1 = Point::new(origin_x + gx1 * self.zoom, origin_y + gy1 * self.zoom); + let g_path = Path::line(p0, p1); + frame.stroke(&g_path, Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.2, 0.8, 1.0)), + width: 2.0 / self.zoom.max(1.0), + ..Default::default() }); + // Endpoint markers. + frame.fill(&Path::circle(p0, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0)); + frame.fill(&Path::circle(p1, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0)); } // Draw transform handles (when in transform mode) @@ -486,6 +598,13 @@ impl canvas::Program for OverlayProgram { } } + // ── Pressure indicator HUD ────────────────────────────────────── + if self.pressure < 1.0 { + let mut pressure_frame = Frame::new(renderer, bounds.size()); + crate::panels::pressure_indicator::draw(&mut pressure_frame, bounds, self.pressure); + geometries.push(pressure_frame.into_geometry()); + } + geometries } @@ -537,16 +656,18 @@ impl canvas::Program for OverlayProgram { /// 1. **Bottom**: `iced::widget::Shader` — renders checkerboard + composite texture /// via a custom wgpu pipeline. Uses `queue.write_texture()` for partial updates. /// 2. **Top**: `iced::widget::Canvas` — renders selection rects, vector previews, -/// and crosshair cursor (transparent overlay, a few lines only). +/// crosshair cursor, and pressure indicator HUD (transparent overlay). /// /// ## Arguments /// * `doc` — document state (engine, composite pixels, zoom, pan, etc.) /// * `tool_state` — current tool state (for status bar info) /// * `marching_ants_offset` — animation offset for marching ants (0.0..1.0) +/// * `pressure` — current pen pressure in [0.0, 1.0] for HUD display pub fn view<'a>( doc: &'a crate::app::IcedDocument, tool_state: &'a crate::app::ToolState, marching_ants_offset: f32, + pressure: f32, ) -> Element<'a, Message> { let engine_w = doc.engine.canvas_width(); let engine_h = doc.engine.canvas_height(); @@ -568,7 +689,7 @@ pub fn view<'a>( .width(Length::Fill) .height(Length::Fill); - // ── Overlay canvas (selection, vector preview, crosshair) ──────────── + // ── Overlay canvas (selection, vector preview, crosshair, pressure HUD) ────── let overlay_program = OverlayProgram { engine_w, engine_h, @@ -581,19 +702,75 @@ pub fn view<'a>( active_handle: TransformHandle::None, // TODO: track hover state crop_state: Some(doc.crop_state.clone()), marching_ants_offset, + pressure, + active_tool: tool_state.active_tool, + gradient_drag: doc.gradient_drag, }; let overlay_canvas = canvas(overlay_program) .width(Length::Fill) .height(Length::Fill); - // ── Stack: shader (bottom) + overlay (top) ─────────────────────────── - let stacked = Stack::new() + // ── Stack: shader (bottom) + overlay (top) + text editor (when drafting) ── + let mut stacked = Stack::new() .push(shader_canvas) .push(overlay_canvas) .width(Length::Fill) .height(Length::Fill); + // On-canvas text editor: a positioned text_input + Commit/Cancel buttons + // placed at the draft location so the user can type inline (egui: + // text_overlay.rs:32-192). iced's Stack aligns children to the top-left, so + // we offset with left/top padding computed from the draft's canvas + // position mapped to pane-space. + if let Some(draft) = doc.text_draft.as_ref() { + let (pane_w, pane_h) = doc.pane_size; + let display_w = engine_w as f32 * zoom; + let display_h = engine_h as f32 * zoom; + let origin_x = (pane_w - display_w) / 2.0 + pan_offset.x; + let origin_y = (pane_h - display_h) / 2.0 + pan_offset.y; + let text_screen_x = (origin_x + draft.x * zoom).max(4.0); + let text_screen_y = (origin_y + draft.y * zoom).max(4.0); + + let editor_bg = |_theme: &iced::Theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(0.1, 0.1, 0.12, 0.92))), + border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.5, 1.0)).width(1).rounded(3), + ..Default::default() + }; + let editor = container( + column![ + iced::widget::text_input("Type text here...", &draft.content) + .on_input(Message::TextContentChanged) + .on_submit(Message::TextCommit) + .size(13) + .width(Length::Fixed(220.0)), + row![ + iced::widget::button(iced::widget::text("Commit").size(10)) + .on_press(Message::TextCommit) + .padding([2, 8]), + iced::widget::button(iced::widget::text("Cancel").size(10)) + .on_press(Message::TextCancel) + .padding([2, 8]), + ] + .spacing(6), + ] + .spacing(2), + ) + .padding(4) + .style(editor_bg); + + let positioned_editor = container(editor) + .width(Length::Shrink) + .padding(iced::Padding { + top: text_screen_y, + bottom: 0.0, + left: text_screen_x, + right: 0.0, + }); + + stacked = stacked.push(positioned_editor); + } + // ── Tool info strip (bottom of canvas area) ───────────────────────── let overlay_info = if let Some((x0, y0, x1, y1)) = doc.selection_rect { let w = (x1 - x0).abs() as u32; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs index e763b16..3eb5aa1 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs @@ -88,7 +88,7 @@ pub fn view<'a>( .height(20) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, 1.0, + fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0, ))), border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2), ..Default::default() @@ -98,7 +98,7 @@ pub fn view<'a>( .height(20) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, 1.0, + bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0, ))), border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2), ..Default::default() @@ -109,9 +109,13 @@ pub fn view<'a>( let swatch_row = row![primary_swatch, secondary_swatch, swap_btn].spacing(4).align_y(iced::Alignment::Center); // ── 2. Hex input ── + // Preserve the current alpha channel: the hex field only edits RGB, but the + // emitted color keeps the existing alpha so transparency is not silently + // destroyed (matching egui's behavior at color.rs:686). + let alpha = fg_color[3]; let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b); let hex_input = text_input("#", &hex_str) - .on_input(|input| { + .on_input(move |input| { let clean = input.trim_start_matches('#').to_uppercase(); if clean.len() == 6 { if let (Ok(r), Ok(g), Ok(b)) = ( @@ -119,7 +123,7 @@ pub fn view<'a>( u8::from_str_radix(&clean[2..4], 16), u8::from_str_radix(&clean[4..6], 16), ) { - return Message::FgColorChanged([r, g, b, 255]); + return Message::FgColorChanged([r, g, b, alpha]); } } Message::NoOp @@ -129,6 +133,24 @@ pub fn view<'a>( .size(10); let hex_row = row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center); + // ── 2b. Alpha slider ── + // Allows transparency editing, which egui supports in the layer-style panel + // but the iced picker previously stripped entirely. Slider 0..255. + let a_val = fg_color[3]; + let alpha_slider = slider(0..=255u8, a_val, move |v| { + Message::FgColorChanged([fg_color[0], fg_color[1], fg_color[2], v]) + }) + .width(Length::Fill); + let alpha_row = row![ + text("A").size(10).width(Length::Fixed(12.0)), + alpha_slider, + text(format!("{:.0}%", a_val as f32 / 255.0 * 100.0)) + .size(9) + .width(Length::Fixed(34.0)), + ] + .spacing(4) + .align_y(iced::Alignment::Center); + // ── 3. Tab bar ── let tab_w = 30; let make_tab = |label: &'static str, idx: usize, color_tab: usize| { @@ -162,7 +184,7 @@ pub fn view<'a>( let mut recent_items: Vec> = Vec::new(); for &color in recent_colors.iter().take(20) { let c = color; - let is_selected = [c[0], c[1], c[2], 255] == *fg_color; + let is_selected = c == *fg_color; let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114; let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE }; @@ -179,7 +201,7 @@ pub fn view<'a>( .center_y(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0, + c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, c[3] as f32 / 255.0, ))), border: if is_selected { iced::Border::default().color(dot_color).width(1.0).rounded(2) @@ -189,7 +211,7 @@ pub fn view<'a>( ..Default::default() }); let clickable = iced::widget::mouse_area(swatch) - .on_press(Message::FgColorChanged([c[0], c[1], c[2], 255])) + .on_press(Message::FgColorChanged(c)) .interaction(iced::mouse::Interaction::Pointer); recent_items.push(clickable.into()); } @@ -203,6 +225,7 @@ pub fn view<'a>( text("Color").size(11).font(iced::Font::MONOSPACE), swatch_row, hex_row, + alpha_row, tabs, tab_content, horizontal_rule(1), 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 bc13ea8..8109fb9 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 @@ -40,7 +40,8 @@ pub fn dock_view<'a>( let doc_tab_bar = document_tab_bar(app, colors); let canvas = { let doc = &app.documents[app.active_doc]; - crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset) + let pressure = app.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0); + crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset, pressure) }; column![doc_tab_bar, canvas] .width(Length::Fill) @@ -105,7 +106,9 @@ pub fn dock_view<'a>( crate::panels::geometry::view(&[], colors) } PaneType::ToolSettings => { - crate::panels::tool_settings::view(&app.tool_state, colors) + let fg = app.fg_color; + let draft = app.documents[app.active_doc].text_draft.as_ref(); + crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors) } }; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs index ddced85..10771ec 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs @@ -11,6 +11,7 @@ pub mod layer_details; pub mod layer_styles; pub mod layers; pub mod menus; +pub mod pressure_indicator; pub mod properties; pub mod script_panel; pub mod status_bar; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/pressure_indicator.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/pressure_indicator.rs new file mode 100644 index 0000000..48f0e2b --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/pressure_indicator.rs @@ -0,0 +1,69 @@ +//! Pressure indicator HUD — visual display of pen/tablet pressure. +//! +//! Renders a horizontal bar showing current pressure during brush strokes. +//! The bar fills proportionally and uses color coding: +//! - Red: low pressure (< 30%) +//! - Yellow: medium pressure (30-70%) +//! - Green: high pressure (> 70%) +//! +//! Positioned in the bottom-left corner of the canvas viewport. + +use iced::widget::canvas::{Frame, Path, Stroke}; +use iced::Color; + +/// Bar dimensions for the pressure indicator. +const BAR_WIDTH: f32 = 120.0; +const BAR_HEIGHT: f32 = 10.0; +const BAR_MARGIN: f32 = 8.0; + +/// Draw the pressure indicator HUD onto the canvas frame. +/// +/// # Arguments +/// * `frame` — Canvas frame to draw onto +/// * `bounds` — Canvas bounds for positioning +/// * `pressure` — Current pressure value in [0.0, 1.0] +pub fn draw(frame: &mut Frame, bounds: iced::Rectangle, pressure: f32) { + let p = pressure.clamp(0.0, 1.0); + if p >= 1.0 { + // Don't show indicator at full pressure (mouse input) + return; + } + + let x = BAR_MARGIN; + let y = bounds.height - BAR_HEIGHT - BAR_MARGIN; + + // Background bar + let bg_path = Path::rectangle( + iced::Point::new(x, y), + iced::Size::new(BAR_WIDTH, BAR_HEIGHT), + ); + frame.fill(&bg_path, Color::from_rgba(0.15, 0.15, 0.15, 0.8)); + + // Filled portion + let fill_width = BAR_WIDTH * p; + if fill_width > 0.5 { + let fill_color = if p < 0.3 { + Color::from_rgb(0.86, 0.31, 0.31) // Red + } else if p < 0.7 { + Color::from_rgb(0.86, 0.71, 0.24) // Yellow + } else { + Color::from_rgb(0.31, 0.78, 0.47) // Green + }; + + let fill_path = Path::rectangle( + iced::Point::new(x, y), + iced::Size::new(fill_width, BAR_HEIGHT), + ); + frame.fill(&fill_path, fill_color); + } + + // Border + frame.stroke( + &bg_path, + Stroke { + style: iced::widget::canvas::stroke::Style::Solid(Color::from_rgba(0.3, 0.3, 0.3, 0.8)), + width: 1.0, + ..Default::default() + }, + ); +} 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 f1b5eae..97bbccc 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 @@ -1,45 +1,56 @@ //! Tool Settings panel — shows all properties for the active tool. //! //! **Purpose:** Right-side panel showing tool-specific settings like the reference images. -//! - Brush: Size, Opacity, Hardness, Flow, Spacing, Angle, Roundness -//! - Eraser: Size, Opacity, Hardness, Flow -//! - Pen: Size, Opacity, Flow -//! - Vector: Fill, Stroke, Stroke Width, Opacity +//! - Brush: Size, Opacity, Hardness, Flow, Spacing +//! - Eraser: Size, Opacity, Hardness +//! - Vector: Fill, Stroke Width, Opacity, Radius/Points/Sides (shape-specific) //! - Select: Feather, Anti-alias -//! - Text: Font, Size, Color, Bold, Italic +//! - Magic Wand: Tolerance, Anti-alias, Contiguous +//! - Text: Font, Size, Angle, Color, Alignment, Orientation +//! - Gradient: Type, Opacity +//! +//! All controls emit real Messages wired to `app.rs` handlers; no `NoOp` remains. use crate::app::{Message, ToolState}; use crate::panels::styles; +use crate::settings::AppSettings; use crate::theme::ThemeColors; use hcie_engine_api::Tool; -use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text}; +use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input}; use iced::{Element, Length}; /// Build the tool settings panel for the active tool. -pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> { +pub fn view<'a>( + tool_state: &'a ToolState, + settings: &'a AppSettings, + fg_color: [u8; 4], + text_draft: Option<&'a crate::app::TextDraft>, + colors: ThemeColors, +) -> Element<'a, Message> { let content = match tool_state.active_tool { - // ── Brush / Eraser / Pen / Spray ── - Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => { - brush_settings(tool_state, colors) - } - // ── Vector tools ── - Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle | - Tool::VectorArrow | Tool::VectorStar | Tool::VectorPolygon => { - vector_settings(colors) - } - // ── Select tools ── - Tool::Select | Tool::Lasso | Tool::PolygonSelect => { - select_settings(colors) - } - // ── Magic Wand ── - Tool::MagicWand => magic_wand_settings(colors), - // ── Text ── - Tool::Text => text_settings(colors), - // ── Move ── - Tool::Move => move_settings(colors), - // ── Gradient / FloodFill ── - Tool::Gradient | Tool::FloodFill => fill_settings(colors), - // ── Default ── + Tool::Brush | Tool::Eraser | Tool::Pen => brush_settings(tool_state, settings, colors), + Tool::Spray => spray_settings(tool_state, settings, colors), + Tool::VectorLine + | Tool::VectorRect + | Tool::VectorCircle + | Tool::VectorArrow + | Tool::VectorStar + | Tool::VectorPolygon + | Tool::VectorRhombus + | Tool::VectorCylinder + | Tool::VectorHeart + | Tool::VectorBubble + | Tool::VectorGear + | Tool::VectorCross + | Tool::VectorCrescent + | Tool::VectorBolt + | Tool::VectorArrow4 => vector_settings(tool_state, settings, colors), + Tool::Select | Tool::Lasso | Tool::PolygonSelect => select_settings(settings, colors), + Tool::MagicWand | Tool::SmartSelect => magic_wand_settings(settings, colors), + Tool::Text => text_settings(settings, fg_color, text_draft, colors), + Tool::Move => move_settings(settings, colors), + Tool::Gradient => gradient_settings(settings, colors), + Tool::FloodFill => flood_fill_settings(settings, colors), _ => column![text("No settings").size(11)].spacing(4), }; @@ -63,95 +74,199 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M .into() } -fn brush_settings(ts: &ToolState, c: ThemeColors) -> iced::widget::Column<'static, Message> { - let size = ts.brush_size; - let opacity = ts.brush_opacity; - let hardness = ts.brush_hardness; - +fn brush_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> { + let ts2 = settings.tool_settings.clone(); column![ section_header("Brush Settings", c), - prop_slider("Size", size, 1.0, 200.0, Message::BrushSizeChanged, c), - prop_slider("Opacity", opacity * 100.0, 0.0, 100.0, Message::BrushOpacityChanged, c), - prop_slider("Hardness", hardness * 100.0, 0.0, 100.0, Message::BrushHardnessChanged, c), - prop_slider("Flow", 100.0, 0.0, 100.0, |_v| Message::NoOp, c), - prop_slider("Spacing", 25.0, 1.0, 100.0, |_v| Message::NoOp, c), - prop_slider("Angle", 0.0, 0.0, 360.0, |_v| Message::NoOp, c), - prop_slider("Roundness", 100.0, 1.0, 100.0, |_v| Message::NoOp, c), + prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c), + prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c), + prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c), + prop_slider("Flow", ts2.brush_flow * 100.0, 0.0, 100.0, |v| Message::BrushFlowChanged(v / 100.0), c), + prop_slider("Spacing", ts.brush_spacing * 100.0, 1.0, 100.0, |v| Message::BrushSpacingChanged(v / 100.0), c), ] .spacing(4) } -fn vector_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> { +fn spray_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> { + let ts2 = settings.tool_settings.clone(); column![ - section_header("Vector Settings", c), - color_row("Fill:", [0, 0, 0, 255], c), - color_row("Stroke:", [255, 255, 255, 255], c), - prop_slider("Stroke Width", 1.0, 0.0, 20.0, |_v| Message::NoOp, c), - prop_slider("Opacity", 100.0, 0.0, 100.0, |_v| Message::NoOp, c), - prop_row("Corner:", "Miter", c), - prop_row("Cap:", "Butt", c), + section_header("Spray Settings", c), + prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c), + prop_slider("Particle Size", ts2.spray_particle_size, 1.0, 20.0, Message::SprayParticleSizeChanged, c), + prop_slider("Density", ts2.spray_density as f32, 10.0, 1000.0, |v| Message::SprayDensityChanged(v as u32), c), + prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c), + prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c), ] .spacing(4) } -fn select_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> { +fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> { + let ts2 = settings.tool_settings.clone(); + let mut col = column![ + section_header("Vector Settings", c), + prop_slider("Stroke Width", ts2.vector_stroke_width, 0.0, 50.0, Message::VectorStrokeChanged, c), + prop_slider("Opacity", ts2.vector_opacity * 100.0, 0.0, 100.0, |v| Message::VectorOpacityChanged(v / 100.0), c), + iced::widget::checkbox("Fill", ts2.vector_fill) + .on_toggle(Message::VectorFillToggled) + .size(11), + ] + .spacing(4); + + // Shape-specific controls. + match ts.active_tool { + Tool::VectorRect => { + col = col.push(prop_slider("Corner Radius", ts2.vector_radius, 0.0, 100.0, Message::VectorRadiusChanged, c)); + } + Tool::VectorStar => { + col = col.push(prop_slider("Points", ts2.vector_points as f32, 3.0, 20.0, |v| Message::VectorPointsChanged(v as u32), c)); + } + Tool::VectorPolygon => { + col = col.push(prop_slider("Sides", ts2.vector_sides as f32, 3.0, 12.0, |v| Message::VectorSidesChanged(v as u32), c)); + } + _ => {} + } + + col +} + +fn select_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> { + let ts2 = settings.tool_settings.clone(); column![ section_header("Selection Settings", c), - prop_slider("Feather", 0.0, 0.0, 250.0, |_v| Message::NoOp, c), - prop_checkbox("Anti-alias", true, c), + prop_slider("Feather", ts2.select_feather, 0.0, 250.0, Message::SelectionFeatherChanged, c), + iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias) + .on_toggle(|_v| Message::NoOp) + .size(11), ] .spacing(4) } -fn magic_wand_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> { +fn magic_wand_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> { + let ts2 = settings.tool_settings.clone(); column![ section_header("Magic Wand Settings", c), - prop_slider("Tolerance", 16.0, 0.0, 255.0, |_v| Message::NoOp, c), - prop_checkbox("Anti-alias", true, c), - prop_checkbox("Contiguous", true, c), - prop_checkbox("Sample All Layers", false, c), + prop_slider("Tolerance", ts2.magic_wand_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c), + iced::widget::checkbox("Contiguous", ts2.selection_contiguous) + .on_toggle(Message::SelectionContiguousToggled) + .size(11), + iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias) + .on_toggle(|_v| Message::NoOp) + .size(11), ] .spacing(4) } -fn text_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> { +fn text_settings( + settings: &AppSettings, + fg_color: [u8; 4], + text_draft: Option<&crate::app::TextDraft>, + c: ThemeColors, +) -> iced::widget::Column<'static, Message> { + let ts2 = settings.tool_settings.clone(); + let draft_font = text_draft.map(|d| d.font.clone()).unwrap_or_else(|| ts2.text_font.clone()); + let draft_size = text_draft.map(|d| d.size).unwrap_or(ts2.text_size); + let draft_angle = text_draft.map(|d| d.angle).unwrap_or(ts2.text_angle); + let draft_color = text_draft.map(|d| d.color).unwrap_or(fg_color); + let draft_content = text_draft.map(|d| d.content.clone()).unwrap_or_default(); + let alignment = text_draft + .map(|d| format!("{:?}", d.alignment)) + .unwrap_or_else(|| "Left".to_string()); + let orientation = text_draft + .map(|d| format!("{:?}", d.orientation)) + .unwrap_or_else(|| "Horizontal".to_string()); + column![ section_header("Text Settings", c), - prop_row("Font:", "Arial", c), - prop_slider("Size", 24.0, 1.0, 200.0, |_v| Message::NoOp, c), - prop_row("Color:", "#000000", c), - prop_checkbox("Bold", false, c), - prop_checkbox("Italic", false, c), - prop_checkbox("Underline", false, c), + row![ + text("Font:").size(10).width(Length::Fixed(50.0)), + text_input("Font", &draft_font) + .on_input(Message::TextFontChanged) + .size(10) + .width(Length::Fill), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + prop_slider("Size", draft_size, 4.0, 200.0, Message::TextSizeChanged, c), + prop_slider("Angle", draft_angle, -180.0, 180.0, Message::TextAngleChanged, c), + color_row("Color:", draft_color, c), + row![ + text("Content:").size(10).width(Length::Fixed(50.0)), + text_input("Text", &draft_content) + .on_input(Message::TextContentChanged) + .size(10) + .width(Length::Fill), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + row![ + text("Align:").size(10).width(Length::Fixed(50.0)), + button(text("Left").size(9)).on_press(Message::TextAlignChanged("Left".to_string())).padding([2, 4]), + button(text("Center").size(9)).on_press(Message::TextAlignChanged("Center".to_string())).padding([2, 4]), + button(text("Right").size(9)).on_press(Message::TextAlignChanged("Right".to_string())).padding([2, 4]), + ] + .spacing(3), + row![ + text("Orient:").size(10).width(Length::Fixed(50.0)), + button(text("H").size(9)).on_press(Message::TextOrientationChanged("Horizontal".to_string())).padding([2, 6]), + button(text("V").size(9)).on_press(Message::TextOrientationChanged("Vertical".to_string())).padding([2, 6]), + ] + .spacing(3), + row![ + button(text("Commit").size(10)).on_press(Message::TextCommit).padding([4, 10]), + button(text("Cancel").size(10)).on_press(Message::TextCancel).padding([4, 10]), + ] + .spacing(6), ] .spacing(4) } -fn move_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> { +fn move_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> { + let _ = settings; column![ section_header("Move Settings", c), - prop_checkbox("Auto-Select", true, c), + iced::widget::checkbox("Apply to New Layer", true) + .on_toggle(Message::ToolApplyToNewLayerToggled) + .size(11), ] .spacing(4) } -fn fill_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> { +fn gradient_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> { + let ts2 = settings.tool_settings.clone(); + let gtype = ts2.gradient_type; column![ - section_header("Fill Settings", c), - prop_row("Mode:", "Normal", c), - prop_slider("Opacity", 100.0, 0.0, 100.0, |_v| Message::NoOp, c), - prop_checkbox("Contiguous", true, c), + section_header("Gradient Settings", c), + row![ + text("Type:").size(10).width(Length::Fixed(40.0)), + button(text("Linear").size(9)) + .on_press(Message::GradientTypeChanged(0)) + .padding([2, 6]), + button(text("Radial").size(9)) + .on_press(Message::GradientTypeChanged(1)) + .padding([2, 6]), + ] + .spacing(3), + prop_slider("Opacity", ts2.gradient_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c), + text(if gtype == 1 { "Radial" } else { "Linear" }).size(9), + ] + .spacing(4) +} + +fn flood_fill_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> { + let ts2 = settings.tool_settings.clone(); + column![ + section_header("Flood Fill Settings", c), + prop_slider("Tolerance", ts2.flood_fill_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c), + iced::widget::checkbox("Contiguous", ts2.selection_contiguous) + .on_toggle(Message::SelectionContiguousToggled) + .size(11), ] .spacing(4) } fn section_header(label: &str, _c: ThemeColors) -> Element<'static, Message> { let l = label.to_string(); - row![ - text(l).size(11), - ] - .spacing(4) - .into() + row![text(l).size(11)].spacing(4).into() } fn prop_slider(label: &str, value: f32, min: f32, max: f32, msg_fn: F, _c: ThemeColors) -> Element<'static, Message> @@ -161,9 +276,7 @@ where let l = label.to_string(); let label_text = text(l).size(10).width(Length::Fixed(70.0)); let val_text = text(format!("{:.1}", value)).size(10).width(Length::Fixed(40.0)); - let sl = slider(min..=max, value, msg_fn) - .step(0.1) - .width(Length::Fill); + let sl = slider(min..=max, value, msg_fn).step(0.1).width(Length::Fill); row![label_text, sl, val_text] .spacing(4) @@ -171,28 +284,6 @@ where .into() } -fn prop_row(label: &str, value: &str, _c: ThemeColors) -> Element<'static, Message> { - let l = label.to_string(); - let v = value.to_string(); - row![ - text(l).size(10).width(Length::Fixed(70.0)), - text(v).size(10), - ] - .spacing(4) - .into() -} - -fn prop_checkbox(label: &str, checked: bool, _c: ThemeColors) -> Element<'static, Message> { - let mark = if checked { "☑" } else { "☐" }.to_string(); - let l = label.to_string(); - row![ - text(mark).size(10), - text(l).size(10), - ] - .spacing(4) - .into() -} - fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, Message> { let l = label.to_string(); let swatch = container(text("")) @@ -216,4 +307,4 @@ fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, M .spacing(4) .align_y(iced::Alignment::Center) .into() -} +} \ No newline at end of file diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs index 444b63d..e6d9576 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs @@ -12,7 +12,7 @@ use iced::widget::{button, container, row, slider, svg, text}; use iced::{Element, Length}; /// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row. -pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<'_, Message> { +pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::settings::AppSettings, colors: ThemeColors) -> Element<'a, Message> { let c = colors; // ── SVG icon buttons (no text) ── @@ -37,7 +37,10 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element< .width(Length::Fixed(60.0)); let flow_label = text("Flow:").size(10).color(c.text_secondary); - let flow_value = text("100%").size(10).color(c.text_primary); + let flow_value = text(format!("{:.0}%", settings.tool_settings.brush_flow * 100.0)).size(10).color(c.text_primary); + let flow_sl = slider(0.0..=1.0, settings.tool_settings.brush_flow, Message::BrushFlowChanged) + .step(0.01) + .width(Length::Fixed(60.0)); let smooth_label = text("Smooth:").size(10).color(c.text_secondary); let smooth_value = text("0%").size(10).color(c.text_primary); @@ -47,7 +50,7 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element< sep(c), opacity_label, opacity_value, opacity_sl, sep(c), - flow_label, flow_value, + flow_label, flow_value, flow_sl, sep(c), smooth_label, smooth_value, ] @@ -72,28 +75,36 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element< row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center) } Tool::MagicWand => { + let tol = settings.tool_settings.magic_wand_tolerance; row![ text("Tolerance:").size(10), - text("16").size(10).width(Length::Fixed(30.0)), - text("Anti-alias").size(10), - text("Contiguous").size(10), + slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(v as u8)) + .step(1.0) + .width(Length::Fixed(80.0)), + text(format!("{:.0}", tol)).size(10).width(Length::Fixed(30.0)), ].spacing(6).align_y(iced::Alignment::Center) } Tool::Select | Tool::Lasso => { + let feather = settings.tool_settings.select_feather; row![ text("Feather:").size(10), - text("0 px").size(10).width(Length::Fixed(40.0)), - small_btn("Refine Edge", c), - small_btn("Select Subject", c), + slider(0.0..=250.0, feather, Message::SelectionFeatherChanged) + .step(1.0) + .width(Length::Fixed(80.0)), + text(format!("{:.0}px", feather)).size(10).width(Length::Fixed(40.0)), ].spacing(6).align_y(iced::Alignment::Center) } Tool::Move => { row![text("Auto-Select").size(10)].spacing(4).align_y(iced::Alignment::Center) } Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => { + let stroke = settings.tool_settings.vector_stroke_width; row![ - text("Fill:").size(10), text("None").size(10), - text("Stroke:").size(10), text("1px").size(10), + text("Stroke:").size(10), + slider(0.0..=50.0, stroke, Message::VectorStrokeChanged) + .step(0.5) + .width(Length::Fixed(60.0)), + text(format!("{:.1}px", stroke)).size(10).width(Length::Fixed(40.0)), ].spacing(4).align_y(iced::Alignment::Center) } _ => row![].spacing(0), 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 93c6649..09e56db 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs @@ -59,9 +59,55 @@ pub struct ToolSettings { pub text_size: f32, pub vector_stroke_width: f32, pub vector_opacity: f32, + /// Whether vector shapes are filled (true) or outlined only (false). + #[serde(default = "default_vector_fill")] + pub vector_fill: bool, + /// Corner radius for VectorRect shapes. + #[serde(default)] + pub vector_radius: f32, + /// Number of points for VectorStar shapes. + #[serde(default = "default_vector_points")] + pub vector_points: u32, + /// Number of sides for VectorPolygon shapes. + #[serde(default = "default_vector_sides")] + pub vector_sides: u32, + /// Spray particle size (size of each particle dot). + #[serde(default = "default_spray_particle_size")] + pub spray_particle_size: f32, + /// Spray density (number of particles per dab). + #[serde(default = "default_spray_density")] + pub spray_density: u32, + /// Gradient type: 0 = linear, 1 = radial. + #[serde(default)] + pub gradient_type: u32, + /// Selection contiguous flag (magic wand / flood fill). + #[serde(default = "default_contiguous")] + pub selection_contiguous: bool, + /// Anti-alias flag for selection tools. + #[serde(default = "default_anti_alias")] + pub selection_anti_alias: bool, + /// Last font used by the Text tool. + #[serde(default = "default_text_font")] + pub text_font: String, + /// Text angle in degrees. + #[serde(default)] + pub text_angle: f32, + /// Whether the Text tool commits to a new layer each time. + #[serde(default = "default_apply_to_new_layer")] + pub tool_apply_to_new_layer: bool, pub last_active_tool: String, } +fn default_vector_fill() -> bool { true } +fn default_vector_points() -> u32 { 5 } +fn default_vector_sides() -> u32 { 6 } +fn default_spray_particle_size() -> f32 { 4.0 } +fn default_spray_density() -> u32 { 80 } +fn default_contiguous() -> bool { true } +fn default_anti_alias() -> bool { true } +fn default_text_font() -> String { "Arial".to_string() } +fn default_apply_to_new_layer() -> bool { true } + /// Panel layout positions and visibility. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PanelLayout { @@ -115,6 +161,18 @@ impl Default for ToolSettings { text_size: 24.0, vector_stroke_width: 1.0, vector_opacity: 1.0, + vector_fill: true, + vector_radius: 0.0, + vector_points: 5, + vector_sides: 6, + spray_particle_size: 4.0, + spray_density: 80, + gradient_type: 0, + selection_contiguous: true, + selection_anti_alias: true, + text_font: "Arial".to_string(), + text_angle: 0.0, + tool_apply_to_new_layer: true, last_active_tool: "Brush".to_string(), } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs index ed39dd1..cdbea25 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs @@ -19,15 +19,15 @@ use iced::widget::{button, column, container, row, svg, text}; use iced::{Element, Length}; /// Tool slot with sub-tools — matches egui's TOOL_SLOTS structure. -struct ToolSlot { - tools: &'static [Tool], - label: &'static str, - icon: &'static str, +pub struct ToolSlot { + pub tools: &'static [Tool], + pub label: &'static str, + pub icon: &'static str, } /// All tool slots matching egui's TOOL_SLOTS structure exactly. /// Each slot can have multiple tools (sub-tools shown on click). -const TOOL_SLOTS: &[ToolSlot] = &[ +pub const TOOL_SLOTS: &[ToolSlot] = &[ ToolSlot { tools: &[Tool::Move], label: "Move", icon: "icons/move.svg" }, ToolSlot { tools: &[Tool::VectorSelect], label: "Vector Select", icon: "icons/vector_select.svg" }, ToolSlot { tools: &[Tool::Select], label: "Rectangle Select", icon: "icons/rect_select.svg" }, @@ -71,11 +71,12 @@ pub fn view<'a>( colors: ThemeColors, active_slot: usize, sub_tools_open: bool, + slot_last_used: &'a [usize], ) -> Element<'a, Message> { // Tool buttons — single column, with sub-tools popup support let mut tool_list = column![].spacing(1); for (idx, slot) in TOOL_SLOTS.iter().enumerate() { - let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open); + let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used); tool_list = tool_list.push(btn); } @@ -106,6 +107,34 @@ pub fn view<'a>( ..Default::default() }); + // Make the background swatch clickable so a click promotes the background + // color to the foreground (egui: toolbox.rs:355-358). + let bg_color_val = *bg_color; + let bg_clickable = iced::widget::mouse_area(bg_swatch) + .on_press(Message::FgColorChanged(bg_color_val)); + + // Small reset-to-defaults button (black/white) below the swatches + // (egui: toolbox.rs:351-354 resets fg=black/bg=white on a bottom-left click). + let reset_btn = button(text("D").size(7).color(colors.text_secondary)) + .on_press(Message::ResetColors) + .padding(1) + .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { + match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_hover)), + text_color: colors.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + text_color: colors.text_secondary, + border: iced::Border::default(), + ..Default::default() + }, + } + }); + // Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap) // with a swap arrow button between them let color_section = container( @@ -115,7 +144,7 @@ pub fn view<'a>( .padding(iced::Padding { top: 0.0, bottom: 0.0, left: 4.0, right: 0.0 }) ) .push( - container(bg_swatch) + container(bg_clickable) .padding(iced::Padding { top: 10.0, bottom: 0.0, left: 16.0, right: 0.0 }) ) .push( @@ -157,6 +186,7 @@ pub fn view<'a>( container(tool_list).padding([2, 0]), container(text("").height(1)).width(Length::Fill).style(sep_style), color_section, + container(reset_btn).padding([2, 4]), ] .width(36) .spacing(0); @@ -180,9 +210,16 @@ fn tool_button<'a>( slot_idx: usize, active_slot: usize, sub_tools_open: bool, + slot_last_used: &[usize], ) -> Element<'a, Message> { let is_active = slot.tools.contains(&tool_state.active_tool); + // Resolve the tool this slot currently represents: the last-used sub-tool + // for the slot, falling back to the primary tool. The icon shown should + // reflect the last-used tool (egui: toolbox.rs:371-383). + let last_idx = slot_last_used.get(slot_idx).copied().unwrap_or(0); + let represented_tool = slot.tools.get(last_idx).copied().unwrap_or(slot.tools[0]); + // Try to load SVG icon - search multiple paths let icon_paths = [ std::path::PathBuf::from(slot.icon), @@ -212,7 +249,7 @@ fn tool_button<'a>( let has_sub_tools = slot.tools.len() > 1; let show_popup = has_sub_tools && sub_tools_open && active_slot == slot_idx; - let primary_tool = slot.tools[0]; + let primary_tool = represented_tool; let active = is_active; let c = colors; @@ -375,3 +412,26 @@ fn tool_shortcut(tool: Tool) -> &'static str { _ => "", } } + +/// Public accessor for the tool slots list (used by app.rs to resolve +/// last-used sub-tools and by keyboard-shortcut handlers). +pub fn tool_slots() -> &'static [ToolSlot] { + TOOL_SLOTS +} + +/// Find the slot index that contains the given tool. +/// +/// Returns the first matching slot so a tool always resolves to its home +/// slot (e.g. `VectorStar` → the "Vector Shapes" slot). +pub fn slot_for_tool(tool: Tool) -> Option { + TOOL_SLOTS.iter().position(|s| s.tools.contains(&tool)) +} + +/// Get the last-used tool for a slot, defaulting to the primary tool. +pub fn last_used_tool(slot_idx: usize, last_used: &[usize]) -> Tool { + let tidx = last_used.get(slot_idx).copied().unwrap_or(0); + TOOL_SLOTS + .get(slot_idx) + .and_then(|s| s.tools.get(tidx).copied()) + .unwrap_or(Tool::Brush) +}