From 827d9b1742d4d412c2ebfabbe45ab7b9988b69d6 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Wed, 15 Jul 2026 16:54:42 +0300 Subject: [PATCH] feat(iced): implement full script editor with line numbers and error highlighting --- hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 58 +++++ .../crates/hcie-iced-gui/src/dock/view.rs | 8 +- .../hcie-iced-gui/src/panels/script_panel.rs | 221 +++++++++++++++--- 3 files changed, 253 insertions(+), 34 deletions(-) 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 494b47b..501ae3e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -153,6 +153,16 @@ pub struct HcieIcedApp { pub marching_ants_offset: f32, /// AI chat state (messages, config, streaming). pub ai_chat_state: AiChatState, + /// Script editor text content (raw string for error display/reference). + pub script_text: String, + /// Script editor content for the Iced text_editor widget. + pub script_content: iced::widget::text_editor::Content, + /// Last script parse/execution error message. + pub script_error: Option, + /// Line number (1-indexed) of the last error, for highlighting. + pub script_error_line: Option, + /// Whether a script is currently executing. + pub script_is_running: bool, } /// A recently opened file entry. @@ -410,6 +420,12 @@ pub enum Message { CropConfirm, CropCancel, + // ── Script Editor ─────────────────────────────────── + ScriptEditorAction(iced::widget::text_editor::Action), + ScriptTextChanged(String), + ScriptRun, + ScriptClear, + // ── AI Chat ───────────────────────────────────────── AiChatInput(String), AiChatSend, @@ -619,6 +635,13 @@ impl HcieIcedApp { layer_style_drag_start: None, marching_ants_offset: 0.0, ai_chat_state: AiChatState::default(), + script_text: crate::script::parser::DEFAULT_SCRIPT.to_string(), + script_content: iced::widget::text_editor::Content::with_text( + crate::script::parser::DEFAULT_SCRIPT, + ), + script_error: None, + script_error_line: None, + script_is_running: false, }; // Apply saved settings to tool state @@ -2246,6 +2269,41 @@ impl HcieIcedApp { self.documents[self.active_doc].crop_state.cancel(); } + // ── Script Editor ────────────────────────────── + Message::ScriptEditorAction(action) => { + self.script_content.perform(action); + self.script_text = self.script_content.text(); + } + Message::ScriptTextChanged(text) => { + self.script_text = text; + } + Message::ScriptRun => { + match crate::script::parser::parse_dsl_script(&self.script_text) { + Ok(actions) => { + self.script_error = None; + self.script_error_line = None; + self.script_is_running = true; + crate::script::executor::execute_actions( + &mut self.documents[self.active_doc].engine, + &actions, + ); + self.script_is_running = false; + self.documents[self.active_doc].modified = true; + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + Err(e) => { + self.script_error_line = crate::script::parser::parse_error_line(&e); + self.script_error = Some(e); + } + } + } + Message::ScriptClear => { + self.script_text.clear(); + self.script_error = None; + self.script_error_line = None; + } + // ── AI Chat ─────────────────────────────────── Message::AiChatInput(text) => { self.ai_chat_state.input = text; 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 9144d69..837f497 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 @@ -83,7 +83,13 @@ pub fn dock_view<'a>( ) } PaneType::Script => { - crate::panels::script_panel::view(colors) + crate::panels::script_panel::view( + &app.script_content, + app.script_error.as_deref(), + app.script_error_line, + app.script_is_running, + colors, + ) } PaneType::AiChat => { crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs index e791c54..994a53c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs @@ -1,35 +1,197 @@ -//! Script panel — procedural drawing DSL editor. -//! Uses ThemeColors for consistent styling. +//! Script panel — procedural drawing DSL editor with line numbers, +//! error highlighting, and collapsible command reference. +//! +//! Uses `iced::widget::text_editor` for the multi-line code editor, +//! with a line-number gutter built from a scrollable column widget. use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; -use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input}; +use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_editor}; use iced::{Element, Length}; -/// Build the script panel. -pub fn view(colors: ThemeColors) -> Element<'static, Message> { - let script_input = text_input("// Write your script here...", "") - .width(Length::Fill); +/// Build the full script editor panel. +/// +/// Displays a monospace text editor with a line-number gutter, run/clear +/// buttons with a status indicator, an error display, and a collapsible +/// command reference. +pub fn view<'a>( + script_content: &'a text_editor::Content, + script_error: Option<&'a str>, + script_error_line: Option, + script_is_running: bool, + colors: ThemeColors, +) -> Element<'a, Message> { + // ── Line-number gutter ────────────────────────────────── + let line_count = script_content.line_count().max(1); + let error_line = script_error_line; - let run_btn = button(text("Run Script").size(11)).on_press(Message::NoOp).padding([6, 12]); - let clear_btn = button(text("Clear").size(11)).on_press(Message::NoOp).padding([6, 12]); - let buttons = row![run_btn, clear_btn].spacing(4); + let gutter_lines = (1..=line_count) + .map(|ln| { + let is_err = error_line == Some(ln); + let color = if is_err { + iced::Color::from_rgb(0.86, 0.24, 0.24) + } else { + iced::Color::from_rgb(0.35, 0.35, 0.35) + }; + let marker = if is_err { "\u{25cf}" } else { " " }; + text(format!("{:>3}{}", ln, marker)) + .size(12) + .font(iced::Font::MONOSPACE) + .color(color) + }) + .fold(column![].spacing(0), |col, t| col.push(t)); - let reference = column![ + let gutter = container(scrollable(gutter_lines).height(Length::Fill)) + .padding(iced::Padding { top: 8.0, right: 4.0, bottom: 8.0, left: 8.0 }) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), + border: iced::Border::default().rounded(4), + ..Default::default() + }); + + // ── Text editor ───────────────────────────────────────── + let editor = text_editor(script_content) + .on_action(Message::ScriptEditorAction) + .placeholder("Write your script here...") + .font(iced::Font::MONOSPACE) + .height(Length::Fixed(280.0)) + .padding(8); + + let editor_row = row![gutter, editor].spacing(0); + + // ── Buttons and status indicator ──────────────────────── + let run_btn = button(text("▶ Run Script").size(11)) + .on_press(Message::ScriptRun) + .padding(iced::Padding::from([6.0, 12.0])); + let clear_btn = button(text("Clear").size(11)) + .on_press(Message::ScriptClear) + .padding(iced::Padding::from([6.0, 12.0])); + + let status_text = if script_is_running { + text("⟳ Executing...").size(11).color(iced::Color::from_rgb(0.9, 0.7, 0.2)) + } else { + text("Ready").size(11).color(iced::Color::from_rgb(0.4, 0.7, 0.4)) + }; + + let buttons = row![run_btn, clear_btn, status_text].spacing(6); + + // ── Error display ─────────────────────────────────────── + let error_display: Element<'_, Message> = if let Some(err) = script_error { + let err_text = text(format!("✘ {}", err)) + .size(11) + .color(iced::Color::from_rgb(0.86, 0.24, 0.24)) + .font(iced::Font::MONOSPACE); + + let mut error_children = column![err_text].spacing(2); + + // Show the offending line with context + if let Some(ln) = script_error_line { + if let Some(line_text) = script_content.line(ln.saturating_sub(1)) { + let line_display = text(format!("{:>3} {}", ln, &*line_text)) + .size(11) + .font(iced::Font::MONOSPACE) + .color(iced::Color::from_rgb(0.78, 0.78, 0.78)); + + let line_box = container(line_display) + .padding(iced::Padding::from([2.0, 6.0])) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.8, 0.2, 0.2, 0.2, + ))), + border: iced::Border::default().rounded(2), + ..Default::default() + }); + + error_children = error_children.push(line_box); + } + } + + // Show suggestion if available + if let Some(suggestion) = crate::script::parser::suggest_fix(err) { + let sug_text = text(format!("💡 {}", suggestion)) + .size(11) + .color(iced::Color::from_rgb(0.78, 0.78, 0.78)); + + let sug_box = container(sug_text) + .padding(iced::Padding::from([2.0, 6.0])) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.04, 0.12, 0.24, 0.2, + ))), + border: iced::Border::default().rounded(2), + ..Default::default() + }); + + error_children = error_children.push(sug_box); + } + + container(error_children) + .padding(6) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), + border: iced::Border::default().rounded(4), + ..Default::default() + }) + .into() + } else { + container(text("No errors").size(10).font(iced::Font::MONOSPACE)) + .padding(4) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), + border: iced::Border::default().rounded(4), + ..Default::default() + }) + .into() + }; + + // ── Command Reference ─────────────────────────────────── + let help_text = [ + ("Layer:", "layer Name — create raster layer"), + ("", "vector_layer Name — create vector layer"), + ("", "select_layer Name — switch active layer"), + ("Brush & Color:", "brush round — set brush style"), + ("", "color #FF0000 — set foreground color"), + ("", "size N — brush size"), + ("", "opacity N — brush opacity"), + ("", "hardness N — brush hardness"), + ("", "flow N — brush flow / density"), + ("Drawing:", "draw x1,y1 x2,y2 ... — multi-point stroke"), + ("", "line x1,y1 x2,y2 — straight line"), + ("", "rect x1,y1 x2,y2 — filled rect"), + ("", "circle cx,cy — single dab"), + ("", "triangle x1,y1 x2,y2 x3,y3 — vector triangle"), + ("", "bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]"), + ("Organic:", "scatter N x,y w,h [size] — golden-angle scatter"), + ("", "gradient x1,y1 x2,y2 #hex1 #hex2 ... N"), + ("", "wiggle N — per-point jitter"), + ("", "pressure / pressure_start / pressure_end"), + ("Meta:", "var name value — declare variable"), + ("", "repeat N { ... } — loop with $i, $n"), + ("", "set key=value key=value — multi-set"), + ]; + + let mut reference = column![ text("Command Reference").size(11).font(iced::Font::MONOSPACE), horizontal_rule(1), - text("layer Create layer").size(10).font(iced::Font::MONOSPACE), - text("select Select layer").size(10).font(iced::Font::MONOSPACE), - text("color Set color").size(10).font(iced::Font::MONOSPACE), - text("size Set brush size").size(10).font(iced::Font::MONOSPACE), - text("line x1 y1 x2 y2 Draw line").size(10).font(iced::Font::MONOSPACE), - text("rect x1 y1 x2 y2 Draw rectangle").size(10).font(iced::Font::MONOSPACE), - text("circle cx cy r Draw circle").size(10).font(iced::Font::MONOSPACE), - text("fill x y Flood fill").size(10).font(iced::Font::MONOSPACE), ] - .spacing(2) - .padding(8); + .spacing(2); + + for (category, cmd_text) in &help_text { + if !category.is_empty() { + reference = reference.push( + text(*category) + .size(11) + .color(iced::Color::from_rgb(0.63, 0.63, 0.63)), + ); + } + reference = reference.push( + text(*cmd_text) + .size(10) + .font(iced::Font::MONOSPACE) + .color(iced::Color::from_rgb(0.51, 0.51, 0.51)), + ); + } let reference_container = container(scrollable(reference).height(Length::Fill)) .style(move |_theme| iced::widget::container::Style { @@ -38,23 +200,16 @@ pub fn view(colors: ThemeColors) -> Element<'static, Message> { ..Default::default() }); - let error_display = container(text("No errors").size(10).font(iced::Font::MONOSPACE)) - .padding(4) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_app)), - border: iced::Border::default().rounded(4), - ..Default::default() - }); - + // ── Assemble the panel ────────────────────────────────── let panel = column![ - text("Script Editor").size(13).font(iced::Font::MONOSPACE), + text("HCIE Script — Organic DSL").size(13).font(iced::Font::MONOSPACE), horizontal_rule(1), - script_input, + editor_row, buttons, horizontal_rule(1), - reference_container, - horizontal_rule(1), error_display, + horizontal_rule(1), + reference_container, ] .spacing(4) .padding(8);