mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
M0.7/M0.8: egui UI (Illustrator layout), control channel, desktop app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bfee80a22c
commit
bea0c16636
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"}),
|
||||
}
|
||||
}
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
];
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
let r = self.session.execute(id, ¶ms).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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(""), "");
|
||||
}
|
||||
}
|
||||
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user