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,
|
pub marching_ants_offset: f32,
|
||||||
/// AI chat state (messages, config, streaming).
|
/// AI chat state (messages, config, streaming).
|
||||||
pub ai_chat_state: AiChatState,
|
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.
|
/// A recently opened file entry.
|
||||||
@@ -410,6 +420,12 @@ pub enum Message {
|
|||||||
CropConfirm,
|
CropConfirm,
|
||||||
CropCancel,
|
CropCancel,
|
||||||
|
|
||||||
|
// ── Script Editor ───────────────────────────────────
|
||||||
|
ScriptEditorAction(iced::widget::text_editor::Action),
|
||||||
|
ScriptTextChanged(String),
|
||||||
|
ScriptRun,
|
||||||
|
ScriptClear,
|
||||||
|
|
||||||
// ── AI Chat ─────────────────────────────────────────
|
// ── AI Chat ─────────────────────────────────────────
|
||||||
AiChatInput(String),
|
AiChatInput(String),
|
||||||
AiChatSend,
|
AiChatSend,
|
||||||
@@ -619,6 +635,13 @@ impl HcieIcedApp {
|
|||||||
layer_style_drag_start: None,
|
layer_style_drag_start: None,
|
||||||
marching_ants_offset: 0.0,
|
marching_ants_offset: 0.0,
|
||||||
ai_chat_state: AiChatState::default(),
|
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
|
// Apply saved settings to tool state
|
||||||
@@ -2246,6 +2269,41 @@ impl HcieIcedApp {
|
|||||||
self.documents[self.active_doc].crop_state.cancel();
|
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 ───────────────────────────────────
|
// ── AI Chat ───────────────────────────────────
|
||||||
Message::AiChatInput(text) => {
|
Message::AiChatInput(text) => {
|
||||||
self.ai_chat_state.input = text;
|
self.ai_chat_state.input = text;
|
||||||
|
|||||||
@@ -83,7 +83,13 @@ pub fn dock_view<'a>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
PaneType::Script => {
|
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 => {
|
PaneType::AiChat => {
|
||||||
crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors)
|
crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors)
|
||||||
|
|||||||
@@ -1,35 +1,197 @@
|
|||||||
//! Script panel — procedural drawing DSL editor.
|
//! Script panel — procedural drawing DSL editor with line numbers,
|
||||||
//! Uses ThemeColors for consistent styling.
|
//! 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::app::Message;
|
||||||
use crate::panels::styles;
|
use crate::panels::styles;
|
||||||
use crate::theme::ThemeColors;
|
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};
|
use iced::{Element, Length};
|
||||||
|
|
||||||
/// Build the script panel.
|
/// Build the full script editor panel.
|
||||||
pub fn view(colors: ThemeColors) -> Element<'static, Message> {
|
///
|
||||||
let script_input = text_input("// Write your script here...", "")
|
/// Displays a monospace text editor with a line-number gutter, run/clear
|
||||||
.width(Length::Fill);
|
/// 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 gutter_lines = (1..=line_count)
|
||||||
let clear_btn = button(text("Clear").size(11)).on_press(Message::NoOp).padding([6, 12]);
|
.map(|ln| {
|
||||||
let buttons = row![run_btn, clear_btn].spacing(4);
|
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),
|
text("Command Reference").size(11).font(iced::Font::MONOSPACE),
|
||||||
horizontal_rule(1),
|
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)
|
.spacing(2);
|
||||||
.padding(8);
|
|
||||||
|
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))
|
let reference_container = container(scrollable(reference).height(Length::Fill))
|
||||||
.style(move |_theme| iced::widget::container::Style {
|
.style(move |_theme| iced::widget::container::Style {
|
||||||
@@ -38,23 +200,16 @@ pub fn view(colors: ThemeColors) -> Element<'static, Message> {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let error_display = container(text("No errors").size(10).font(iced::Font::MONOSPACE))
|
// ── Assemble the panel ──────────────────────────────────
|
||||||
.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()
|
|
||||||
});
|
|
||||||
|
|
||||||
let panel = column![
|
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),
|
horizontal_rule(1),
|
||||||
script_input,
|
editor_row,
|
||||||
buttons,
|
buttons,
|
||||||
horizontal_rule(1),
|
horizontal_rule(1),
|
||||||
reference_container,
|
|
||||||
horizontal_rule(1),
|
|
||||||
error_display,
|
error_display,
|
||||||
|
horizontal_rule(1),
|
||||||
|
reference_container,
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.padding(8);
|
.padding(8);
|
||||||
|
|||||||
Reference in New Issue
Block a user