From d083c2ab50d29d32c011f0e8a57426da987fffca Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 09:36:51 -0400 Subject: [PATCH] continued progress --- apps/drawcraft/src/main.rs | 41 ++++++++ assets/icons/dc-actions.svg | 1 + crates/engine/src/cmd/file.rs | 45 ++++++++- crates/engine/src/tests_file.rs | 18 ++++ crates/render/src/lib.rs | 23 +++++ crates/ui-egui/src/dialogs.rs | 110 ++++++++++++++++++++- crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/menus.rs | 11 ++- crates/ui-egui/src/panels/actions.rs | 139 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/state.rs | 28 +++++- 11 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 assets/icons/dc-actions.svg create mode 100644 crates/ui-egui/src/panels/actions.rs diff --git a/apps/drawcraft/src/main.rs b/apps/drawcraft/src/main.rs index ad561577..dde772ac 100644 --- a/apps/drawcraft/src/main.rs +++ b/apps/drawcraft/src/main.rs @@ -37,6 +37,46 @@ impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); } + fn on_exit(&mut self) { + save_prefs(&self.0); + } +} + +/// Where UI preferences live: ~/Library/Application Support/DrawCraft (macOS), +/// %APPDATA%\DrawCraft (Windows), $XDG_CONFIG_HOME or ~/.config/drawcraft (Linux). +fn prefs_path() -> Option { + let base = if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DrawCraft")) + } else if cfg!(windows) { + std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DrawCraft")) + } else { + std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))).map(|c| c.join("drawcraft")) + }; + base.map(|b| b.join("ui.json")) +} + +fn load_prefs(app: &mut DrawcraftApp) { + if std::env::var_os("DRAWCRAFT_NO_PREFS").is_some() { + return; + } + if let Some(p) = prefs_path() + && let Ok(bytes) = std::fs::read(&p) + && let Ok(ui) = serde_json::from_slice::(&bytes) + { + app.ui = ui.sanitized(); + } +} + +fn save_prefs(app: &DrawcraftApp) { + if std::env::var_os("DRAWCRAFT_NO_PREFS").is_some() { + return; + } + if let Some(p) = prefs_path() { + let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); + if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { + let _ = std::fs::write(p, bytes); + } + } } fn services() -> Services { @@ -84,6 +124,7 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = DrawcraftApp::new(Session::new(), services()); + load_prefs(&mut app); app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/assets/icons/dc-actions.svg b/assets/icons/dc-actions.svg new file mode 100644 index 00000000..837807e8 --- /dev/null +++ b/assets/icons/dc-actions.svg @@ -0,0 +1 @@ + diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1225c7e8..ff093a28 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -22,6 +22,7 @@ pub fn specs() -> Vec { cmd!("document.save", "Save Document", [], None, "{path?} native .drawcraft (default: the document's path) → {path}", has_doc, save), cmd!(query "document.serialize", "Serialize Document", [], None, "{format: drawcraft|svg|pdf|png} → {dataBase64 | text}", has_doc, serialize), cmd!("document.export", "Export Document", [], None, "{format: svg|png|pdf|drawcraft, path, scale?: 1, artboard?: 0}", has_doc, export), + cmd!("document.exportForScreens", "Export for Screens", ["File", "Export"], None, "{folder, artboards?: [index…] (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1, suffix?: \"@2x\"}], prefix?} → {files: [...]}", has_doc, export_for_screens), cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch), ] } @@ -81,9 +82,14 @@ pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> "drawcraft" => drawcraft_format::save(doc, true), "svg" => drawcraft_svg::export(doc, &drawcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(), "pdf" => drawcraft_pdf::export(doc, &drawcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?, - "png" => { + "png" | "jpg" | "jpeg" | "webp" => { let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; - drawcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), false).to_png() + let img = drawcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), format != "png" && format != "webp"); + match format { + "png" => img.to_png(), + "webp" => img.to_webp(), + _ => img.to_jpeg(90), + } } other => return Err(EngineError::Other(format!("unknown format `{other}`"))), }) @@ -138,3 +144,38 @@ fn batch(s: &mut Session, p: &Value) -> Result { s.commit_interaction()?; Ok(json!({ "results": results })) } + +fn export_for_screens(s: &mut Session, p: &Value) -> Result { + let folder = str_param(p, "folder").ok_or_else(|| bad("document.exportForScreens", "missing folder"))?.to_string(); + let n = s.doc()?.doc.artboards.len(); + let boards: Vec = match p.get("artboards").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_u64).map(|v| v as usize).filter(|i| *i < n).collect(), + None => (0..n).collect(), + }; + let formats: Vec<(String, f64, String)> = match p.get("formats").and_then(Value::as_array) { + Some(a) => a + .iter() + .map(|f| { + let fmt = str_param(f, "format").unwrap_or("png").to_string(); + let sc = f64_or(f, "scale", 1.0); + let suffix = str_param(f, "suffix").map(str::to_string).unwrap_or_else(|| if (sc - 1.0).abs() < 1e-9 { String::new() } else { format!("@{sc}x") }); + (fmt, sc, suffix) + }) + .collect(), + None => vec![("png".into(), 1.0, String::new())], + }; + let prefix = str_param(p, "prefix").unwrap_or("").to_string(); + std::fs::create_dir_all(&folder).map_err(|e| EngineError::Other(format!("{folder}: {e}")))?; + let mut files = vec![]; + for b in boards { + let name: String = s.doc()?.doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); + for (fmt, sc, suffix) in &formats { + let bytes = encode(s, fmt, *sc, b)?; + let ext = if fmt == "jpeg" { "jpg" } else { fmt.as_str() }; + let path = format!("{folder}/{prefix}{name}{suffix}.{ext}"); + std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + files.push(path); + } + } + Ok(json!({ "files": files })) +} diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index 6532c67b..a144ca4b 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -109,3 +109,21 @@ fn draw_behind_and_inside() { s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap(); assert!(s.draw_inside.is_none()); } + +#[test] +fn export_for_screens_writes_every_combination() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 2})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let dir = std::env::temp_dir().join(format!("dc-efs-{}", std::process::id())); + let r = s + .execute("document.exportForScreens", &json!({"folder": dir.to_string_lossy(), "formats": [{"format": "png", "scale": 1}, {"format": "png", "scale": 2}, {"format": "jpg"}, {"format": "svg"}, {"format": "webp"}]})) + .unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 10); + for f in files { + assert!(std::fs::metadata(f.as_str().unwrap()).unwrap().len() > 50, "{f}"); + } + assert!(files.iter().any(|f| f.as_str().unwrap().ends_with("Artboard-2@2x.png"))); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 995e0f55..f4c3d507 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -70,6 +70,29 @@ impl Rendered { img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).expect("png encode"); buf } + /// Encode as JPEG (flattened on white) at `quality` 1..=100. + pub fn to_jpeg(&self, quality: u8) -> Vec { + let rgba = self.to_straight(); + let rgb: Vec = rgba + .chunks_exact(4) + .flat_map(|p| { + let a = p[3] as u32; + let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8; + [mix(p[0]), mix(p[1]), mix(p[2])] + }) + .collect(); + let mut buf = Vec::new(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8); + buf + } + /// Encode as lossless WebP. + pub fn to_webp(&self) -> Vec { + let mut buf = Vec::new(); + let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut buf); + let _ = image::ImageEncoder::write_image(enc, &self.to_straight(), self.width, self.height, image::ExtendedColorType::Rgba8); + buf + } /// Straight-alpha RGBA at (x, y). pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { let i = ((y * self.width + x) * 4) as usize; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 72140290..f34f5bcc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -112,6 +112,7 @@ fn title(kind: &str) -> &'static str { "preferences" => "Preferences", "artboardOptions" => "Artboard Options", "allTools" => "All Tools", + "exportForScreens" => "Export for Screens", _ => "Dialog", } } @@ -120,6 +121,17 @@ fn title(kind: &str) -> &'static str { pub fn confirm(app: &mut DrawcraftApp) -> Result { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; let copy = d.bool("copy"); + if d.kind == "exportForScreens" { + let boards: Vec = d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect()).unwrap_or_default(); + let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")}); + app.ui.dialog = None; + let r = app.run("document.exportForScreens", params); + if let Ok(v) = &r { + let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0); + app.status(format!("Exported {n} file(s) to {}", d.str("folder"))); + } + return r; + } if d.kind == "command" { let cmd = d.str("__command"); let params = effect_params(&d); @@ -250,6 +262,7 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(heading).font(theme::semibold(16.0)).color(t.text)); ui.add_space(12.0); match d.kind.as_str() { + "exportForScreens" => export_for_screens_ui(app, ui, &mut d), "command" => { effect_fields(ui, &mut d); } @@ -304,7 +317,11 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { } ui.add_space(16.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let label = if d.kind == "newDocument" { "Create" } else { "OK" }; + let label = match d.kind.as_str() { + "newDocument" => "Create", + "exportForScreens" => "Export Artboard", + _ => "OK", + }; if d.kind != "allTools" && widgets::primary_button(ui, label).clicked() { ok = true; } @@ -393,3 +410,94 @@ fn new_document(ui: &mut egui::Ui, d: &mut Dialog) { }); }); } + +/// Export for Screens: artboard picker (thumbnails + checkboxes), format/scale rows, destination. +fn export_for_screens_ui(app: &mut DrawcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let names: Vec = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); + let mut boards: Vec = d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default(); + boards.resize(names.len(), true); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(260.0); + ui.label(egui::RichText::new("Artboards").color(t.text)); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + boards.iter_mut().for_each(|b| *b = true); + } + if ui.small_button("Clear").clicked() { + boards.iter_mut().for_each(|b| *b = false); + } + }); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (i, name) in names.iter().enumerate() { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, format!("{}", i + 1), egui::FontId::proportional(12.0), egui::Color32::from_gray(120)); + ui.checkbox(&mut boards[i], name); + }); + } + }); + }); + ui.separator(); + ui.vertical(|ui| { + ui.set_width(330.0); + ui.label(egui::RichText::new("Export to").color(t.text)); + let mut folder = d.str("folder"); + if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() { + d.fields.insert("folder".into(), json!(folder)); + } + ui.add_space(8.0); + ui.label(egui::RichText::new("Formats").color(t.text)); + let mut formats: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut remove = None; + egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + ui.label(egui::RichText::new("Scale").color(t.text_dim)); + ui.label(egui::RichText::new("Suffix").color(t.text_dim)); + ui.label(egui::RichText::new("Format").color(t.text_dim)); + ui.label(""); + ui.end_row(); + for (i, f) in formats.iter_mut().enumerate() { + let mut sc = f["scale"].as_f64().unwrap_or(1.0); + if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() { + f["scale"] = json!(sc); + } + let mut suffix = f["suffix"].as_str().unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() { + f["suffix"] = json!(suffix); + } + let cur = f["format"].as_str().unwrap_or("png").to_string(); + egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| { + for fm in ["png", "jpg", "webp", "svg", "pdf"] { + if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() { + f["format"] = json!(fm); + } + } + }); + if ui.small_button("✕").clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + formats.remove(i); + } + if ui.button("+ Add Scale").clicked() { + let next = formats.len() as f64 + 1.0; + formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")})); + } + d.fields.insert("formats".into(), Value::Array(formats)); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Prefix").color(t.text_dim)); + let mut prefix = d.str("prefix"); + if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() { + d.fields.insert("prefix".into(), json!(prefix)); + } + }); + }); + }); + d.fields.insert("boards".into(), json!(boards)); +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 09e3034a..1c11f82b 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -29,6 +29,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("cloud", include_bytes!("../../../assets/icons/cloud.svg")), ("combine", include_bytes!("../../../assets/icons/combine.svg")), ("copy", include_bytes!("../../../assets/icons/copy.svg")), + ("dc-actions", include_bytes!("../../../assets/icons/dc-actions.svg")), ("dc-al-bottom", include_bytes!("../../../assets/icons/dc-al-bottom.svg")), ("dc-al-hcenter", include_bytes!("../../../assets/icons/dc-al-hcenter.svg")), ("dc-al-left", include_bytes!("../../../assets/icons/dc-al-left.svg")), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2422b815..81a76d20 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -144,6 +144,15 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})), + "file.exportForScreens" if p.get("path").is_none() && p.get("scale").is_none() => { + let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + app.ui.dialog = Some(crate::state::Dialog::new( + "exportForScreens", + json!({"boards": vec![true; n], "formats": [{"format": "png", "scale": 1, "suffix": ""}, {"format": "png", "scale": 2, "suffix": "@2x"}], "folder": format!("{home}/Desktop/DrawCraft Export"), "prefix": ""}), + )); + Ok(Value::Null) + } "file.export.png" | "file.exportForScreens" => { io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p})) } @@ -864,7 +873,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Tools", "window.toolbar"), sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Single / Double Column", "window.toolbarColumns")]), Sep, - todo("Actions"), + panel("Actions", "actions"), panel("Align", "align"), panel("Appearance", "appearance"), panel("Artboards", "artboards"), diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs new file mode 100644 index 00000000..e673cd34 --- /dev/null +++ b/crates/ui-egui/src/panels/actions.rs @@ -0,0 +1,139 @@ +//! The Actions panel: record commands from the engine journal, play them back as one undo step. +//! Every recorded step is an ordinary command invocation, so actions are also plain JSON that +//! agents can author and replay (`command.batch`). + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::widgets::{self, dim_label}; +use crate::{DrawcraftApp, icons}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Action { + pub name: String, + pub steps: Vec<(String, Value)>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ActionSet { + pub name: String, + pub actions: Vec, +} + +/// Built-in actions (our own, handy defaults). +pub fn default_sets() -> Vec { + let step = |c: &str, p: Value| (c.to_string(), p); + vec![ActionSet { + name: "Default Actions".into(), + actions: vec![ + Action { name: "Duplicate and Offset 10 pt".into(), steps: vec![step("object.move", json!({"dx": 10, "dy": 10, "copy": true}))] }, + Action { name: "Unite and Simplify".into(), steps: vec![step("object.pathfinder.unite", json!({})), step("object.path.simplify", json!({"tolerance": 0.5}))] }, + Action { name: "Center on Artboard".into(), steps: vec![step("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard"}))] }, + Action { name: "Outline Text".into(), steps: vec![step("type.createOutlines", json!({}))] }, + Action { name: "Rotate 90° CW".into(), steps: vec![step("object.rotate", json!({"angle": -90}))] }, + Action { name: "Reflect Horizontal".into(), steps: vec![step("object.reflect", json!({"axis": "vertical"}))] }, + ], + }] +} + +/// Run an action as a single undoable batch. +pub fn play(app: &mut DrawcraftApp, set: usize, idx: usize) -> Result { + let Some(a) = app.ui.action_sets.get(set).and_then(|s| s.actions.get(idx)).cloned() else { return Err("no such action".into()) }; + let commands: Vec = a.steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect(); + app.run("command.batch", json!({"label": a.name, "commands": commands})) +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let selected_id = egui::Id::new("actions-selected"); + let mut selected: Option<(usize, usize)> = ui.data(|d| d.get_temp(selected_id)); + let mut play_req = None; + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (si, set) in app.ui.action_sets.iter().enumerate() { + ui.label(egui::RichText::new(format!("▾ {}", set.name)).color(t.text)); + for (ai, a) in set.actions.iter().enumerate() { + let sel = selected == Some((si, ai)); + let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 22.0), egui::Sense::click()); + if sel { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + ui.painter().text(r.left_center() + egui::vec2(18.0, 0.0), egui::Align2::LEFT_CENTER, &a.name, egui::FontId::proportional(12.5), t.text_strong); + ui.painter().text(r.right_center() - egui::vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, format!("{} step{}", a.steps.len(), if a.steps.len() == 1 { "" } else { "s" }), egui::FontId::proportional(11.0), t.text_dim); + if resp.clicked() { + selected = Some((si, ai)); + } + if resp.double_clicked() { + play_req = Some((si, ai)); + } + } + } + }); + ui.data_mut(|d| d.insert_temp(selected_id, selected)); + widgets::divider(ui); + // Bottom bar: stop, record, play, new, delete. + ui.horizontal(|ui| { + let recording = app.ui.recording.is_some(); + if widgets::icon_button(ui, "square", "Stop Playing/Recording", false, 24.0).clicked() + && let Some((set, name, start)) = app.ui.recording.take() + { + let steps: Vec<(String, Value)> = app.session.journal.iter().skip(start).cloned().collect(); + if let Some(s) = app.ui.action_sets.get_mut(set) { + s.actions.push(Action { name, steps }); + } + } + let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); + ui.painter().circle_filled(r.center(), 6.0, if recording { egui::Color32::from_rgb(0xe0, 0x30, 0x30) } else { t.icon }); + if resp.on_hover_text("Begin Recording").clicked() && !recording { + let n = app.ui.action_sets.first().map(|s| s.actions.len()).unwrap_or(0) + 1; + if app.ui.action_sets.is_empty() { + app.ui.action_sets.push(ActionSet { name: "Set 1".into(), actions: vec![] }); + } + let set = selected.map(|s| s.0).unwrap_or(0); + app.ui.recording = Some((set, format!("Action {n}"), app.session.journal.len())); + } + if widgets::icon_button(ui, "dc-actions", "Play Current Selection", false, 24.0).clicked() + && let Some(s) = selected + { + play_req = Some(s); + } + if widgets::icon_button(ui, "trash-2", "Delete Selection", false, 24.0).clicked() + && let Some((si, ai)) = selected + && let Some(s) = app.ui.action_sets.get_mut(si) + && ai < s.actions.len() + { + s.actions.remove(ai); + ui.data_mut(|d| d.insert_temp::>(selected_id, None)); + } + if recording { + ui.label(egui::RichText::new("● Recording").color(egui::Color32::from_rgb(0xe0, 0x30, 0x30))); + } + }); + if let Some((si, ai)) = play_req + && let Err(e) = play(app, si, ai) + { + app.status(e); + } + let _ = icons::exists; + if app.ui.action_sets.is_empty() { + dim_label(ui, "No actions. Press ● to record."); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_valid_commands() { + for set in default_sets() { + for a in set.actions { + for (c, _) in a.steps { + assert!(drawcraft_engine::find_command(&c).is_some(), "{c}"); + } + } + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7d05123e..6db61545 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -3,6 +3,7 @@ //! //! Every icon panel has a body (`show_icon_panel`) and a panel (≡) menu (`panel_menu`). +pub mod actions; pub mod align; pub mod appearance; pub mod artboards; @@ -58,6 +59,7 @@ pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "pathfinder" => pathfinder::show(app, ui), "transform" => transform::show(app, ui), "history" => history::show(app, ui), + "actions" => actions::show(app, ui), "info" => info::show(app, ui), "artboards" => artboards::show(app, ui), "gradient" => gradient::show(app, ui), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 62121b70..3c0bf965 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -75,6 +75,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("character", "Character", "type"), ("paragraph", "Paragraph", "pilcrow"), ("history", "History", "history"), + ("actions", "Actions", "dc-actions"), ("info", "Info", "info"), ("navigator", "Navigator", "map"), ]; @@ -88,7 +89,7 @@ pub const ICON_PANEL_GROUPS: &[&[&str]] = &[ &["artboards"], &["transform", "align", "pathfinder"], &["character", "paragraph"], - &["history", "info", "navigator"], + &["history", "actions", "info", "navigator"], ]; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -157,6 +158,7 @@ impl Dialog { } #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] pub struct UiState { pub brightness: Brightness, pub dock_tab: DockTab, @@ -189,6 +191,28 @@ pub struct UiState { /// Draw Normal / Behind / Inside. pub draw_mode: u8, pub about: bool, + /// Recorded actions (Actions panel), persisted with the UI preferences. + #[serde(default = "crate::panels::actions::default_sets")] + pub action_sets: Vec, + /// Recording in progress: (set index, action name, journal length when recording started). + #[serde(skip)] + pub recording: Option<(usize, String, usize)>, +} + +impl UiState { + /// Clear transient state after loading saved preferences. + pub fn sanitized(mut self) -> Self { + self.dialog = None; + self.flyout = None; + self.palette_open = false; + self.status.clear(); + self.about = false; + self.open_panel = None; + if self.group_tool.len() != drawcraft_tools::TOOL_GROUPS.len() { + self.group_tool = UiState::default().group_tool; + } + self + } } impl Default for UiState { @@ -215,6 +239,8 @@ impl Default for UiState { screen_mode: 0, draw_mode: 0, about: false, + action_sets: crate::panels::actions::default_sets(), + recording: None, } } }