feat(iced): implement full script editor with line numbers and error highlighting
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user