mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
continued progress
This commit is contained in:
1 parent
42df20ca7f
commit
d083c2ab50
11 files changed
+414
-5
No files matched your search
@@ -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<std::path::PathBuf> {
|
||||
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::<drawcraft_ui_egui::UiState>(&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());
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4l12 8-12 8z" fill="currentColor" fill-opacity="0.35"/><circle cx="19" cy="5" r="2.2" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -22,6 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
s.commit_interaction()?;
|
||||
Ok(json!({ "results": results }))
|
||||
}
|
||||
|
||||
fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<usize> = 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 }))
|
||||
}
|
||||
@@ -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("[email protected]")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -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<u8> {
|
||||
let rgba = self.to_straight();
|
||||
let rgb: Vec<u8> = 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<u8> {
|
||||
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;
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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<usize> = 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<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
|
||||
let mut boards: Vec<bool> = 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<Value> = 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));
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -144,6 +144,15 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
|
||||
}
|
||||
}
|
||||
"file.export.svg" => 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<Item>)> {
|
||||
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"),
|
||||
|
||||
@@ -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<Action>,
|
||||
}
|
||||
|
||||
/// Built-in actions (our own, handy defaults).
|
||||
pub fn default_sets() -> Vec<ActionSet> {
|
||||
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<Value, String> {
|
||||
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<Value> = 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::<Option<(usize, usize)>>(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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<crate::panels::actions::ActionSet>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user