feat(iced): implement full script editor with line numbers and error highlighting

This commit is contained in:
2026-07-15 16:54:42 +03:00
parent 828ff4b6af
commit 827d9b1742
3 changed files with 253 additions and 34 deletions
@@ -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<String>,
/// Line number (1-indexed) of the last error, for highlighting.
pub script_error_line: Option<usize>,
/// 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;
@@ -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)
@@ -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<usize>,
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 <name> Create layer").size(10).font(iced::Font::MONOSPACE),
text("select <name> Select layer").size(10).font(iced::Font::MONOSPACE),
text("color <r> <g> <b> Set color").size(10).font(iced::Font::MONOSPACE),
text("size <pixels> 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);