mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
M0.7/M0.8: egui UI (Illustrator layout), control channel, desktop app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bfee80a22c
commit
bea0c16636
@@ -0,0 +1,265 @@
|
||||
//! Modal dialogs. Fields live in `UiState::dialog` (string/number JSON) so agents can fill them
|
||||
//! through `ui.dialog.set` and press OK with `ui.dialog.confirm`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{DrawcraftApp, widgets};
|
||||
|
||||
/// A click with a shape tool opens its size dialog.
|
||||
pub fn open_tool_dialog(app: &mut DrawcraftApp, kind: &str, p: Value) {
|
||||
let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let d = match kind {
|
||||
"rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt"})),
|
||||
"roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})),
|
||||
"polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})),
|
||||
"star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})),
|
||||
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
|
||||
_ => return,
|
||||
};
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
|
||||
fn title(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"newDocument" => "New Document",
|
||||
"rectangle" => "Rectangle",
|
||||
"roundedRectangle" => "Rounded Rectangle",
|
||||
"ellipse" => "Ellipse",
|
||||
"polygon" => "Polygon",
|
||||
"star" => "Star",
|
||||
"lineSegment" => "Line Segment Tool Options",
|
||||
"move" => "Move",
|
||||
"rotate" => "Rotate",
|
||||
"scale" => "Scale",
|
||||
"reflect" => "Reflect",
|
||||
"shear" => "Shear",
|
||||
"average" => "Average",
|
||||
"offsetPath" => "Offset Path",
|
||||
"simplify" => "Simplify",
|
||||
"splitIntoGrid" => "Split Into Grid",
|
||||
"documentSetup" => "Document Setup",
|
||||
"preferences" => "Preferences",
|
||||
"allTools" => "All Tools",
|
||||
_ => "Dialog",
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the open dialog (OK).
|
||||
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");
|
||||
let r = match d.kind.as_str() {
|
||||
"newDocument" => {
|
||||
let r = app.run("file.new", json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}));
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
"rectangle" | "roundedRectangle" => app.run("shape.rectangle", json!({"x": d.f64("x", 0.0), "y": d.f64("y", 0.0), "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)})),
|
||||
"ellipse" => app.run("shape.ellipse", json!({"x": d.f64("x", 0.0), "y": d.f64("y", 0.0), "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})),
|
||||
"polygon" => app.run("shape.polygon", json!({"cx": d.f64("x", 0.0), "cy": d.f64("y", 0.0), "radius": d.f64("radius", 50.0), "sides": d.f64("sides", 6.0) as u64})),
|
||||
"star" => app.run("shape.star", json!({"cx": d.f64("x", 0.0), "cy": d.f64("y", 0.0), "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64})),
|
||||
"lineSegment" => {
|
||||
let (x, y, l, a) = (d.f64("x", 0.0), d.f64("y", 0.0), d.f64("length", 100.0), d.f64("angle", 0.0).to_radians());
|
||||
app.run("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))
|
||||
}
|
||||
"move" => app.run("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})),
|
||||
"rotate" => app.run("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})),
|
||||
"scale" => {
|
||||
let sx = d.f64("sx", 100.0);
|
||||
let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) };
|
||||
app.run("object.scale", json!({"sx": sx, "sy": sy, "copy": copy}))
|
||||
}
|
||||
"reflect" => app.run("object.reflect", json!({"axis": d.str("axis"), "copy": copy})),
|
||||
"shear" => app.run("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})),
|
||||
"average" => app.run("path.average", json!({"axis": d.str("axis")})),
|
||||
"offsetPath" => app.run("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)})),
|
||||
"simplify" => app.run("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
|
||||
"splitIntoGrid" => app.run("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
|
||||
"documentSetup" => app.run("document.setUnits", json!({"units": d.str("units")})),
|
||||
"preferences" => {
|
||||
app.session.prefs.keyboard_increment = d.f64("keyboardIncrement", 1.0).max(0.001);
|
||||
app.session.prefs.scale_strokes = d.bool("scaleStrokes");
|
||||
Ok(Value::Null)
|
||||
}
|
||||
_ => Ok(Value::Null),
|
||||
};
|
||||
app.ui.dialog = None;
|
||||
r
|
||||
}
|
||||
|
||||
fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim));
|
||||
let mut s = d.str(key);
|
||||
let r = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(egui::CornerRadius::same(3)).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0)));
|
||||
if r.inner.changed() {
|
||||
d.fields.insert(key.into(), Value::String(s));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
let mut b = d.bool(key);
|
||||
if ui.checkbox(&mut b, label).changed() {
|
||||
d.fields.insert(key.into(), Value::Bool(b));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||
if app.ui.about {
|
||||
let mut open = true;
|
||||
egui::Window::new("About DrawCraft").collapsible(false).resizable(false).open(&mut open).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
|
||||
ui.label(egui::RichText::new("DrawCraft").font(theme::semibold(22.0)));
|
||||
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + DrawCraft.");
|
||||
});
|
||||
app.ui.about = open;
|
||||
}
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.painter().rect_filled(ctx.content_rect(), 0.0, egui::Color32::from_black_alpha(90));
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(title(&d.kind))
|
||||
.id(egui::Id::new("dialog"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 });
|
||||
ui.label(egui::RichText::new(title(&d.kind)).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
match d.kind.as_str() {
|
||||
"newDocument" => new_document(ui, &mut d),
|
||||
"allTools" => {
|
||||
for g in drawcraft_tools::TOOL_GROUPS {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for tool in g.iter() {
|
||||
if ui.button(tool.label.trim_end_matches(" Tool")).clicked() {
|
||||
app.select_tool(tool.id);
|
||||
cancel = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
for (k, v) in keys {
|
||||
if k == "x" || k == "y" || v.is_boolean() {
|
||||
continue;
|
||||
}
|
||||
field(ui, &mut d, &k, &humanize(&k));
|
||||
}
|
||||
});
|
||||
if matches!(d.kind.as_str(), "move" | "rotate" | "scale" | "reflect" | "shear") {
|
||||
ui.add_space(6.0);
|
||||
if d.kind == "scale" {
|
||||
check(ui, &mut d, "uniform", "Uniform");
|
||||
}
|
||||
check(ui, &mut d, "copy", "Copy (make a transformed copy)");
|
||||
}
|
||||
if d.kind == "preferences" {
|
||||
check(ui, &mut d, "scaleStrokes", "Scale Strokes & Effects");
|
||||
}
|
||||
}
|
||||
}
|
||||
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" };
|
||||
if d.kind != "allTools" && widgets::primary_button(ui, label).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, if d.kind == "allTools" { "Close" } else { "Cancel" }).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" {
|
||||
ok = true;
|
||||
}
|
||||
app.ui.dialog = Some(d);
|
||||
if cancel {
|
||||
app.ui.dialog = None;
|
||||
} else if ok && let Err(e) = confirm(app) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
fn humanize(k: &str) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, c) in k.chars().enumerate() {
|
||||
if i == 0 {
|
||||
s.extend(c.to_uppercase());
|
||||
} else if c.is_uppercase() {
|
||||
s.push(' ');
|
||||
s.push(c);
|
||||
} else {
|
||||
s.push(c);
|
||||
}
|
||||
}
|
||||
match s.as_str() {
|
||||
"Dx" => "Horizontal".into(),
|
||||
"Dy" => "Vertical".into(),
|
||||
"Sx" => "Horizontal %".into(),
|
||||
"Sy" => "Vertical %".into(),
|
||||
"Radius1" => "Radius 1".into(),
|
||||
"Radius2" => "Radius 2".into(),
|
||||
_ => format!("{s}:"),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_document(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let presets: [(&str, &str, &str, &str); 8] = [
|
||||
("Letter", "612 pt", "792 pt", "Points"),
|
||||
("Legal", "612 pt", "1008 pt", "Points"),
|
||||
("Tabloid", "792 pt", "1224 pt", "Points"),
|
||||
("A4", "595.28 pt", "841.89 pt", "Points"),
|
||||
("A3", "841.89 pt", "1190.55 pt", "Points"),
|
||||
("Web 1920", "1920 px", "1080 px", "Pixels"),
|
||||
("iPhone", "390 px", "844 px", "Pixels"),
|
||||
("Square Post", "1080 px", "1080 px", "Pixels"),
|
||||
];
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(300.0);
|
||||
ui.label(egui::RichText::new("Presets").color(t.text_dim));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (name, w, h, u) in presets {
|
||||
let sel = d.str("preset") == name;
|
||||
if ui.selectable_label(sel, name).clicked() {
|
||||
d.fields.insert("preset".into(), json!(name));
|
||||
d.fields.insert("width".into(), json!(w));
|
||||
d.fields.insert("height".into(), json!(h));
|
||||
d.fields.insert("units".into(), json!(u));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5)));
|
||||
egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
field(ui, d, "name", "Name:");
|
||||
field(ui, d, "width", "Width:");
|
||||
field(ui, d, "height", "Height:");
|
||||
field(ui, d, "artboards", "Artboards:");
|
||||
field(ui, d, "colorMode", "Color Mode:");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user