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:
Brandon Thomas
2026-09-30 06:16:46 -04:00
co-authored by Claude Opus 5.5
parent bfee80a22c
commit bea0c16636
193 changed files with 11666 additions and 61 deletions
+4 -1
View File
@@ -3,7 +3,7 @@ name = "drawcraft-format"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft native .drawcraft format"
description = "DrawCraft native .drawcraft format (versioned JSON, embedded images)"
[lints]
workspace = true
@@ -13,3 +13,6 @@ drawcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
drawcraft-geom = { workspace = true }
+158 -1
View File
@@ -1,2 +1,159 @@
//! DrawCraft native .drawcraft format
//! The native `.drawcraft` format.
//!
//! A `.drawcraft` file is UTF-8 JSON:
//! ```json
//! { "format": "drawcraft", "version": 1, "generator": "DrawCraft 0.1.0",
//! "document": { …drawcraft_doc::Document… },
//! "images": { "<key>": { "mime": "image/png", "data": "<base64>" } } }
//! ```
//! It is lossless for everything in the document model and preserves unknown fields under
//! `document.unknown`. Readers must reject files whose `version` is newer than they support.
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::sync::Arc;
use drawcraft_doc::{Document, ImageBlob};
use serde::{Deserialize, Serialize};
pub const VERSION: u32 = 1;
pub const EXTENSION: &str = "drawcraft";
#[derive(Debug, thiserror::Error)]
pub enum FormatError {
#[error("not a DrawCraft file: {0}")]
NotDrawcraft(String),
#[error("file version {0} is newer than this DrawCraft supports ({VERSION})")]
TooNew(u32),
#[error("invalid image data for `{0}`")]
BadImage(String),
}
#[derive(Serialize, Deserialize)]
struct Image {
mime: String,
data: String,
}
#[derive(Serialize, Deserialize)]
struct File {
format: String,
version: u32,
#[serde(default)]
generator: String,
document: Document,
#[serde(default)]
images: BTreeMap<String, Image>,
}
/// Serialize a document (pretty = human-diffable).
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect();
let f = File { format: "drawcraft".into(), version: VERSION, generator: format!("DrawCraft {}", env!("CARGO_PKG_VERSION")), document: doc.clone(), images };
if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() }
}
pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotDrawcraft(e.to_string()))?;
if f.format != "drawcraft" {
return Err(FormatError::NotDrawcraft(format!("format is `{}`", f.format)));
}
if f.version > VERSION {
return Err(FormatError::TooNew(f.version));
}
let mut doc = f.document;
for (k, img) in f.images {
let bytes = base64_decode(&img.data).ok_or_else(|| FormatError::BadImage(k.clone()))?;
doc.images.insert(k, ImageBlob { mime: img.mime, bytes: Arc::new(bytes) });
}
doc.fix_next_id();
Ok(doc)
}
/// Does this look like a `.drawcraft` file?
pub fn sniff(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(256)];
std::str::from_utf8(head).is_ok_and(|s| s.trim_start().starts_with('{') && s.contains("\"drawcraft\""))
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
out.push(B64[(n >> 18) as usize & 63] as char);
out.push(B64[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
}
out
}
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32);
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if !clean.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
for c in clean.chunks(4) {
let mut n = 0u32;
let mut pad = 0;
for &b in c {
n <<= 6;
if b == b'=' {
pad += 1;
} else {
n |= val(b)?;
}
}
out.push((n >> 16) as u8);
if pad < 2 {
out.push((n >> 8) as u8);
}
if pad < 1 {
out.push(n as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use drawcraft_doc::{Appearance, Node};
use drawcraft_geom::{Rect, shapes};
#[test]
fn roundtrip() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
d.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![1, 2, 3, 250]) });
let bytes = save(&d, true);
assert!(sniff(&bytes));
let back = load(&bytes).unwrap();
assert_eq!(back.node_count(), d.node_count());
assert_eq!(back.images["k"].bytes.as_slice(), &[1, 2, 3, 250]);
assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data());
}
#[test]
fn rejects_foreign_and_future() {
assert!(load(b"{}").is_err());
let d = Document::new(1.0, 1.0);
let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap();
v["version"] = 99.into();
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(FormatError::TooNew(99))));
}
#[test]
fn base64() {
for s in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar"] {
assert_eq!(base64_decode(&base64_encode(s)).unwrap(), s);
}
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
}
+15
View File
@@ -3,8 +3,23 @@ name = "drawcraft-ui-egui"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft egui frontend (Illustrator-style UI); swappable — nothing below depends on it"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-color = { workspace = true }
drawcraft-doc = { workspace = true }
drawcraft-render = { workspace = true }
drawcraft-text = { workspace = true }
drawcraft-tools = { workspace = true }
drawcraft-engine = { workspace = true }
drawcraft-svg = { workspace = true }
drawcraft-format = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+748
View File
@@ -0,0 +1,748 @@
//! The document canvas: rendering, rulers, navigation, pointer routing to tools and on-canvas
//! selection visuals (bounding box, anchors, handles, smart-guide style labels).
use drawcraft_doc::{Node, NodeKind};
use drawcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
use drawcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use crate::state::View;
use crate::theme::{self, Tokens};
use crate::{CacheKey, DrawcraftApp, now_ms, widgets};
const RULER: f32 = 16.0;
/// Screen ↔ document mapping for one frame.
#[derive(Clone, Copy, Debug)]
pub struct Xf {
pub rect: egui::Rect,
pub zoom: f64,
pub center: Point,
}
impl Xf {
pub fn to_screen(&self, p: Point) -> Pos2 {
let c = self.rect.center();
pos2(c.x + ((p.x - self.center.x) * self.zoom) as f32, c.y + ((p.y - self.center.y) * self.zoom) as f32)
}
pub fn to_doc(&self, p: Pos2) -> Point {
let c = self.rect.center();
Point::new(self.center.x + (p.x - c.x) as f64 / self.zoom, self.center.y + (p.y - c.y) as f64 / self.zoom)
}
pub fn rect_to_screen(&self, r: Rect) -> egui::Rect {
egui::Rect::from_two_pos(self.to_screen(Point::new(r.x0, r.y0)), self.to_screen(Point::new(r.x1, r.y1)))
}
/// Affine mapping document points to screen points.
pub fn affine(&self) -> Affine {
let c = self.rect.center();
Affine::translate((c.x as f64, c.y as f64)) * Affine::scale(self.zoom) * Affine::translate(-self.center.to_vec2())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drag {
Tool,
Pan { start: Pos2, center: Point },
ZoomBox { start: Pos2 },
/// Cmd held: temporary selection tool; restore this tool on release.
TempSelect,
}
fn drag_id() -> egui::Id {
egui::Id::new("canvas-drag")
}
fn temp_tool_id() -> egui::Id {
egui::Id::new("canvas-temp-tool")
}
pub fn mods(m: egui::Modifiers, space: bool) -> Mods {
Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space }
}
/// Fit the view (View → Fit Artboard / Fit All / Actual Size).
pub fn fit(app: &mut DrawcraftApp, how: &str) {
let Some(rect) = app.canvas_rect else {
if let Some(v) = app.view_mut() {
v.fitted = false;
}
return;
};
let Some(st) = app.session.active() else { return };
let target = match how {
"view.fitAll" => st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)),
_ => st.doc.artboards.first().map(|a| a.rect),
};
let Some(target) = target else { return };
let v = app.view_mut().unwrap();
v.center = target.center();
v.fitted = true;
if how == "view.actualSize" {
v.zoom = 1.0;
} else {
let zx = (rect.width() as f64 - 60.0) / target.width().max(1.0);
let zy = (rect.height() as f64 - 60.0) / target.height().max(1.0);
v.zoom = zx.min(zy).clamp(0.0313, 640.0);
}
}
pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
if app.session.active().is_none() {
home(app, ui, full);
return;
}
let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
app.canvas_rect = Some(rect);
let fitted = app.view().is_some_and(|v| v.fitted);
if !fitted {
fit(app, "view.fitArtboard");
}
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
handle_input(app, ui, &resp, rect);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf { rect, zoom: v.zoom, center: v.center };
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
// Pasteboard, artboard shadows and paper.
painter.rect_filled(rect, 0.0, t.pasteboard);
if app.ui.view.artboards && !app.ui.view.outline {
for ab in &doc.artboards {
let r = xf.rect_to_screen(ab.rect);
painter.rect_filled(r.translate(vec2(2.0, 2.0)).expand(1.0), 1.0, Color32::from_black_alpha(90));
painter.rect_filled(r.translate(vec2(1.0, 1.0)), 0.0, Color32::from_black_alpha(110));
if app.ui.view.transparency_grid {
checker(&painter, r);
} else {
painter.rect_filled(r, 0.0, Color32::WHITE);
}
}
} else if app.ui.view.outline {
for ab in &doc.artboards {
painter.rect_filled(xf.rect_to_screen(ab.rect), 0.0, Color32::WHITE);
}
}
if app.ui.view.grid {
grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions);
}
// Artwork raster.
let ppp = ui.ctx().pixels_per_point();
let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32);
let key = CacheKey { doc: app.session.active_index().unwrap_or(0), revision: st.revision, zoom: v.zoom, cx: v.center.x, cy: v.center.y, w, h, outline: app.ui.view.outline, ppp, hidden: vec![] };
if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() {
let t0 = now_ms();
let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) * Affine::scale(v.zoom * ppp as f64) * Affine::translate(-v.center.to_vec2());
let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() };
let img = app.canvas.renderer.render(&doc, w, h, view, &opts);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
match &mut app.canvas.texture {
Some(tex) => tex.set(color, egui::TextureOptions::LINEAR),
None => app.canvas.texture = Some(ui.ctx().load_texture("canvas", color, egui::TextureOptions::LINEAR)),
}
app.canvas.key = Some(key);
app.perf.render_ms = now_ms() - t0;
}
if let Some(tex) = &app.canvas.texture {
painter.image(tex.id(), rect, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
// Artboard edges and names.
let active_ab = 0;
for (i, ab) in doc.artboards.iter().enumerate() {
let r = xf.rect_to_screen(ab.rect);
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
painter.rect_stroke(r, 0.0, Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c), StrokeKind::Outside);
if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 {
painter.text(r.left_top() - vec2(0.0, 4.0), egui::Align2::LEFT_BOTTOM, format!("{:02} - {}", i + 1, ab.name), egui::FontId::proportional(11.0), t.text_dim);
}
}
if app.ui.view.guides {
for g in &doc.guides {
let (a, b) = if g.vertical {
let x = xf.to_screen(Point::new(g.pos, 0.0)).x;
(pos2(x, rect.top()), pos2(x, rect.bottom()))
} else {
let y = xf.to_screen(Point::new(0.0, g.pos)).y;
(pos2(rect.left(), y), pos2(rect.right(), y))
};
painter.line_segment([a, b], Stroke::new(1.0, t.guide));
}
}
// Selection visuals and tool overlays.
if app.ui.view.edges {
hover_highlight(app, &painter, &xf);
selection_overlay(app, &painter, &xf);
}
let view_info = app.view_info();
let overlays = app.session.overlays(view_info);
draw_overlays(&painter, &xf, &overlays, &t);
if app.ui.view.rulers && app.ui.screen_mode < 3 {
rulers(ui, full, &xf, app.hover_doc, &t);
}
// Cursor.
if resp.hovered() {
let m = ui.input(|i| i.modifiers);
let space = ui.input(|i| i.key_down(egui::Key::Space));
let cur = if space || app.session.tool_id() == "hand" {
if ui.input(|i| i.pointer.primary_down()) { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }
} else if app.session.tool_id() == "zoom" {
if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn }
} else if let Some(p) = app.hover_doc {
let c = app.session.cursor(p, mods(m, space), view_info);
if matches!(c, Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue)
&& let Some(hp) = ui.input(|i| i.pointer.hover_pos())
{
pen_cursor_badge(ui, hp, c, &t);
}
cursor_icon(c)
} else {
egui::CursorIcon::Default
};
ui.ctx().set_cursor_icon(cur);
}
}
fn pen_cursor_badge(ui: &Ui, p: Pos2, c: Cursor, t: &Tokens) {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("pen-cursor")));
let r = egui::Rect::from_min_size(p + vec2(6.0, 4.0), vec2(16.0, 16.0));
// Pen nib glyph (black with white outline, like a system cursor).
let tip = r.left_top();
let nib = vec![tip, tip + vec2(11.0, 4.0), tip + vec2(13.0, 13.0), tip + vec2(4.0, 11.0)];
painter.add(Shape::convex_polygon(nib, Color32::BLACK, Stroke::new(1.0, Color32::WHITE)));
let _ = t;
let badge = match c {
Cursor::PenClose => "o",
Cursor::PenContinue => "/",
Cursor::PenAdd => "+",
Cursor::PenDelete => "–",
_ => "",
};
if !badge.is_empty() {
painter.text(r.right_bottom() + vec2(1.0, -2.0), egui::Align2::LEFT_BOTTOM, badge, egui::FontId::proportional(11.0), Color32::BLACK);
}
}
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
use egui::CursorIcon as C;
match c {
Cursor::Arrow => C::Default,
Cursor::ArrowHollow => C::Default,
Cursor::Move => C::Move,
Cursor::Crosshair => C::Crosshair,
Cursor::ResizeH => C::ResizeHorizontal,
Cursor::ResizeV => C::ResizeVertical,
Cursor::ResizeNwSe => C::ResizeNwSe,
Cursor::ResizeNeSw => C::ResizeNeSw,
Cursor::Rotate => C::Alias,
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue => C::Crosshair,
Cursor::Text => C::Text,
Cursor::Hand => C::Grab,
Cursor::HandGrab => C::Grabbing,
Cursor::ZoomIn => C::ZoomIn,
Cursor::ZoomOut => C::ZoomOut,
Cursor::Eyedropper => C::Crosshair,
Cursor::NotAllowed => C::NotAllowed,
}
}
fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let (pointer, m, space, scroll, zoom_delta) = ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta()));
let v = *app.view().unwrap_or(&View::default());
let xf = Xf { rect, zoom: v.zoom, center: v.center };
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
app.hover_doc = hover.map(|p| xf.to_doc(p));
let view = app.view_info();
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
// Zoom: pinch / Cmd-scroll / Alt-scroll around the pointer. Plain scroll pans.
if resp.hovered() {
let mut factor = zoom_delta as f64;
if m.alt && scroll.y != 0.0 {
factor *= (scroll.y as f64 * 0.01).exp();
}
if (factor - 1.0).abs() > 1e-6 {
if let (Some(p), Some(vm)) = (hover, app.view_mut()) {
let before = xf.to_doc(p);
vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0);
let nx = Xf { rect, zoom: vm.zoom, center: vm.center };
let after = nx.to_doc(p);
vm.center += before - after;
}
} else if (scroll.x != 0.0 || scroll.y != 0.0)
&& !m.alt
&& let Some(vm) = app.view_mut()
{
vm.center.x -= scroll.x as f64 / vm.zoom;
vm.center.y -= scroll.y as f64 / vm.zoom;
}
}
let tool = app.session.tool_id();
let pan_mode = space || tool == "hand";
if pointer.primary_pressed() && resp.hovered() {
ui.ctx().memory_mut(|mem| mem.stop_text_input());
app.ui.flyout = None;
let p = hover.unwrap_or(rect.center());
let d = if pan_mode {
Drag::Pan { start: p, center: v.center }
} else if tool == "zoom" {
Drag::ZoomBox { start: p }
} else {
let selection_family = matches!(tool, "selection" | "directSelection" | "groupSelection");
let mut kind = Drag::Tool;
if m.command && !selection_family {
ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string()));
app.select_tool("selection");
kind = Drag::TempSelect;
}
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
kind
};
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
} else if let Some(d) = drag {
let p = pointer.interact_pos().unwrap_or(rect.center());
if pointer.primary_down() {
match d {
Drag::Pan { start, center } => {
if let Some(vm) = app.view_mut() {
vm.center = center - (p - start).to_vec2_f64() / vm.zoom;
}
}
Drag::ZoomBox { .. } => {}
Drag::Tool | Drag::TempSelect => {
if pointer.delta() != egui::Vec2::ZERO {
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
}
}
}
} else {
ui.data_mut(|dd| dd.remove::<Drag>(drag_id()));
match d {
Drag::ZoomBox { start } => {
let r = egui::Rect::from_two_pos(start, p);
if let Some(vm) = app.view_mut() {
if r.width() > 8.0 && r.height() > 8.0 {
let a = xf.to_doc(r.min);
let b = xf.to_doc(r.max);
vm.center = Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0);
vm.zoom = (rect.width() as f64 / (b.x - a.x)).min(rect.height() as f64 / (b.y - a.y)).clamp(0.0313, 640.0);
} else {
let before = xf.to_doc(p);
vm.zoom = crate::state::next_zoom(vm.zoom, !m.alt);
let nx = Xf { rect, zoom: vm.zoom, center: vm.center };
vm.center += before - nx.to_doc(p);
}
}
}
Drag::Tool | Drag::TempSelect => {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
if d == Drag::TempSelect
&& let Some(prev) = ui.data(|dd| dd.get_temp::<String>(temp_tool_id()))
{
app.select_tool(&prev);
}
}
Drag::Pan { .. } => {}
}
}
} else if let Some(p) = hover {
if pointer.is_moving() {
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
}
}
if resp.double_clicked()
&& let Some(p) = hover
{
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
}
if drag.is_some() || pointer.is_moving() {
ui.ctx().request_repaint();
}
}
trait ToVec2F64 {
fn to_vec2_f64(self) -> drawcraft_geom::Vec2;
}
impl ToVec2F64 for egui::Vec2 {
fn to_vec2_f64(self) -> drawcraft_geom::Vec2 {
drawcraft_geom::Vec2::new(self.x as f64, self.y as f64)
}
}
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
pub fn dispatch(app: &mut DrawcraftApp, ev: &PointerEvent, view: drawcraft_engine::ViewInfo) {
match app.session.pointer(ev, view) {
Ok(reqs) => {
for r in reqs {
match r {
drawcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p),
drawcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
}
}
}
Err(e) => app.status(e.to_string()),
}
}
fn checker(p: &egui::Painter, r: egui::Rect) {
p.rect_filled(r, 0.0, Color32::WHITE);
let s = 8.0;
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
let (nx, ny) = (((r.width() / s).ceil() as i32).min(400), ((r.height() / s).ceil() as i32).min(400));
for y in 0..ny {
for x in 0..nx {
if (x + y) % 2 == 1 {
clip.rect_filled(egui::Rect::from_min_size(r.min + vec2(x as f32 * s, y as f32 * s), vec2(s, s)), 0.0, Color32::from_gray(204));
}
}
}
}
fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32) {
let r = xf.rect;
let a = xf.to_doc(r.min);
let b = xf.to_doc(r.max);
let sub = spacing / subdiv.max(1) as f64;
let step = if sub * xf.zoom >= 6.0 { sub } else { spacing };
if step * xf.zoom < 4.0 {
return;
}
let mut x = (a.x / step).floor() * step;
while x <= b.x {
let major = (x / spacing).round() * spacing == x || (x / spacing - (x / spacing).round()).abs() < 1e-6;
let sx = xf.to_screen(Point::new(x, 0.0)).x;
p.line_segment([pos2(sx, r.top()), pos2(sx, r.bottom())], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 })));
x += step;
}
let mut y = (a.y / step).floor() * step;
while y <= b.y {
let major = (y / spacing - (y / spacing).round()).abs() < 1e-6;
let sy = xf.to_screen(Point::new(0.0, y)).y;
p.line_segment([pos2(r.left(), sy), pos2(r.right(), sy)], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 })));
y += step;
}
}
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, t: &Tokens) {
let p = ui.painter();
let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER));
let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom()));
let corner = egui::Rect::from_min_size(full.min, vec2(RULER, RULER));
for r in [top, left, corner] {
p.rect_filled(r, 0.0, t.ruler);
}
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.border));
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
// Crosshair in the origin box.
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick));
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick));
// Pick a label step that gives ≥ 50 px between labels.
let steps = [1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
let step = steps.iter().copied().find(|s| s * xf.zoom >= 50.0).unwrap_or(10000.0);
let minor = step / 10.0;
let font = egui::FontId::proportional(9.5);
let a = xf.to_doc(top.left_top());
let b = xf.to_doc(top.right_top());
let clip_top = p.with_clip_rect(top);
let mut x = (a.x / minor).floor() * minor;
while x <= b.x {
let sx = xf.to_screen(Point::new(x, 0.0)).x;
let is_major = ((x / step).round() * step - x).abs() < minor * 0.01;
let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01;
let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 };
clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
if is_major {
clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, format!("{}", x.round() as i64), font.clone(), t.ruler_tick);
}
x += minor;
}
let a = xf.to_doc(left.left_top());
let b = xf.to_doc(left.left_bottom());
let clip_left = p.with_clip_rect(left);
let mut y = (a.y / minor).floor() * minor;
while y <= b.y {
let sy = xf.to_screen(Point::new(0.0, y)).y;
let is_major = ((y / step).round() * step - y).abs() < minor * 0.01;
let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01;
let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 };
clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if is_major {
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
let s = format!("{}", y.round() as i64);
for (k, ch) in s.chars().enumerate() {
clip_left.text(pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5), egui::Align2::LEFT_TOP, ch.to_string(), font.clone(), t.ruler_tick);
}
}
y += minor;
}
if let Some(h) = hover {
let s = xf.to_screen(h);
clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text));
clip_left.line_segment([pos2(left.left(), s.y), pos2(left.right(), s.y)], Stroke::new(1.0, t.text));
}
}
fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec<Vec<Pos2>> {
// Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px).
let mut out: Vec<Vec<Pos2>> = vec![];
let a = xf.affine();
let mut t = bp.clone();
t.apply_affine(a);
let mut cur: Vec<Pos2> = vec![];
kurbo_flatten(&t, 0.25, &mut |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
cur.push(pos2(p.x as f32, p.y as f32));
}
PathEl::LineTo(p) => cur.push(pos2(p.x as f32, p.y as f32)),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
out
}
fn stroke_path(p: &egui::Painter, bp: &BezPath, xf: &Xf, s: Stroke) {
for line in to_screen_path(bp, xf) {
p.add(Shape::line(line, s));
}
}
fn c32(rgb: [u8; 3]) -> Color32 {
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
}
/// Outline of a node for highlighting (paths, compound children, text/image bounds).
fn node_outline(n: &Node) -> BezPath {
let mut bp = BezPath::new();
n.walk(&mut |c| match &c.kind {
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
if let Some(b) = c.geometric_bounds() {
bp.extend(drawcraft_geom::shapes::rectangle(b).to_bezpath());
}
}
_ => {}
});
bp
}
fn hover_highlight(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(h) = app.hover_doc else { return };
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
return;
}
let Some(st) = app.session.active() else { return };
let opt = drawcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false };
let Some(hit) = drawcraft_doc::hit::hit_test(&st.doc, h, opt) else { return };
let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf };
if st.selection.contains(id) {
return;
}
if let Some(n) = st.doc.node(id) {
let color = c32(st.doc.layer_color(id));
stroke_path(p, &node_outline(n), xf, Stroke::new(1.5, color));
}
}
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
let r = egui::Rect::from_center_size(c, vec2(size, size));
if filled {
p.rect_filled(r, 0.0, color);
} else {
p.rect_filled(r, 0.0, Color32::WHITE);
p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
}
fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(st) = app.session.active() else { return };
let tool = app.session.tool_id();
let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature");
for id in &st.selection.objects {
let Some(n) = st.doc.node(*id) else { continue };
let color = c32(st.doc.layer_color(*id));
let partial = st.selection.partial(*id);
// Path outlines.
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
// Anchors (and handles for selected anchors in direct mode).
n.walk(&mut |c| {
let NodeKind::Path { path, .. } = &c.kind else { return };
for (si, ai, a) in path.anchors() {
let sel = match partial {
Some(set) => set.contains(&(si, ai)),
None => !direct || c.id == *id,
};
let sp = xf.to_screen(a.p);
if sel && (direct || partial.is_some()) {
for h in [a.h_in, a.h_out] {
if h.distance(a.p) > 1e-6 {
let hp = xf.to_screen(h);
p.line_segment([sp, hp], Stroke::new(1.0, color));
p.circle_filled(hp, 2.8, color);
}
}
}
anchor_square(p, sp, color, sel, if partial.is_some() || direct { 6.0 } else { 4.5 });
}
});
// Text: baseline marker.
if let NodeKind::Text(tx) = &n.kind {
let o = xf.to_screen(tx.xf * Point::ZERO);
let b = n.geometric_bounds().unwrap_or_default();
let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y));
p.line_segment([o, e], Stroke::new(1.0, color));
p.circle_filled(o, 2.5, color);
}
}
// Bounding box with handles (Selection tool).
if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() {
let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return };
let color = c32(st.doc.layer_color(st.selection.objects[0]));
let r = xf.rect_to_screen(b);
p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
for h in drawcraft_tools::bbox::Handle::ALL {
let c = xf.to_screen(h.pos(b));
let hr = egui::Rect::from_center_size(c, vec2(7.0, 7.0));
p.rect_filled(hr, 0.0, Color32::WHITE);
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
// Centre point.
let c = r.center();
p.rect_filled(egui::Rect::from_center_size(c, vec2(3.0, 3.0)), 0.0, color);
// Live corner widgets on single live rectangles.
if st.selection.len() == 1
&& let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = st.doc.node(st.selection.objects[0]).map(|n| &n.kind)
&& r.width() > 40.0
&& r.height() > 40.0
{
let inset = (radii[0].max(20.0 / xf.zoom)).min(w.min(*h) / 2.0);
for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] {
let sp = xf.to_screen(*lxf * Point::new(cx, cy));
p.circle_stroke(sp, 3.5, Stroke::new(1.0, color));
p.circle_filled(sp, 1.3, color);
}
}
}
}
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
for o in overlays {
match o {
Overlay::Marquee(r) => {
let sr = xf.rect_to_screen(*r);
let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()];
for w in pts.windows(2) {
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 3.0, 3.0));
}
}
Overlay::Path { path, color, width, dashed } => {
let s = Stroke::new(*width, c32(*color));
if *dashed {
for line in to_screen_path(path, xf) {
p.extend(Shape::dashed_line(&line, s, 4.0, 3.0));
}
} else {
stroke_path(p, path, xf, s);
}
}
Overlay::Line { a, b, color, dashed } => {
let s = Stroke::new(1.0, c32(*color));
let (a, b) = (xf.to_screen(*a), xf.to_screen(*b));
if *dashed {
p.extend(Shape::dashed_line(&[a, b], s, 4.0, 3.0));
} else {
p.line_segment([a, b], s);
}
}
Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size),
Overlay::Handle { p: pt, color } => {
p.circle_filled(xf.to_screen(*pt), 2.8, c32(*color));
}
Overlay::Label { p: pt, text, color } => {
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
p.text(sp, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(11.0), c32(*color));
}
Overlay::Measure { p: pt, text } => {
let sp = xf.to_screen(*pt) + vec2(14.0, 14.0);
let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0);
let r = egui::Rect::from_min_size(sp, galley.size() + vec2(12.0, 8.0));
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
}
}
}
}
/// The Home screen shown when no document is open.
fn home(app: &mut DrawcraftApp, ui: &mut Ui, rect: egui::Rect) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(rect, 0.0, t.panel_darker);
let inner = rect.shrink2(vec2((rect.width() - 820.0).max(40.0) / 2.0, 60.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
let ui = &mut child;
ui.label(egui::RichText::new("Welcome to DrawCraft").font(theme::semibold(26.0)).color(t.text));
ui.add_space(4.0);
ui.label(egui::RichText::new("Vector illustration — fast, open, scriptable.").size(14.0).color(t.text_dim));
ui.add_space(22.0);
ui.horizontal(|ui| {
if widgets::primary_button(ui, "New file").clicked() {
app.run("file.newDialog", json!({})).ok();
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Open").clicked() {
app.run("file.open", json!({})).ok();
}
});
ui.add_space(28.0);
ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
let presets: [(&str, &str, f64, f64); 6] = [
("Letter", "612 × 792 pt", 612.0, 792.0),
("A4", "595.28 × 841.89 pt", 595.28, 841.89),
("Web 1920", "1920 × 1080 px", 1920.0, 1080.0),
("Mobile", "390 × 844 px", 390.0, 844.0),
("Postcard", "288 × 432 pt", 432.0, 288.0),
("Square", "1080 × 1080 px", 1080.0, 1080.0),
];
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
for (name, size, w, h) in presets {
let (r, resp) = ui.allocate_exact_size(vec2(120.0, 132.0), Sense::click());
ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel });
let s = (70.0 / w.max(h)) as f32;
let pr = egui::Rect::from_center_size(r.center_top() + vec2(0.0, 50.0), vec2(w as f32 * s, h as f32 * s));
ui.painter().rect_filled(pr.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(80));
ui.painter().rect_filled(pr, 0.0, Color32::WHITE);
ui.painter().text(r.center_bottom() - vec2(0.0, 30.0), egui::Align2::CENTER_CENTER, name, theme::semibold(12.5), t.text);
ui.painter().text(r.center_bottom() - vec2(0.0, 14.0), egui::Align2::CENTER_CENTER, size, egui::FontId::proportional(11.0), t.text_dim);
if resp.clicked() {
app.run("file.new", json!({"width": w, "height": h, "units": if size.ends_with("px") { "Pixels" } else { "Points" }})).ok();
}
}
});
}
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
drawcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f);
}
+285
View File
@@ -0,0 +1,285 @@
//! Window chrome: application bar (menus), Control bar, document tabs, status bar.
use drawcraft_color::Paint;
use drawcraft_doc::NodeKind;
use egui::{Color32, CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
use crate::state::{ZOOM_STOPS, zoom_label};
use crate::theme::{self, Tokens};
use crate::widgets::{self, paint_chip};
use crate::{DrawcraftApp, icons, menus};
pub fn app_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let left = if app.integrated_titlebar { 78 } else { 8 };
egui::Panel::top("app_bar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 })).show(ui, |ui| {
ui.horizontal_centered(|ui| {
// Brand mark: a small rounded square with "Dc".
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_rgb(0x2b, 0x1d, 0x0c));
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.2, Color32::from_rgb(0xff, 0x9a, 0x00)), StrokeKind::Inside);
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "Dc", theme::semibold(11.5), Color32::from_rgb(0xff, 0x9a, 0x00));
ui.add_space(4.0);
if widgets::icon_button(ui, "house", "Home", false, 24.0).clicked() {
app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"})));
}
ui.add_space(2.0);
menus::menu_bar(app, ui);
let full = ui.max_rect();
let right = egui::Rect::from_min_max(egui::pos2(full.right() - 340.0, full.top()), full.right_bottom());
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
(|ui: &mut Ui| {
let ws = egui::Frame::NONE.fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::symmetric(8, 3));
ws.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Essentials").size(12.0).color(t.text));
icons::icon(ui, "chevron-down", 12.0, t.text_dim);
});
});
ui.add_space(6.0);
// Search box → command palette.
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click());
ui.painter().rect_filled(r, CornerRadius::same(12), t.input);
ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside);
icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim);
ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim);
if resp.clicked() {
app.ui.palette_open = true;
app.ui.palette_query.clear();
}
})(&mut rui);
});
});
}
fn chip_button(ui: &mut Ui, paint: &Paint, stroke_style: bool, tip: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::click());
let chip = egui::Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(18.0, 18.0));
paint_chip(ui, chip, paint);
if stroke_style {
ui.painter().rect_filled(chip.shrink(5.0), 0.0, t.panel);
}
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
icons::paint(ui, "chevron-down", egui::Rect::from_min_size(r.min + vec2(21.0, 5.0), vec2(12.0, 12.0)), t.text_dim);
resp.on_hover_text(tip)
}
/// The Control bar (Window → Control), context-sensitive like Illustrator's.
pub fn control_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("control_bar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
ui.horizontal_centered(|ui| {
// Gripper
let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover());
for i in 0..5 {
ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled);
}
let Some(st) = app.session.active() else {
ui.label(egui::RichText::new("No Document").color(t.text_dim));
return;
};
let sel = st.selection.objects.clone();
let units = st.doc.units;
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchor_mode = !st.selection.anchors.is_empty();
let label = match (&first, sel.len()) {
(None, _) => "No Selection".to_string(),
(_, n) if n > 1 => "Mixed Objects".to_string(),
(Some(_), _) if anchor_mode => "Anchor Point".to_string(),
(Some(n), _) => match &n.kind {
NodeKind::Text(_) => "Type".into(),
NodeKind::Image(_) => "Embedded".into(),
NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(),
_ => n.kind_label().to_string(),
},
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
ui.add_space(6.0);
let (fill, stroke, weight, opacity) = match &first {
Some(n) => (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0), n.opacity),
None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone(), app.session.paint.stroke_width, 1.0),
};
if chip_button(ui, &fill, false, "Fill").clicked() {
app.run("paint.toggleActive", json!({})).ok();
app.session.fill_active = true;
app.ui.open_panel = Some("swatches".into());
}
if chip_button(ui, &stroke, true, "Stroke").clicked() {
app.session.fill_active = false;
app.ui.open_panel = Some("swatches".into());
}
if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("stroke".into());
}
if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), drawcraft_doc::Unit::Points, 64.0) {
app.run("stroke.set", json!({"weight": w})).ok();
}
ui.add_space(4.0);
ui.separator();
if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
&& !sel.is_empty()
{
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok();
}
ui.separator();
if sel.is_empty() {
if widgets::flat_button(ui, "Document Setup", 112.0).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, "Preferences", 90.0).clicked() {
app.run("edit.preferences", json!({})).ok();
}
return;
}
// Align buttons.
for (icon, tip, p) in [
("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})),
("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})),
("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})),
("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})),
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
let mut p = p;
if sel.len() == 1 {
p["to"] = json!("artboard");
}
app.run("object.align", p).ok();
}
}
ui.separator();
// Transform fields.
let b = app.session.active().and_then(|s| s.doc.bounds_of(&s.selection.objects, false));
if let Some(b) = b {
for (k, lbl, v) in [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] {
ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 74.0) {
app.run("object.setBounds", json!({k: nv, "reference": 4})).ok();
}
}
}
});
});
}
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)".
pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let mut x = strip.left();
let mut activate = None;
let mut close = None;
let active = app.session.active_index();
for (i, d) in app.session.documents().iter().enumerate() {
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
let mode = if d.doc.color_mode == drawcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
let vm = if app.ui.view.outline { "Outline" } else { "Preview" };
let title = format!("{}{} @ {} ({mode}/{vm})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom));
let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(12.0), if Some(i) == active { t.text } else { t.text_dim });
let w = galley.size().x + 44.0;
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height()));
let resp = ui.interact(r, ui.id().with(("tab", i)), Sense::click());
if Some(i) == active {
ui.painter().rect_filled(r, 0.0, t.panel);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.5));
}
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
let xr = egui::Rect::from_center_size(egui::pos2(r.right() - 15.0, r.center().y), vec2(14.0, 14.0));
let xresp = ui.interact(xr, ui.id().with(("tabx", i)), Sense::click());
if xresp.hovered() || Some(i) == active || resp.hovered() {
icons::paint(ui, "x", xr.shrink(1.5), if xresp.hovered() { t.text } else { t.text_dim });
}
if xresp.clicked() {
close = Some(i);
} else if resp.clicked() {
activate = Some(i);
}
x += w;
}
if let Some(i) = close {
app.session.close_document(i);
app.views.remove(i.min(app.views.len().saturating_sub(1)));
app.sync_views();
} else if let Some(i) = activate {
app.session.set_active(i);
}
// Isolation mode breadcrumb bar.
if let Some(st) = app.session.active()
&& let Some(iso) = st.isolation
{
let crumbs: Vec<String> = st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect();
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
ui.painter().rect_filled(bar, 0.0, t.panel);
let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0));
let bresp = ui.interact(back, ui.id().with("iso-back"), Sense::click());
icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon });
ui.painter().text(bar.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, crumbs.join(" › "), egui::FontId::proportional(12.0), t.text);
if bresp.clicked() {
app.run("object.exitIsolation", json!({})).ok();
}
}
}
pub fn status_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("status_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
ui.horizontal_centered(|ui| {
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui(ui, |ui| {
for z in ZOOM_STOPS.iter().rev() {
if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() {
app.run("view.setZoom", json!({"zoom": z})).ok();
}
}
ui.separator();
if ui.selectable_label(false, "Fit on Screen").clicked() {
app.run("view.fitArtboard", json!({})).ok();
}
if ui.selectable_label(false, "Fit All").clicked() {
app.run("view.fitAll", json!({})).ok();
}
});
let rot = app.view().map(|v| v.rotation).unwrap_or(0.0);
egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(ui, |ui| {
for a in [0.0, 15.0, 30.0, 45.0, 60.0, 90.0, 180.0, -15.0, -30.0, -45.0, -60.0, -90.0] {
if ui.selectable_label(rot == a, format!("{a:.0}°")).clicked()
&& let Some(v) = app.view_mut()
{
v.rotation = a;
}
}
});
ui.separator();
let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
for icon in ["chevrons-left", "chevron-left"] {
widgets::icon_button(ui, icon, "", false, 18.0);
}
ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5));
for icon in ["chevron-right", "chevrons-right"] {
widgets::icon_button(ui, icon, "", false, 18.0);
}
ui.separator();
let tool = drawcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or("");
ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(format!("render {:.1} ms · ui {:.1} ms · {:.0} fps", app.perf.render_ms, app.perf.frame_ms, app.perf.fps)).size(10.5).color(t.text_disabled));
if let Some(p) = app.hover_doc {
ui.label(egui::RichText::new(format!("X: {:.2} Y: {:.2}", p.x, p.y)).font(theme::mono(10.5)).color(t.text_dim));
}
if !app.ui.status.is_empty() {
ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text));
}
});
});
});
}
+264
View File
@@ -0,0 +1,264 @@
//! Programmatic control of the running app (agents, tests, MCP).
//!
//! Methods (JSON-lines over the host's transport):
//! - `engine.execute {command, params}` / `ui.menu.invoke {command|id, params}`: run any command
//! - `engine.commands`: engine + UI commands with enablement
//! - `document.inspect`: document summary; `ui.inspect`: UI state
//! - `ui.menu.list`: flattened menu tree
//! - `ui.tool.select {tool}`, `ui.tool.list`
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
//! drive the active tool through the same path as the mouse
//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input
//! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}`
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
//! - `app.open {path}` / `app.save {path?}` / `app.export {format, path, scale?}` / `app.quit`
use std::sync::mpsc::Sender;
use drawcraft_tools::{Mods, PointerEvent, PointerKind};
use serde_json::{Value, json};
use crate::DrawcraftApp;
use crate::canvas::Xf;
pub type ControlResponse = Value;
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<ControlResponse>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
}
}
pub enum Outcome {
Done(Value),
Screenshot { path: Option<String> },
}
fn ok(v: Value) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
}
fn wrap(r: Result<Value, String>) -> Outcome {
match r {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
pub fn all_commands(app: &DrawcraftApp) -> Value {
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, sc, params) in crate::menus::UI_COMMANDS {
v.push(json!({"id": id, "label": label, "shortcut": sc, "params": params, "enabled": crate::menus::enabled(app, id), "ui": true}));
}
Value::Array(v)
}
pub fn inspect(app: &DrawcraftApp, ctx: &egui::Context) -> Value {
let r = ctx.content_rect();
json!({
"tool": app.session.tool_id(),
"toolOptions": app.session.tool_options(),
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"window": [r.width(), r.height()],
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
"activeDocument": app.session.active_index(),
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
})
}
fn key_from(name: &str) -> Option<egui::Key> {
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
"enter" | "return" => Some(egui::Key::Enter),
"esc" | "escape" => Some(egui::Key::Escape),
"delete" => Some(egui::Key::Delete),
"backspace" => Some(egui::Key::Backspace),
"left" => Some(egui::Key::ArrowLeft),
"right" => Some(egui::Key::ArrowRight),
"up" => Some(egui::Key::ArrowUp),
"down" => Some(egui::Key::ArrowDown),
"space" => Some(egui::Key::Space),
"tab" => Some(egui::Key::Tab),
_ => None,
})
}
pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
match req.method.as_str() {
"engine.execute" | "ui.menu.invoke" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().unwrap_or(json!({}));
let params = if params.is_null() { json!({}) } else { params };
wrap(app.run(id, params))
}
"engine.commands" => ok(all_commands(app)),
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
"ui.tool.list" => ok(serde_json::to_value(drawcraft_tools::TOOL_GROUPS).unwrap_or_default()),
"ui.pointer" => {
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
let view = app.view_info();
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf { rect, zoom: v.zoom, center: v.center });
for e in events {
let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") {
"down" => PointerKind::Down,
"drag" => PointerKind::Drag,
"up" => PointerKind::Up,
"move" => PointerKind::Move,
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
other => return err(format!("unknown pointer kind `{other}`")),
};
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let pos = if e.get("space").and_then(Value::as_str) == Some("screen") {
match xf {
Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)),
None => return err("no canvas yet"),
}
} else {
drawcraft_geom::Point::new(x, y)
};
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: 1.0 }, view);
}
ctx.request_repaint();
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()})))
}
"ui.key" => {
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
let m = egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") };
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
if let Some(t) = s("text") {
app.synthetic.push(egui::Event::Text(t.to_string()));
}
ctx.request_repaint();
ok(Value::Null)
}
"ui.text" => {
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ctx.request_repaint();
ok(Value::Null)
}
"ui.set" => {
let mut r = Ok(Value::Null);
if let Some(b) = s("brightness") {
r = app.run("window.brightness", json!({"brightness": b}));
}
if let Some(pn) = s("panel") {
r = app.run("window.panel", json!({"panel": pn}));
}
for (k, flag) in [("rulers", "view.rulers"), ("outline", "view.outline"), ("grid", "view.grid"), ("smartGuides", "view.smartGuides"), ("boundingBox", "view.boundingBox")] {
if let Some(want) = p.get(k).and_then(Value::as_bool) {
let cur = match k {
"rulers" => app.ui.view.rulers,
"outline" => app.ui.view.outline,
"grid" => app.ui.view.grid,
"smartGuides" => app.ui.view.smart_guides,
_ => app.ui.view.bounding_box,
};
if cur != want {
r = app.run(flag, json!({}));
}
}
}
if let Some(v) = p.get("controlBar").and_then(Value::as_bool) {
app.ui.control_bar = v;
}
if p.get("closePanel").is_some() {
app.ui.open_panel = None;
}
wrap(r)
}
"ui.dialog.set" => match app.ui.dialog.as_mut() {
Some(d) => {
let Some(f) = s("field") else { return err("missing `field`") };
d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
ok(serde_json::to_value(&*d).unwrap_or_default())
}
None => err("no dialog open"),
},
"ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)),
"ui.dialog.cancel" => {
app.ui.dialog = None;
ok(Value::Null)
}
"ui.resize" => {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(1440.0) as f32;
let h = p.get("height").and_then(Value::as_f64).unwrap_or(900.0) as f32;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
ok(Value::Null)
}
"ui.focus" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Value::Null)
}
"ui.screenshot" => {
if p.get("focus").and_then(Value::as_bool).unwrap_or(true) {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
ctx.request_repaint();
Outcome::Screenshot { path: s("path").map(str::to_string) }
}
"ui.render" => {
let Some(st) = app.session.active() else { return err("no document") };
let doc = st.doc.clone();
let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") };
let img = app.canvas.renderer.render_region(&doc, r, p.get("scale").and_then(Value::as_f64).unwrap_or(1.0), true);
let png = img.to_png();
match s("path") {
Some(path) => match app.services.write.as_mut() {
Some(w) => wrap(w(path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height}))),
None => err("no writer"),
},
None => ok(json!({"width": img.width, "height": img.height, "pngBase64": drawcraft_format::base64_encode(&png)})),
}
}
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
"app.export" => {
let f = s("format").unwrap_or("png");
wrap(crate::io::export(app, f, s("path").map(str::to_string), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p})))
}
"app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
other => err(format!("unknown method `{other}`")),
}
}
pub fn save_screenshot(app: &mut DrawcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
let [w, h] = image.size;
let Some(path) = path else {
return json!({"ok": true, "result": {"width": w, "height": h}});
};
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let img = drawcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba };
let png = img.to_png();
match app.services.write.as_mut() {
Some(wr) => match wr(path, &png) {
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
Err(e) => json!({"ok": false, "error": e}),
},
None => json!({"ok": false, "error": "no writer configured"}),
}
}
+265
View File
@@ -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:");
});
});
});
}
+93
View File
@@ -0,0 +1,93 @@
//! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column
//! whose panels pop out to the left.
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS};
use crate::theme::{self, Tokens};
use crate::{DrawcraftApp, icons, panels, widgets};
const ICON_COL: f32 = 38.0;
pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Collapsed icon column (rightmost).
egui::Panel::right("icon_column").resizable(false).exact_size(ICON_COL).frame(egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::symmetric(3, 6)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
if gi > 0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
}
for id in group.iter() {
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
let open = app.ui.open_panel.as_deref() == Some(*id);
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
}
}
});
// Main tabbed group.
egui::Panel::right("dock").resizable(true).default_size(286.0).size_range(230.0..=520.0).frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
// Tab strip.
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
let mut x = strip.left();
for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] {
let active = app.ui.dock_tab == tab;
let galley = ui.painter().layout_no_wrap(label.to_string(), if active { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) }, if active { t.text } else { t.text_dim });
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height()));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
if active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
if resp.clicked() {
app.ui.dock_tab = tab;
}
x = r.right();
}
icons::paint(ui, "menu", egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)), t.text_dim);
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
DockTab::Properties => {
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
}
DockTab::Layers => panels::layers::show(app, ui),
DockTab::Libraries => panels::libraries(app, ui),
});
});
}
/// An icon panel popped out next to the icon column.
pub fn floating_panel(app: &mut DrawcraftApp, ctx: &egui::Context) {
let Some(id) = app.ui.open_panel.clone() else { return };
let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return };
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let x = screen.right() - ICON_COL - 286.0 - 262.0;
let mut open = true;
egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).fixed_pos(egui::pos2(x, 110.0)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
ui.set_width(256.0);
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
let tab = egui::Rect::from_min_size(strip.min, vec2(ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0));
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, *label, theme::semibold(12.0), t.text);
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
if cr.clicked() {
open = false;
}
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(236.0);
panels::show_icon_panel(app, ui, &id);
});
});
});
if !open {
app.ui.open_panel = None;
}
}
+171
View File
@@ -0,0 +1,171 @@
//! Generated icon table (`assets/icons`). Lucide icons are ISC-licensed; `dc-*` icons are DrawCraft's own.
//! Regenerate when icons are added: see crates/ui-egui/README.md.
pub static ICONS: &[(&str, &[u8])] = &[
("align-center-horizontal", include_bytes!("../../../assets/icons/align-center-horizontal.svg")),
("align-center-vertical", include_bytes!("../../../assets/icons/align-center-vertical.svg")),
("align-end-horizontal", include_bytes!("../../../assets/icons/align-end-horizontal.svg")),
("align-end-vertical", include_bytes!("../../../assets/icons/align-end-vertical.svg")),
("align-horizontal-justify-center", include_bytes!("../../../assets/icons/align-horizontal-justify-center.svg")),
("align-horizontal-justify-end", include_bytes!("../../../assets/icons/align-horizontal-justify-end.svg")),
("align-horizontal-justify-start", include_bytes!("../../../assets/icons/align-horizontal-justify-start.svg")),
("align-start-horizontal", include_bytes!("../../../assets/icons/align-start-horizontal.svg")),
("align-start-vertical", include_bytes!("../../../assets/icons/align-start-vertical.svg")),
("align-vertical-justify-center", include_bytes!("../../../assets/icons/align-vertical-justify-center.svg")),
("align-vertical-justify-end", include_bytes!("../../../assets/icons/align-vertical-justify-end.svg")),
("align-vertical-justify-start", include_bytes!("../../../assets/icons/align-vertical-justify-start.svg")),
("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")),
("arrow-up-down", include_bytes!("../../../assets/icons/arrow-up-down.svg")),
("blend", include_bytes!("../../../assets/icons/blend.svg")),
("brush", include_bytes!("../../../assets/icons/brush.svg")),
("chart-column", include_bytes!("../../../assets/icons/chart-column.svg")),
("chevron-down", include_bytes!("../../../assets/icons/chevron-down.svg")),
("chevron-left", include_bytes!("../../../assets/icons/chevron-left.svg")),
("chevron-right", include_bytes!("../../../assets/icons/chevron-right.svg")),
("chevrons-left", include_bytes!("../../../assets/icons/chevrons-left.svg")),
("chevrons-right", include_bytes!("../../../assets/icons/chevrons-right.svg")),
("circle", include_bytes!("../../../assets/icons/circle.svg")),
("clipboard-paste", include_bytes!("../../../assets/icons/clipboard-paste.svg")),
("cloud", include_bytes!("../../../assets/icons/cloud.svg")),
("combine", include_bytes!("../../../assets/icons/combine.svg")),
("copy", include_bytes!("../../../assets/icons/copy.svg")),
("dc-align", include_bytes!("../../../assets/icons/dc-align.svg")),
("dc-anchor", include_bytes!("../../../assets/icons/dc-anchor.svg")),
("dc-appearance", include_bytes!("../../../assets/icons/dc-appearance.svg")),
("dc-arc", include_bytes!("../../../assets/icons/dc-arc.svg")),
("dc-artboards", include_bytes!("../../../assets/icons/dc-artboards.svg")),
("dc-blend", include_bytes!("../../../assets/icons/dc-blend.svg")),
("dc-bloat", include_bytes!("../../../assets/icons/dc-bloat.svg")),
("dc-color-guide", include_bytes!("../../../assets/icons/dc-color-guide.svg")),
("dc-crystallize", include_bytes!("../../../assets/icons/dc-crystallize.svg")),
("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")),
("dc-draw-behind", include_bytes!("../../../assets/icons/dc-draw-behind.svg")),
("dc-draw-inside", include_bytes!("../../../assets/icons/dc-draw-inside.svg")),
("dc-draw-normal", include_bytes!("../../../assets/icons/dc-draw-normal.svg")),
("dc-ellipse", include_bytes!("../../../assets/icons/dc-ellipse.svg")),
("dc-fill-none", include_bytes!("../../../assets/icons/dc-fill-none.svg")),
("dc-flare", include_bytes!("../../../assets/icons/dc-flare.svg")),
("dc-free-transform", include_bytes!("../../../assets/icons/dc-free-transform.svg")),
("dc-gradient", include_bytes!("../../../assets/icons/dc-gradient.svg")),
("dc-graphic-styles", include_bytes!("../../../assets/icons/dc-graphic-styles.svg")),
("dc-group-select", include_bytes!("../../../assets/icons/dc-group-select.svg")),
("dc-join", include_bytes!("../../../assets/icons/dc-join.svg")),
("dc-knife", include_bytes!("../../../assets/icons/dc-knife.svg")),
("dc-line", include_bytes!("../../../assets/icons/dc-line.svg")),
("dc-live-bucket", include_bytes!("../../../assets/icons/dc-live-bucket.svg")),
("dc-live-select", include_bytes!("../../../assets/icons/dc-live-select.svg")),
("dc-measure", include_bytes!("../../../assets/icons/dc-measure.svg")),
("dc-mesh", include_bytes!("../../../assets/icons/dc-mesh.svg")),
("dc-path-eraser", include_bytes!("../../../assets/icons/dc-path-eraser.svg")),
("dc-pathfinder", include_bytes!("../../../assets/icons/dc-pathfinder.svg")),
("dc-pen-add", include_bytes!("../../../assets/icons/dc-pen-add.svg")),
("dc-pen-delete", include_bytes!("../../../assets/icons/dc-pen-delete.svg")),
("dc-perspective", include_bytes!("../../../assets/icons/dc-perspective.svg")),
("dc-polar-grid", include_bytes!("../../../assets/icons/dc-polar-grid.svg")),
("dc-polygon", include_bytes!("../../../assets/icons/dc-polygon.svg")),
("dc-pucker", include_bytes!("../../../assets/icons/dc-pucker.svg")),
("dc-puppet", include_bytes!("../../../assets/icons/dc-puppet.svg")),
("dc-rect-grid", include_bytes!("../../../assets/icons/dc-rect-grid.svg")),
("dc-reference-point", include_bytes!("../../../assets/icons/dc-reference-point.svg")),
("dc-reshape", include_bytes!("../../../assets/icons/dc-reshape.svg")),
("dc-rotate-view", include_bytes!("../../../assets/icons/dc-rotate-view.svg")),
("dc-rounded-rect", include_bytes!("../../../assets/icons/dc-rounded-rect.svg")),
("dc-scallop", include_bytes!("../../../assets/icons/dc-scallop.svg")),
("dc-screen-mode", include_bytes!("../../../assets/icons/dc-screen-mode.svg")),
("dc-selection", include_bytes!("../../../assets/icons/dc-selection.svg")),
("dc-shape-builder", include_bytes!("../../../assets/icons/dc-shape-builder.svg")),
("dc-shear", include_bytes!("../../../assets/icons/dc-shear.svg")),
("dc-smooth", include_bytes!("../../../assets/icons/dc-smooth.svg")),
("dc-stroke", include_bytes!("../../../assets/icons/dc-stroke.svg")),
("dc-swap", include_bytes!("../../../assets/icons/dc-swap.svg")),
("dc-symbol-sprayer", include_bytes!("../../../assets/icons/dc-symbol-sprayer.svg")),
("dc-touch-type", include_bytes!("../../../assets/icons/dc-touch-type.svg")),
("dc-transform-panel", include_bytes!("../../../assets/icons/dc-transform-panel.svg")),
("dc-transparency", include_bytes!("../../../assets/icons/dc-transparency.svg")),
("dc-twirl", include_bytes!("../../../assets/icons/dc-twirl.svg")),
("dc-type-area", include_bytes!("../../../assets/icons/dc-type-area.svg")),
("dc-type-path", include_bytes!("../../../assets/icons/dc-type-path.svg")),
("dc-type-vertical", include_bytes!("../../../assets/icons/dc-type-vertical.svg")),
("dc-width", include_bytes!("../../../assets/icons/dc-width.svg")),
("dc-wrinkle", include_bytes!("../../../assets/icons/dc-wrinkle.svg")),
("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")),
("eraser", include_bytes!("../../../assets/icons/eraser.svg")),
("eye", include_bytes!("../../../assets/icons/eye.svg")),
("eye-off", include_bytes!("../../../assets/icons/eye-off.svg")),
("file-plus", include_bytes!("../../../assets/icons/file-plus.svg")),
("flip-horizontal-2", include_bytes!("../../../assets/icons/flip-horizontal-2.svg")),
("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")),
("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")),
("frame", include_bytes!("../../../assets/icons/frame.svg")),
("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")),
("group", include_bytes!("../../../assets/icons/group.svg")),
("hand", include_bytes!("../../../assets/icons/hand.svg")),
("hexagon", include_bytes!("../../../assets/icons/hexagon.svg")),
("history", include_bytes!("../../../assets/icons/history.svg")),
("house", include_bytes!("../../../assets/icons/house.svg")),
("image", include_bytes!("../../../assets/icons/image.svg")),
("info", include_bytes!("../../../assets/icons/info.svg")),
("lasso", include_bytes!("../../../assets/icons/lasso.svg")),
("layers", include_bytes!("../../../assets/icons/layers.svg")),
("layout-grid", include_bytes!("../../../assets/icons/layout-grid.svg")),
("library", include_bytes!("../../../assets/icons/library.svg")),
("link", include_bytes!("../../../assets/icons/link.svg")),
("link-2-off", include_bytes!("../../../assets/icons/link-2-off.svg")),
("lock", include_bytes!("../../../assets/icons/lock.svg")),
("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")),
("map", include_bytes!("../../../assets/icons/map.svg")),
("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")),
("menu", include_bytes!("../../../assets/icons/menu.svg")),
("minus", include_bytes!("../../../assets/icons/minus.svg")),
("monitor", include_bytes!("../../../assets/icons/monitor.svg")),
("move", include_bytes!("../../../assets/icons/move.svg")),
("move-diagonal-2", include_bytes!("../../../assets/icons/move-diagonal-2.svg")),
("paint-bucket", include_bytes!("../../../assets/icons/paint-bucket.svg")),
("paintbrush", include_bytes!("../../../assets/icons/paintbrush.svg")),
("palette", include_bytes!("../../../assets/icons/palette.svg")),
("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")),
("pencil", include_bytes!("../../../assets/icons/pencil.svg")),
("pilcrow", include_bytes!("../../../assets/icons/pilcrow.svg")),
("pin", include_bytes!("../../../assets/icons/pin.svg")),
("pipette", include_bytes!("../../../assets/icons/pipette.svg")),
("plus", include_bytes!("../../../assets/icons/plus.svg")),
("printer", include_bytes!("../../../assets/icons/printer.svg")),
("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")),
("rotate-3d", include_bytes!("../../../assets/icons/rotate-3d.svg")),
("rotate-ccw", include_bytes!("../../../assets/icons/rotate-ccw.svg")),
("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")),
("ruler", include_bytes!("../../../assets/icons/ruler.svg")),
("save", include_bytes!("../../../assets/icons/save.svg")),
("scaling", include_bytes!("../../../assets/icons/scaling.svg")),
("scissors", include_bytes!("../../../assets/icons/scissors.svg")),
("search", include_bytes!("../../../assets/icons/search.svg")),
("settings", include_bytes!("../../../assets/icons/settings.svg")),
("shapes", include_bytes!("../../../assets/icons/shapes.svg")),
("share-2", include_bytes!("../../../assets/icons/share-2.svg")),
("slice", include_bytes!("../../../assets/icons/slice.svg")),
("sparkles", include_bytes!("../../../assets/icons/sparkles.svg")),
("spline", include_bytes!("../../../assets/icons/spline.svg")),
("spline-pointer", include_bytes!("../../../assets/icons/spline-pointer.svg")),
("spray-can", include_bytes!("../../../assets/icons/spray-can.svg")),
("square", include_bytes!("../../../assets/icons/square.svg")),
("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")),
("squares-exclude", include_bytes!("../../../assets/icons/squares-exclude.svg")),
("squares-intersect", include_bytes!("../../../assets/icons/squares-intersect.svg")),
("squares-subtract", include_bytes!("../../../assets/icons/squares-subtract.svg")),
("squares-unite", include_bytes!("../../../assets/icons/squares-unite.svg")),
("squircle", include_bytes!("../../../assets/icons/squircle.svg")),
("star", include_bytes!("../../../assets/icons/star.svg")),
("sun", include_bytes!("../../../assets/icons/sun.svg")),
("swatch-book", include_bytes!("../../../assets/icons/swatch-book.svg")),
("text-cursor-input", include_bytes!("../../../assets/icons/text-cursor-input.svg")),
("tornado", include_bytes!("../../../assets/icons/tornado.svg")),
("trash-2", include_bytes!("../../../assets/icons/trash-2.svg")),
("triangle", include_bytes!("../../../assets/icons/triangle.svg")),
("type", include_bytes!("../../../assets/icons/type.svg")),
("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")),
("ungroup", include_bytes!("../../../assets/icons/ungroup.svg")),
("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")),
("waves", include_bytes!("../../../assets/icons/waves.svg")),
("x", include_bytes!("../../../assets/icons/x.svg")),
("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")),
];
+120
View File
@@ -0,0 +1,120 @@
//! Icon lookup and drawing (SVGs rasterized by egui_extras, tinted with theme colours).
use std::collections::HashMap;
use std::sync::OnceLock;
use egui::{Color32, ImageSource, Rect, Ui, Vec2};
fn table() -> &'static HashMap<&'static str, &'static [u8]> {
static T: OnceLock<HashMap<&'static str, &'static [u8]>> = OnceLock::new();
T.get_or_init(|| {
crate::icon_data::ICONS
.iter()
.map(|(n, b)| {
// Icons use currentColor; render white and tint at draw time. Slightly thinner strokes
// than Lucide's default read closer to Illustrator's glyph weight.
let s = String::from_utf8_lossy(b).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
let leaked: &'static [u8] = Box::leak(s.into_bytes().into_boxed_slice());
(*n, leaked)
})
.collect()
})
}
pub fn exists(name: &str) -> bool {
table().contains_key(name)
}
/// Map tool-catalogue icon names to SVG files.
pub fn tool_icon(name: &str) -> &'static str {
match name {
"tool-selection" => "dc-selection",
"tool-direct" => "dc-direct",
"tool-group-select" => "dc-group-select",
"tool-magic-wand" => "wand-sparkles",
"tool-lasso" => "lasso",
"tool-pen" => "pen-tool",
"tool-pen-add" => "dc-pen-add",
"tool-pen-delete" => "dc-pen-delete",
"tool-anchor" => "dc-anchor",
"tool-curvature" => "spline",
"tool-type" => "type",
"tool-type-area" => "dc-type-area",
"tool-type-path" => "dc-type-path",
"tool-type-vertical" => "dc-type-vertical",
"tool-touch-type" => "dc-touch-type",
"tool-line" => "dc-line",
"tool-arc" => "dc-arc",
"tool-spiral" => "tornado",
"tool-rect-grid" => "dc-rect-grid",
"tool-polar-grid" => "dc-polar-grid",
"tool-rect" => "square",
"tool-rounded-rect" => "dc-rounded-rect",
"tool-ellipse" => "dc-ellipse",
"tool-polygon" => "dc-polygon",
"tool-star" => "star",
"tool-flare" => "dc-flare",
"tool-brush" => "paintbrush",
"tool-blob-brush" => "brush",
"tool-shaper" => "shapes",
"tool-pencil" => "pencil",
"tool-smooth" => "dc-smooth",
"tool-path-eraser" => "dc-path-eraser",
"tool-join" => "dc-join",
"tool-eraser" => "eraser",
"tool-scissors" => "scissors",
"tool-knife" => "dc-knife",
"tool-rotate" => "rotate-ccw",
"tool-reflect" => "flip-horizontal-2",
"tool-scale" => "scaling",
"tool-shear" => "dc-shear",
"tool-reshape" => "dc-reshape",
"tool-width" => "dc-width",
"tool-warp" => "waves",
"tool-twirl" => "dc-twirl",
"tool-pucker" => "dc-pucker",
"tool-bloat" => "dc-bloat",
"tool-scallop" => "dc-scallop",
"tool-crystallize" => "dc-crystallize",
"tool-wrinkle" => "dc-wrinkle",
"tool-free-transform" => "dc-free-transform",
"tool-puppet" => "dc-puppet",
"tool-shape-builder" => "dc-shape-builder",
"tool-bucket" => "dc-live-bucket",
"tool-live-select" => "dc-live-select",
"tool-perspective" => "dc-perspective",
"tool-perspective-select" => "dc-perspective",
"tool-mesh" => "dc-mesh",
"tool-gradient" => "dc-gradient",
"tool-eyedropper" => "pipette",
"tool-measure" => "dc-measure",
"tool-blend" => "dc-blend",
"tool-symbol" => "dc-symbol-sprayer",
"tool-graph" => "chart-column",
"tool-artboard" => "frame",
"tool-slice" => "slice",
"tool-hand" => "hand",
"tool-rotate-view" => "dc-rotate-view",
"tool-print-tiling" => "printer",
"tool-zoom" => "zoom-in",
other => if exists(other) { table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed") } else { "square-dashed" },
}
}
pub fn source(name: &str) -> ImageSource<'static> {
let bytes = table().get(name).or_else(|| table().get("square-dashed")).copied().unwrap_or(&[]);
ImageSource::Bytes { uri: format!("bytes://icon/{name}.svg").into(), bytes: egui::load::Bytes::Static(bytes) }
}
/// Paint icon `name` into `rect` tinted with `tint`.
pub fn paint(ui: &Ui, name: &str, rect: Rect, tint: Color32) {
egui::Image::new(source(name)).tint(tint).fit_to_exact_size(rect.size()).paint_at(ui, rect);
}
/// An icon widget of `size` points.
pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), egui::Sense::hover());
paint(ui, name, rect, tint);
resp
}
+225
View File
@@ -0,0 +1,225 @@
//! File I/O through the injected services: open, save, export, place.
use std::sync::Arc;
use drawcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use drawcraft_geom::Affine;
use crate::DrawcraftApp;
fn ext(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// Decode an image and make a new document (or place into the active one when `place`).
fn image_doc(name: &str, bytes: &[u8]) -> Result<Document, String> {
let (w, h) = image_size(bytes)?;
let mut d = Document::new(w as f64, h as f64);
d.title = name.to_string();
let key = format!("img{:016x}", hash(bytes));
d.images.insert(key.clone(), ImageBlob { mime: mime_of(name), bytes: Arc::new(bytes.to_vec()) });
let l = d.layers[0].id;
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf: Affine::IDENTITY, link: None }));
n.name = Some(name.to_string());
d.insert(Some(l), 0, n).map_err(|e| e.to_string())?;
Ok(d)
}
pub fn image_size(bytes: &[u8]) -> Result<(u32, u32), String> {
// PNG / JPEG / GIF / WebP headers — enough to size a placed image without decoding.
if bytes.len() > 24 && &bytes[..8] == b"\x89PNG\r\n\x1a\n" {
let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
return Ok((w, h));
}
if bytes.len() > 10 && &bytes[..6] == b"GIF89a" || bytes.len() > 10 && &bytes[..6] == b"GIF87a" {
return Ok((u16::from_le_bytes([bytes[6], bytes[7]]) as u32, u16::from_le_bytes([bytes[8], bytes[9]]) as u32));
}
if bytes.len() > 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
let mut i = 2;
while i + 9 < bytes.len() {
if bytes[i] != 0xFF {
i += 1;
continue;
}
let marker = bytes[i + 1];
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32;
let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32;
return Ok((w, h));
}
i += 2 + len;
}
}
Err("unsupported image format".into())
}
fn mime_of(name: &str) -> String {
match ext(name).as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "application/octet-stream",
}
.to_string()
}
fn hash(b: &[u8]) -> u64 {
// FNV-1a: stable content key for embedded images.
let mut h: u64 = 0xcbf29ce484222325;
for x in b {
h ^= *x as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
/// Open bytes as a new document.
pub fn open_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
let e = ext(name);
let (doc, keep_path) = if e == "drawcraft" || drawcraft_format::sniff(bytes) {
let mut d = drawcraft_format::load(bytes).map_err(|e| e.to_string())?;
if d.title.is_empty() {
d.title = name.to_string();
}
(d, true)
} else if e == "svg" || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
let (mut d, warnings) = drawcraft_svg::import_with_report(s).map_err(|e| e.to_string())?;
d.title = name.to_string();
if !warnings.is_empty() {
app.status(format!("Opened with {} warning(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
}
(d, false)
} else if ["png", "jpg", "jpeg", "gif", "webp"].contains(&e.as_str()) {
(image_doc(name, bytes)?, false)
} else {
return Err(format!("DrawCraft can't open .{e} files yet"));
};
app.session.add_document(doc, if keep_path { path } else { None });
app.sync_views();
Ok(())
}
/// File → Open…
pub fn open_dialog(app: &mut DrawcraftApp) -> Result<(), String> {
if let Some(f) = app.services.open_async.as_mut() {
f();
return Ok(());
}
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
open_path(app, &path)
}
pub fn open_path(app: &mut DrawcraftApp, path: &str) -> Result<(), String> {
let read = app.services.read.as_ref().ok_or("no file reader")?;
let bytes = read(path)?;
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string());
open_bytes(app, &name, &bytes, Some(path.to_string()))
}
fn write_out(app: &mut DrawcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> {
if let Some(dl) = app.services.download.as_mut() {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string());
dl(&name, bytes);
return Ok(());
}
let w = app.services.write.as_mut().ok_or("no file writer")?;
w(path, bytes)
}
fn suggested(app: &DrawcraftApp, ext: &str) -> String {
let t = app.session.active().map(|d| d.title()).unwrap_or_else(|| "Untitled".into());
let stem = std::path::Path::new(&t).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(t);
format!("{stem}.{ext}")
}
/// File → Save / Save As (native format).
pub fn save(app: &mut DrawcraftApp, path: Option<String>, save_as: bool) -> Result<String, String> {
let st = app.session.active().ok_or("no document")?;
let existing = if save_as { None } else { st.path.clone() };
let path = match path.or(existing) {
Some(p) => p,
None if app.services.download.is_some() => suggested(app, "drawcraft"),
None => {
let s = suggested(app, "drawcraft");
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
}
};
let bytes = drawcraft_format::save(&app.session.active().unwrap().doc, true);
write_out(app, &path, &bytes)?;
if let Some(st) = app.session.active_mut() {
st.path = Some(path.clone());
st.saved_revision = st.revision;
}
app.status(format!("Saved {path}"));
Ok(path)
}
/// Export the document as SVG / PNG / JPEG.
pub fn export(app: &mut DrawcraftApp, format: &str, path: Option<String>, scale: f64) -> Result<String, String> {
let st = app.session.active().ok_or("no document")?;
let doc = st.doc.clone();
let path = match path {
Some(p) => p,
None if app.services.download.is_some() => suggested(app, format),
None => {
let s = suggested(app, format);
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
}
};
let bytes = match format {
"svg" => drawcraft_svg::export(&doc, &drawcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(),
"png" | "jpg" | "jpeg" => {
let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?;
let img = app.canvas.renderer.render_region(&doc, r, scale, format != "png");
if format == "png" { img.to_png() } else { encode_jpeg(&img)? }
}
other => return Err(format!("unknown export format `{other}`")),
};
write_out(app, &path, &bytes)?;
app.status(format!("Exported {path}"));
Ok(path)
}
fn encode_jpeg(img: &drawcraft_render::Rendered) -> Result<Vec<u8>, String> {
let _ = img;
Err("JPEG export lands with M4.6".into())
}
/// File → Place… (embed an image or SVG into the active document).
pub fn place_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
let e = ext(name);
if e == "svg" {
let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
let src = drawcraft_svg::import(s).map_err(|e| e.to_string())?;
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
app.session.clipboard = nodes;
app.run("edit.pasteInPlace", serde_json::json!({}))?;
return Ok(());
}
let (w, h) = image_size(bytes)?;
let key = format!("img{:016x}", hash(bytes));
let st = app.session.active().ok_or("no document")?;
let ab = st.doc.artboards.first().map(|a| a.rect).unwrap_or_default();
let s = (ab.width() / w as f64).min(ab.height() / h as f64).min(1.0);
let xf = Affine::translate((ab.center().x - w as f64 * s / 2.0, ab.center().y - h as f64 * s / 2.0)) * Affine::scale(s);
let mime = mime_of(name);
let bytes = Arc::new(bytes.to_vec());
let parent = st.insertion_parent();
let name = name.to_string();
app.session
.edit("Place", |d, sel| {
d.images.insert(key.clone(), ImageBlob { mime, bytes });
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
n.name = Some(name);
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(())
})
.map_err(|e| e.to_string())
}
+372 -1
View File
@@ -1 +1,372 @@
//! stub
//! DrawCraft's egui frontend: an Illustrator-style UI over `drawcraft-engine`.
//!
//! The UI is thin: every action goes through [`DrawcraftApp::run`], which dispatches UI commands
//! (view/window) here and everything else to the engine. The same entry point serves menus,
//! shortcuts, the ⌘K palette and the control channel ([`control`]).
#![forbid(unsafe_code)]
pub mod canvas;
pub mod chrome;
pub mod control;
pub mod dialogs;
pub mod dock;
pub mod icon_data;
pub mod icons;
pub mod io;
pub mod menus;
pub mod palette;
pub mod panels;
pub mod shortcuts;
pub mod state;
pub mod theme;
pub mod toolbar;
pub mod widgets;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use drawcraft_engine::{Session, ViewInfo};
use serde_json::{Value, json};
pub use control::{ControlRequest, ControlResponse};
pub use state::{UiState, View};
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
pub struct Services {
/// Show an open dialog; returns a path.
pub pick_open: Option<Box<dyn FnMut() -> Option<String>>>,
/// Show a save dialog with a suggested file name; returns a path.
pub pick_save: Option<Box<dyn FnMut(&str) -> Option<String>>>,
pub read: Option<Box<dyn Fn(&str) -> Result<Vec<u8>, String>>>,
pub write: Option<Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>>,
/// Files that arrived asynchronously (web open / drops): (name, bytes).
pub inbox: Option<Arc<Mutex<Vec<(String, Vec<u8>)>>>>,
/// Web: trigger a browser download instead of writing a path.
pub download: Option<Box<dyn FnMut(&str, &[u8])>>,
/// Web: start an async open (bytes arrive via `inbox`).
pub open_async: Option<Box<dyn FnMut()>>,
}
/// Cached canvas raster.
pub struct CanvasCache {
pub renderer: drawcraft_render::Renderer,
pub texture: Option<egui::TextureHandle>,
pub key: Option<CacheKey>,
pub last_ms: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CacheKey {
pub doc: usize,
pub revision: u64,
pub zoom: f64,
pub cx: f64,
pub cy: f64,
pub w: u32,
pub h: u32,
pub outline: bool,
pub ppp: f32,
pub hidden: Vec<u64>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Perf {
pub frame_ms: f64,
pub render_ms: f64,
pub fps: f64,
}
pub struct DrawcraftApp {
pub session: Session,
pub ui: UiState,
pub views: Vec<View>,
pub services: Services,
pub canvas: CanvasCache,
pub perf: Perf,
/// macOS: draw our own title strip under the traffic lights.
pub integrated_titlebar: bool,
control_rx: Option<Receiver<ControlRequest>>,
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
queued_screenshots: Vec<(u64, f64, u32)>,
screenshot_token: u64,
/// Synthetic input events (from the control channel) injected one step per frame.
pub synthetic: Vec<egui::Event>,
styled: bool,
fonts_ready: bool,
frame: u64,
last_time: f64,
/// Canvas rect of the last frame (screen points), for control-channel coordinate mapping.
pub canvas_rect: Option<egui::Rect>,
/// Hover position in document coordinates.
pub hover_doc: Option<drawcraft_geom::Point>,
}
impl DrawcraftApp {
pub fn new(session: Session, services: Services) -> Self {
let views = session.documents().iter().map(|_| View::default()).collect();
Self {
session,
ui: UiState::default(),
views,
services,
canvas: CanvasCache { renderer: drawcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0 },
perf: Perf::default(),
integrated_titlebar: false,
control_rx: None,
pending_screenshots: vec![],
queued_screenshots: vec![],
screenshot_token: 0,
synthetic: vec![],
styled: false,
fonts_ready: false,
frame: 0,
last_time: 0.0,
canvas_rect: None,
hover_doc: None,
}
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
/// Keep `views` aligned with the session's documents.
pub fn sync_views(&mut self) {
let n = self.session.documents().len();
while self.views.len() < n {
self.views.push(View::default());
}
self.views.truncate(n);
}
pub fn view(&self) -> Option<&View> {
self.session.active_index().and_then(|i| self.views.get(i))
}
pub fn view_mut(&mut self) -> Option<&mut View> {
self.sync_views();
let i = self.session.active_index()?;
self.views.get_mut(i)
}
pub fn view_info(&self) -> ViewInfo {
ViewInfo {
zoom: self.view().map(|v| v.zoom).unwrap_or(1.0),
outline: self.ui.view.outline,
smart_guides: self.ui.view.smart_guides,
snap_to_grid: self.ui.view.snap_to_grid,
show_bbox: self.ui.view.bounding_box,
}
}
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
}
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
self.sync_views();
match &r {
Err(e) => self.ui.status = e.clone(),
Ok(_) => {
if id == "file.new" {
self.ui.status.clear();
}
}
}
r
}
/// Select a tool (also used by the toolbar and shortcuts).
pub fn select_tool(&mut self, id: &str) {
let v = self.view_info();
if let Err(e) = self.session.select_tool(id, v) {
self.ui.status = e.to_string();
}
if let Some(g) = drawcraft_tools::catalog::group_of(id)
&& let Some(slot) = self.ui.group_tool.get_mut(g)
{
*slot = id.to_string();
}
self.ui.flyout = None;
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path } => {
self.screenshot_token += 1;
let token = self.screenshot_token;
let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0;
self.queued_screenshots.push((token, now_ms() + settle, 0));
self.pending_screenshots.push((token, path, reply));
}
}
}
self.control_rx = Some(rx);
}
fn issue_screenshots(&mut self, ctx: &egui::Context) {
let now = now_ms();
self.queued_screenshots.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
false
} else {
true
}
});
if !self.queued_screenshots.is_empty() || !self.pending_screenshots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
fn collect_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_screenshots.is_empty() {
return;
}
let events: Vec<_> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
Some((token, image.clone()))
}
_ => None,
})
.collect()
});
for (token, image) in events {
if let Some(i) = self.pending_screenshots.iter().position(|(t, _, _)| *t == token) {
let (_, path, reply) = self.pending_screenshots.remove(i);
let _ = reply.send(control::save_screenshot(self, &image, path.as_deref()));
}
}
}
/// Show a transient status message.
pub fn status(&mut self, s: impl Into<String>) {
self.ui.status = s.into();
}
fn drain_inbox(&mut self) {
let arrived: Vec<(String, Vec<u8>)> = self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
for (name, bytes) in arrived {
if let Err(e) = io::open_bytes(self, &name, &bytes, None) {
self.status(format!("Couldn't open {name}: {e}"));
}
}
}
}
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(target_arch = "wasm32")]
{
0.0
}
}
/// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are
/// called from the host's `eframe::App` impl.
impl DrawcraftApp {
/// Per-frame logic before layout (control channel, shortcuts, inbox).
pub fn logic(&mut self, ctx: &egui::Context) {
if !self.styled {
theme::install_fonts(ctx);
theme::apply(ctx, self.ui.brightness);
egui_extras::install_image_loaders(ctx);
self.styled = true;
} else {
self.fonts_ready = true;
}
self.frame += 1;
let now = ctx.input(|i| i.time);
let dt = now - self.last_time;
if dt > 0.0 {
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
}
self.last_time = now;
self.sync_views();
self.drain_control(ctx);
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
self.drain_inbox();
if self.fonts_ready {
shortcuts::handle(self, ctx);
}
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
for f in dropped {
let path = f.path().to_path_buf();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes() {
Ok(b) => {
let p = Some(path.to_string_lossy().to_string()).filter(|s| !s.is_empty());
if let Err(e) = io::open_bytes(self, &name, &b, p) {
self.status(format!("Couldn't open {name}: {e}"));
}
}
Err(e) => self.status(format!("Couldn't read {name}: {e}")),
}
}
}
/// Inject synthetic events (one press/release step per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if !self.synthetic.is_empty() {
let n = self.synthetic.iter().position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1);
raw.events.extend(self.synthetic.drain(..n));
}
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();
return;
}
let t0 = now_ms();
let t = theme::Tokens::get(&ctx);
if self.ui.screen_mode < 2 {
chrome::app_bar(self, ui);
if self.ui.control_bar {
chrome::control_bar(self, ui);
}
}
if self.ui.status_bar && self.ui.screen_mode < 3 {
chrome::status_bar(self, ui);
}
if self.ui.toolbar && self.ui.screen_mode < 3 {
toolbar::show(self, ui);
}
if self.ui.dock && self.ui.screen_mode < 3 {
dock::show(self, ui);
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {
if self.ui.screen_mode < 3 {
chrome::doc_tabs(self, ui);
}
canvas::show(self, ui);
});
dock::floating_panel(self, &ctx);
dialogs::show(self, &ctx);
palette::show(self, &ctx);
self.perf.frame_ms = now_ms() - t0;
let _ = json!(null);
}
}
+939
View File
@@ -0,0 +1,939 @@
//! The menu bar (Illustrator's menu tree) and UI-level commands.
//!
//! Items bound to a command id run through [`DrawcraftApp::run`]. Items not implemented yet are
//! listed (disabled, with their shortcut) so the full surface is visible and discoverable; the
//! parity tracker drives them to "done".
use serde::Serialize;
use serde_json::{Value, json};
use crate::DrawcraftApp;
use crate::io;
use crate::state::{DockTab, ICON_PANELS, next_zoom};
use crate::theme::{self, Brightness, Tokens};
#[derive(Clone, Debug)]
pub enum Item {
/// (label, command id, params)
Cmd(&'static str, &'static str, Value),
/// Not implemented yet: (label, shortcut)
Todo(&'static str, &'static str),
Sub(&'static str, Vec<Item>),
Sep,
/// Section header (disabled label, e.g. "Illustrator Effects").
Header(&'static str),
}
fn c(label: &'static str, id: &'static str) -> Item {
Item::Cmd(label, id, Value::Null)
}
fn cp(label: &'static str, id: &'static str, p: Value) -> Item {
Item::Cmd(label, id, p)
}
fn todo(label: &'static str) -> Item {
Item::Todo(label, "")
}
fn todos(label: &'static str, sc: &'static str) -> Item {
Item::Todo(label, sc)
}
fn sub(label: &'static str, items: Vec<Item>) -> Item {
Item::Sub(label, items)
}
use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("file.open", "Open…", "Cmd+O", "{path?}"),
("file.save", "Save", "Cmd+S", "{path?}"),
("file.saveAs", "Save As…", "Cmd+Shift+S", "{path?}"),
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?}"),
("file.revert", "Revert", "F12", "{}"),
("file.place", "Place…", "Cmd+Shift+P", "{path?}"),
("file.export.svg", "Export As SVG…", "", "{path?}"),
("file.export.png", "Export As PNG…", "", "{path?, scale?: 1}"),
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{path?, scale?}"),
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
("edit.preferences", "Preferences…", "Cmd+K", "{}"),
("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"),
("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"),
("view.zoomIn", "Zoom In", "Cmd+=", "{}"),
("view.zoomOut", "Zoom Out", "Cmd+-", "{}"),
("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"),
("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"),
("view.actualSize", "Actual Size", "Cmd+1", "{}"),
("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"),
("view.edges", "Hide Edges", "Cmd+H", "{}"),
("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"),
("view.rulers", "Show Rulers", "Cmd+R", "{}"),
("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"),
("view.transparencyGrid", "Show Transparency Grid", "Cmd+Shift+D", "{}"),
("view.guides", "Hide Guides", "Cmd+;", "{}"),
("view.smartGuides", "Smart Guides", "Cmd+U", "{}"),
("view.grid", "Show Grid", "Cmd+'", "{}"),
("view.snapToGrid", "Snap to Grid", "Cmd+Shift+'", "{}"),
("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"),
("view.presentation", "Presentation Mode", "Shift+F", "{}"),
("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"),
("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"),
("window.control", "Control", "", "{}"),
("window.toolbar", "Tools", "", "{}"),
("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
("window.workspace.reset", "Reset Essentials", "", "{}"),
("window.newWindow", "New Window", "", "{}"),
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
("tool.setOption", "Tool Option", "", "{key, value}"),
("help.about", "About DrawCraft", "", "{}"),
("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"),
("app.quit", "Quit DrawCraft", "Cmd+Q", "{}"),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let flag = |b: &mut bool| {
*b = !*b;
Ok(json!(*b))
};
let r = match id {
"file.newDialog" => {
app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"})));
Ok(Value::Null)
}
"file.open" => match s("path") {
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
None => io::open_dialog(app).map(|_| Value::Null),
},
"file.save" => io::save(app, s("path"), false).map(|p| json!({"path": p})),
"file.saveAs" | "file.saveCopy" => io::save(app, s("path"), true).map(|p| json!({"path": p})),
"file.revert" => {
let path = app.session.active().and_then(|d| d.path.clone());
match path {
Some(path) => {
let i = app.session.active_index().unwrap_or(0);
app.session.close_document(i);
io::open_path(app, &path).map(|_| Value::Null)
}
None => Err("document has never been saved".into()),
}
}
"file.place" => {
let path = match s("path") {
Some(p) => Some(p),
None => app.services.pick_open.as_mut().and_then(|f| f()),
};
match path {
Some(path) => {
let bytes = app.services.read.as_ref().ok_or("no reader".to_string()).and_then(|r| r(&path));
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
bytes.and_then(|b| io::place_bytes(app, &name, &b)).map(|_| Value::Null)
}
None => Err("cancelled".into()),
}
}
"file.export.svg" => io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})),
"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})),
"file.documentSetup" => {
let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points");
app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units})));
Ok(Value::Null)
}
"edit.preferences" => {
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"keyboardIncrement": app.session.prefs.keyboard_increment, "scaleStrokes": app.session.prefs.scale_strokes})));
Ok(Value::Null)
}
"view.outline" => flag(&mut app.ui.view.outline),
"view.pixelPreview" => flag(&mut app.ui.view.pixel_preview),
"view.edges" => flag(&mut app.ui.view.edges),
"view.artboards" => flag(&mut app.ui.view.artboards),
"view.rulers" => flag(&mut app.ui.view.rulers),
"view.boundingBox" => flag(&mut app.ui.view.bounding_box),
"view.transparencyGrid" => flag(&mut app.ui.view.transparency_grid),
"view.guides" => flag(&mut app.ui.view.guides),
"view.smartGuides" => flag(&mut app.ui.view.smart_guides),
"view.grid" => flag(&mut app.ui.view.grid),
"view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid),
"view.snapToPoint" => flag(&mut app.ui.view.snap_to_point),
"view.zoomIn" | "view.zoomOut" => {
let up = id == "view.zoomIn";
match app.view_mut() {
Some(v) => {
v.zoom = next_zoom(v.zoom, up);
Ok(json!({"zoom": v.zoom * 100.0}))
}
None => Err("no document".into()),
}
}
"view.setZoom" => {
let z = p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0) / 100.0;
let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(drawcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
match app.view_mut() {
Some(v) => {
v.zoom = z.clamp(0.0313, 640.0);
v.fitted = true;
if let Some(c) = center {
v.center = c;
}
Ok(Value::Null)
}
None => Err("no document".into()),
}
}
"view.fitArtboard" | "view.fitAll" | "view.actualSize" => {
crate::canvas::fit(app, id);
Ok(Value::Null)
}
"view.presentation" => {
app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 };
Ok(json!(app.ui.screen_mode))
}
"view.screenMode" => {
app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) {
Some(m) => m.min(3) as u8,
None => (app.ui.screen_mode + 1) % 3,
};
Ok(json!(app.ui.screen_mode))
}
"view.rotateReset" => {
if let Some(v) = app.view_mut() {
v.rotation = 0.0;
}
Ok(Value::Null)
}
"window.control" => flag(&mut app.ui.control_bar),
"window.toolbar" => flag(&mut app.ui.toolbar),
"window.toolbarColumns" => flag(&mut app.ui.toolbar_double),
"window.dock" => {
let on = !(app.ui.dock && app.ui.toolbar);
app.ui.dock = on;
app.ui.toolbar = on;
app.ui.control_bar = on;
Ok(json!(on))
}
"window.panel" => {
let panel = s("panel").unwrap_or_default();
match panel.as_str() {
"properties" => {
app.ui.dock_tab = DockTab::Properties;
Ok(Value::Null)
}
"layers" => {
app.ui.dock_tab = DockTab::Layers;
Ok(Value::Null)
}
"libraries" => {
app.ui.dock_tab = DockTab::Libraries;
Ok(Value::Null)
}
p if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
app.ui.dock = true;
Ok(json!({"open": app.ui.open_panel}))
}
other => Err(format!("unknown panel `{other}`")),
}
}
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
Some(b) => {
app.ui.brightness = b;
app.canvas.key = None;
Ok(json!(b.id()))
}
None => Err("brightness must be dark|mediumDark|mediumLight|light".into()),
},
"window.workspace.reset" => {
let b = app.ui.brightness;
app.ui = crate::state::UiState { brightness: b, ..Default::default() };
Ok(Value::Null)
}
"window.newWindow" => Err("multiple windows land with M11.5".into()),
"tool.select" => match s("tool") {
Some(t) if drawcraft_tools::tool_info(&t).is_some() => {
app.select_tool(&t);
Ok(json!({"tool": app.session.tool_id()}))
}
Some(t) => Err(format!("unknown tool `{t}`")),
None => Err("missing `tool`".into()),
},
"tool.setOption" => {
let k = s("key").unwrap_or_default();
app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null));
Ok(app.session.tool_options())
}
"help.about" => {
app.ui.about = true;
Ok(Value::Null)
}
"help.commandPalette" => {
app.ui.palette_open = !app.ui.palette_open;
app.ui.palette_query.clear();
Ok(Value::Null)
}
"app.quit" => {
app.ui.status = "quit".into();
Ok(Value::Null)
}
_ => return None,
};
Some(r)
}
/// Checked state for toggle items.
pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option<bool> {
let v = &app.ui.view;
Some(match id {
"view.outline" => v.outline,
"view.pixelPreview" => v.pixel_preview,
"view.smartGuides" => v.smart_guides,
"view.grid" => v.grid,
"view.snapToGrid" => v.snap_to_grid,
"view.snapToPoint" => v.snap_to_point,
"view.rulers" => v.rulers,
"window.control" => app.ui.control_bar,
"window.toolbar" => app.ui.toolbar,
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
match panel {
"properties" => app.ui.dock_tab == DockTab::Properties,
"layers" => app.ui.dock_tab == DockTab::Layers,
"libraries" => app.ui.dock_tab == DockTab::Libraries,
_ => app.ui.open_panel.as_deref() == Some(panel),
}
}
"window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness),
_ => return None,
})
}
/// Label for toggles whose text flips (Outline/Preview, Hide/Show …).
fn dynamic_label(app: &DrawcraftApp, id: &str, label: &'static str) -> String {
let v = &app.ui.view;
match id {
"view.outline" => if v.outline { "Preview" } else { "Outline" }.into(),
"view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(),
"view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(),
"view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(),
"view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(),
"view.transparencyGrid" => if v.transparency_grid { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into(),
"view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(),
"view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(),
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
_ => label.into(),
}
}
pub fn shortcut_of(id: &str) -> Option<&'static str> {
if let Some(c) = drawcraft_engine::find_command(id) {
return c.shortcut;
}
UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty())
}
/// Is a command currently enabled?
pub fn enabled(app: &DrawcraftApp, id: &str) -> bool {
if let Some(c) = drawcraft_engine::find_command(id) {
return (c.enabled)(&app.session).is_ok();
}
match id {
"file.save" | "file.saveAs" | "file.saveCopy" | "file.place" | "file.export.svg" | "file.export.png" | "file.exportForScreens" | "file.documentSetup" | "view.zoomIn" | "view.zoomOut" | "view.fitArtboard" | "view.fitAll" | "view.actualSize" => app.session.active().is_some(),
"file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()),
_ => true,
}
}
pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id }));
vec![
(
"DrawCraft",
vec![c("About DrawCraft", "help.about"), Sep, c("Settings…", "edit.preferences"), Sep, sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()), Sep, c("Quit DrawCraft", "app.quit")],
),
(
"File",
vec![
c("New…", "file.newDialog"),
todos("New from Template…", "Cmd+Shift+N"),
c("Open…", "file.open"),
todo("Open Recent Files"),
Sep,
c("Close", "file.close"),
todos("Close All", "Cmd+Alt+W"),
c("Save", "file.save"),
c("Save As…", "file.saveAs"),
c("Save a Copy…", "file.saveCopy"),
todo("Save as Template…"),
c("Revert", "file.revert"),
Sep,
c("Place…", "file.place"),
Sep,
sub("Export", vec![c("Export for Screens…", "file.exportForScreens"), c("Export As SVG…", "file.export.svg"), c("Export As PNG…", "file.export.png"), todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S")]),
todo("Export Selection…"),
Sep,
todos("Package…", "Cmd+Alt+Shift+P"),
sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]),
Sep,
c("Document Setup…", "file.documentSetup"),
sub("Document Color Mode", vec![todo("CMYK Color"), todo("RGB Color")]),
todos("File Info…", "Cmd+Alt+Shift+I"),
Sep,
todos("Print…", "Cmd+P"),
],
),
(
"Edit",
vec![
c("Undo", "edit.undo"),
c("Redo", "edit.redo"),
Sep,
c("Cut", "edit.cut"),
c("Copy", "edit.copy"),
c("Paste", "edit.paste"),
c("Paste in Front", "edit.pasteInFront"),
c("Paste in Back", "edit.pasteInBack"),
c("Paste in Place", "edit.pasteInPlace"),
c("Paste on All Artboards", "edit.pasteOnAllArtboards"),
todos("Paste without Formatting", "Cmd+Alt+V"),
c("Clear", "edit.clear"),
Sep,
todo("Find and Replace…"),
todo("Find Next"),
sub("Spelling", vec![todo("Auto Spell Check"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")]),
Sep,
sub(
"Edit Colors",
vec![
todo("Recolor Artwork…"),
todo("Adjust Color Balance…"),
todo("Blend Front to Back"),
todo("Blend Horizontally"),
todo("Blend Vertically"),
todo("Convert to CMYK"),
todo("Convert to Grayscale"),
todo("Convert to RGB"),
todo("Invert Colors"),
todo("Overprint Black…"),
todo("Saturate…"),
],
),
todo("Edit Original"),
Sep,
todo("Transparency Flattener Presets…"),
todo("Print Presets…"),
todo("Adobe PDF Presets…"),
todo("Perspective Grid Presets…"),
Sep,
todos("Color Settings…", "Cmd+Shift+K"),
todo("Assign Profile…"),
Sep,
todos("Keyboard Shortcuts…", "Cmd+Alt+Shift+K"),
c("Preferences…", "edit.preferences"),
],
),
(
"Object",
vec![
sub(
"Transform",
vec![
c("Transform Again", "object.transformAgain"),
Sep,
c("Move…", "object.move"),
c("Rotate…", "object.rotate"),
c("Reflect…", "object.reflect"),
c("Scale…", "object.scale"),
c("Shear…", "object.shear"),
Sep,
todos("Transform Each…", "Cmd+Alt+Shift+D"),
Sep,
todo("Reset Bounding Box"),
],
),
sub(
"Arrange",
vec![
c("Bring to Front", "object.arrange.bringToFront"),
c("Bring Forward", "object.arrange.bringForward"),
c("Send Backward", "object.arrange.sendBackward"),
c("Send to Back", "object.arrange.sendToBack"),
Sep,
c("Send to Current Layer", "object.arrange.sendToCurrentLayer"),
],
),
sub(
"Align",
vec![
cp("Horizontal Align Left", "object.align", json!({"horizontal": "left"})),
cp("Horizontal Align Center", "object.align", json!({"horizontal": "center"})),
cp("Horizontal Align Right", "object.align", json!({"horizontal": "right"})),
cp("Vertical Align Top", "object.align", json!({"vertical": "top"})),
cp("Vertical Align Center", "object.align", json!({"vertical": "center"})),
cp("Vertical Align Bottom", "object.align", json!({"vertical": "bottom"})),
],
),
Sep,
c("Group", "object.group"),
c("Ungroup", "object.ungroup"),
sub("Lock", vec![c("Selection", "object.lock"), todo("All Artwork Above"), todo("Other Layers")]),
c("Unlock All", "object.unlockAll"),
sub("Hide", vec![c("Selection", "object.hide"), todo("All Artwork Above"), todo("Other Layers")]),
c("Show All", "object.showAll"),
Sep,
todo("Expand…"),
todo("Expand Appearance"),
todo("Crop Image"),
todo("Rasterize…"),
todo("Create Gradient Mesh…"),
todo("Create Object Mosaic…"),
todo("Create Trim Marks"),
todo("Flatten Transparency…"),
Sep,
todo("Make Pixel Perfect"),
Sep,
sub("Slice", vec![todo("Make"), todo("Release"), todo("Create from Guides"), todo("Create from Selection")]),
Sep,
sub(
"Path",
vec![
c("Join", "path.join"),
c("Average…", "path.average"),
Sep,
c("Outline Stroke", "object.path.outlineStroke"),
c("Offset Path…", "object.path.offsetPath"),
c("Reverse Path Direction", "path.reverse"),
Sep,
c("Simplify…", "object.path.simplify"),
c("Add Anchor Points", "object.path.addAnchorPoints"),
c("Remove Anchor Points", "path.deleteAnchors"),
todo("Divide Objects Below"),
c("Split Into Grid…", "object.path.splitIntoGrid"),
Sep,
todo("Clean Up…"),
],
),
sub("Shape", vec![todo("Convert to Shape"), c("Expand Shape", "object.expandShape")]),
sub("Pattern", vec![todo("Make"), todo("Edit Pattern"), todo("Tile Edge Color…")]),
sub("Repeat", vec![todo("Radial"), todo("Grid"), todo("Mirror"), Sep, todo("Release"), todo("Options…")]),
sub("Blend", vec![todos("Make", "Cmd+Alt+B"), todos("Release", "Cmd+Alt+Shift+B"), Sep, todo("Blend Options…"), Sep, todo("Expand"), Sep, todo("Replace Spine"), todo("Reverse Spine"), todo("Reverse Front to Back")]),
sub("Envelope Distort", vec![todos("Make with Warp…", "Cmd+Alt+Shift+W"), todos("Make with Mesh…", "Cmd+Alt+M"), todos("Make with Top Object", "Cmd+Alt+C"), Sep, todo("Release"), todo("Envelope Options…"), todo("Expand")]),
sub("Perspective", vec![todo("Attach to Active Plane"), todo("Release with Perspective")]),
sub("Live Paint", vec![todos("Make", "Cmd+Alt+X"), todo("Merge"), todo("Release"), Sep, todo("Gap Options…"), Sep, todo("Expand")]),
sub("Image Trace", vec![todo("Make"), todo("Make and Expand"), todo("Release"), todo("Expand")]),
sub("Text Wrap", vec![todo("Make"), todo("Release"), todo("Text Wrap Options…")]),
Sep,
sub("Clipping Mask", vec![c("Make", "object.clippingMask.make"), c("Release", "object.clippingMask.release"), todo("Edit Contents")]),
sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]),
sub("Artboards", vec![todo("Convert to Artboards"), todo("Rearrange All Artboards…"), Sep, c("Fit to Artwork Bounds", "artboard.fitToArt"), c("Fit to Selected Art", "artboard.fitToSelection")]),
sub("Graph", vec![todo("Type…"), todo("Data…"), todo("Design…"), todo("Column…"), todo("Marker…")]),
],
),
(
"Type",
vec![
sub("Font", drawcraft_text::FontDb::global().families().into_iter().take(0).map(|_| todo("")).chain([todo("Source Sans 3"), todo("Source Serif 4"), todo("Inter"), todo("JetBrains Mono")]).collect()),
todo("Recent Fonts"),
sub("Size", [6, 8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72].iter().map(|_| todo("pt")).take(0).chain([todo("6 pt"), todo("8 pt"), todo("12 pt"), todo("24 pt"), todo("36 pt"), todo("72 pt")]).collect()),
Sep,
todo("Glyphs"),
Sep,
todo("Convert To Area Type"),
todo("Area Type Options…"),
sub("Type on a Path", vec![todo("Rainbow"), todo("Skew"), todo("3D Ribbon"), todo("Stair Step"), todo("Gravity"), Sep, todo("Type on a Path Options…")]),
sub("Threaded Text", vec![todo("Create"), todo("Release Selection"), todo("Remove Threading")]),
todo("Fit Headline"),
Sep,
todo("Find Font…"),
sub("Change Case", vec![todo("UPPERCASE"), todo("lowercase"), todo("Title Case"), todo("Sentence case")]),
todo("Smart Punctuation…"),
Sep,
c("Create Outlines", "type.createOutlines"),
todo("Optical Margin Alignment"),
Sep,
todo("Fill with Placeholder Text"),
Sep,
todos("Show Hidden Characters", "Cmd+Alt+I"),
sub("Type Orientation", vec![todo("Horizontal"), todo("Vertical")]),
],
),
(
"Select",
vec![
c("All", "select.all"),
c("All on Active Artboard", "select.allOnArtboard"),
c("Deselect", "select.none"),
c("Reselect", "select.reselect"),
Sep,
c("Inverse", "select.inverse"),
Sep,
c("Next Object Above", "select.nextAbove"),
c("Next Object Below", "select.nextBelow"),
Sep,
sub(
"Same",
vec![
c("Appearance", "select.same.appearance"),
todo("Appearance Attribute"),
c("Blending Mode", "select.same.blendingMode"),
c("Fill & Stroke", "select.same.fillAndStroke"),
c("Fill Color", "select.same.fillColor"),
c("Opacity", "select.same.opacity"),
c("Stroke Color", "select.same.strokeColor"),
c("Stroke Weight", "select.same.strokeWeight"),
todo("Graphic Style"),
c("Shape", "select.same.shapeType"),
todo("Symbol Instance"),
Sep,
todo("Font Family"),
todo("Font Size"),
todo("Text Fill Color"),
],
),
sub(
"Object",
vec![
c("All on Same Layers", "select.object.allOnSameLayers"),
todo("Direction Handles"),
Sep,
todo("Brush Strokes"),
c("Clipping Masks", "select.object.clippingMasks"),
c("Stray Points", "select.object.strayPoints"),
c("Open Paths", "select.object.openPaths"),
Sep,
c("All Text Objects", "select.object.textObjects"),
],
),
todo("Start Global Edit"),
Sep,
todo("Save Selection…"),
todo("Edit Selection…"),
],
),
(
"Effect",
vec![
todos("Apply Last Effect", "Cmd+Shift+E"),
todos("Last Effect", "Cmd+Alt+Shift+E"),
Sep,
todo("Document Raster Effects Settings…"),
Sep,
Item::Header("Illustrator Effects"),
sub("3D and Materials", vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]),
sub("Convert to Shape", vec![todo("Rectangle…"), todo("Rounded Rectangle…"), todo("Ellipse…")]),
todo("Crop Marks"),
sub("Distort & Transform", vec![todo("Free Distort…"), todo("Pucker & Bloat…"), todo("Roughen…"), todo("Transform…"), todo("Tweak…"), todo("Twist…"), todo("Zig Zag…")]),
sub("Path", vec![todo("Offset Path…"), todo("Outline Object"), todo("Outline Stroke")]),
sub("Pathfinder", vec![todo("Add"), todo("Intersect"), todo("Exclude"), todo("Subtract"), todo("Minus Back"), Sep, todo("Divide"), todo("Trim"), todo("Merge"), todo("Crop"), todo("Outline")]),
todo("Rasterize…"),
sub("Stylize", vec![todo("Drop Shadow…"), todo("Feather…"), todo("Inner Glow…"), todo("Outer Glow…"), todo("Round Corners…"), todo("Scribble…")]),
sub("SVG Filters", vec![todo("Apply SVG Filter…"), todo("Import SVG Filter…")]),
sub(
"Warp",
vec![
todo("Arc…"),
todo("Arc Lower…"),
todo("Arc Upper…"),
todo("Arch…"),
todo("Bulge…"),
todo("Shell Lower…"),
todo("Shell Upper…"),
todo("Flag…"),
todo("Wave…"),
todo("Fish…"),
todo("Rise…"),
todo("Fisheye…"),
todo("Inflate…"),
todo("Squeeze…"),
todo("Twist…"),
],
),
Sep,
Item::Header("Raster Effects"),
todo("Effect Gallery…"),
sub("Blur", vec![todo("Gaussian Blur…"), todo("Radial Blur…"), todo("Smart Blur…")]),
sub("Pixelate", vec![todo("Color Halftone…"), todo("Crystallize…"), todo("Mezzotint…"), todo("Pointillize…")]),
sub("Stylize", vec![todo("Glowing Edges…")]),
],
),
(
"View",
vec![
c("Outline", "view.outline"),
todos("Overprint Preview", "Cmd+Alt+Shift+Y"),
c("Pixel Preview", "view.pixelPreview"),
todo("Trim View"),
c("Presentation Mode", "view.presentation"),
sub("Screen Mode", vec![cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), cp("Full Screen Mode", "view.screenMode", json!({"mode": 2}))]),
Sep,
sub("Proof Setup", vec![todo("Working CMYK"), todo("Internet Standard RGB (sRGB)"), todo("Color blindness – Protanopia-type"), todo("Color blindness – Deuteranopia-type")]),
todo("Proof Colors"),
Sep,
c("Zoom In", "view.zoomIn"),
c("Zoom Out", "view.zoomOut"),
c("Fit Artboard in Window", "view.fitArtboard"),
c("Fit All in Window", "view.fitAll"),
c("Actual Size", "view.actualSize"),
Sep,
c("Reset Rotate View", "view.rotateReset"),
Sep,
c("Hide Edges", "view.edges"),
c("Hide Artboards", "view.artboards"),
todo("Show Print Tiling"),
Sep,
sub("Rulers", vec![c("Show Rulers", "view.rulers"), todos("Change to Global Rulers", "Cmd+Alt+R"), todo("Show Video Rulers")]),
c("Hide Bounding Box", "view.boundingBox"),
c("Show Transparency Grid", "view.transparencyGrid"),
todos("Hide Text Threads", "Cmd+Shift+Y"),
todos("Hide Gradient Annotator", "Cmd+Alt+G"),
todo("Hide Corner Widget"),
Sep,
sub("Guides", vec![c("Hide Guides", "view.guides"), todos("Lock Guides", "Cmd+Alt+;"), todos("Make Guides", "Cmd+5"), todos("Release Guides", "Cmd+Alt+5"), todo("Clear Guides")]),
c("Smart Guides", "view.smartGuides"),
sub("Perspective Grid", vec![todos("Show Grid", "Cmd+Shift+I"), todo("Define Grid…")]),
c("Show Grid", "view.grid"),
c("Snap to Grid", "view.snapToGrid"),
todo("Snap to Pixel"),
c("Snap to Point", "view.snapToPoint"),
todo("Snap to Glyph"),
Sep,
todo("New View…"),
todo("Edit Views…"),
],
),
(
"Window",
vec![
c("New Window", "window.newWindow"),
sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]),
sub("Workspace", vec![todo("Essentials"), todo("Essentials Classic"), todo("Painting"), todo("Typography"), Sep, c("Reset Essentials", "window.workspace.reset")]),
Sep,
c("Control", "window.control"),
c("Tools", "window.toolbar"),
sub("Toolbars", vec![c("Single / Double Column", "window.toolbarColumns")]),
Sep,
todo("Actions"),
panel("Align", "align"),
panel("Appearance", "appearance"),
panel("Artboards", "artboards"),
todo("Asset Export"),
todo("Attributes"),
panel("Brushes", "brushes"),
panel("Color", "color"),
panel("Color Guide", "colorGuide"),
todo("Document Info"),
panel("Gradient", "gradient"),
panel("Graphic Styles", "graphicStyles"),
panel("History", "history"),
todo("Image Trace"),
panel("Info", "info"),
panel("Layers", "layers"),
panel("Libraries", "libraries"),
todo("Links"),
todo("Magic Wand"),
panel("Navigator", "navigator"),
panel("Pathfinder", "pathfinder"),
todo("Pattern Options"),
panel("Properties", "properties"),
todo("Separations Preview"),
panel("Stroke", "stroke"),
todo("SVG Interactivity"),
panel("Swatches", "swatches"),
panel("Symbols", "symbols"),
panel("Transform", "transform"),
panel("Transparency", "transparency"),
sub("Type", vec![panel("Character", "character"), todo("Character Styles"), todo("Glyphs"), todo("OpenType"), panel("Paragraph", "paragraph"), todo("Paragraph Styles"), todo("Tabs")]),
todo("Variables"),
Sep,
sub("Brush Libraries", vec![todo("Arrows"), todo("Artistic"), todo("Borders"), todo("Decorative")]),
sub("Graphic Style Libraries", vec![todo("Additive"), todo("Artistic Effects"), todo("Buttons and Rollovers")]),
sub("Swatch Libraries", vec![todo("Art History"), todo("Celebration"), todo("Color Properties"), todo("Nature"), todo("Web")]),
sub("Symbol Libraries", vec![todo("Arrows"), todo("Charts"), todo("Web Buttons and Bars")]),
],
),
("Help", vec![c("Search Commands…", "help.commandPalette"), todos("DrawCraft Help…", "F1"), Sep, c("About DrawCraft", "help.about")]),
]
}
/// Render the menu bar.
pub fn menu_bar(app: &mut DrawcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let mut clicked: Option<(String, Value)> = None;
let tree = menu_tree();
egui::MenuBar::new().ui(ui, |ui| {
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 { egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) } else { egui::RichText::new(*title).size(13.0).color(t.text) };
ui.menu_button(text, |ui| {
ui.set_min_width(230.0);
render_items(app, ui, items, &mut clicked);
});
}
});
if let Some((id, p)) = clicked {
invoke(app, &id, p);
}
}
fn render_items(app: &DrawcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
let t = Tokens::get(ui.ctx());
for it in items {
match it {
Item::Sep => {
ui.separator();
}
Item::Header(h) => {
ui.label(egui::RichText::new(*h).size(11.0).color(t.text_dim));
}
Item::Sub(label, children) => {
ui.menu_button(*label, |ui| {
ui.set_min_width(200.0);
render_items(app, ui, children, clicked);
});
}
Item::Todo(label, sc) => {
ui.add_enabled_ui(false, |ui| {
ui.add(egui::Button::new(*label).shortcut_text(pretty_shortcut(sc)).min_size(egui::vec2(ui.available_width(), 0.0)));
})
.response
.on_disabled_hover_text("Coming soon — tracked in the parity plan");
}
Item::Cmd(label, id, p) => {
let en = enabled(app, id);
let label = dynamic_label(app, id, label);
let sc = if p.is_null() { shortcut_of(id).map(pretty_shortcut).unwrap_or_default() } else { String::new() };
let chk = checked(app, id, p);
let text = match chk {
Some(true) => format!("✓ {label}"),
Some(false) => format!(" {label}"),
None => label,
};
let r = ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc).min_size(egui::vec2(ui.available_width(), 0.0)));
if r.clicked() {
*clicked = Some((id.to_string(), if p.is_null() { json!({}) } else { p.clone() }));
ui.close();
}
}
}
}
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
pub fn invoke(app: &mut DrawcraftApp, id: &str, p: Value) {
// Commands whose menu item opens a dialog in Illustrator.
let dialog = match id {
"object.move" => Some(("move", json!({"dx": "0 pt", "dy": "0 pt"}))),
"object.rotate" => Some(("rotate", json!({"angle": 0}))),
"object.scale" => Some(("scale", json!({"sx": 100, "sy": 100, "uniform": true}))),
"object.reflect" => Some(("reflect", json!({"axis": "vertical"}))),
"object.shear" => Some(("shear", json!({"angle": 0, "axis": "horizontal"}))),
"path.average" => Some(("average", json!({"axis": "both"}))),
"object.path.offsetPath" => Some(("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4}))),
"object.path.simplify" => Some(("simplify", json!({"tolerance": "1 pt"}))),
"object.path.splitIntoGrid" => Some(("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"}))),
_ => None,
};
if let Some((kind, fields)) = dialog
&& p.as_object().is_none_or(|o| o.is_empty())
{
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
return;
}
if let Err(e) = app.run(id, p) {
app.status(e);
}
}
/// `Cmd+Shift+]` → `⇧⌘]` on macOS, `Ctrl+Shift+]` elsewhere.
pub fn pretty_shortcut(s: &str) -> String {
if s.is_empty() {
return String::new();
}
if cfg!(target_os = "macos") {
let mut mods = String::new();
let parts: Vec<&str> = s.split('+').collect();
let (key, ms) = if s.ends_with("++") { ("+", &parts[..parts.len() - 2]) } else { (*parts.last().unwrap(), &parts[..parts.len() - 1]) };
for m in ["Ctrl", "Alt", "Shift", "Cmd"] {
if ms.contains(&m) {
mods.push_str(match m {
"Ctrl" => "⌃",
"Alt" => "⌥",
"Shift" => "⇧",
_ => "⌘",
});
}
}
format!("{mods}{key}")
} else {
s.replace("Cmd", "Ctrl")
}
}
#[derive(Serialize)]
pub struct MenuEntry {
pub path: Vec<String>,
pub label: String,
pub command: Option<String>,
pub params: Value,
pub enabled: bool,
pub shortcut: String,
}
/// Flattened menu for `ui.menu.list`.
pub fn menu_entries(app: &DrawcraftApp) -> Vec<MenuEntry> {
fn walk(app: &DrawcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
for it in items {
match it {
Item::Cmd(l, id, p) => out.push(MenuEntry { path: path.clone(), label: dynamic_label(app, id, l), command: Some(id.to_string()), params: p.clone(), enabled: enabled(app, id), shortcut: shortcut_of(id).unwrap_or("").to_string() }),
Item::Todo(l, sc) => out.push(MenuEntry { path: path.clone(), label: l.to_string(), command: None, params: Value::Null, enabled: false, shortcut: sc.to_string() }),
Item::Sub(l, ch) => {
let mut p = path.clone();
p.push(l.to_string());
walk(app, p, ch, out);
}
_ => {}
}
}
}
let mut out = vec![];
for (title, items) in menu_tree() {
walk(app, vec![title.to_string()], &items, &mut out);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_bound_menu_command_exists() {
fn walk(items: &[Item], bad: &mut Vec<String>) {
for it in items {
match it {
Item::Cmd(_, id, _) => {
if drawcraft_engine::find_command(id).is_none() && !UI_COMMANDS.iter().any(|c| c.0 == *id) && !id.starts_with("object.path.") && *id != "type.createOutlines" {
bad.push(id.to_string());
}
}
Item::Sub(_, ch) => walk(ch, bad),
_ => {}
}
}
}
let mut bad = vec![];
for (_, items) in menu_tree() {
walk(&items, &mut bad);
}
assert!(bad.is_empty(), "menu items bound to unknown commands: {bad:?}");
}
#[test]
fn shortcut_pretty() {
if cfg!(target_os = "macos") {
assert_eq!(pretty_shortcut("Cmd+Shift+]"), "⇧⌘]");
assert_eq!(pretty_shortcut("Cmd+Alt+2"), "⌥⌘2");
}
assert_eq!(pretty_shortcut(""), "");
}
}
+55
View File
@@ -0,0 +1,55 @@
//! The command palette: fuzzy search over every command and tool.
use serde_json::json;
use crate::theme::Tokens;
use crate::{DrawcraftApp, menus};
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
if !app.ui.palette_open {
return;
}
let t = Tokens::get(ctx);
let q = app.ui.palette_query.to_lowercase();
let mut items: Vec<(String, String, String)> = vec![]; // (label, kind/id, shortcut)
for c in drawcraft_engine::command_specs() {
let path = c.menu.join(" › ");
let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) };
items.push((label, c.id.to_string(), c.shortcut.unwrap_or("").to_string()));
}
for c in menus::UI_COMMANDS {
items.push((c.1.to_string(), c.0.to_string(), c.2.to_string()));
}
for tool in drawcraft_tools::catalog::all_tools() {
items.push((tool.label.to_string(), format!("tool:{}", tool.id), tool.shortcut.unwrap_or("").to_string()));
}
let matches: Vec<&(String, String, String)> = items.iter().filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w))).take(14).collect();
let mut run: Option<String> = None;
egui::Area::new(egui::Id::new("palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, [0.0, 90.0]).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(520.0);
let r = ui.add(egui::TextEdit::singleline(&mut app.ui.palette_query).hint_text("Search commands and tools…").desired_width(500.0));
r.request_focus();
ui.add_space(6.0);
for (i, (label, id, sc)) in matches.iter().enumerate() {
let resp = ui.add(egui::Button::new(label.as_str()).shortcut_text(menus::pretty_shortcut(sc)).min_size(egui::vec2(500.0, 24.0)).selected(i == 0));
if resp.clicked() {
run = Some(id.clone());
}
}
if ui.input(|i| i.key_pressed(egui::Key::Enter))
&& let Some(first) = matches.first()
{
run = Some(first.1.clone());
}
});
});
if let Some(id) = run {
app.ui.palette_open = false;
if let Some(tool) = id.strip_prefix("tool:") {
app.select_tool(tool);
} else {
menus::invoke(app, &id, json!({}));
}
}
}
+217
View File
@@ -0,0 +1,217 @@
//! The Layers panel: tree with visibility/lock columns, layer colour bars, thumbnails, target
//! circles and selection squares; bottom bar with new/delete.
use std::collections::HashSet;
use drawcraft_doc::{Node, NodeId, NodeKind};
use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
use crate::theme::Tokens;
use crate::{DrawcraftApp, icons, widgets};
const ROW: f32 = 26.0;
fn expanded_id() -> egui::Id {
egui::Id::new("layers-expanded")
}
pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let sel: HashSet<NodeId> = st.selection.objects.iter().copied().collect();
let current = st.active_layer;
let mut expanded: HashSet<u64> = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect());
let mut actions: Vec<(String, serde_json::Value)> = vec![];
let h = ui.available_height() - 34.0;
egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
for l in doc.layers.iter().rev() {
row(ui, &doc, l, 0, &sel, current, &mut expanded, &mut actions, &t);
}
});
ui.data_mut(|d| d.insert_temp(expanded_id(), expanded));
// Bottom bar.
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider));
let n = doc.layers.len();
ui.painter().text(bar.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, format!("{n} Layer{}", if n == 1 { "" } else { "s" }), egui::FontId::proportional(11.5), t.text_dim);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
if widgets::icon_button(&mut child, "trash-2", "Delete Selection", false, 24.0).clicked() {
if app.session.active().is_some_and(|d| !d.selection.is_empty()) {
actions.push(("edit.clear".into(), json!({})));
} else {
actions.push(("layer.delete".into(), json!({})));
}
}
if widgets::icon_button(&mut child, "file-plus", "Create New Layer", false, 24.0).clicked() {
actions.push(("layer.new".into(), json!({})));
}
if widgets::icon_button(&mut child, "plus", "Create New Sublayer", false, 24.0).clicked() {
actions.push(("layer.newSublayer".into(), json!({})));
}
if widgets::icon_button(&mut child, "frame", "Make/Release Clipping Mask", false, 24.0).clicked() {
actions.push(("object.clippingMask.make".into(), json!({})));
}
if widgets::icon_button(&mut child, "search", "Locate Object", false, 24.0).clicked() {
// Expand ancestors of the selection.
if let Some(st) = app.session.active() {
let mut ex: HashSet<u64> = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_default();
for id in &st.selection.objects {
for a in st.doc.ancestry(*id).unwrap_or_default() {
ex.insert(a.0);
}
}
ui.data_mut(|d| d.insert_temp(expanded_id(), ex));
}
}
for (c, p) in actions {
app.run(&c, p).ok();
}
}
#[allow(clippy::too_many_arguments)]
fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: &HashSet<NodeId>, current: Option<NodeId>, expanded: &mut HashSet<u64>, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click());
let is_sel = sel.contains(&n.id);
let child_sel = !is_sel && n.children().is_some_and(|_| {
let mut any = false;
n.walk(&mut |c| any |= c.id != n.id && sel.contains(&c.id));
any
});
let color = {
let c = doc.layer_color(n.id);
Color32::from_rgb(c[0], c[1], c[2])
};
if n.is_layer() && Some(n.id) == current {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
}
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
// Eye and lock columns.
let eye = egui::Rect::from_min_size(r.min, vec2(24.0, ROW));
let lock = egui::Rect::from_min_size(r.min + vec2(24.0, 0.0), vec2(22.0, ROW));
ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.border));
ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.border));
let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click());
if n.visible {
icons::paint(ui, "eye", egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)), t.icon);
}
if er.clicked() {
let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" };
let key = if n.is_layer() { "id" } else { "ids" };
let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) };
actions.push((cmd.into(), json!({key: idv, "visible": !n.visible})));
}
let lr = ui.interact(lock, ui.id().with(("lock", n.id.0)), Sense::click());
if n.locked {
icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon);
} else if lr.hovered() {
icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled);
}
if lr.clicked() {
let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" };
let key = if n.is_layer() { "id" } else { "ids" };
let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) };
actions.push((cmd.into(), json!({key: idv, "locked": !n.locked})));
}
// Layer colour bar.
let mut x = lock.right() + 2.0;
if n.is_layer() {
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(3.0, ROW - 4.0)), 0.0, color);
}
x += 6.0 + depth as f32 * 14.0;
// Disclosure.
let has_children = n.children().is_some_and(|c| !c.is_empty()) && !matches!(n.kind, NodeKind::Compound { .. });
if has_children {
let open = expanded.contains(&n.id.0);
let dr = egui::Rect::from_min_size(egui::pos2(x, r.top() + 5.0), vec2(14.0, 16.0));
let dresp = ui.interact(dr, ui.id().with(("disc", n.id.0)), Sense::click());
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim);
if dresp.clicked() {
if open {
expanded.remove(&n.id.0);
} else {
expanded.insert(n.id.0);
}
}
}
x += 16.0;
// Thumbnail.
let th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 3.0), vec2(20.0, 20.0));
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
thumb(ui, n, th);
x += 26.0;
// Name.
let name = n.display_name();
let font = if n.is_layer() { egui::FontId::proportional(12.5) } else { egui::FontId::proportional(12.0) };
ui.painter().with_clip_rect(egui::Rect::from_min_max(egui::pos2(x, r.top()), egui::pos2(r.right() - 44.0, r.bottom()))).text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, font, t.text);
// Target circle and selection square.
let tc = egui::pos2(r.right() - 30.0, r.center().y);
let styled = !n.appearance.is_basic() || n.opacity < 1.0;
ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.text_dim));
if is_sel {
ui.painter().circle_stroke(tc, 3.0, Stroke::new(1.0, t.text_dim));
}
if styled {
ui.painter().circle_filled(tc, 3.5, t.text_dim);
}
let sq = egui::pos2(r.right() - 12.0, r.center().y);
if is_sel {
ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(8.0, 8.0)), 0.0, color);
} else if child_sel {
ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color);
}
let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, ROW)), ui.id().with(("selsq", n.id.0)), Sense::click());
if sq_resp.clicked() {
if n.is_layer() {
actions.push(("layer.selectAll".into(), json!({"id": n.id.0})));
} else if ui.input(|i| i.modifiers.shift) {
actions.push(("select.toggle".into(), json!({"id": n.id.0})));
} else {
actions.push(("select.set".into(), json!({"ids": [n.id.0]})));
}
} else if resp.clicked() {
if n.is_layer() {
actions.push(("layer.setCurrent".into(), json!({"id": n.id.0})));
} else {
actions.push(("select.set".into(), json!({"ids": [n.id.0]})));
}
}
if resp.double_clicked() && n.is_layer() {
// Rename via Layer Options (later: inline edit).
}
if has_children && expanded.contains(&n.id.0) {
for c in n.children().unwrap().iter().rev() {
row(ui, doc, c, depth + 1, sel, current, expanded, actions, t);
}
}
}
/// Tiny vector thumbnail painted with egui (fast, no raster).
fn thumb(ui: &Ui, n: &Node, r: egui::Rect) {
let Some(b) = n.visual_bounds() else { return };
let s = ((r.width() - 3.0) as f64 / b.width().max(b.height()).max(1e-6)) as f32;
let off = r.center() - vec2(b.center().x as f32 * s, b.center().y as f32 * s);
let p = ui.painter().with_clip_rect(r);
let mut count = 0;
n.walk(&mut |c| {
if count > 40 {
return;
}
if let Some(pd) = c.path_data() {
count += 1;
let col = c.appearance.fill_paint().color().or(c.appearance.stroke_paint().color()).map(|cc| {
let [a, b2, d, _] = cc.to_rgba8(1.0);
Color32::from_rgb(a, b2, d)
});
if let Some(bb) = pd.bounds() {
let rr = egui::Rect::from_min_max(off + vec2(bb.x0 as f32 * s, bb.y0 as f32 * s), off + vec2(bb.x1 as f32 * s, bb.y1 as f32 * s));
p.rect_filled(rr, 0.0, col.unwrap_or(Color32::from_gray(120)));
}
}
});
}
+487
View File
@@ -0,0 +1,487 @@
//! Panels. Each panel reads engine state and acts only through `app.run(...)`.
pub mod layers;
pub mod properties;
use drawcraft_color::{BlendMode, Color, Paint};
use drawcraft_doc::{AppearanceItem, Node};
use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
use crate::theme::Tokens;
use crate::widgets::{self, dim_label, paint_chip};
use crate::{DrawcraftApp, icons};
/// The first selected node (cloned), if any.
pub fn first_selected(app: &DrawcraftApp) -> Option<Node> {
let st = app.session.active()?;
st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned()
}
pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) {
match id {
"swatches" => swatches(app, ui),
"color" => color(app, ui),
"colorGuide" => color_guide(app, ui),
"stroke" => stroke(app, ui),
"transparency" => transparency(app, ui),
"appearance" => appearance(app, ui),
"graphicStyles" => graphic_styles(app, ui),
"align" => align(app, ui),
"pathfinder" => pathfinder(app, ui),
"transform" => properties::transform_section(app, ui),
"history" => history(app, ui),
"info" => info(app, ui),
"artboards" => artboards(app, ui),
"gradient" => gradient(app, ui),
"character" | "paragraph" => properties::type_sections(app, ui),
"navigator" => navigator(app, ui),
_ => {
dim_label(ui, "This panel is on the roadmap (see the parity plan).");
}
}
}
pub fn libraries(_app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.add_space(20.0);
ui.vertical_centered(|ui| {
icons::icon(ui, "library", 40.0, t.text_dim);
ui.add_space(8.0);
ui.label(egui::RichText::new("Local Libraries").size(14.0).color(t.text));
dim_label(ui, "Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.");
});
}
fn paint_target(app: &DrawcraftApp) -> &'static str {
if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" }
}
pub fn swatches(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else {
dim_label(ui, "Open a document to see its swatches.");
return;
};
let mut all: Vec<(String, Paint)> = st.doc.swatches.iter().map(|s| (s.name.clone(), s.paint.clone())).collect();
for g in &st.doc.swatch_groups {
for s in &g.swatches {
all.push((s.name.clone(), s.paint.clone()));
}
}
let target = paint_target(app);
let mut chosen = None;
ui.horizontal(|ui| {
let (f, s) = (app.session.paint.fill.clone(), app.session.paint.stroke.clone());
let (a, b, _, _) = widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, 34.0);
if a {
app.session.fill_active = true;
}
if b {
app.session.fill_active = false;
}
});
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
for (name, paint) in &all {
let (r, resp) = ui.allocate_exact_size(vec2(19.0, 19.0), Sense::click());
paint_chip(ui, r, paint);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.border }), StrokeKind::Inside);
if resp.on_hover_text(name).clicked() {
chosen = Some(name.clone());
}
}
});
if let Some(name) = chosen {
app.run(target, json!({"swatch": name})).ok();
}
widgets::divider(ui);
ui.horizontal(|ui| {
if widgets::icon_button(ui, "plus", "New Swatch", false, 22.0).clicked() {
app.run("swatch.new", json!({})).ok();
}
});
}
pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) {
let current = match first_selected(app) {
Some(n) => if app.session.fill_active { n.appearance.fill_paint() } else { n.appearance.stroke_paint() },
None => if app.session.fill_active { app.session.paint.fill.clone() } else { app.session.paint.stroke.clone() },
};
let c = current.color().unwrap_or(Color::BLACK);
let mut rgb = c.to_rgba8(1.0);
let mut changed = false;
for (i, lbl) in ["R", "G", "B"].iter().enumerate() {
ui.horizontal(|ui| {
ui.label(*lbl);
let mut v = rgb[i] as i32;
if ui.add(egui::Slider::new(&mut v, 0..=255)).changed() {
rgb[i] = v as u8;
changed = true;
}
});
}
let mut hex = c.to_hex();
ui.horizontal(|ui| {
ui.label("#");
if ui.add(egui::TextEdit::singleline(&mut hex).desired_width(80.0)).lost_focus()
&& let Some(nc) = Color::from_hex(&hex)
{
rgb = nc.to_rgba8(1.0);
changed = true;
}
});
// Spectrum ramp.
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click_and_drag());
let n = 64;
for i in 0..n {
for j in 0..4 {
let h = i as f32 / n as f32 * 360.0;
let v = 1.0 - j as f32 / 4.0 * 0.8;
let [cr, cg, cb, _] = Color::from_hsb(h, 1.0, v).to_rgba8(1.0);
let cell = egui::Rect::from_min_size(r.min + vec2(i as f32 * r.width() / n as f32, j as f32 * 10.0), vec2(r.width() / n as f32 + 0.5, 10.0));
ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb(cr, cg, cb));
}
}
if (resp.clicked() || resp.dragged())
&& let Some(p) = resp.interact_pointer_pos()
{
let h = ((p.x - r.left()) / r.width()).clamp(0.0, 1.0) * 360.0;
let v = 1.0 - ((p.y - r.top()) / r.height()).clamp(0.0, 1.0) * 0.8;
rgb = Color::from_hsb(h, 1.0, v).to_rgba8(1.0);
changed = true;
}
if changed {
let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]);
app.run(paint_target(app), json!({"color": hex})).ok();
}
}
fn color_guide(app: &mut DrawcraftApp, ui: &mut Ui) {
let base = first_selected(app).and_then(|n| n.appearance.fill_paint().color()).or(app.session.paint.fill.color()).unwrap_or(Color::rgb(0.9, 0.3, 0.1));
for h in drawcraft_color::harmony::Harmony::ALL {
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(h.label()).size(11.0)));
for c in h.apply(base) {
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
paint_chip(ui, r, &Paint::solid(c));
if resp.clicked() {
app.run(paint_target(app), json!({"color": c.to_hex()})).ok();
}
}
});
}
}
pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) {
let n = first_selected(app);
let st = n.as_ref().and_then(|n| n.appearance.stroke().cloned());
let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width);
ui.horizontal(|ui| {
dim_label(ui, "Weight:");
if let Some(w) = widgets::num_field(ui, "stroke-w", Some(weight), drawcraft_doc::Unit::Points, 80.0) {
app.run("stroke.set", json!({"weight": w})).ok();
}
});
let cap = st.as_ref().map(|s| s.cap).unwrap_or_default();
let join = st.as_ref().map(|s| s.join).unwrap_or_default();
let align = st.as_ref().map(|s| s.align).unwrap_or_default();
ui.horizontal(|ui| {
dim_label(ui, "Cap:");
for (v, lbl, name) in [(drawcraft_doc::LineCap::Butt, "Butt", "butt"), (drawcraft_doc::LineCap::Round, "Round", "round"), (drawcraft_doc::LineCap::Square, "Projecting", "square")] {
if ui.selectable_label(cap == v, lbl).clicked() {
app.run("stroke.set", json!({"cap": name})).ok();
}
}
});
ui.horizontal(|ui| {
dim_label(ui, "Corner:");
for (v, lbl, name) in [(drawcraft_doc::LineJoin::Miter, "Miter", "miter"), (drawcraft_doc::LineJoin::Round, "Round", "round"), (drawcraft_doc::LineJoin::Bevel, "Bevel", "bevel")] {
if ui.selectable_label(join == v, lbl).clicked() {
app.run("stroke.set", json!({"join": name})).ok();
}
}
});
ui.horizontal(|ui| {
dim_label(ui, "Align:");
for (v, lbl, name) in [(drawcraft_doc::StrokeAlign::Center, "Center", "center"), (drawcraft_doc::StrokeAlign::Inside, "Inside", "inside"), (drawcraft_doc::StrokeAlign::Outside, "Outside", "outside")] {
if ui.selectable_label(align == v, lbl).clicked() {
app.run("stroke.set", json!({"align": name})).ok();
}
}
});
let mut dashed = st.as_ref().is_some_and(|s| s.dash.is_some());
if ui.checkbox(&mut dashed, "Dashed Line").changed() {
app.run("stroke.set", if dashed { json!({"dash": [12, 6]}) } else { json!({"dash": null}) }).ok();
}
ui.horizontal(|ui| {
dim_label(ui, "Arrowheads:");
let end = st.as_ref().and_then(|s| s.end_arrow).map(|a| format!("{a:?}")).unwrap_or("None".into());
let opts = ["None", "Arrow", "Triangle", "Circle", "Square", "Diamond", "Bar"];
if let Some(i) = widgets::dropdown(ui, "arrow-end", &end, &opts, 110.0) {
let v = if i == 0 { json!(null) } else { json!(opts[i]) };
app.run("stroke.set", json!({"endArrow": v})).ok();
}
});
ui.horizontal(|ui| {
dim_label(ui, "Profile:");
let opts = ["Uniform", "Width Profile 1 (lens)", "Taper Start", "Taper End"];
let ids = ["uniform", "lens", "taperStart", "taperEnd"];
if let Some(i) = widgets::dropdown(ui, "profile", "Uniform", &opts, 150.0) {
app.run("stroke.set", json!({"profile": ids[i]})).ok();
}
});
}
fn blend_dropdown(app: &mut DrawcraftApp, ui: &mut Ui, current: BlendMode) {
let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect();
if let Some(i) = widgets::dropdown(ui, "blend", current.label(), &labels, 130.0) {
app.run("object.setProps", json!({"blend": labels[i]})).ok();
}
}
pub fn transparency(app: &mut DrawcraftApp, ui: &mut Ui) {
let n = first_selected(app);
let (blend, op) = n.as_ref().map(|n| (n.blend, n.opacity)).unwrap_or((BlendMode::Normal, 1.0));
ui.horizontal(|ui| {
blend_dropdown(app, ui, blend);
dim_label(ui, "Opacity:");
if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 60.0) {
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok();
}
});
let mut o = op * 100.0;
if ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)).drag_stopped() {
app.run("object.setProps", json!({"opacity": o / 100.0})).ok();
}
}
pub fn appearance(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(n) = first_selected(app) else {
dim_label(ui, "No Selection");
return;
};
ui.label(egui::RichText::new(n.kind_label()).strong());
let items: Vec<(usize, AppearanceItem)> = n.appearance.items.iter().cloned().enumerate().rev().collect();
for (i, it) in items {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.divider));
let (label, paint, extra) = match &it {
AppearanceItem::Fill(f) => ("Fill:", f.paint.clone(), String::new()),
AppearanceItem::Stroke(s) => ("Stroke:", s.paint.clone(), format!("{} pt", s.width)),
};
ui.painter().text(r.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text);
let chip = egui::Rect::from_min_size(r.left_center() + vec2(74.0, -8.0), vec2(16.0, 16.0));
paint_chip(ui, chip, &paint);
ui.painter().text(r.left_center() + vec2(98.0, 0.0), egui::Align2::LEFT_CENTER, extra, egui::FontId::proportional(12.0), t.text_dim);
let del = egui::Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(14.0, 14.0));
let dr = ui.interact(del, ui.id().with(("ap-del", i)), Sense::click());
icons::paint(ui, "trash-2", del, if dr.hovered() { t.text } else { t.text_disabled });
if dr.clicked() {
app.run("appearance.removeItem", json!({"index": i})).ok();
}
icons::paint(ui, "eye", egui::Rect::from_center_size(r.left_center() + vec2(9.0, 0.0), vec2(13.0, 13.0)), t.icon);
}
ui.horizontal(|ui| {
dim_label(ui, "Opacity:");
dim_label(ui, &format!("{:.0}% {}", n.opacity * 100.0, n.blend.label()));
});
widgets::divider(ui);
ui.horizontal(|ui| {
if widgets::flat_button(ui, "+ Stroke", 70.0).clicked() {
app.run("appearance.addStroke", json!({})).ok();
}
if widgets::flat_button(ui, "+ Fill", 60.0).clicked() {
app.run("appearance.addFill", json!({})).ok();
}
if widgets::flat_button(ui, "Clear", 50.0).clicked() {
app.run("appearance.clear", json!({})).ok();
}
});
}
fn graphic_styles(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let styles: Vec<(String, drawcraft_doc::Appearance)> = app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
for (name, ap) in styles {
let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click());
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
let inner = r.shrink(8.0);
paint_chip(ui, inner, &ap.fill_paint());
if let Some(s) = ap.stroke() {
let col = s.paint.color().map(|c| {
let [a, b, cc, _] = c.to_rgba8(1.0);
egui::Color32::from_rgb(a, b, cc)
});
if let Some(col) = col {
ui.painter().rect_stroke(inner, 0.0, Stroke::new((s.width as f32).clamp(1.0, 4.0), col), StrokeKind::Middle);
}
}
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.border }), StrokeKind::Inside);
if resp.on_hover_text(&name).clicked() {
app.run("graphicStyle.apply", json!({"name": name})).ok();
}
}
});
}
pub fn align(app: &mut DrawcraftApp, ui: &mut Ui) {
dim_label(ui, "Align Objects:");
ui.horizontal(|ui| {
for (icon, tip, p) in [
("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})),
("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})),
("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})),
("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})),
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() {
app.run("object.align", p).ok();
}
}
});
dim_label(ui, "Distribute Objects:");
ui.horizontal(|ui| {
for (icon, tip, p) in [
("align-vertical-justify-start", "Vertical Distribute Top", json!({"vertical": "top"})),
("align-vertical-justify-center", "Vertical Distribute Center", json!({"vertical": "center"})),
("align-vertical-justify-end", "Vertical Distribute Bottom", json!({"vertical": "bottom"})),
("align-horizontal-justify-start", "Horizontal Distribute Left", json!({"horizontal": "left"})),
("align-horizontal-justify-center", "Horizontal Distribute Center", json!({"horizontal": "center"})),
("align-horizontal-justify-end", "Horizontal Distribute Right", json!({"horizontal": "right"})),
] {
if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() {
app.run("object.distribute", p).ok();
}
}
});
dim_label(ui, "Distribute Spacing:");
ui.horizontal(|ui| {
if widgets::icon_button(ui, "arrow-up-down", "Vertical Distribute Space", false, 28.0).clicked() {
app.run("object.distributeSpacing", json!({"axis": "vertical"})).ok();
}
if widgets::icon_button(ui, "arrow-left-right", "Horizontal Distribute Space", false, 28.0).clicked() {
app.run("object.distributeSpacing", json!({"axis": "horizontal"})).ok();
}
});
}
pub fn pathfinder(app: &mut DrawcraftApp, ui: &mut Ui) {
dim_label(ui, "Shape Modes:");
ui.horizontal(|ui| {
for (icon, tip, op) in [("squares-unite", "Unite", "unite"), ("squares-subtract", "Minus Front", "minusFront"), ("squares-intersect", "Intersect", "intersect"), ("squares-exclude", "Exclude", "exclude")] {
if widgets::icon_button(ui, icon, tip, false, 30.0).clicked() {
app.run(&format!("object.pathfinder.{op}"), json!({})).ok();
}
}
});
dim_label(ui, "Pathfinders:");
ui.horizontal_wrapped(|ui| {
for (tip, op) in [("Divide", "divide"), ("Trim", "trim"), ("Merge", "merge"), ("Crop", "crop"), ("Outline", "outline"), ("Minus Back", "minusBack")] {
if widgets::flat_button(ui, tip, 72.0).clicked() {
app.run(&format!("object.pathfinder.{op}"), json!({})).ok();
}
}
});
}
fn history(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let undo: Vec<String> = st.history.undo.iter().map(|h| h.label.clone()).collect();
let redo: Vec<String> = st.history.redo.iter().rev().map(|h| h.label.clone()).collect();
let n_undo = undo.len();
let mut steps: Option<i64> = None;
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, "Open", egui::FontId::proportional(12.0), t.text_dim);
if resp.clicked() {
steps = Some(-(n_undo as i64));
}
for (i, l) in undo.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if i + 1 == n_undo {
ui.painter().rect_filled(r, 0.0, t.row_selected);
}
ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, l, egui::FontId::proportional(12.0), t.text);
if resp.clicked() {
steps = Some(i as i64 + 1 - n_undo as i64);
}
}
for (i, l) in redo.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, l, egui::FontId::proportional(12.0), t.text_disabled);
if resp.clicked() {
steps = Some(i as i64 + 1);
}
}
});
if let Some(s) = steps {
let (cmd, n) = if s < 0 { ("edit.undo", -s) } else { ("edit.redo", s) };
for _ in 0..n {
app.run(cmd, json!({})).ok();
}
}
}
fn info(app: &mut DrawcraftApp, ui: &mut Ui) {
let p = app.hover_doc.unwrap_or_default();
ui.monospace(format!("X: {:>9.2} pt Y: {:>9.2} pt", p.x, p.y));
if let Some(st) = app.session.active()
&& let Some(b) = st.doc.bounds_of(&st.selection.objects, false)
{
ui.monospace(format!("W: {:>9.2} pt H: {:>9.2} pt", b.width(), b.height()));
}
if let Some(n) = first_selected(app) {
ui.label(format!("Fill: {}", n.appearance.fill_paint().label()));
ui.label(format!("Stroke: {}", n.appearance.stroke_paint().label()));
}
}
fn artboards(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let abs: Vec<(usize, String)> = app.session.active().map(|d| d.doc.artboards.iter().enumerate().map(|(i, a)| (i, a.name.clone())).collect()).unwrap_or_default();
for (i, name) in abs {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, format!("{}", i + 1), egui::FontId::proportional(12.0), t.text_dim);
ui.painter().text(r.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text);
}
ui.horizontal(|ui| {
if widgets::icon_button(ui, "plus", "New Artboard", false, 22.0).clicked() {
app.run("artboard.new", json!({})).ok();
}
});
}
fn gradient(app: &mut DrawcraftApp, ui: &mut Ui) {
let n = first_selected(app);
let paint = n.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or(Paint::None);
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover());
let preview = if matches!(paint, Paint::Gradient(_)) { paint.clone() } else { Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))) };
paint_chip(ui, r, &preview);
ui.horizontal(|ui| {
dim_label(ui, "Type:");
if widgets::flat_button(ui, "Linear", 60.0).clicked() {
app.run("paint.setFill", json!({"gradient": {"kind": "linear"}})).ok();
}
if widgets::flat_button(ui, "Radial", 60.0).clicked() {
app.run("paint.setFill", json!({"gradient": {"kind": "radial"}})).ok();
}
});
}
fn navigator(app: &mut DrawcraftApp, ui: &mut Ui) {
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
let mut z = zoom * 100.0;
if ui.add(egui::Slider::new(&mut z, 3.13..=6400.0).logarithmic(true).suffix("%")).changed() {
app.run("view.setZoom", json!({"zoom": z})).ok();
}
let _ = CornerRadius::ZERO;
}
+354
View File
@@ -0,0 +1,354 @@
//! The Properties panel: context-sensitive sections like Illustrator's.
use drawcraft_doc::{NodeKind, Unit};
use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
use super::first_selected;
use crate::theme::Tokens;
use crate::widgets::{self, dim_label, divider, section_header};
use crate::{DrawcraftApp, icons};
pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else {
dim_label(ui, "No document open");
return;
};
let n_sel = st.selection.len();
let first = first_selected(app);
let label = match (&first, n_sel) {
(None, _) => "Document".to_string(),
(_, n) if n > 1 => format!("{n} Objects"),
(Some(n), _) => n.kind_label().to_string(),
};
ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
ui.add_space(4.0);
if first.is_none() {
document_sections(app, ui);
return;
}
transform_section(app, ui);
divider(ui);
if matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Text(_))) {
type_sections(app, ui);
divider(ui);
}
appearance_section(app, ui);
divider(ui);
section_header(ui, "Align");
ui.horizontal(|ui| {
for (icon, tip, p) in [
("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})),
("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})),
("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})),
("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})),
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tip, false, 26.0).clicked() {
let mut p = p;
if n_sel == 1 {
p["to"] = json!("artboard");
}
app.run("object.align", p).ok();
}
}
});
if n_sel > 1 {
divider(ui);
section_header(ui, "Pathfinder");
ui.horizontal(|ui| {
for (icon, tip, op) in [("squares-unite", "Unite", "unite"), ("squares-subtract", "Minus Front", "minusFront"), ("squares-intersect", "Intersect", "intersect"), ("squares-exclude", "Exclude", "exclude")] {
if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() {
app.run(&format!("object.pathfinder.{op}"), json!({})).ok();
}
}
if widgets::icon_button(ui, "ellipsis", "More Pathfinder options", false, 28.0).clicked() {
app.ui.open_panel = Some("pathfinder".into());
}
});
}
divider(ui);
section_header(ui, "Quick Actions");
let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. }));
let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(_), .. }));
let mut actions: Vec<(&str, &str)> = vec![];
if n_sel > 1 {
actions.push(("Group", "object.group"));
actions.push(("Make Clipping Mask", "object.clippingMask.make"));
}
if is_group {
actions.push(("Ungroup", "object.ungroup"));
actions.push(("Isolate Group", "object.isolate"));
}
if is_live {
actions.push(("Expand Shape", "object.expandShape"));
}
actions.push(("Offset Path", "object.path.offsetPath"));
actions.push(("Simplify", "object.path.simplify"));
actions.push(("Arrange: Bring to Front", "object.arrange.bringToFront"));
actions.push(("Lock", "object.lock"));
let w = (ui.available_width() - 6.0) / 2.0;
for pair in actions.chunks(2) {
ui.horizontal(|ui| {
for (label, id) in pair {
if widgets::flat_button(ui, label, w).clicked() {
crate::menus::invoke(app, id, json!({}));
}
}
});
}
}
fn document_sections(app: &mut DrawcraftApp, ui: &mut Ui) {
let units = app.session.active().map(|d| d.doc.units).unwrap_or_default();
section_header(ui, "Document");
ui.horizontal(|ui| {
dim_label(ui, "Units");
let labels: Vec<&str> = Unit::ALL.iter().map(|u| u.label()).collect();
if let Some(i) = widgets::dropdown(ui, "units", units.label(), &labels, 140.0) {
app.run("document.setUnits", json!({"units": labels[i]})).ok();
}
});
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
if widgets::flat_button(ui, "Document Setup", w).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, "Edit Artboards", w).clicked() {
app.select_tool("artboard");
}
});
divider(ui);
section_header(ui, "Rulers & Grids");
ui.horizontal(|ui| {
let v = app.ui.view.clone();
if widgets::icon_button(ui, "ruler", "Show Rulers (⌘R)", v.rulers, 26.0).clicked() {
app.run("view.rulers", json!({})).ok();
}
if widgets::icon_button(ui, "grid-3x3", "Show Grid (⌘')", v.grid, 26.0).clicked() {
app.run("view.grid", json!({})).ok();
}
if widgets::icon_button(ui, "square-dashed", "Show Transparency Grid", v.transparency_grid, 26.0).clicked() {
app.run("view.transparencyGrid", json!({})).ok();
}
});
divider(ui);
section_header(ui, "Guides");
ui.horizontal(|ui| {
let v = app.ui.view.clone();
if widgets::icon_button(ui, "layout-grid", "Show Guides", v.guides, 26.0).clicked() {
app.run("view.guides", json!({})).ok();
}
if widgets::icon_button(ui, "sparkles", "Smart Guides (⌘U)", v.smart_guides, 26.0).clicked() {
app.run("view.smartGuides", json!({})).ok();
}
});
divider(ui);
section_header(ui, "Snap Options");
let mut sp = app.ui.view.snap_to_point;
if ui.checkbox(&mut sp, "Snap to Point").changed() {
app.ui.view.snap_to_point = sp;
}
let mut sg = app.ui.view.snap_to_grid;
if ui.checkbox(&mut sg, "Snap to Grid").changed() {
app.ui.view.snap_to_grid = sg;
}
divider(ui);
section_header(ui, "Preferences");
ui.horizontal(|ui| {
dim_label(ui, "Keyboard Increment");
if let Some(v) = widgets::num_field(ui, "kbinc", Some(app.session.prefs.keyboard_increment), units, 80.0) {
app.session.prefs.keyboard_increment = v.max(0.001);
}
});
let mut ss = app.session.prefs.scale_strokes;
if ui.checkbox(&mut ss, "Scale Strokes & Effects").changed() {
app.session.prefs.scale_strokes = ss;
}
divider(ui);
section_header(ui, "Quick Actions");
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
if widgets::flat_button(ui, "Document Setup", w).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, "Preferences", w).clicked() {
app.run("edit.preferences", json!({})).ok();
}
});
}
pub fn transform_section(app: &mut DrawcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
let units = st.doc.units;
let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else {
dim_label(ui, "No Selection");
return;
};
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
let rp = drawcraft_geom::reference_point(b, refi);
section_header(ui, "Transform");
ui.horizontal(|ui| {
if let Some(i) = widgets::reference_point(ui, refi) {
ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i));
}
ui.add_space(6.0);
egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 5.0]).show(ui, |ui| {
let fw = 78.0;
dim_label(ui, "X:");
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) {
app.run("object.setBounds", json!({"x": v, "reference": refi})).ok();
}
dim_label(ui, "W:");
if let Some(v) = widgets::num_field(ui, "tw", Some(b.width()), units, fw) {
app.run("object.setBounds", json!({"width": v, "reference": refi})).ok();
}
ui.end_row();
dim_label(ui, "Y:");
if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y), units, fw) {
app.run("object.setBounds", json!({"y": v, "reference": refi})).ok();
}
dim_label(ui, "H:");
if let Some(v) = widgets::num_field(ui, "th", Some(b.height()), units, fw) {
app.run("object.setBounds", json!({"height": v, "reference": refi})).ok();
}
ui.end_row();
});
});
ui.horizontal(|ui| {
icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
if let Some(a) = widgets::plain_field(ui, "rot", 0.0, "°", 2, 70.0) {
app.run("object.rotate", json!({"angle": a})).ok();
}
ui.add_space(10.0);
if widgets::icon_button(ui, "flip-horizontal-2", "Flip Along Horizontal Axis", false, 24.0).clicked() {
app.run("object.reflect", json!({"axis": "vertical"})).ok();
}
if widgets::icon_button(ui, "flip-vertical-2", "Flip Along Vertical Axis", false, 24.0).clicked() {
app.run("object.reflect", json!({"axis": "horizontal"})).ok();
}
});
// Live shape properties.
if let Some(n) = first_selected(app)
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
{
match live {
drawcraft_doc::LiveShape::Rectangle { radii, .. } => {
ui.horizontal(|ui| {
dim_label(ui, "Corner Radius:");
if let Some(r) = widgets::num_field(ui, "radius", Some(radii[0]), units, 80.0) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
});
}
drawcraft_doc::LiveShape::Polygon { sides, .. } => {
ui.horizontal(|ui| {
dim_label(ui, "Sides:");
if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, 60.0) {
app.run("object.setLiveShape", json!({"sides": s as u32})).ok();
}
});
}
_ => {}
}
}
}
fn appearance_section(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(n) = first_selected(app) else { return };
section_header(ui, "Appearance");
let (fill, stroke, weight) = if let NodeKind::Text(tx) = &n.kind {
let s = tx.first_style();
(s.fill.clone(), s.stroke.clone(), s.stroke_width)
} else {
(n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0))
};
for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] {
ui.horizontal(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
widgets::paint_chip(ui, r.shrink(2.0), &paint);
if !is_fill {
ui.painter().rect_filled(r.shrink(7.0), 0.0, t.panel);
}
ui.painter().rect_stroke(r.shrink(2.0), 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
if resp.clicked() {
app.session.fill_active = is_fill;
app.ui.open_panel = Some("swatches".into());
}
ui.label(egui::RichText::new(label).size(12.0));
if !is_fill {
ui.add_space(8.0);
if let Some(w) = widgets::num_field(ui, "ap-w", Some(weight), Unit::Points, 70.0) {
app.run("stroke.set", json!({"weight": w})).ok();
}
if widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0).clicked() {
app.ui.open_panel = Some("stroke".into());
}
}
});
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Opacity").size(12.0));
ui.add_space(8.0);
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, 64.0) {
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok();
}
if widgets::icon_button(ui, "ellipsis", "Transparency", false, 22.0).clicked() {
app.ui.open_panel = Some("transparency".into());
}
});
ui.horizontal(|ui| {
let r = widgets::flat_button(ui, "fx", 34.0);
if r.on_hover_text("Effects").clicked() {
app.ui.open_panel = Some("appearance".into());
}
if widgets::icon_button(ui, "ellipsis", "Appearance panel", false, 22.0).clicked() {
app.ui.open_panel = Some("appearance".into());
}
});
}
pub fn type_sections(app: &mut DrawcraftApp, ui: &mut Ui) {
let Some(n) = first_selected(app) else {
dim_label(ui, "Select a text object");
return;
};
let NodeKind::Text(tx) = &n.kind else {
dim_label(ui, "Select a text object");
return;
};
let s = tx.first_style();
section_header(ui, "Character");
let fams = drawcraft_text::FontDb::global().families();
let names: Vec<&str> = fams.iter().map(String::as_str).collect();
if let Some(i) = widgets::dropdown(ui, "font", &s.font_family, &names, ui.available_width() - 4.0) {
app.run("text.setStyle", json!({"font": names[i]})).ok();
}
ui.horizontal(|ui| {
dim_label(ui, "Size");
if let Some(v) = widgets::num_field(ui, "fsize", Some(s.size), Unit::Points, 70.0) {
app.run("text.setStyle", json!({"size": v})).ok();
}
dim_label(ui, "Leading");
if let Some(v) = widgets::num_field(ui, "lead", Some(s.effective_leading()), Unit::Points, 70.0) {
app.run("text.setStyle", json!({"leading": v})).ok();
}
});
ui.horizontal(|ui| {
dim_label(ui, "Tracking");
if let Some(v) = widgets::plain_field(ui, "track", s.tracking, "", 0, 60.0) {
app.run("text.setStyle", json!({"tracking": v})).ok();
}
});
section_header(ui, "Paragraph");
ui.horizontal(|ui| {
for (icon, j) in [("align-start-vertical", "left"), ("align-center-vertical", "center"), ("align-end-vertical", "right")] {
if widgets::icon_button(ui, icon, j, false, 24.0).clicked() {
app.run("text.setStyle", json!({"justify": j})).ok();
}
}
});
}
+208
View File
@@ -0,0 +1,208 @@
//! Keyboard shortcuts: command shortcuts from the registry, single-key tool shortcuts, arrows,
//! and tool keys (Enter/Esc/↑/↓ while drawing).
use drawcraft_tools::{Mods, ToolKey};
use egui::{Key, KeyboardShortcut, Modifiers};
use serde_json::json;
use crate::DrawcraftApp;
/// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere.
pub fn parse(s: &str) -> Option<KeyboardShortcut> {
if s.is_empty() {
return None;
}
let (mods_part, key_part) = if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').map(|(m, k)| (m, k)).unwrap_or(("", s)) };
let mut m = Modifiers::NONE;
for part in mods_part.split('+').filter(|p| !p.is_empty()) {
match part {
"Cmd" => m = m | Modifiers::COMMAND,
"Shift" => m = m | Modifiers::SHIFT,
"Alt" => m = m | Modifiers::ALT,
"Ctrl" => m = m | Modifiers::CTRL,
_ => return None,
}
}
let key = match key_part {
"]" => Key::CloseBracket,
"[" => Key::OpenBracket,
";" => Key::Semicolon,
"'" => Key::Quote,
"/" => Key::Slash,
"\\" => Key::Backslash,
"=" => Key::Equals,
"-" => Key::Minus,
"+" => Key::Plus,
"Delete" => Key::Delete,
"Backspace" => Key::Backspace,
"Tab" => Key::Tab,
"~" => Key::Backtick,
k => Key::from_name(k)?,
};
Some(KeyboardShortcut::new(m, key))
}
fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
let mut v: Vec<(KeyboardShortcut, &'static str)> = vec![];
for c in drawcraft_engine::command_specs() {
if let Some(sc) = c.shortcut.and_then(parse) {
v.push((sc, c.id));
}
}
for c in crate::menus::UI_COMMANDS {
if let Some(sc) = parse(c.2) {
v.push((sc, c.0));
}
}
// Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z.
v.sort_by_key(|(sc, _)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8));
v
}
pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
if app.ui.dialog.is_some() || app.ui.palette_open {
if ctx.input(|i| i.key_pressed(Key::Escape)) {
app.ui.dialog = None;
app.ui.palette_open = false;
}
return;
}
let typing = ctx.egui_wants_keyboard_input();
let view = app.view_info();
// Tool keys first (Enter/Escape end paths; arrows change polygon sides while dragging).
let busy = app.session.tool_busy();
for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] {
if !typing && ctx.input(|i| i.key_pressed(k)) {
let _ = app.session.tool_key(tk, Mods::default(), view);
if k == Key::Escape && !busy {
if app.session.active().is_some_and(|d| d.isolation.is_some()) {
let _ = app.run("object.exitIsolation", json!({}));
} else if app.ui.flyout.is_some() {
app.ui.flyout = None;
}
}
}
}
if typing {
return;
}
if busy {
for (k, tk) in [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)] {
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
let _ = app.session.tool_key(tk, Mods::default(), view);
}
}
return;
}
// Command shortcuts.
let mut fire: Option<&'static str> = None;
for (sc, id) in all_shortcuts() {
// Letter / slash keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts, X, D, /).
let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl);
if plain && (sc.logical_key.name().len() == 1 || sc.logical_key == Key::Slash) {
continue;
}
if ctx.input_mut(|i| i.consume_shortcut(&sc)) {
fire = Some(id);
break;
}
}
if let Some(id) = fire {
crate::menus::invoke(app, id, json!({}));
return;
}
// Arrow nudges (Shift = ×10, Alt = copy).
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
for (k, dx, dy) in arrows {
let m = ctx.input(|i| i.modifiers);
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt}));
}
}
// Delete / Backspace clear the selection.
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
let _ = app.run("edit.clear", json!({}));
}
// Single-key tool shortcuts (no Cmd/Ctrl/Alt).
let events: Vec<(String, Modifiers)> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Text(t) => Some((t.clone(), i.modifiers)),
_ => None,
})
.collect()
});
for (text, m) in events {
if m.command || m.alt || m.ctrl {
continue;
}
let upper = text.to_uppercase();
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() };
match key.as_str() {
"X" => {
let _ = app.run("paint.toggleActive", json!({}));
continue;
}
"Shift+X" => {
let _ = app.run("paint.swap", json!({}));
continue;
}
"D" => {
let _ = app.run("paint.default", json!({}));
continue;
}
"/" => {
let _ = app.run("paint.none", json!({}));
continue;
}
"F" => {
let _ = app.run("view.screenMode", json!({}));
continue;
}
"Shift+F" => {
let _ = app.run("view.presentation", json!({}));
continue;
}
_ => {}
}
if let Some(t) = drawcraft_tools::catalog::tool_for_shortcut(&key).or_else(|| drawcraft_tools::catalog::tool_for_shortcut(&text)) {
app.select_tool(t.id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses() {
let s = parse("Cmd+Shift+]").unwrap();
assert_eq!(s.logical_key, Key::CloseBracket);
assert!(s.modifiers.shift && s.modifiers.command);
assert_eq!(parse("Cmd+=").unwrap().logical_key, Key::Equals);
assert_eq!(parse("Cmd+Alt+2").unwrap().logical_key, Key::Num2);
assert_eq!(parse("F12").unwrap().logical_key, Key::F12);
assert!(parse("").is_none());
}
#[test]
fn all_registered_shortcuts_parse() {
for c in drawcraft_engine::command_specs() {
if let Some(s) = c.shortcut
&& s != "D"
&& s != "X"
&& s != "Shift+X"
&& s != "/"
{
assert!(parse(s).is_some(), "{} has unparsable shortcut {s}", c.id);
}
}
for c in crate::menus::UI_COMMANDS {
if !c.2.is_empty() && c.2 != "F" && c.2 != "Shift+F" {
assert!(parse(c.2).is_some(), "{} has unparsable shortcut {}", c.0, c.2);
}
}
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Serializable UI state (read and driven by the control channel).
use drawcraft_geom::Point;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::theme::Brightness;
/// Per-document view (camera).
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct View {
/// Screen points per document point (1.0 = 100%).
pub zoom: f64,
/// Document point at the centre of the canvas.
pub center: Point,
/// False until the view was fitted to the artboard on first display.
pub fitted: bool,
/// View rotation in degrees (Rotate View tool).
pub rotation: f64,
}
impl Default for View {
fn default() -> Self {
Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0 }
}
}
/// Illustrator's preset zoom stops, in percent.
pub const ZOOM_STOPS: [f64; 26] = [
3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0, 4800.0, 6400.0, 12800.0, 25600.0, 64000.0,
];
pub fn next_zoom(z: f64, up: bool) -> f64 {
let pct = z * 100.0;
let stop = if up { ZOOM_STOPS.iter().find(|s| **s > pct + 0.01).copied().unwrap_or(64000.0) } else { ZOOM_STOPS.iter().rev().find(|s| **s < pct - 0.01).copied().unwrap_or(3.13) };
stop / 100.0
}
/// Format like Illustrator's tab/zoom field: up to 2 decimals, no trailing zeros.
pub fn zoom_label(z: f64) -> String {
let s = format!("{:.2}", z * 100.0);
let s = s.trim_end_matches('0').trim_end_matches('.');
format!("{s}%")
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum DockTab {
#[default]
Properties,
Layers,
Libraries,
}
/// Panels that live as collapsed icons in the dock (Essentials Classic).
pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("color", "Color", "palette"),
("colorGuide", "Color Guide", "dc-color-guide"),
("swatches", "Swatches", "swatch-book"),
("brushes", "Brushes", "paintbrush"),
("symbols", "Symbols", "spray-can"),
("stroke", "Stroke", "dc-stroke"),
("gradient", "Gradient", "dc-gradient"),
("transparency", "Transparency", "dc-transparency"),
("appearance", "Appearance", "dc-appearance"),
("graphicStyles", "Graphic Styles", "dc-graphic-styles"),
("artboards", "Artboards", "dc-artboards"),
("transform", "Transform", "dc-transform-panel"),
("align", "Align", "dc-align"),
("pathfinder", "Pathfinder", "dc-pathfinder"),
("character", "Character", "type"),
("paragraph", "Paragraph", "pilcrow"),
("history", "History", "history"),
("info", "Info", "info"),
("navigator", "Navigator", "map"),
];
/// Groups of icon panels separated by dividers in the collapsed column.
pub const ICON_PANEL_GROUPS: &[&[&str]] = &[
&["color", "colorGuide"],
&["swatches", "brushes", "symbols"],
&["stroke", "gradient", "transparency"],
&["appearance", "graphicStyles"],
&["artboards"],
&["transform", "align", "pathfinder"],
&["character", "paragraph"],
&["history", "info", "navigator"],
];
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ViewFlags {
pub rulers: bool,
pub grid: bool,
pub guides: bool,
pub smart_guides: bool,
pub bounding_box: bool,
pub outline: bool,
pub pixel_preview: bool,
pub snap_to_grid: bool,
pub snap_to_point: bool,
pub artboards: bool,
pub transparency_grid: bool,
pub edges: bool,
}
impl Default for ViewFlags {
fn default() -> Self {
Self { rulers: true, grid: false, guides: true, smart_guides: true, bounding_box: true, outline: false, pixel_preview: false, snap_to_grid: false, snap_to_point: true, artboards: true, transparency_grid: false, edges: true }
}
}
/// An open modal dialog. `fields` are string-valued so agents can set them uniformly.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Dialog {
pub kind: String,
pub fields: serde_json::Map<String, Value>,
}
impl Dialog {
pub fn new(kind: &str, fields: Value) -> Self {
Self { kind: kind.to_string(), fields: fields.as_object().cloned().unwrap_or_default() }
}
pub fn f64(&self, k: &str, d: f64) -> f64 {
match self.fields.get(k) {
Some(Value::Number(n)) => n.as_f64().unwrap_or(d),
Some(Value::String(s)) => drawcraft_doc::Unit::Points.parse(s).unwrap_or(d),
_ => d,
}
}
pub fn str(&self, k: &str) -> String {
match self.fields.get(k) {
Some(Value::String(s)) => s.clone(),
Some(v) => v.to_string(),
None => String::new(),
}
}
pub fn bool(&self, k: &str) -> bool {
self.fields.get(k).and_then(Value::as_bool).unwrap_or(false)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState {
pub brightness: Brightness,
pub dock_tab: DockTab,
/// Icon panel currently popped out of the collapsed column.
pub open_panel: Option<String>,
pub control_bar: bool,
pub toolbar: bool,
pub toolbar_double: bool,
pub status_bar: bool,
pub dock: bool,
pub view: ViewFlags,
pub dialog: Option<Dialog>,
/// Toolbar group whose flyout is open.
pub flyout: Option<usize>,
/// Last tool shown for each toolbar group (flyout selection sticks).
pub group_tool: Vec<String>,
pub status: String,
pub palette_open: bool,
pub palette_query: String,
/// Screen mode: 0 normal, 1 full screen with menu, 2 full screen, 3 presentation.
pub screen_mode: u8,
/// Draw Normal / Behind / Inside.
pub draw_mode: u8,
pub about: bool,
}
impl Default for UiState {
fn default() -> Self {
Self {
brightness: Brightness::Dark,
dock_tab: DockTab::Properties,
open_panel: None,
control_bar: true,
toolbar: true,
toolbar_double: false,
status_bar: true,
dock: true,
view: ViewFlags::default(),
dialog: None,
flyout: None,
group_tool: drawcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
status: String::new(),
palette_open: false,
palette_query: String::new(),
screen_mode: 0,
draw_mode: 0,
about: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zoom_steps() {
assert_eq!(next_zoom(1.0, true), 1.5);
assert_eq!(next_zoom(1.0, false), 0.6667);
assert_eq!(next_zoom(640.0, true), 640.0);
assert_eq!(zoom_label(0.6667), "66.67%");
assert_eq!(zoom_label(1.0), "100%");
assert_eq!(zoom_label(0.125), "12.5%");
}
#[test]
fn dialog_fields() {
let d = Dialog::new("rectangle", serde_json::json!({"width": "1in", "height": 50}));
assert_eq!(d.f64("width", 0.0), 72.0);
assert_eq!(d.f64("height", 0.0), 50.0);
}
}
+272
View File
@@ -0,0 +1,272 @@
//! Design tokens (Illustrator's four UI brightness levels), fonts and egui style.
use std::sync::Arc;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Brightness {
#[default]
Dark,
MediumDark,
MediumLight,
Light,
}
impl Brightness {
pub const ALL: [Brightness; 4] = [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light];
pub fn label(self) -> &'static str {
match self {
Brightness::Dark => "Dark",
Brightness::MediumDark => "Medium Dark",
Brightness::MediumLight => "Medium Light",
Brightness::Light => "Light",
}
}
pub fn id(self) -> &'static str {
match self {
Brightness::Dark => "dark",
Brightness::MediumDark => "mediumDark",
Brightness::MediumLight => "mediumLight",
Brightness::Light => "light",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s) || b.label().eq_ignore_ascii_case(s))
}
}
/// All colours and metrics the UI uses. Never hard-code a colour in a widget.
#[derive(Clone, Copy, Debug)]
pub struct Tokens {
pub dark: bool,
pub app_bar: Color32,
pub panel: Color32,
pub panel_darker: Color32,
pub input: Color32,
pub input_border: Color32,
pub border: Color32,
pub divider: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_disabled: Color32,
pub icon: Color32,
pub hover: Color32,
pub tool_active: Color32,
pub accent: Color32,
pub accent_strong: Color32,
pub row_selected: Color32,
pub pasteboard: Color32,
pub ruler: Color32,
pub ruler_tick: Color32,
pub smart_guide: Color32,
pub guide: Color32,
pub measure_bg: Color32,
pub button: Color32,
pub radius: u8,
}
impl Tokens {
pub fn for_brightness(b: Brightness) -> Self {
let hex = |s: u32| Color32::from_rgb((s >> 16) as u8, (s >> 8) as u8, s as u8);
let base = Tokens {
dark: true,
app_bar: hex(0x262626),
panel: hex(0x323232),
panel_darker: hex(0x282828),
input: hex(0x1f1f1f),
input_border: hex(0x464646),
border: hex(0x1b1b1b),
divider: hex(0x3f3f3f),
text: hex(0xd8d8d8),
text_dim: hex(0xa8a8a8),
text_disabled: hex(0x6e6e6e),
icon: hex(0xcfcfcf),
hover: hex(0x444444),
tool_active: hex(0x1d1d1d),
accent: hex(0x378ef0),
accent_strong: hex(0x1473e6),
row_selected: hex(0x484b50),
pasteboard: hex(0x1e1e1e),
ruler: hex(0x2f2f2f),
ruler_tick: hex(0x8a8a8a),
smart_guide: hex(0xff3dfc),
guide: hex(0x4affff),
measure_bg: Color32::from_rgba_unmultiplied(92, 92, 92, 235),
button: hex(0x444444),
radius: 3,
};
match b {
Brightness::Dark => base,
Brightness::MediumDark => Tokens {
app_bar: hex(0x444444),
panel: hex(0x535353),
panel_darker: hex(0x474747),
input: hex(0x3a3a3a),
input_border: hex(0x6a6a6a),
border: hex(0x383838),
divider: hex(0x656565),
text: hex(0xeeeeee),
text_dim: hex(0xc4c4c4),
icon: hex(0xe6e6e6),
hover: hex(0x646464),
tool_active: hex(0x383838),
row_selected: hex(0x6a6d72),
pasteboard: hex(0x494949),
ruler: hex(0x4c4c4c),
ruler_tick: hex(0xb0b0b0),
button: hex(0x656565),
..base
},
Brightness::MediumLight => Tokens {
dark: false,
app_bar: hex(0xa9a9a9),
panel: hex(0xb8b8b8),
panel_darker: hex(0xaaaaaa),
input: hex(0xd6d6d6),
input_border: hex(0x8c8c8c),
border: hex(0x969696),
divider: hex(0x9e9e9e),
text: hex(0x1f1f1f),
text_dim: hex(0x3c3c3c),
text_disabled: hex(0x7c7c7c),
icon: hex(0x2a2a2a),
hover: hex(0xc8c8c8),
tool_active: hex(0x969696),
row_selected: hex(0x9fb4d6),
pasteboard: hex(0xa0a0a0),
ruler: hex(0xc2c2c2),
ruler_tick: hex(0x4a4a4a),
button: hex(0xcacaca),
..base
},
Brightness::Light => Tokens {
dark: false,
app_bar: hex(0xe4e4e4),
panel: hex(0xf0f0f0),
panel_darker: hex(0xe2e2e2),
input: hex(0xffffff),
input_border: hex(0xb4b4b4),
border: hex(0xcfcfcf),
divider: hex(0xd6d6d6),
text: hex(0x1f1f1f),
text_dim: hex(0x4b4b4b),
text_disabled: hex(0x9a9a9a),
icon: hex(0x303030),
hover: hex(0xdddddd),
tool_active: hex(0xcdcdcd),
row_selected: hex(0xc8daf7),
pasteboard: hex(0xdcdcdc),
ruler: hex(0xeaeaea),
ruler_tick: hex(0x5a5a5a),
button: hex(0xe0e0e0),
..base
},
}
}
pub fn get(ctx: &egui::Context) -> Tokens {
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::NULL)).unwrap_or_else(|| Tokens::for_brightness(Brightness::Dark))
}
pub fn cr(&self) -> CornerRadius {
CornerRadius::same(self.radius)
}
}
pub const FONT_UI: &str = "ui";
pub const FONT_UI_SEMIBOLD: &str = "ui-semibold";
pub const FONT_MONO: &str = "mono";
pub fn semibold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(FONT_UI_SEMIBOLD.into()))
}
pub fn mono(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(FONT_MONO.into()))
}
pub fn install_fonts(ctx: &egui::Context) {
let mut fonts = FontDefinitions::default();
let add = |f: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
f.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
};
add(&mut fonts, "SourceSans3", include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"));
add(&mut fonts, "SourceSans3-Semibold", include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"));
add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"));
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut prop = vec!["SourceSans3".to_string()];
prop.extend(fallback.clone());
fonts.families.insert(FontFamily::Proportional, prop);
let mut semi = vec!["SourceSans3-Semibold".to_string()];
semi.extend(fallback.clone());
fonts.families.insert(FontFamily::Name(FONT_UI_SEMIBOLD.into()), semi);
let mut ui = vec!["SourceSans3".to_string()];
ui.extend(fallback.clone());
fonts.families.insert(FontFamily::Name(FONT_UI.into()), ui);
let mut mono = vec!["JetBrainsMono".to_string()];
mono.extend(fallback);
fonts.families.insert(FontFamily::Name(FONT_MONO.into()), mono);
ctx.set_fonts(fonts);
}
/// Apply tokens to egui's global style.
pub fn apply(ctx: &egui::Context, b: Brightness) {
let t = Tokens::for_brightness(b);
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.panel;
v.window_fill = t.panel;
v.extreme_bg_color = t.input;
v.faint_bg_color = t.panel_darker;
v.window_stroke = Stroke::new(1.0, t.border);
v.window_corner_radius = CornerRadius::same(6);
v.menu_corner_radius = CornerRadius::same(6);
v.selection.bg_fill = t.accent.gamma_multiply(0.55);
v.selection.stroke = Stroke::new(1.0, t.accent);
v.hyperlink_color = t.accent;
v.override_text_color = Some(t.text);
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) };
let w = &mut v.widgets;
for (wv, fill) in [(&mut w.noninteractive, t.panel), (&mut w.inactive, t.button), (&mut w.hovered, t.hover), (&mut w.active, t.tool_active), (&mut w.open, t.hover)] {
wv.bg_fill = fill;
wv.weak_bg_fill = fill;
wv.corner_radius = t.cr();
wv.fg_stroke = Stroke::new(1.0, t.text);
}
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
w.inactive.bg_stroke = Stroke::NONE;
w.hovered.bg_stroke = Stroke::new(1.0, t.input_border);
w.active.bg_stroke = Stroke::new(1.0, t.accent);
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 5.0);
s.spacing.button_padding = egui::vec2(6.0, 2.0);
s.spacing.interact_size = egui::vec2(24.0, 22.0);
s.spacing.menu_margin = egui::Margin::same(6);
s.spacing.slider_width = 120.0;
s.text_styles = [
(TextStyle::Small, FontId::proportional(10.5)),
(TextStyle::Body, FontId::proportional(12.5)),
(TextStyle::Button, FontId::proportional(12.5)),
(TextStyle::Heading, FontId::new(15.0, FontFamily::Name(FONT_UI_SEMIBOLD.into()))),
(TextStyle::Monospace, FontId::new(11.5, FontFamily::Name(FONT_MONO.into()))),
]
.into();
s.animation_time = 0.08;
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn brightness_roundtrip() {
for b in Brightness::ALL {
assert_eq!(Brightness::parse(b.id()), Some(b));
assert_eq!(Brightness::parse(b.label()), Some(b));
}
assert!(!Tokens::for_brightness(Brightness::Light).dark);
}
}
+182
View File
@@ -0,0 +1,182 @@
//! The Tools panel: tool groups with flyouts, fill/stroke proxy, colour/gradient/none, drawing
//! modes, screen mode and the Edit Toolbar drawer.
use drawcraft_tools::{TOOL_GROUPS, ToolInfo};
use egui::{CornerRadius, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use crate::theme::Tokens;
use crate::{DrawcraftApp, icons, menus, widgets};
const CELL: f32 = 32.0;
fn tool_tip(t: &ToolInfo) -> String {
match t.shortcut {
Some(s) => format!("{} ({})", t.label, s),
None => t.label.to_string(),
}
}
pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let avail = ui.available_height();
let need = TOOL_GROUPS.len() as f32 * (CELL - 1.0) + 190.0;
let cols = if app.ui.toolbar_double || avail < need { 2 } else { 1 };
let w = CELL * cols as f32 + 10.0;
egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(5, 4)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
// Header: gripper with «/» toggle.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), Sense::click());
let chev = if app.ui.toolbar_double { "chevrons-left" } else { "chevrons-right" };
icons::paint(ui, chev, egui::Rect::from_center_size(hdr.center(), vec2(11.0, 11.0)), if hresp.hovered() { t.text } else { t.text_dim });
if hresp.on_hover_text("Toggle single/double column").clicked() {
app.ui.toolbar_double = !app.ui.toolbar_double;
}
ui.add_space(2.0);
let active = app.session.tool_id();
let mut open_flyout: Option<(usize, egui::Rect)> = None;
let groups: Vec<(usize, &'static [ToolInfo])> = TOOL_GROUPS.iter().copied().enumerate().collect();
for row in groups.chunks(cols) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (gi, group) in row {
let shown_id = app.ui.group_tool.get(*gi).cloned().unwrap_or_else(|| group[0].id.to_string());
let shown = group.iter().find(|x| x.id == shown_id).unwrap_or(&group[0]);
let is_active = group.iter().any(|x| x.id == active);
let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL - 2.0), Sense::click_and_drag());
let bg = if is_active {
t.tool_active
} else if resp.hovered() {
t.hover
} else {
egui::Color32::TRANSPARENT
};
ui.painter().rect_filled(rect.shrink(1.0), CornerRadius::same(4), bg);
let icon = icons::tool_icon(shown.icon);
icons::paint(ui, icon, rect.shrink(7.0), if is_active { t.text } else { t.icon });
if group.len() > 1 {
let c = rect.right_bottom() + vec2(-4.0, -4.0);
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-4.0, 0.0), c + vec2(0.0, -4.0)], t.text_dim, Stroke::NONE));
}
let long_press = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35));
if resp.secondary_clicked() || long_press || (resp.clicked() && ui.input(|i| i.modifiers.alt) && group.len() > 1) {
if ui.input(|i| i.modifiers.alt) && resp.clicked() {
// Option-click cycles through the group.
let idx = group.iter().position(|x| x.id == shown.id).unwrap_or(0);
let next = group[(idx + 1) % group.len()].id;
app.select_tool(next);
} else if group.len() > 1 {
open_flyout = Some((*gi, rect));
}
} else if resp.clicked() {
app.select_tool(shown.id);
}
resp.on_hover_text(tool_tip(shown));
}
});
ui.add_space(1.0);
}
if let Some(f) = open_flyout {
app.ui.flyout = Some(f.0);
ui.data_mut(|d| d.insert_temp(egui::Id::new("flyout-anchor"), f.1));
}
ui.add_space(6.0);
// Fill / stroke proxy.
let (fill, stroke) = (app.session.paint.fill.clone(), app.session.paint.stroke.clone());
let (fill, stroke) = match app.session.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))) {
Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()),
_ => (fill, stroke),
};
ui.vertical_centered(|ui| {
let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 40.0);
if f {
app.session.fill_active = true;
}
if s {
app.session.fill_active = false;
}
if swap {
app.run("paint.swap", json!({})).ok();
}
if def {
app.run("paint.default", json!({})).ok();
}
});
ui.add_space(4.0);
// Color / Gradient / None.
let target = if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" };
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
let s = 10.0;
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
let last = app.session.paint.fill.color().unwrap_or(drawcraft_color::Color::BLACK);
widgets::paint_chip(ui, r, &drawcraft_color::Paint::solid(last));
if resp.on_hover_text("Color (,)").clicked() {
app.run(target, json!({"color": last.to_hex()})).ok();
}
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
widgets::paint_chip(ui, r, &drawcraft_color::Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))));
if resp.on_hover_text("Gradient (.)").clicked() {
app.run(target, json!({"gradient": {"kind": "linear"}})).ok();
}
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
widgets::paint_chip(ui, r, &drawcraft_color::Paint::None);
if resp.on_hover_text("None (/)").clicked() {
app.run(target, json!({"none": true})).ok();
}
});
ui.add_space(6.0);
ui.vertical_centered(|ui| {
let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"];
let names = ["Draw Normal", "Draw Behind", "Draw Inside"];
let m = app.ui.draw_mode as usize % 3;
if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", names[m]), false, 26.0).clicked() {
app.ui.draw_mode = (app.ui.draw_mode + 1) % 3;
}
if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
app.run("view.screenMode", json!({})).ok();
}
if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() {
app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({})));
}
});
});
flyout(app, ui.ctx());
}
fn flyout(app: &mut DrawcraftApp, ctx: &egui::Context) {
let Some(gi) = app.ui.flyout else { return };
let Some(group) = TOOL_GROUPS.get(gi) else { return };
let anchor: egui::Rect = ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0)));
let t = Tokens::get(ctx);
let mut chosen = None;
let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(4.0, 0.0)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_width(230.0);
for tool in group.iter() {
let active = app.session.tool_id() == tool.id;
let (r, resp) = ui.allocate_exact_size(vec2(230.0, 28.0), Sense::click());
if resp.hovered() || active {
ui.painter().rect_filled(r, CornerRadius::same(4), if resp.hovered() { t.accent_strong } else { t.hover });
}
if active {
ui.painter().rect_filled(egui::Rect::from_min_size(r.min + vec2(3.0, 11.0), vec2(4.0, 6.0)), 0.0, t.text);
}
let icon = icons::tool_icon(tool.icon);
icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(12.0, 5.0), vec2(18.0, 18.0)), t.text);
ui.painter().text(r.left_center() + vec2(38.0, 0.0), egui::Align2::LEFT_CENTER, tool.label, egui::FontId::proportional(12.5), t.text);
if let Some(sc) = tool.shortcut {
ui.painter().text(r.right_center() - vec2(10.0, 0.0), egui::Align2::RIGHT_CENTER, sc, egui::FontId::proportional(12.0), t.text_dim);
}
if resp.clicked() {
chosen = Some(tool.id);
}
}
});
});
if let Some(id) = chosen {
app.select_tool(id);
} else if resp.response.clicked_elsewhere() {
app.ui.flyout = None;
}
let _ = menus::pretty_shortcut("");
}
+290
View File
@@ -0,0 +1,290 @@
//! Shared widgets in Illustrator's panel style. Colours come from theme tokens.
use drawcraft_color::Paint;
use drawcraft_doc::Unit;
use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use crate::icons;
use crate::theme::{self, Tokens};
/// Square icon button; `selected` draws the pressed well.
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let bg = if selected {
t.tool_active
} else if resp.hovered() {
t.hover
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(rect, CornerRadius::same(3), bg);
let pad = (size * 0.2).round();
icons::paint(ui, icon, rect.shrink(pad), if selected { t.text } else { t.icon });
if !tip.is_empty() {
resp.on_hover_text(tip)
} else {
resp
}
}
/// Small flat text button (Quick Actions style).
pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click());
let bg = if resp.is_pointer_button_down_on() {
t.tool_active
} else if resp.hovered() {
t.hover
} else {
t.button
};
ui.painter().rect_filled(rect, CornerRadius::same(4), bg);
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(12.0), t.text);
resp
}
/// Blue call-to-action pill (dialog OK/Create).
pub fn primary_button(ui: &mut Ui, text: &str) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(text.to_string(), theme::semibold(12.5), Color32::WHITE);
let w = galley.size().x + 32.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click());
let bg = if resp.hovered() { t.accent } else { t.accent_strong };
ui.painter().rect_filled(rect, CornerRadius::same(14), bg);
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE);
resp
}
/// Outlined secondary pill (dialog Cancel).
pub fn secondary_button(ui: &mut Ui, text: &str) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(text.to_string(), theme::semibold(12.5), t.text);
let w = galley.size().x + 32.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(14), t.hover);
}
ui.painter().rect_stroke(rect, CornerRadius::same(14), Stroke::new(1.5, t.text_dim), StrokeKind::Inside);
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text);
resp
}
/// Bold section header (Properties panel).
pub fn section_header(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(2.0);
ui.label(egui::RichText::new(text).font(theme::semibold(12.0)).color(t.text));
ui.add_space(2.0);
}
/// Full-width 1 px divider with vertical margins.
pub fn divider(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.add_space(6.0);
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover());
ui.painter().rect_filled(rect, 0.0, t.divider);
ui.add_space(6.0);
}
pub fn dim_label(ui: &mut Ui, text: &str) -> Response {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).color(t.text_dim).size(11.5))
}
/// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points)
/// when the user commits (Enter / focus loss). Supports unit suffixes and arithmetic.
pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, unit: Unit, width: f32) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let id = ui.id().with(id);
let shown = value.map(|v| unit.format(v)).unwrap_or_default();
let mut buf: String = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
let editing = ui.memory(|m| m.has_focus(id));
if !editing {
buf = shown.clone();
}
let resp = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(5, 2))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(width - 12.0).font(egui::FontId::proportional(12.0)).text_color(t.text)))
.inner;
let commit = resp.lost_focus() && buf != shown;
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
if commit { unit.parse(&buf) } else { None }
}
/// A plain number field (percent, degrees, counts) with optional suffix.
pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let id = ui.id().with(id);
let shown = {
let s = format!("{:.*}", decimals, value);
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
format!("{s}{suffix}")
};
let editing = ui.memory(|m| m.has_focus(id));
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
let resp = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(5, 2))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(width - 12.0).font(egui::FontId::proportional(12.0)).text_color(t.text)))
.inner;
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
if resp.lost_focus() && buf != shown {
buf.trim().trim_end_matches(suffix.trim()).trim().trim_end_matches(['%', '°']).parse::<f64>().ok()
} else {
None
}
}
/// Draw a paint preview (swatch chip) into `rect`.
pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) {
let p = ui.painter();
match paint {
Paint::None => {
p.rect_filled(rect, 0.0, Color32::WHITE);
p.line_segment([rect.left_bottom(), rect.right_top()], Stroke::new(1.6, Color32::from_rgb(0xe0, 0x20, 0x20)));
}
Paint::Solid { color, .. } => {
let [r, g, b, _] = color.to_rgba8(1.0);
p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b));
}
Paint::Gradient(gp) => {
let n = 24;
let w = rect.width() / n as f32;
for i in 0..n {
let tt = (i as f32 + 0.5) / n as f32;
let (c, _) = gp.gradient.sample(tt);
let [r, g, b, _] = c.to_rgba8(1.0);
let rr = Rect::from_min_size(pos2(rect.left() + i as f32 * w, rect.top()), vec2(w + 0.5, rect.height()));
if gp.gradient.kind == drawcraft_color::GradientKind::Radial {
let (c, _) = gp.gradient.sample(1.0 - tt);
let [r, g, b, _] = c.to_rgba8(1.0);
let s = rect.width().min(rect.height()) * tt / 2.0;
p.circle_filled(rect.center(), s.max(0.5), Color32::from_rgb(r, g, b));
} else {
p.rect_filled(rr, 0.0, Color32::from_rgb(r, g, b));
}
}
}
Paint::Pattern { .. } => {
p.rect_filled(rect, 0.0, Color32::from_gray(200));
for i in 0..4 {
let x = rect.left() + rect.width() * i as f32 / 4.0;
p.line_segment([pos2(x, rect.bottom()), pos2(x + rect.width() / 4.0, rect.top())], Stroke::new(1.0, Color32::from_gray(90)));
}
}
}
}
/// The Fill/Stroke proxy pair (Illustrator's overlapping squares). Returns (fill clicked, stroke clicked, swap, default).
pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: bool, size: f32) -> (bool, bool, bool, bool) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
let s = size * 0.62;
let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s));
let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s));
let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click());
let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click());
let draw_fill = |ui: &Ui| {
paint_chip(ui, fill_r, fill);
ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
};
let draw_stroke = |ui: &Ui| {
let w = s * 0.28;
paint_chip(ui, stroke_r, stroke);
let inner = stroke_r.shrink(w);
ui.painter().rect_filled(inner, 0.0, t.panel);
ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside);
ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
};
if fill_active {
draw_stroke(ui);
draw_fill(ui);
} else {
draw_fill(ui);
draw_stroke(ui);
}
// Swap arrow (top-right) and default (bottom-left) mini buttons.
let swap_r = Rect::from_min_size(pos2(rect.right() - size * 0.3, rect.top()), Vec2::splat(size * 0.3));
let def_r = Rect::from_min_size(pos2(rect.left(), rect.bottom() - size * 0.3), Vec2::splat(size * 0.3));
let swap = ui.interact(swap_r, ui.id().with("swap-proxy"), Sense::click());
let def = ui.interact(def_r, ui.id().with("default-proxy"), Sense::click());
icons::paint(ui, "dc-swap", swap_r.shrink(1.0), if swap.hovered() { t.text } else { t.icon });
let a = Rect::from_min_size(def_r.min + vec2(1.0, 1.0), Vec2::splat(size * 0.14));
let b = Rect::from_min_size(def_r.min + vec2(size * 0.1, size * 0.1), Vec2::splat(size * 0.14));
ui.painter().rect_filled(b, 0.0, Color32::BLACK);
ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside);
ui.painter().rect_filled(a, 0.0, Color32::WHITE);
ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside);
(
fill_resp.on_hover_text("Fill (X)").clicked(),
stroke_resp.on_hover_text("Stroke (X)").clicked(),
swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(),
def.on_hover_text("Default Fill and Stroke (D)").clicked(),
)
}
/// A compact dropdown. Returns the chosen index.
pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let mut chosen = None;
let resp = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, t.input_border))
.corner_radius(CornerRadius::same(3))
.show(ui, |ui| {
egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| {
for (i, o) in options.iter().enumerate() {
if ui.selectable_label(*o == current, *o).clicked() {
chosen = Some(i);
}
}
})
});
let _ = resp;
chosen
}
/// The 3×3 reference point locator. Returns a new index when clicked.
pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let size = 22.0;
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
let mut out = None;
let step = size / 2.0 - 2.5;
ui.painter().rect_stroke(rect.shrink(4.5), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Middle);
for i in 0..9 {
let c = Pos2::new(rect.left() + 3.0 + (i % 3) as f32 * step + 1.5, rect.top() + 3.0 + (i / 3) as f32 * step + 1.5);
let r = Rect::from_center_size(c, Vec2::splat(5.0));
let resp = ui.interact(r.expand(1.5), ui.id().with(("refpt", i)), Sense::click());
if resp.clicked() {
out = Some(i);
}
if i == current {
ui.painter().rect_filled(r, 0.0, t.text);
} else {
ui.painter().rect_filled(r, 0.0, t.panel);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
}
}
out
}
/// A toggle icon (e.g. eye / lock columns) — returns clicked.
pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
if on {
icons::paint(ui, on_icon, rect.shrink(size * 0.2), if resp.hovered() { t.text } else { t.icon });
} else if resp.hovered() {
icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled);
}
resp.on_hover_text(tip).clicked()
}