migrate : glm and gemini pro . no proper achievements
This commit is contained in:
@@ -142,6 +142,11 @@ pub struct HcieIcedApp {
|
||||
/// Window ID for window control operations (drag, minimize, maximize, close).
|
||||
pub window_id: Option<iced::window::Id>,
|
||||
/// New Image dialog state.
|
||||
pub dialog_input: String,
|
||||
pub dialog_input2: String,
|
||||
pub show_performance: bool,
|
||||
pub ui_scale: f32,
|
||||
pub modifiers: iced::keyboard::Modifiers,
|
||||
pub dialog_new_name: String,
|
||||
pub dialog_new_width: u32,
|
||||
pub dialog_new_height: u32,
|
||||
@@ -246,6 +251,8 @@ pub struct HcieIcedApp {
|
||||
pub show_dock_profile_dialog: bool,
|
||||
/// Current UI language for internationalization.
|
||||
pub language: i18n::Language,
|
||||
/// Context menu screen position (x, y). None means closed.
|
||||
pub canvas_context_menu: Option<(f32, f32)>,
|
||||
/// 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,
|
||||
@@ -325,6 +332,10 @@ pub struct IcedDocument {
|
||||
pub vision_rect: Option<(f32, f32, f32, f32)>,
|
||||
/// Draft text state for the on-canvas text tool.
|
||||
pub text_draft: Option<TextDraft>,
|
||||
/// Cache for extracted selection edges (marching ants).
|
||||
pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache,
|
||||
/// Extracted selection edges for overlay rendering.
|
||||
pub marching_ants_edges: Option<Arc<Vec<(u32, u32, u32, u32)>>>,
|
||||
}
|
||||
|
||||
/// In-progress text layer being authored with the Text tool.
|
||||
@@ -387,6 +398,16 @@ pub struct ToolState {
|
||||
pub last_update_instant: std::time::Instant,
|
||||
/// Imported brush presets from ABR/KPP files.
|
||||
pub imported_brushes: Vec<hcie_engine_api::BrushPreset>,
|
||||
/// True if currently holding shift to resize brush
|
||||
pub is_resizing_brush: bool,
|
||||
/// Starting brush size before resize drag
|
||||
pub brush_resize_start_size: f32,
|
||||
/// Starting pointer position for brush resize drag
|
||||
pub brush_resize_start_pos: Option<(f32, f32)>,
|
||||
/// Last click time for double-click detection
|
||||
pub last_click_time: std::time::Instant,
|
||||
/// Last click pos for double-click detection
|
||||
pub last_click_pos: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl Default for ToolState {
|
||||
@@ -404,6 +425,11 @@ impl Default for ToolState {
|
||||
last_composite_refresh: std::time::Instant::now(),
|
||||
last_update_instant: std::time::Instant::now(),
|
||||
imported_brushes: Vec::new(),
|
||||
is_resizing_brush: false,
|
||||
brush_resize_start_size: 20.0,
|
||||
brush_resize_start_pos: None,
|
||||
last_click_time: std::time::Instant::now(),
|
||||
last_click_pos: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,11 +453,14 @@ pub enum Message {
|
||||
CanvasPointerPressed { x: f32, y: f32 },
|
||||
CanvasPointerMoved { x: f32, y: f32 },
|
||||
CanvasPointerReleased,
|
||||
CanvasPointerRightClicked { x: f32, y: f32, screen_x: f32, screen_y: f32 },
|
||||
CanvasContextMenuClose,
|
||||
CanvasPanZoom { zoom: f32, pan_offset: Vector },
|
||||
CanvasZoomSet(f32),
|
||||
CanvasZoomRelative(f32),
|
||||
CanvasSize((f32, f32)),
|
||||
CanvasCursorPos { x: u32, y: u32 },
|
||||
ModifiersChanged(iced::keyboard::Modifiers),
|
||||
|
||||
// ── Engine operations ───────────────────────────────
|
||||
CompositeRefresh,
|
||||
@@ -945,6 +974,8 @@ impl HcieIcedApp {
|
||||
gradient_drag: None,
|
||||
vision_rect: None,
|
||||
text_draft: None,
|
||||
selection_edge_cache: Default::default(),
|
||||
marching_ants_edges: None,
|
||||
};
|
||||
|
||||
// Load saved settings
|
||||
@@ -962,6 +993,12 @@ impl HcieIcedApp {
|
||||
active_tool_slot: 0,
|
||||
sub_tools_open: false,
|
||||
slot_last_used: vec![0; 20],
|
||||
dialog_input: String::new(),
|
||||
dialog_input2: String::new(),
|
||||
show_performance: false,
|
||||
ui_scale: 1.0,
|
||||
modifiers: iced::keyboard::Modifiers::default(),
|
||||
canvas_context_menu: None,
|
||||
settings: settings.clone(),
|
||||
window_id: None,
|
||||
dialog_new_name: "Untitled".to_string(),
|
||||
@@ -1190,6 +1227,18 @@ impl HcieIcedApp {
|
||||
doc.engine.clear_dirty_flags();
|
||||
}
|
||||
|
||||
// Update selection edge cache if mask changed
|
||||
if let Some(mask) = doc.engine.get_selection_mask() {
|
||||
let edges = doc.selection_edge_cache.get(mask).unwrap_or_else(|| {
|
||||
doc.selection_edge_cache.rebuild(mask, doc.engine.canvas_width(), doc.engine.canvas_height())
|
||||
});
|
||||
doc.marching_ants_edges = Some(edges);
|
||||
} else {
|
||||
doc.marching_ants_edges = None;
|
||||
// Clear cache if selection is dropped so next time it won't mistakenly hit
|
||||
doc.selection_edge_cache = Default::default();
|
||||
}
|
||||
|
||||
// Always refresh cached panel data (cheap operation)
|
||||
doc.cached_layers = doc.engine.layer_infos();
|
||||
let history_len = doc.engine.history_len();
|
||||
@@ -1309,6 +1358,47 @@ impl HcieIcedApp {
|
||||
self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
|
||||
let tool = self.tool_state.active_tool;
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let _is_double_click = if let Some((lx, ly)) = self.tool_state.last_click_pos {
|
||||
let dx = canvas_x - lx;
|
||||
let dy = canvas_y - ly;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
now.duration_since(self.tool_state.last_click_time).as_millis() < 300 && dist_sq < 25.0
|
||||
} else {
|
||||
false
|
||||
};
|
||||
self.tool_state.last_click_time = now;
|
||||
self.tool_state.last_click_pos = Some((canvas_x, canvas_y));
|
||||
|
||||
if let Some(req_type) = tool.allowed_layer_type() {
|
||||
// Reject raster edits on non-editable layers
|
||||
if req_type == hcie_engine_api::LayerType::Raster
|
||||
&& !self.documents[self.active_doc].engine.active_layer_is_editable()
|
||||
{
|
||||
// In the future: emit a status message here
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Auto-create layer if current tool requires a different layer type
|
||||
if req_type != hcie_engine_api::LayerType::Text {
|
||||
if let Some(active) = self.documents[self.active_doc].engine.active_layer() {
|
||||
if active.layer_type != req_type {
|
||||
match req_type {
|
||||
hcie_engine_api::LayerType::Vector => {
|
||||
let id = self.documents[self.active_doc].engine.add_layer("Vector Layer");
|
||||
self.documents[self.active_doc].engine.set_active_layer(id);
|
||||
}
|
||||
_ => {
|
||||
let id = self.documents[self.active_doc].engine.add_layer("Layer");
|
||||
self.documents[self.active_doc].engine.set_active_layer(id);
|
||||
}
|
||||
}
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match tool {
|
||||
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
||||
// Apply brush parameters before starting stroke
|
||||
@@ -1546,39 +1636,67 @@ impl HcieIcedApp {
|
||||
self.canvas_cursor_pos = Some((x as u32, y as u32));
|
||||
|
||||
if self.tool_state.is_drawing {
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
|
||||
let spacing: f32 = match self.tool_state.active_tool {
|
||||
Tool::Spray => 2.0_f32,
|
||||
_ => 1.0_f32,
|
||||
}.max(1.0);
|
||||
|
||||
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
|
||||
let dx = x - last_x;
|
||||
let dy = y - last_y;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
// Use constant pressure for mouse input
|
||||
// Real tablet pressure comes from tablet_state
|
||||
let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
|
||||
|
||||
if dist >= spacing {
|
||||
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure);
|
||||
self.tool_state.last_stroke_pos = Some((x, y));
|
||||
|
||||
// Throttle composite refresh to ~60 FPS during strokes.
|
||||
let now = std::time::Instant::now();
|
||||
let elapsed = now.duration_since(self.tool_state.last_composite_refresh);
|
||||
if elapsed.as_secs_f32() >= 0.016 {
|
||||
self.tool_state.last_composite_refresh = now;
|
||||
// Performance log for measuring drawing throttle frequency (should fire roughly every 16ms / 60 FPS).
|
||||
// log::info!("[perf] throttle fire: {:.1}ms since last refresh", elapsed.as_secs_f64() * 1000.0);
|
||||
self.refresh_composite_if_needed();
|
||||
let is_ctrl = self.modifiers.control() || self.modifiers.logo();
|
||||
if is_ctrl {
|
||||
// Pick color
|
||||
let cx = x as u32;
|
||||
let cy = y as u32;
|
||||
let w = self.documents[self.active_doc].engine.canvas_width();
|
||||
let h = self.documents[self.active_doc].engine.canvas_height();
|
||||
if cx < w && cy < h {
|
||||
let pixels = &self.documents[self.active_doc].composite_raw;
|
||||
let idx = ((cy * w + cx) * 4) as usize;
|
||||
if idx + 3 < pixels.len() {
|
||||
let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
|
||||
self.fg_color = color;
|
||||
self.documents[self.active_doc].engine.set_color(color);
|
||||
}
|
||||
}
|
||||
} else if self.tool_state.is_resizing_brush {
|
||||
if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos {
|
||||
let dx = x - start_x;
|
||||
let new_size = (self.tool_state.brush_resize_start_size + dx).clamp(1.0, 500.0);
|
||||
match self.tool_state.active_tool {
|
||||
Tool::Pen => self.settings.tool_settings.pen_size = new_size,
|
||||
Tool::Brush => self.settings.tool_settings.brush_size = new_size,
|
||||
Tool::Eraser => self.settings.tool_settings.eraser_size = new_size,
|
||||
Tool::Spray => self.settings.tool_settings.spray_radius = new_size,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0);
|
||||
self.tool_state.last_stroke_pos = Some((x, y));
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
|
||||
let spacing: f32 = match self.tool_state.active_tool {
|
||||
Tool::Spray => 2.0_f32,
|
||||
_ => 1.0_f32,
|
||||
}.max(1.0);
|
||||
|
||||
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
|
||||
let dx = x - last_x;
|
||||
let dy = y - last_y;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
// Use constant pressure for mouse input
|
||||
// Real tablet pressure comes from tablet_state
|
||||
let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
|
||||
|
||||
if dist >= spacing {
|
||||
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure);
|
||||
self.tool_state.last_stroke_pos = Some((x, y));
|
||||
|
||||
// Throttle composite refresh to ~60 FPS during strokes.
|
||||
let now = std::time::Instant::now();
|
||||
let elapsed = now.duration_since(self.tool_state.last_composite_refresh);
|
||||
if elapsed.as_secs_f32() >= 0.016 {
|
||||
self.tool_state.last_composite_refresh = now;
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0);
|
||||
self.tool_state.last_stroke_pos = Some((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1638,6 +1756,8 @@ impl HcieIcedApp {
|
||||
}
|
||||
|
||||
Message::CanvasPointerReleased => {
|
||||
self.tool_state.is_resizing_brush = false;
|
||||
|
||||
// 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;
|
||||
@@ -1699,6 +1819,15 @@ impl HcieIcedApp {
|
||||
}
|
||||
}
|
||||
|
||||
Message::CanvasPointerRightClicked { x: _, y: _, screen_x, screen_y } => {
|
||||
// Ignore canvas_x, canvas_y for now, just pop the menu
|
||||
self.canvas_context_menu = Some((screen_x, screen_y));
|
||||
}
|
||||
|
||||
Message::CanvasContextMenuClose => {
|
||||
self.canvas_context_menu = None;
|
||||
}
|
||||
|
||||
Message::CanvasPanZoom { zoom, pan_offset } => {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
doc.zoom = zoom;
|
||||
@@ -2789,6 +2918,8 @@ impl HcieIcedApp {
|
||||
gradient_drag: None,
|
||||
vision_rect: None,
|
||||
text_draft: None,
|
||||
selection_edge_cache: Default::default(),
|
||||
marching_ants_edges: None,
|
||||
});
|
||||
self.active_doc = self.documents.len() - 1;
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
@@ -3943,6 +4074,8 @@ impl HcieIcedApp {
|
||||
gradient_drag: None,
|
||||
vision_rect: None,
|
||||
text_draft: None,
|
||||
selection_edge_cache: Default::default(),
|
||||
marching_ants_edges: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4532,6 +4665,9 @@ impl HcieIcedApp {
|
||||
return self.update(Message::ViewerEnter);
|
||||
}
|
||||
}
|
||||
Message::ModifiersChanged(modifiers) => {
|
||||
self.modifiers = modifiers;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist colors to disk when they have changed (debounced via the
|
||||
@@ -4696,8 +4832,9 @@ impl HcieIcedApp {
|
||||
let has_dialog = self.active_dialog != ActiveDialog::None;
|
||||
let has_menu = self.active_menu.is_some();
|
||||
let has_dock_profile_dialog = self.show_dock_profile_dialog;
|
||||
let has_context_menu = self.canvas_context_menu.is_some();
|
||||
|
||||
if has_dialog || has_menu || has_dock_profile_dialog {
|
||||
if has_dialog || has_menu || has_dock_profile_dialog || has_context_menu {
|
||||
let mut stack = iced::widget::Stack::new().push(content);
|
||||
if has_dialog {
|
||||
stack = stack.push(dialog_overlay);
|
||||
@@ -4712,6 +4849,14 @@ impl HcieIcedApp {
|
||||
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files, self.theme_state.colors()) {
|
||||
stack = stack.push(menu_overlay);
|
||||
}
|
||||
if let Some((cx, cy)) = self.canvas_context_menu {
|
||||
stack = stack.push(panels::menus::canvas_context_menu(
|
||||
cx,
|
||||
cy,
|
||||
&self.documents[self.active_doc],
|
||||
self.theme_state.colors(),
|
||||
));
|
||||
}
|
||||
let _elapsed = view_start.elapsed();
|
||||
// Performance log measuring Elm view reconstruction time.
|
||||
// useful to track widget layout allocation and view model reconstruction times.
|
||||
@@ -4990,6 +5135,9 @@ impl HcieIcedApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) => {
|
||||
Some(Message::ModifiersChanged(modifiers))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
@@ -5012,3 +5160,22 @@ impl HcieIcedApp {
|
||||
iced::Subscription::batch(vec![keyboard, mouse, marching_ants])
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the topmost text layer whose rasterized pixels contain the point
|
||||
/// `(x, y)` in canvas coordinates.
|
||||
fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<u64> {
|
||||
let ux = x.round().clamp(0.0, u32::MAX as f32) as u32;
|
||||
let uy = y.round().clamp(0.0, u32::MAX as f32) as u32;
|
||||
let infos = engine.layer_infos();
|
||||
// layer_infos returns bottom-to-top; iterate in reverse for topmost first.
|
||||
for info in infos.iter().rev() {
|
||||
if info.layer_type == hcie_engine_api::LayerType::Text && info.visible {
|
||||
if let Some(px) = engine.get_pixel(info.id, ux, uy) {
|
||||
if px[3] > 10 {
|
||||
return Some(info.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SelectionEdgeCache {
|
||||
mask_ptr: usize,
|
||||
mask_len: usize,
|
||||
edges: Arc<Vec<(u32, u32, u32, u32)>>,
|
||||
}
|
||||
|
||||
impl SelectionEdgeCache {
|
||||
pub fn get(&self, mask: &[u8]) -> Option<Arc<Vec<(u32, u32, u32, u32)>>> {
|
||||
if mask.as_ptr() as usize == self.mask_ptr && mask.len() == self.mask_len {
|
||||
Some(Arc::clone(&self.edges))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rebuild(&mut self, mask: &[u8], w: u32, h: u32) -> Arc<Vec<(u32, u32, u32, u32)>> {
|
||||
let edges = Arc::new(extract_selection_edges(mask, w, h));
|
||||
self.mask_ptr = mask.as_ptr() as usize;
|
||||
self.mask_len = mask.len();
|
||||
self.edges = Arc::clone(&edges);
|
||||
edges
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_selection_edges(
|
||||
selection_mask: &[u8],
|
||||
canvas_width: u32,
|
||||
canvas_height: u32,
|
||||
) -> Vec<(u32, u32, u32, u32)> {
|
||||
let w = canvas_width as usize;
|
||||
let h = canvas_height as usize;
|
||||
if selection_mask.len() != w * h {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let threshold: u8 = 128;
|
||||
let mut edges = Vec::with_capacity(1024);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let idx = y * w + x;
|
||||
if selection_mask[idx] <= threshold {
|
||||
continue;
|
||||
}
|
||||
if x == 0 || selection_mask[idx - 1] <= threshold {
|
||||
edges.push((x as u32, y as u32, x as u32, (y + 1) as u32));
|
||||
}
|
||||
if x == w - 1 || selection_mask[idx + 1] <= threshold {
|
||||
edges.push(((x + 1) as u32, y as u32, (x + 1) as u32, (y + 1) as u32));
|
||||
}
|
||||
if y == 0 || selection_mask[idx - w] <= threshold {
|
||||
edges.push((x as u32, y as u32, (x + 1) as u32, y as u32));
|
||||
}
|
||||
if y == h - 1 || selection_mask[idx + w] <= threshold {
|
||||
edges.push((x as u32, (y + 1) as u32, (x + 1) as u32, (y + 1) as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
//! - The checkerboard is procedural (in the fragment shader) — no CPU geometry
|
||||
|
||||
pub mod shader_canvas;
|
||||
pub mod edge_cache;
|
||||
// pub mod viewport;
|
||||
// pub mod render;
|
||||
|
||||
@@ -69,6 +70,8 @@ struct OverlayProgram {
|
||||
active_tool: hcie_engine_api::Tool,
|
||||
/// Gradient drag preview endpoints in canvas-space.
|
||||
gradient_drag: Option<((f32, f32), (f32, f32))>,
|
||||
/// Extracted edges for marching ants rendering.
|
||||
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
|
||||
}
|
||||
|
||||
/// Overlay state — tracks hover and cursor position for crosshair.
|
||||
@@ -391,32 +394,74 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
let total_cycle = dash_length + gap_length;
|
||||
let animated_offset = (self.marching_ants_offset * total_cycle) as usize;
|
||||
|
||||
// Draw black dashes (background)
|
||||
let sel_path = Path::rectangle(
|
||||
Point::new(sel_x, sel_y),
|
||||
Size::new(sel_w, sel_h),
|
||||
);
|
||||
frame.stroke(&sel_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[dash_length, gap_length],
|
||||
offset: animated_offset,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Draw white dashes (foreground) - offset by half cycle for marching effect
|
||||
let white_offset = (animated_offset + (total_cycle / 2.0) as usize) % (total_cycle as usize);
|
||||
frame.stroke(&sel_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[dash_length, gap_length],
|
||||
offset: white_offset,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if let Some(edges) = &self.marching_ants_edges {
|
||||
// Irregular selection bounds (marching ants along extracted edges)
|
||||
for &(x1, y1, x2, y2) in edges.iter() {
|
||||
let s1 = Point::new(origin_x + x1 as f32 * self.zoom, origin_y + y1 as f32 * self.zoom);
|
||||
let s2 = Point::new(origin_x + x2 as f32 * self.zoom, origin_y + y2 as f32 * self.zoom);
|
||||
|
||||
// Optimization: we could clip, but `Path::line` handles it generally.
|
||||
let dist = ((s1.x - s2.x).powi(2) + (s1.y - s2.y).powi(2)).sqrt();
|
||||
if dist < 1e-6 { continue; }
|
||||
|
||||
let line_path = Path::line(s1, s2);
|
||||
frame.stroke(&line_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)),
|
||||
width: 1.5,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let dir_x = (s2.x - s1.x) / dist;
|
||||
let dir_y = (s2.y - s1.y) / dist;
|
||||
|
||||
let mut curr = -(self.marching_ants_offset * total_cycle);
|
||||
let mut iter = 0u32;
|
||||
while curr < dist && iter < 1000 {
|
||||
iter += 1;
|
||||
let start_d = curr.max(0.0);
|
||||
let end_d = (curr + dash_length).min(dist);
|
||||
if end_d > start_d {
|
||||
let p_start = Point::new(s1.x + dir_x * start_d, s1.y + dir_y * start_d);
|
||||
let p_end = Point::new(s1.x + dir_x * end_d, s1.y + dir_y * end_d);
|
||||
let dash_path = Path::line(p_start, p_end);
|
||||
frame.stroke(&dash_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
|
||||
width: 1.5,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
curr += total_cycle;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to bounding box marching ants
|
||||
let sel_path = Path::rectangle(
|
||||
Point::new(sel_x, sel_y),
|
||||
Size::new(sel_w, sel_h),
|
||||
);
|
||||
frame.stroke(&sel_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[dash_length, gap_length],
|
||||
offset: animated_offset,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
frame.stroke(&sel_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[dash_length, gap_length],
|
||||
offset: white_offset,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +750,7 @@ pub fn view<'a>(
|
||||
pressure,
|
||||
active_tool: tool_state.active_tool,
|
||||
gradient_drag: doc.gradient_drag,
|
||||
marching_ants_edges: doc.marching_ants_edges.clone(),
|
||||
};
|
||||
|
||||
let overlay_canvas = canvas(overlay_program)
|
||||
|
||||
@@ -899,6 +899,23 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
state.pan_start = Some(pos);
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
} else if button == mouse::Button::Right {
|
||||
if let Some(pos) = cursor.position() {
|
||||
if bounds.contains(pos) {
|
||||
let local_pos = Point::new(pos.x - bounds.x, pos.y - bounds.y);
|
||||
let (cx_opt, cy_opt) = screen_to_canvas_local(
|
||||
local_pos, bounds.width, bounds.height, self.engine_w as f32, self.engine_h as f32, self.zoom, self.pan_offset
|
||||
);
|
||||
if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
|
||||
return (iced::event::Status::Captured, Some(Message::CanvasPointerRightClicked {
|
||||
x: cx,
|
||||
y: cy,
|
||||
screen_x: pos.x,
|
||||
screen_y: pos.y,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{column, container, horizontal_rule, mouse_area, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
@@ -502,3 +503,131 @@ pub fn dropdown_overlay(
|
||||
|
||||
Some(overlay.into())
|
||||
}
|
||||
|
||||
/// Canvas context menu popup.
|
||||
pub fn canvas_context_menu<'a>(
|
||||
x: f32,
|
||||
y: f32,
|
||||
doc: &crate::app::IcedDocument,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let mut items = vec![];
|
||||
|
||||
items.push(MenuItem::with_shortcut("Copy", "Ctrl+C"));
|
||||
if doc.selection_bounds.is_some() {
|
||||
items.push(MenuItem::with_shortcut("Cut", "Ctrl+X"));
|
||||
}
|
||||
items.push(MenuItem::with_shortcut("Paste", "Ctrl+V"));
|
||||
items.push(MenuItem::separator());
|
||||
items.push(MenuItem::new("New Layer"));
|
||||
|
||||
if doc.selection_bounds.is_some() {
|
||||
items.push(MenuItem::with_shortcut("Deselect", "Ctrl+D"));
|
||||
items.push(MenuItem::new("Crop Image"));
|
||||
}
|
||||
|
||||
items.push(MenuItem::separator());
|
||||
items.push(MenuItem::new("Gaussian Blur (5px)"));
|
||||
items.push(MenuItem::new("Mosaic / Pixelate (8px)"));
|
||||
items.push(MenuItem::new("Unsharp Mask"));
|
||||
|
||||
let mut col_items = vec![];
|
||||
for item in items {
|
||||
if item.label == "─" {
|
||||
col_items.push(
|
||||
container(horizontal_rule(1).style(move |_theme| iced::widget::rule::Style {
|
||||
color: colors.border_high,
|
||||
width: 1,
|
||||
radius: 0.0.into(),
|
||||
fill_mode: iced::widget::rule::FillMode::Full,
|
||||
}))
|
||||
.padding([4, 0])
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
let label = text(item.label.clone()).size(14).style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_primary),
|
||||
});
|
||||
let mut label_row = row![label].align_y(iced::Alignment::Center);
|
||||
|
||||
if !item.shortcut.is_empty() {
|
||||
label_row = label_row.push(iced::widget::Space::with_width(Length::Fill));
|
||||
label_row = label_row.push(
|
||||
text(item.shortcut)
|
||||
.size(13)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let msg = match item.label.as_str() {
|
||||
"Copy" => Message::CopyImage,
|
||||
"Cut" => Message::CopyImage, // TODO: CutImage
|
||||
"Paste" => Message::PasteImage,
|
||||
"New Layer" => Message::LayerAdd,
|
||||
"Deselect" => Message::Deselect,
|
||||
"Crop Image" => Message::ToolSelected(Tool::Crop),
|
||||
"Gaussian Blur (5px)" => Message::FilterSelect(hcie_engine_api::FilterType::GaussianBlur),
|
||||
"Mosaic / Pixelate (8px)" => Message::FilterSelect(hcie_engine_api::FilterType::Mosaic),
|
||||
"Unsharp Mask" => Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask),
|
||||
_ => Message::CanvasContextMenuClose,
|
||||
};
|
||||
|
||||
let c = colors;
|
||||
let menu_item = iced::widget::button(
|
||||
container(label_row)
|
||||
.width(Length::Fill)
|
||||
.padding([6, 16])
|
||||
)
|
||||
.padding(0)
|
||||
.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(c.menu_hover)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_bg)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
.on_press(msg);
|
||||
|
||||
col_items.push(menu_item.into());
|
||||
}
|
||||
}
|
||||
|
||||
let col = column(col_items).spacing(0);
|
||||
|
||||
let dropdown = container(col)
|
||||
.width(260)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.menu_bg)),
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
||||
offset: iced::Vector::new(2.0, 4.0),
|
||||
blur_radius: 8.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let overlay = mouse_area(
|
||||
container(dropdown)
|
||||
.padding(iced::Padding {
|
||||
top: y,
|
||||
bottom: 0.0,
|
||||
left: x,
|
||||
right: 0.0,
|
||||
})
|
||||
)
|
||||
.on_press(Message::CanvasContextMenuClose);
|
||||
|
||||
overlay.into()
|
||||
}
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
//! DSL script executor for the HCIE-Rust ICED GUI.
|
||||
//!
|
||||
//! Translates parsed `ScriptAction` values into `hcie_engine_api::Engine`
|
||||
//! calls. This module handles:
|
||||
//!
|
||||
//! - Hex color conversion to `[u8; 4]` RGBA
|
||||
//! - Blend mode string parsing to `BlendMode` enum
|
||||
//! - Brush style string parsing to `BrushStyle` enum
|
||||
//! - Layer lookup by name or index
|
||||
//! - Stroke drawing with per-point pressure
|
||||
//! - Raster rectangle fills
|
||||
//! - Vector shape creation
|
||||
|
||||
use crate::script::parser::ScriptAction;
|
||||
use hcie_engine_api::Engine;
|
||||
use hcie_engine_api::{BlendMode, BrushStyle, BrushTip};
|
||||
|
||||
/// Converts a hex color string to `[r, g, b, a]` byte array.
|
||||
///
|
||||
/// Supports both 6-character (`#RRGGBB`) and 8-character (`#RRGGBBAA`) formats.
|
||||
/// Defaults alpha to 255 if not specified.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `hex` — The hex color string (leading `#` is stripped).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A 4-element array `[r, g, b, a]` with values in `[0, 255]`.
|
||||
fn hex_to_rgba(hex: &str) -> [u8; 4] {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||
@@ -40,18 +15,6 @@ fn hex_to_rgba(hex: &str) -> [u8; 4] {
|
||||
[r, g, b, a]
|
||||
}
|
||||
|
||||
/// Parses a blend mode string into the corresponding `BlendMode` enum variant.
|
||||
///
|
||||
/// Accepts both camelCase and snake_case forms (e.g. `"colorBurn"` or
|
||||
/// `"color_burn"`). Defaults to `Normal` for unrecognized strings.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` — The blend mode name string.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The matching `BlendMode` variant.
|
||||
fn parse_blend_mode(s: &str) -> BlendMode {
|
||||
match s.to_lowercase().as_str() {
|
||||
"normal" => BlendMode::Normal,
|
||||
@@ -85,18 +48,6 @@ fn parse_blend_mode(s: &str) -> BlendMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a brush style string into the corresponding `BrushStyle` enum variant.
|
||||
///
|
||||
/// Accepts various aliases (e.g. `"soft_round"`, `"inkpen"`, `"wet_paint"`).
|
||||
/// Defaults to `Round` for unrecognized strings.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` — The brush style name string.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The matching `BrushStyle` variant.
|
||||
fn parse_brush_style(s: &str) -> BrushStyle {
|
||||
match s.to_lowercase().as_str() {
|
||||
"default" | "round" => BrushStyle::Round,
|
||||
@@ -130,19 +81,6 @@ fn parse_brush_style(s: &str) -> BrushStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds a layer ID by name or numeric index.
|
||||
///
|
||||
/// First attempts to parse `name_or_index` as a `usize` index into the
|
||||
/// layer list. If that fails, searches layer names for an exact match.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `engine` — The engine instance to query.
|
||||
/// * `name_or_index` — The layer name or zero-based index string.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The layer `u64` ID if found, otherwise `None`.
|
||||
fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option<u64> {
|
||||
if let Ok(idx) = name_or_index.parse::<usize>() {
|
||||
let ids = engine.get_all_layer_ids();
|
||||
@@ -157,16 +95,6 @@ fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Executes a sequence of parsed `ScriptAction` values against the engine.
|
||||
///
|
||||
/// Iterates through actions in order, translating each into the appropriate
|
||||
/// engine API call. Layer operations, brush settings, drawing commands, and
|
||||
/// vector shapes are all handled here.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `engine` — The mutable engine instance to operate on.
|
||||
/// * `actions` — The slice of actions to execute.
|
||||
pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
|
||||
for action in actions {
|
||||
match action {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/// DSL parser module — tokenizer, recursive-descent parser, and action types
|
||||
/// for the HCIE-Rust scripting engine.
|
||||
pub mod types;
|
||||
pub mod parser;
|
||||
pub mod executor;
|
||||
pub mod parser;
|
||||
pub mod types;
|
||||
|
||||
pub use executor::execute_actions;
|
||||
pub use parser::parse_dsl_script;
|
||||
pub use parser::parse_error_line;
|
||||
pub use parser::suggest_fix;
|
||||
pub use parser::DEFAULT_SCRIPT;
|
||||
|
||||
@@ -1,60 +1,19 @@
|
||||
//! Full DSL parser for the HCIE-Rust scripting engine.
|
||||
//!
|
||||
//! Provides a recursive-descent parser that converts a text-based DSL
|
||||
//! into a sequence of `ScriptAction` values. The DSL supports:
|
||||
//!
|
||||
//! - Layer management (`layer`, `select_layer`, `vector_layer`)
|
||||
//! - Brush and color configuration (`brush`, `color`, `size`, `opacity`, etc.)
|
||||
//! - Drawing commands (`draw`, `line`, `rect`, `circle`, `bezier`, `scatter`, `gradient`)
|
||||
//! - Vector shapes (`triangle`, `bezier`)
|
||||
//! - Variable definitions and arithmetic expressions (`var`, `$name`)
|
||||
//! - Loop constructs (`repeat N { ... }`)
|
||||
//! - Compound settings (`set key=value ...`)
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! 1. **Tokenizer** — Breaks arithmetic expressions into `Token` values.
|
||||
//! 2. **Expression evaluator** — Parses infix arithmetic with proper precedence.
|
||||
//! 3. **Line parser** — Dispatches each DSL command to the appropriate handler.
|
||||
//! 4. **Main parser** — Iterates lines, handles `repeat` blocks, collects actions.
|
||||
|
||||
use crate::script::types::ScriptParserState;
|
||||
|
||||
// ── Expression Evaluator ──────────────────────────────────────────────────
|
||||
|
||||
/// Token type for the arithmetic expression tokenizer.
|
||||
#[derive(Debug, Clone)]
|
||||
enum Token {
|
||||
/// Numeric literal.
|
||||
Num(f32),
|
||||
/// Variable reference (e.g. `$i`, `$count`).
|
||||
Var(String),
|
||||
/// Addition operator.
|
||||
Plus,
|
||||
/// Subtraction operator.
|
||||
Minus,
|
||||
/// Multiplication operator.
|
||||
Mul,
|
||||
/// Division operator.
|
||||
Div,
|
||||
/// Left parenthesis.
|
||||
LParen,
|
||||
/// Right parenthesis.
|
||||
RParen,
|
||||
}
|
||||
|
||||
/// Tokenizes an arithmetic expression string into a sequence of `Token` values.
|
||||
///
|
||||
/// Supports numbers, variables (`$name` or `$[name]`), and the operators
|
||||
/// `+`, `-`, `*`, `/`, `(`, `)`. Whitespace is ignored.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` — The expression string to tokenize.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `Vec<Token>` on success, or an error message describing the invalid character.
|
||||
fn tokenize(s: &str) -> Result<Vec<Token>, String> {
|
||||
let mut tokens = Vec::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
@@ -124,10 +83,6 @@ fn tokenize(s: &str) -> Result<Vec<Token>, String> {
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
/// Parses an expression (addition/subtraction precedence level).
|
||||
///
|
||||
/// Delegates to `parse_term` for multiplication/division, then combines
|
||||
/// results with addition or subtraction operators.
|
||||
fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
|
||||
let (mut left, mut pos) = parse_term(tokens, pos, vars)?;
|
||||
while pos < tokens.len() {
|
||||
@@ -148,10 +103,6 @@ fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashM
|
||||
Ok((left, pos))
|
||||
}
|
||||
|
||||
/// Parses a term (multiplication/division precedence level).
|
||||
///
|
||||
/// Delegates to `parse_factor` for atoms, then combines results with
|
||||
/// multiplication or division operators.
|
||||
fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
|
||||
let (mut left, mut pos) = parse_factor(tokens, pos, vars)?;
|
||||
while pos < tokens.len() {
|
||||
@@ -175,10 +126,6 @@ fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<Str
|
||||
Ok((left, pos))
|
||||
}
|
||||
|
||||
/// Parses a factor — the highest-precedence atom in the expression grammar.
|
||||
///
|
||||
/// Supports numeric literals, variable references (resolved from `vars`),
|
||||
/// unary negation, and parenthesized sub-expressions.
|
||||
fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
|
||||
if pos >= tokens.len() {
|
||||
return Err("unexpected end of expression".to_string());
|
||||
@@ -210,21 +157,6 @@ fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<S
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates an arithmetic expression string using the given variable map.
|
||||
///
|
||||
/// Handles three fast paths before falling through to the full tokenizer:
|
||||
/// 1. Pure numeric literal — parsed directly.
|
||||
/// 2. Bare variable reference (`$name`) — looked up in `vars`.
|
||||
/// 3. Full expression — tokenized and parsed via recursive descent.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `expr` — The expression string (e.g. `"3 + $i * 2"`).
|
||||
/// * `vars` — Map of variable names to their current values.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The evaluated `f32` result, or an error message.
|
||||
fn eval_expr(expr: &str, vars: &std::collections::HashMap<String, f32>) -> Result<f32, String> {
|
||||
let expr = expr.trim();
|
||||
if expr.is_empty() {
|
||||
@@ -254,26 +186,14 @@ fn eval_expr(expr: &str, vars: &std::collections::HashMap<String, f32>) -> Resul
|
||||
|
||||
// ── Parsed Action Types ──────────────────────────────────────────────────
|
||||
|
||||
/// Represents a single executable action produced by the DSL parser.
|
||||
///
|
||||
/// Each variant maps to one or more engine operations. The executor
|
||||
/// processes these in order, translating them into `hcie_engine_api`
|
||||
/// calls.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ScriptAction {
|
||||
/// Create a new layer with the given name and type.
|
||||
CreateLayer { name: String, layer_type: String },
|
||||
/// Delete a layer identified by name or index.
|
||||
DeleteLayer { name_or_index: String },
|
||||
/// Select (make active) a layer by name or index.
|
||||
SelectLayer { name_or_index: String },
|
||||
/// Rename a layer identified by name or index.
|
||||
RenameLayer { name_or_index: String, new_name: String },
|
||||
/// Set the opacity of a layer identified by name or index.
|
||||
SetLayerOpacity { name_or_index: String, opacity: f32 },
|
||||
/// Set the blend mode of a layer identified by name or index.
|
||||
SetLayerBlendMode { name_or_index: String, blend_mode: String },
|
||||
/// Paint a stroke through the given points with specified brush parameters.
|
||||
PaintStroke {
|
||||
points: Vec<[f32; 2]>,
|
||||
color: String,
|
||||
@@ -283,7 +203,6 @@ pub enum ScriptAction {
|
||||
spacing: f32,
|
||||
hardness: f32,
|
||||
},
|
||||
/// Update brush settings (any field that is `Some` is applied).
|
||||
SetBrush {
|
||||
style: Option<String>,
|
||||
size: Option<f32>,
|
||||
@@ -291,9 +210,7 @@ pub enum ScriptAction {
|
||||
spacing: Option<f32>,
|
||||
hardness: Option<f32>,
|
||||
},
|
||||
/// Set the foreground color from a hex string.
|
||||
SetForegroundColor { hex: String },
|
||||
/// Fill a rectangle on the raster layer with a solid color.
|
||||
FillRasterRect {
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
@@ -301,7 +218,6 @@ pub enum ScriptAction {
|
||||
y2: f32,
|
||||
color: String,
|
||||
},
|
||||
/// Add a vector path (free-form or closed shape).
|
||||
AddVectorPath {
|
||||
points: Vec<[f32; 2]>,
|
||||
closed: bool,
|
||||
@@ -309,31 +225,24 @@ pub enum ScriptAction {
|
||||
stroke_color: String,
|
||||
stroke_width: f32,
|
||||
},
|
||||
/// Script completion marker.
|
||||
Finish { summary: String },
|
||||
}
|
||||
|
||||
// ── Helper functions ────────────────────────────────────────────────────
|
||||
|
||||
/// Converts an absolute pixel coordinate to normalized `[0, 1]` range.
|
||||
fn to_norm(coord: f32, canvas_dim: f32) -> f32 {
|
||||
(coord / canvas_dim).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Evaluates an expression and normalizes the result to canvas coordinates.
|
||||
fn parse_coord(s: &str, vars: &std::collections::HashMap<String, f32>, canvas: f32) -> Result<f32, String> {
|
||||
let val = eval_expr(s, vars)?;
|
||||
Ok(to_norm(val, canvas))
|
||||
}
|
||||
|
||||
/// Evaluates an expression and returns the raw value (no normalization).
|
||||
fn parse_coord_abs(s: &str, vars: &std::collections::HashMap<String, f32>) -> Result<f32, String> {
|
||||
eval_expr(s, vars)
|
||||
}
|
||||
|
||||
/// Parses a 6- or 8-character hex color string into `(r, g, b)` components.
|
||||
///
|
||||
/// Each component is in `[0, 255]` range. The alpha channel (if present) is ignored.
|
||||
fn hex_to_rgba_components(hex: &str) -> Result<(f32, f32, f32), String> {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() != 6 && hex.len() != 8 {
|
||||
@@ -345,17 +254,6 @@ fn hex_to_rgba_components(hex: &str) -> Result<(f32, f32, f32), String> {
|
||||
Ok((r as f32, g as f32, b as f32))
|
||||
}
|
||||
|
||||
/// Linearly interpolates between two hex colors at parameter `t`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `hex1` — Start color as hex string.
|
||||
/// * `hex2` — End color as hex string.
|
||||
/// * `t` — Interpolation parameter in `[0, 1]`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The interpolated color as a 6-character hex string.
|
||||
fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result<String, String> {
|
||||
let (r1, g1, b1) = hex_to_rgba_components(hex1)?;
|
||||
let (r2, g2, b2) = hex_to_rgba_components(hex2)?;
|
||||
@@ -365,16 +263,6 @@ fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result<String, String> {
|
||||
Ok(format!("#{:02X}{:02X}{:02X}", r, g, b))
|
||||
}
|
||||
|
||||
/// Samples a cubic Bezier curve at evenly-spaced parameter values.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `p0`, `p1`, `p2`, `p3` — The four control points.
|
||||
/// * `steps` — Number of segments to divide the curve into.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `Vec` of `steps + 1` points along the curve.
|
||||
fn bezier_sample(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], steps: usize) -> Vec<[f32; 2]> {
|
||||
let mut pts = Vec::with_capacity(steps + 1);
|
||||
for i in 0..=steps {
|
||||
@@ -387,17 +275,6 @@ fn bezier_sample(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], steps:
|
||||
pts
|
||||
}
|
||||
|
||||
/// Generates pseudo-random scattered points within a bounding box.
|
||||
///
|
||||
/// Uses a deterministic hash-based RNG to produce reproducible scatter
|
||||
/// patterns. Points are distributed using golden-ratio spacing for
|
||||
/// uniform coverage.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `count` — Number of points to generate.
|
||||
/// * `bx`, `by` — Top-left corner of the bounding box (absolute coords).
|
||||
/// * `bw`, `bh` — Width and height of the bounding box.
|
||||
fn pseudo_scatter(count: usize, bx: f32, by: f32, bw: f32, bh: f32) -> Vec<[f32; 2]> {
|
||||
let mut pts = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
@@ -414,17 +291,6 @@ fn pseudo_scatter(count: usize, bx: f32, by: f32, bw: f32, bh: f32) -> Vec<[f32;
|
||||
pts
|
||||
}
|
||||
|
||||
/// Applies a single deterministic wiggle displacement to a point.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `px`, `py` — Original point coordinates.
|
||||
/// * `amp` — Maximum displacement amplitude.
|
||||
/// * `seed` — Seed for the deterministic displacement.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The displaced `(x, y)` coordinates.
|
||||
fn wiggle_point(px: f32, py: f32, amp: f32, seed: u64) -> (f32, f32) {
|
||||
if amp <= 0.0 {
|
||||
return (px, py);
|
||||
@@ -436,10 +302,6 @@ fn wiggle_point(px: f32, py: f32, amp: f32, seed: u64) -> (f32, f32) {
|
||||
(px + dx, py + dy)
|
||||
}
|
||||
|
||||
/// Applies wiggle displacement to all points in a normalized coordinate slice.
|
||||
///
|
||||
/// Points are temporarily un-normalized to pixel space (assumed 800x600 canvas),
|
||||
/// displaced, then re-normalized.
|
||||
fn apply_wiggle(pts: &mut [[f32; 2]], wiggle: f32) {
|
||||
if wiggle <= 0.0 {
|
||||
return;
|
||||
@@ -453,15 +315,6 @@ fn apply_wiggle(pts: &mut [[f32; 2]], wiggle: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a list of points into one or more `PaintStroke` actions.
|
||||
///
|
||||
/// Handles single-point strokes, uniform-pressure strokes, and
|
||||
/// pressure-tapered strokes (where each segment gets interpolated pressure).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `points` — The stroke points in normalized coordinates.
|
||||
/// * `state` — Current parser state for brush/color/pressure settings.
|
||||
fn build_strokes(mut points: Vec<[f32; 2]>, state: &ScriptParserState) -> Vec<ScriptAction> {
|
||||
if points.len() < 2 {
|
||||
if points.len() == 1 {
|
||||
@@ -510,7 +363,6 @@ fn build_strokes(mut points: Vec<[f32; 2]>, state: &ScriptParserState) -> Vec<Sc
|
||||
actions
|
||||
}
|
||||
|
||||
/// Strips leading `[` and trailing `]` from a string (for bracket-enclosed arguments).
|
||||
fn strip_brackets(s: &str) -> &str {
|
||||
let s = s.strip_prefix('[').unwrap_or(s);
|
||||
s.strip_suffix(']').unwrap_or(s)
|
||||
@@ -518,20 +370,6 @@ fn strip_brackets(s: &str) -> &str {
|
||||
|
||||
// ── Line Parser ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Parses a single DSL command line and returns the corresponding actions.
|
||||
///
|
||||
/// Dispatches on the first word of the line to the appropriate handler.
|
||||
/// Each handler updates `state` in place and returns zero or more actions.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `state` — Mutable parser state (updated by side-effect commands).
|
||||
/// * `line` — The trimmed DSL command line.
|
||||
/// * `line_num` — Line number for error reporting.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `Vec<ScriptAction>` on success, or a formatted error string with line number.
|
||||
fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -> Result<Vec<ScriptAction>, String> {
|
||||
let err = |msg: &str| format!("Line {}: {}", line_num, msg);
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
@@ -1067,19 +905,6 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
|
||||
// ── Main Parser ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Parses a complete DSL script into a sequence of executable actions.
|
||||
///
|
||||
/// Iterates through lines, handles `repeat N { ... }` block constructs
|
||||
/// by forking parser state and injecting loop variables `$i` and `$n`,
|
||||
/// and collects all resulting actions. A final `Finish` action is appended.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `script_text` — The complete DSL script as a string.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `Vec<ScriptAction>` on success, or a formatted error string with line number.
|
||||
pub fn parse_dsl_script(script_text: &str) -> Result<Vec<ScriptAction>, String> {
|
||||
let mut actions = Vec::new();
|
||||
let mut state = ScriptParserState::default();
|
||||
@@ -1168,30 +993,12 @@ pub fn parse_dsl_script(script_text: &str) -> Result<Vec<ScriptAction>, String>
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
/// Extracts a line number from a DSL error string (e.g. `"Line 42: ..."`).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `err` — The error message to parse.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The line number if the error matches the expected format, otherwise `None`.
|
||||
pub fn parse_error_line(err: &str) -> Option<usize> {
|
||||
let rest = err.strip_prefix("Line ")?;
|
||||
let colon = rest.find(':')?;
|
||||
rest[..colon].trim().parse().ok()
|
||||
}
|
||||
|
||||
/// Provides context-aware fix suggestions for common DSL parse errors.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `err` — The error message to analyze.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A static hint string if the error pattern is recognized, otherwise `None`.
|
||||
pub fn suggest_fix(err: &str) -> Option<&'static str> {
|
||||
if err.contains("gradient expects") {
|
||||
Some("gradient x1,y1 x2,y2 #hex1 #hex2 N — e.g. gradient 0,0 800,600 #87CEEB #FFD700 30")
|
||||
@@ -1220,11 +1027,6 @@ pub fn suggest_fix(err: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default DSL script demonstrating a mountain lake landscape.
|
||||
///
|
||||
/// This script showcases the full range of DSL capabilities:
|
||||
/// layer management, gradients, bezier curves, scatter, pressure
|
||||
/// tapering, wiggle, blend modes, and multiple brush styles.
|
||||
pub const DEFAULT_SCRIPT: &str = r#"# Mountain Lake Landscape — 800×600
|
||||
# Back-to-front drawing
|
||||
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Persistent parser state for the DSL script interpreter.
|
||||
///
|
||||
/// Tracks the current drawing context — active brush settings, color,
|
||||
/// layer properties, variables, and pressure mapping — across lines
|
||||
/// of a parsed DSL script. Each `repeat` block forks a copy of this
|
||||
/// state so inner iterations can override values without leaking back
|
||||
/// to the outer scope.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `color` — Current foreground color as a hex string (e.g. `"#FF0000"`).
|
||||
/// * `style` — Current brush style name (e.g. `"round"`, `"watercolor"`).
|
||||
/// * `size` — Current brush size in pixels.
|
||||
/// * `opacity` — Current drawing opacity, clamped to `[0.0, 1.0]`.
|
||||
/// * `hardness` — Current brush hardness, clamped to `[0.0, 1.0]`.
|
||||
/// * `spacing` — Brush spacing factor derived from the `flow` setting.
|
||||
/// * `blend_mode` — Current layer blend mode as a lowercase string.
|
||||
/// * `vars` — User-defined variables accessible via `$name` syntax.
|
||||
/// * `scatter_size` — Brush size used by the `scatter` command.
|
||||
/// * `flow` — Brush flow value (higher = denser strokes).
|
||||
/// * `pressure` — Uniform pen pressure multiplier.
|
||||
/// * `pressure_start` — Pressure at stroke start for tapering.
|
||||
/// * `pressure_end` — Pressure at stroke end for tapering.
|
||||
/// * `wiggle` — Random displacement amplitude applied to stroke points.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScriptParserState {
|
||||
pub color: String,
|
||||
|
||||
Reference in New Issue
Block a user