Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b71d877a0a | ||
|
|
48cfb414ca | ||
|
|
e04385e950 | ||
|
|
4c9d27b4f5 | ||
|
|
45f0dcc397 | ||
|
|
ad28cf4793 |
No files matched your search
+4
-1
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419) and Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -44,6 +44,7 @@ since 2026-10-01.
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
|
||||
@@ -104,7 +105,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 45 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, the smart guide display options, performance, devices…).
|
||||
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
|
||||
@@ -130,6 +130,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("scaleCorners", "General", "Options", "Scale Corners", bool),
|
||||
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
|
||||
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
|
||||
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
|
||||
// Selection & Anchor Display
|
||||
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
|
||||
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
|
||||
|
||||
@@ -89,6 +89,15 @@ pub struct Interaction {
|
||||
pub perspective_again: Option<Value>,
|
||||
}
|
||||
|
||||
/// An open undo group ([`Session::begin_undo_group`]): the edits made in it are one undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UndoGroup {
|
||||
/// The document before the group's first edit, as the undo step that edit recorded keeps it.
|
||||
first: Option<Arc<Document>>,
|
||||
/// The journal's length when the group began: a cancelled group drops the entries after it.
|
||||
journal: usize,
|
||||
}
|
||||
|
||||
/// Per-document editing state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
@@ -112,6 +121,8 @@ pub struct DocState {
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Edits made while this is open are one undo step (a scrubbed numeric field).
|
||||
pub undo_group: Option<UndoGroup>,
|
||||
/// For Object → Transform → Transform Again (⌘D).
|
||||
pub last_transform: Option<(Affine, bool)>,
|
||||
/// Selection saved by Select → Reselect.
|
||||
@@ -166,6 +177,7 @@ impl DocState {
|
||||
layer_rows: vec![],
|
||||
isolation: None,
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
last_transform: None,
|
||||
last_selection_cmd: None,
|
||||
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
@@ -197,11 +209,26 @@ impl DocState {
|
||||
/// Keep what interaction `it` (taken from this document) changed, as one undo step.
|
||||
pub(crate) fn keep_interaction(&mut self, it: Interaction) {
|
||||
if !Arc::ptr_eq(&it.doc, &self.doc) {
|
||||
self.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
|
||||
self.history.redo.clear();
|
||||
self.push_undo(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
|
||||
self.revision += 1;
|
||||
}
|
||||
}
|
||||
/// Record undo step `e` (the document before an edit). In an undo group only the group's first
|
||||
/// edit records one: the edits after it extend that step.
|
||||
fn push_undo(&mut self, e: HistoryEntry) {
|
||||
if let Some(g) = &mut self.undo_group {
|
||||
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
|
||||
if g.first.as_ref().is_some_and(recorded) {
|
||||
return;
|
||||
}
|
||||
g.first = Some(e.doc.clone());
|
||||
}
|
||||
self.history.undo.push(e);
|
||||
if self.history.undo.len() > self.history.limit {
|
||||
self.history.undo.remove(0);
|
||||
}
|
||||
self.history.redo.clear();
|
||||
}
|
||||
/// End the interaction in progress, undoing what it changed.
|
||||
pub(crate) fn undo_interaction(&mut self) {
|
||||
if let Some(it) = self.interaction.take() {
|
||||
@@ -332,6 +359,8 @@ pub struct Prefs {
|
||||
/// Scale Strokes & Effects.
|
||||
pub scale_strokes: bool,
|
||||
pub zoom_with_mouse_wheel: bool,
|
||||
/// A horizontal drag on a numeric field or its label steps its value (#400).
|
||||
pub scrub_numeric_fields: bool,
|
||||
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
|
||||
pub paste_offset: f64,
|
||||
// Selection & Anchor Display
|
||||
@@ -552,6 +581,7 @@ impl Default for Prefs {
|
||||
scale_corners: false,
|
||||
scale_strokes: false,
|
||||
zoom_with_mouse_wheel: false,
|
||||
scrub_numeric_fields: true,
|
||||
paste_offset: 10.0,
|
||||
selection_tolerance: 3.0,
|
||||
object_selection_by_path_only: false,
|
||||
@@ -1066,11 +1096,7 @@ impl Session {
|
||||
st.selection.prune(&st.doc);
|
||||
st.revision += 1;
|
||||
if st.interaction.is_none() {
|
||||
st.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel });
|
||||
if st.history.undo.len() > st.history.limit {
|
||||
st.history.undo.remove(0);
|
||||
}
|
||||
st.history.redo.clear();
|
||||
st.push_undo(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel });
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
@@ -1184,6 +1210,44 @@ impl Session {
|
||||
self.active().is_some_and(|d| d.interaction.is_some())
|
||||
}
|
||||
|
||||
// ---------- undo groups (scrubbed numeric fields) ----------
|
||||
|
||||
/// Open an undo group in the active document: until [`Session::end_undo_group`], the edits
|
||||
/// made there (commands, committed interactions) are one undo step. A scrubbed numeric field
|
||||
/// applies each value it passes as its own command, as a typed value is applied; the drag is
|
||||
/// one step.
|
||||
pub fn begin_undo_group(&mut self) {
|
||||
let journal = self.journal.len();
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.undo_group.is_none()
|
||||
{
|
||||
st.undo_group = Some(UndoGroup { first: None, journal });
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are
|
||||
/// undone and dropped from the journal.
|
||||
pub fn end_undo_group(&mut self, cancel: bool) {
|
||||
let mut journal = None;
|
||||
for st in &mut self.docs {
|
||||
let Some(g) = st.undo_group.take() else { continue };
|
||||
// Only while the group's step is still the newest one.
|
||||
if cancel
|
||||
&& let Some(first) = g.first
|
||||
&& st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, &first))
|
||||
&& let Some(e) = st.history.undo.pop()
|
||||
{
|
||||
st.doc = e.doc;
|
||||
st.selection = e.selection;
|
||||
st.revision += 1;
|
||||
journal = Some(g.journal);
|
||||
}
|
||||
}
|
||||
if let Some(len) = journal {
|
||||
self.journal.truncate(len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands with enablement (for menus, palette, MCP `list_commands`).
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
|
||||
@@ -249,3 +249,50 @@ fn a_failed_batch_gives_back_the_highlighted_rows_and_the_current_layer() {
|
||||
let r = replayed(&s);
|
||||
assert_eq!(documents(&r), documents(&s));
|
||||
}
|
||||
|
||||
/// The edits made in an undo group (a scrubbed numeric field, #400) are one undo step, journaled
|
||||
/// one by one; a cancelled group undoes them and leaves the journal as it was.
|
||||
#[test]
|
||||
fn an_undo_group_is_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let width = |s: &Session| s.transform_box(&s.doc().unwrap().selection.objects).unwrap().rect.width();
|
||||
let undo_len = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let (undo, journal) = (undo_len(&s), s.journal.len());
|
||||
s.begin_undo_group();
|
||||
for w in [101, 102, 103] {
|
||||
s.execute("object.setBounds", &json!({"width": w})).unwrap();
|
||||
}
|
||||
// A live drag committed in the group (the Transparency panel's opacity field) joins it too.
|
||||
s.begin_interaction("Opacity").unwrap();
|
||||
s.preview("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
s.end_undo_group(false);
|
||||
assert!((width(&s) - 103.0).abs() < 1e-9);
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step");
|
||||
assert_eq!(s.journal.len(), journal + 4, "each edit journaled");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((width(&s) - 100.0).abs() < 1e-9, "undo takes the whole drag back");
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((width(&s) - 103.0).abs() < 1e-9);
|
||||
|
||||
// Escape: back where it started, with no trace in the history or the journal.
|
||||
let (undo, journal) = (undo_len(&s), s.journal.len());
|
||||
s.begin_undo_group();
|
||||
s.execute("object.setBounds", &json!({"width": 150})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width": 160})).unwrap();
|
||||
s.end_undo_group(true);
|
||||
assert!((width(&s) - 103.0).abs() < 1e-9);
|
||||
assert_eq!((undo_len(&s), s.journal.len()), (undo, journal));
|
||||
// Closed: the next edits are steps of their own again.
|
||||
s.execute("object.setBounds", &json!({"width": 110})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width": 120})).unwrap();
|
||||
assert_eq!(undo_len(&s), undo + 2);
|
||||
// A group with no edits records nothing, and cancelling it undoes nothing.
|
||||
s.begin_undo_group();
|
||||
s.end_undo_group(true);
|
||||
assert!((width(&s) - 120.0).abs() < 1e-9);
|
||||
assert_eq!(undo_len(&s), undo + 2);
|
||||
}
|
||||
@@ -217,7 +217,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if k == "height" {
|
||||
crate::panels::transform::constrain_link(app, ui);
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
|
||||
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) {
|
||||
app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.open(&mut open)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18)))
|
||||
.show(ctx, |ui| {
|
||||
// Tabs: About · Contributors · Models (the credits are compiled in, see `credits`).
|
||||
|
||||
@@ -180,7 +180,7 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
ui.end_row();
|
||||
for (key, label) in [("superscript", tl!("Superscript:")), ("subscript", tl!("Subscript:"))] {
|
||||
ui.label(egui::RichText::new(label).color(t.text));
|
||||
widgets::field_label(ui, egui::RichText::new(label).color(t.text));
|
||||
for part in ["size", "position"] {
|
||||
let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
if let Some(n) = widgets::plain_field(ui, (key, part), v, "%", 1, 70.0) {
|
||||
@@ -191,7 +191,7 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Small Caps:")).color(t.text));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Small Caps:")).color(t.text));
|
||||
if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) {
|
||||
d.fields.insert("smallCapsSize".into(), json!(n));
|
||||
}
|
||||
|
||||
@@ -17,16 +17,20 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
|
||||
/// [`field`] `width` points wide.
|
||||
pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
|
||||
row_label(ui, label);
|
||||
text(ui, d, key, width);
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// A labelled [`length`] field (one grid row).
|
||||
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
|
||||
/// The label cell of a grid row.
|
||||
fn row_label(ui: &mut egui::Ui, label: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
|
||||
}
|
||||
|
||||
/// A labelled [`length`] field (one grid row).
|
||||
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
|
||||
row_label(ui, label);
|
||||
length(ui, d, key, unit, FIELD_W);
|
||||
ui.end_row();
|
||||
}
|
||||
@@ -57,6 +61,30 @@ fn lengths(kind: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by
|
||||
/// name).
|
||||
fn order(kind: &str) -> &'static [&'static str] {
|
||||
match kind {
|
||||
"offsetPath" => &["offset", "joins", "miterLimit"],
|
||||
"splitIntoGrid" => &["rows", "columns", "gutter"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The choices of a form dialog's ([`grid`]) dropdown field, as (value, label in the UI
|
||||
/// language); empty for a field typed as text.
|
||||
fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> {
|
||||
// The axes in the "axis" context: some languages read the plain Horizontal and Vertical as
|
||||
// type orientations.
|
||||
let axis = |value, label| (value, crate::i18n::tr_ctx(crate::i18n::current(), "axis", label));
|
||||
match (kind, key) {
|
||||
("offsetPath", "joins") => vec![("miter", tl!("Miter")), ("round", tl!("Round")), ("bevel", tl!("Bevel"))],
|
||||
("average", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical"), axis("both", "Both")],
|
||||
("reflect" | "shear", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical")],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed.
|
||||
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool {
|
||||
text_edit(ui, d, key, width).changed()
|
||||
@@ -111,30 +139,49 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
/// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the
|
||||
/// dropdown `widths.1` wide; `options` are (value, label).
|
||||
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) {
|
||||
crate::widgets::label_row(ui, label, widths.0, |ui| dropdown(ui, d, key, widths.1, options, false));
|
||||
}
|
||||
|
||||
/// A dropdown `width` wide bound to the string `d.fields[key]`; `options` are (value, label), the
|
||||
/// labels shown as they are when `translated` (else the dropdown translates them). A value that
|
||||
/// isn't among them shows as it is.
|
||||
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &[(&str, &str)], translated: bool) {
|
||||
let cur = d.str(key);
|
||||
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
|
||||
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
|
||||
crate::widgets::label_row(ui, label, widths.0, |ui| {
|
||||
if let Some((value, _)) = crate::widgets::dropdown(ui, key, shown, &labels, widths.1).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(key.into(), json!(value));
|
||||
}
|
||||
});
|
||||
let chosen = if translated {
|
||||
crate::widgets::dropdown_names(ui, key, shown, &labels, width)
|
||||
} else {
|
||||
crate::widgets::dropdown(ui, key, shown, &labels, width)
|
||||
};
|
||||
if let Some((value, _)) = chosen.and_then(|i| options.get(i)) {
|
||||
d.fields.insert(key.into(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
/// The generic dialog body: a field per non-boolean value (positions and indices hidden), the
|
||||
/// distances in `unit`.
|
||||
/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys
|
||||
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns.
|
||||
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
let lengths = lengths(&d.kind);
|
||||
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
|
||||
let mut keys: Vec<String> = d
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|(k, v)| !matches!(k.as_str(), "x" | "y" | "origin" | "index") && !k.starts_with("__") && !v.is_boolean())
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
// Stable: the fields `order` doesn't name keep their order after the ones it does.
|
||||
keys.sort_by_key(|k| first.iter().position(|f| f == k).unwrap_or(first.len()));
|
||||
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" || k == "origin" || k == "index" || v.is_boolean() {
|
||||
continue;
|
||||
}
|
||||
for k in keys {
|
||||
let options = choices(&d.kind, &k);
|
||||
if lengths.contains(&k.as_str()) {
|
||||
length_field(ui, d, &k, &humanized(&k), unit);
|
||||
} else {
|
||||
} else if options.is_empty() {
|
||||
field(ui, d, &k, &humanized(&k));
|
||||
} else {
|
||||
row_label(ui, &humanized(&k));
|
||||
dropdown(ui, d, &k, FIELD_W, &options, true);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod layer_options;
|
||||
pub mod layers_panel_options;
|
||||
pub mod liquify;
|
||||
pub mod missing_links;
|
||||
pub(crate) mod modal;
|
||||
pub mod new_color_group;
|
||||
mod new_document;
|
||||
pub mod new_swatch;
|
||||
@@ -99,7 +100,6 @@ pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
|
||||
pub use tools::open_tool_dialog;
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
type DialogResult = Result<Value, String>;
|
||||
@@ -342,62 +342,48 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if let Some(window) = spec.window {
|
||||
return window(app, ctx);
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
let mut discard = false;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
let heading = (spec.heading)(&d);
|
||||
egui::Window::new(heading.as_str())
|
||||
// One window per kind, so a dialog never inherits another dialog's size.
|
||||
.id(egui::Id::new(("dialog", d.kind.as_str())))
|
||||
.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(MARGIN)))
|
||||
.show(ctx, |ui| {
|
||||
// Never wider than the window (a large UI scale in a small window): the text wraps.
|
||||
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
|
||||
ui.set_min_width(spec.min_width.min(room));
|
||||
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
|
||||
ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
|
||||
// value, press Enter). Only the body's fields can take it.
|
||||
if focus_first {
|
||||
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
|
||||
modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| {
|
||||
// Never wider than the window (a large UI scale in a small window): the text wraps.
|
||||
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
|
||||
ui.set_min_width(spec.min_width.min(room));
|
||||
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
|
||||
modal::heading(ui, &heading);
|
||||
ui.add_space(12.0);
|
||||
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
|
||||
// value, press Enter). Only the body's fields can take it.
|
||||
if focus_first {
|
||||
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
|
||||
}
|
||||
cancel = (spec.body)(app, ui, &mut d);
|
||||
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
|
||||
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
|
||||
ui.data_mut(|m| m.remove::<bool>(focus_id));
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
// The button row is as wide as the fields above it and as tall as the buttons: a
|
||||
// right-to-left layout would otherwise take all the room left in the window, so the
|
||||
// window could never shrink to its content.
|
||||
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
|
||||
&& widgets::primary_button(ui, label).clicked()
|
||||
{
|
||||
ok = true;
|
||||
}
|
||||
cancel = (spec.body)(app, ui, &mut d);
|
||||
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
|
||||
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
|
||||
ui.data_mut(|m| m.remove::<bool>(focus_id));
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
if let Some(label) = spec.discard {
|
||||
ui.add_space(28.0);
|
||||
discard = widgets::secondary_button(ui, label).clicked();
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
// The button row is as wide as the fields above it and as tall as the buttons: a
|
||||
// right-to-left layout would otherwise take all the room left in the window, so the
|
||||
// window could never shrink to its content.
|
||||
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
|
||||
&& widgets::primary_button(ui, label).clicked()
|
||||
{
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
if let Some(label) = spec.discard {
|
||||
ui.add_space(28.0);
|
||||
discard = widgets::secondary_button(ui, label).clicked();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
ok = true;
|
||||
}
|
||||
@@ -482,3 +468,6 @@ mod tests_scale;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_perspective;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_modal;
|
||||
@@ -0,0 +1,100 @@
|
||||
//! The modal dialog window: a backdrop that keeps the rest of the app from taking clicks, and a
|
||||
//! window with no title bar that opens centred and moves by dragging its heading (the band from its
|
||||
//! top edge down to the heading), as a title bar would. Where it was moved to stays, per window, for
|
||||
//! the session, and the window never leaves the screen.
|
||||
|
||||
use egui::{Id, Rect, Vec2};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// Show the modal dialog window `id` (`title` is its accessible name), `nudge` points below the
|
||||
/// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and
|
||||
/// should start with [`heading`] (or [`drag_band`]) so the window can be moved.
|
||||
pub(crate) fn show<R>(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Area::new(Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| {
|
||||
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(title)
|
||||
.id(id)
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge))
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
|
||||
.show(ctx, add)
|
||||
.and_then(|r| r.inner)
|
||||
}
|
||||
|
||||
/// The dialog's heading, which also moves the window ([`drag_band`]).
|
||||
pub(crate) fn heading(ui: &mut egui::Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Not selectable: a drag on it moves the window instead of selecting its text.
|
||||
let r = ui.add(egui::Label::new(egui::RichText::new(text).font(theme::semibold(16.0)).color(t.text)).selectable(false));
|
||||
drag_band(ui, r.rect.bottom());
|
||||
}
|
||||
|
||||
/// Make the band of the dialog window [`show`] draws `ui` in, from its top edge down to `bottom`, a
|
||||
/// handle that moves the window. Widgets added later on the band stay clickable (they sit above it).
|
||||
pub(crate) fn drag_band(ui: &mut egui::Ui, bottom: f32) {
|
||||
let id = ui.layer_id().id;
|
||||
// Last frame's rectangle: none while the window measures itself the first time it shows.
|
||||
let Some(outer) = ui.ctx().memory(|m| m.area_rect(id)) else { return };
|
||||
let band = Rect::from_x_y_ranges(outer.x_range(), outer.top()..=bottom.max(outer.top()));
|
||||
let r = ui.interact(band, handle(id), egui::Sense::drag());
|
||||
if r.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
} else if r.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
|
||||
/// The drag handle of window `id`.
|
||||
fn handle(id: Id) -> Id {
|
||||
id.with("modal-drag")
|
||||
}
|
||||
|
||||
/// Where window `id` sits, from the screen's centre: `nudge` down until it is first moved. While
|
||||
/// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved
|
||||
/// since the press, kept on screen (applied before the window places itself, so it follows the
|
||||
/// pointer without a frame's lag).
|
||||
fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 {
|
||||
let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start"));
|
||||
let offset = ctx.data(|m| m.get_temp::<Vec2>(key)).unwrap_or(egui::vec2(0.0, nudge));
|
||||
let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged());
|
||||
let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?));
|
||||
let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else {
|
||||
ctx.data_mut(|m| m.remove::<Vec2>(start_key));
|
||||
return offset;
|
||||
};
|
||||
let start = ctx.data_mut(|m| *m.get_temp_mut_or(start_key, offset));
|
||||
let screen = ctx.content_rect();
|
||||
let to = on_screen(Rect::from_center_size(screen.center() + start + moved, rect.size()), screen).center() - screen.center();
|
||||
ctx.data_mut(|m| m.insert_temp(key, to));
|
||||
to
|
||||
}
|
||||
|
||||
/// `r` moved the least to lie inside `screen` (its top left inside when it is larger).
|
||||
fn on_screen(r: Rect, screen: Rect) -> Rect {
|
||||
let x = r.left().min(screen.right() - r.width()).max(screen.left());
|
||||
let y = r.top().min(screen.bottom() - r.height()).max(screen.top());
|
||||
Rect::from_min_size(egui::pos2(x, y), r.size())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_moved_window_stays_on_screen() {
|
||||
let screen = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
let r = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, h));
|
||||
assert_eq!(on_screen(r(100.0, 100.0, 200.0, 100.0), screen), r(100.0, 100.0, 200.0, 100.0));
|
||||
assert_eq!(on_screen(r(-50.0, 550.0, 200.0, 100.0), screen), r(0.0, 500.0, 200.0, 100.0));
|
||||
assert_eq!(on_screen(r(700.0, -20.0, 200.0, 100.0), screen), r(600.0, 0.0, 200.0, 100.0));
|
||||
// Larger than the screen: its top left stays in view.
|
||||
assert_eq!(on_screen(r(-10.0, -10.0, 900.0, 700.0), screen), r(0.0, 0.0, 900.0, 700.0));
|
||||
}
|
||||
}
|
||||
@@ -151,21 +151,9 @@ fn set_layout(d: &mut Dialog, key: &str, v: Value) {
|
||||
d.fields.insert("artboardLayout".into(), l);
|
||||
}
|
||||
|
||||
/// The dimmed backdrop and a centred dialog window in the shared dialog style.
|
||||
/// The New Document dialog window `id` in the shared modal frame.
|
||||
fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(id)
|
||||
.id(egui::Id::new(("dialog", id)))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
|
||||
.show(ctx, add);
|
||||
super::modal::show(ctx, id, egui::Id::new(("dialog", id)), -20.0, margin, add);
|
||||
}
|
||||
|
||||
/// What the buttons of a window asked for.
|
||||
@@ -194,6 +182,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let mut b = Buttons::default();
|
||||
window(ctx, KIND, 0, |ui| {
|
||||
// No heading: the top margin (14) and the empty part of the category tabs' row (30) move the
|
||||
// window.
|
||||
super::modal::drag_band(ui, ui.max_rect().top() + 44.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
@@ -489,7 +480,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
const L: f32 = 150.0;
|
||||
window(ctx, MORE, 22, |ui| {
|
||||
ui.set_width(560.0);
|
||||
ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(16.0)).color(t.text));
|
||||
super::modal::heading(ui, tl!("More Settings"));
|
||||
ui.add_space(12.0);
|
||||
widgets::label_row(ui, tl!("Name:"), L, |ui| {
|
||||
form::text(ui, &mut d, "name", 300.0);
|
||||
@@ -533,7 +524,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
set_layout(&mut d, "spacing", json!(v.max(0.0)));
|
||||
}
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(tl!("Columns:")).color(t.text));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Columns:")).color(t.text));
|
||||
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
|
||||
ui.add_enabled_ui(grid, |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Path dialogs: Average, Offset Path, Simplify and Split Into Grid.
|
||||
//! Path dialogs: Average, Offset Path (with Preview), Simplify and Split Into Grid.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{DialogSpec, run_and_close};
|
||||
use super::{DialogSpec, form, run_and_close};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, preview: true, ..DialogSpec::FORM };
|
||||
|
||||
fn title(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
@@ -17,14 +17,28 @@ fn title(kind: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let (id, params) = match d.kind.as_str() {
|
||||
/// The command the dialog runs and its parameters.
|
||||
fn command(d: &Dialog) -> (&'static str, Value) {
|
||||
match d.kind.as_str() {
|
||||
"average" => ("path.average", json!({"axis": d.str("axis")})),
|
||||
"offsetPath" => {
|
||||
("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)}))
|
||||
}
|
||||
"simplify" => ("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
|
||||
_ => ("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
|
||||
};
|
||||
run_and_close(app, id, params)
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::grid(ui, d, app.session.general_unit());
|
||||
if d.kind == "offsetPath" {
|
||||
let (id, params) = command(d);
|
||||
form::preview(app, ui, d, "Offset Path", id, params);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let (id, params) = command(d);
|
||||
if d.kind == "offsetPath" { form::commit_preview(app, id, params) } else { run_and_close(app, id, params) }
|
||||
}
|
||||
@@ -813,7 +813,7 @@ fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option
|
||||
if let Some(v) = widgets::plain_field(ui, "sfw-w", f64::from(w), " px", 0, 80.0) {
|
||||
set(d, "width", json!(v.round().max(1.0)));
|
||||
}
|
||||
ui.label(tl!("H:"));
|
||||
widgets::field_label(ui, tl!("H:"));
|
||||
if let Some(v) = widgets::plain_field(ui, "sfw-h", f64::from(h), " px", 0, 80.0) {
|
||||
set(d, "height", json!(v.round().max(1.0)));
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
|
||||
choice(ui, d, "images", tl!("Images:"), &IMAGES, None);
|
||||
}
|
||||
choice(ui, d, "objectIds", tl!("Object IDs:"), &OBJECT_IDS, None);
|
||||
ui.label(egui::RichText::new(tl!("Decimal:")).color(t.text_dim));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Decimal:")).color(t.text_dim));
|
||||
let decimals = d.f64("decimals", 3.0);
|
||||
if let Some(v) = widgets::spin_plain(ui, "svg-decimals", decimals, "", 0, 70.0, 1.0, 1.0, &[]) {
|
||||
let (lo, hi) = (*vectorcraft_svg::DECIMALS.start() as f64, *vectorcraft_svg::DECIMALS.end() as f64);
|
||||
|
||||
@@ -146,7 +146,7 @@ pub(super) fn grid<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -
|
||||
|
||||
/// A dimmed field label in the swatch dialogs' grid.
|
||||
pub(super) fn label(ui: &mut egui::Ui, s: &str) {
|
||||
ui.label(egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim));
|
||||
crate::widgets::field_label(ui, egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
|
||||
/// The Swatch Name row of the grid. Returns true when it changed.
|
||||
|
||||
@@ -4,6 +4,7 @@ use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -9,6 +9,7 @@ use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use crate::theme;
|
||||
|
||||
pub(super) type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
|
||||
use super::*;
|
||||
use crate::io;
|
||||
use crate::theme;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! #423: modal dialogs move by their heading (and stay on screen and where they were moved), and
|
||||
//! the form dialogs' choice fields are dropdowns (Offset Path's Joins).
|
||||
|
||||
use egui::{Event, Pos2, Rect, Vec2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
const SCREEN: Vec2 = vec2(1280.0, 900.0);
|
||||
|
||||
/// A context drawing the dialog layer in a 1280 × 900 window.
|
||||
fn ctx() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
ctx
|
||||
}
|
||||
|
||||
/// One frame of the dialog layer with `events`: every text drawn, with its screen rect.
|
||||
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
|
||||
texts
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// Drag with the mouse from `from` by `by`, in steps, then let go.
|
||||
fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: Pos2, by: Vec2) {
|
||||
frame(ctx, app, vec![Event::PointerMoved(from), button(from, true)]);
|
||||
for i in 1..=6 {
|
||||
frame(ctx, app, vec![Event::PointerMoved(from + by * (i as f32 / 6.0))]);
|
||||
}
|
||||
frame(ctx, app, vec![button(from + by, false)]);
|
||||
frame(ctx, app, vec![]);
|
||||
}
|
||||
|
||||
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2) -> Vec<(String, Rect)> {
|
||||
frame(ctx, app, vec![Event::PointerMoved(pos), button(pos, true)]);
|
||||
frame(ctx, app, vec![button(pos, false)]);
|
||||
frame(ctx, app, vec![])
|
||||
}
|
||||
|
||||
/// Frames until a window that just opened has measured and placed itself.
|
||||
fn settle(ctx: &egui::Context, app: &mut VectorcraftApp) {
|
||||
for _ in 0..6 {
|
||||
frame(ctx, app, vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_of(ctx: &egui::Context, id: egui::Id) -> Rect {
|
||||
ctx.memory(|m| m.area_rect(id)).expect("the dialog window was shown")
|
||||
}
|
||||
|
||||
fn app_with_selection() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn close_to(a: Vec2, b: Vec2) -> bool {
|
||||
(a - b).length() < 1.5
|
||||
}
|
||||
|
||||
/// Opens a dialog.
|
||||
type Open = fn(&mut VectorcraftApp);
|
||||
|
||||
/// Every kind of modal window: the shared frame (Offset Path), and the ones drawing their own
|
||||
/// window (New Document without a heading, Preferences, Keyboard Shortcuts).
|
||||
#[test]
|
||||
fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||||
let opens: [(&str, Open, egui::Id); 4] = [
|
||||
("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::new(("dialog", "offsetPath"))),
|
||||
("New Document", open_new_document, egui::Id::new(("dialog", "newDocument"))),
|
||||
("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::new("dialog-preferences")),
|
||||
("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::new("dialog-shortcuts")),
|
||||
];
|
||||
for (name, open, id) in opens {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
open(&mut app);
|
||||
settle(&ctx, &mut app);
|
||||
let start = rect_of(&ctx, id);
|
||||
// Dragging the heading (the band from the top edge down to it) moves the window.
|
||||
drag(&ctx, &mut app, start.left_top() + vec2(40.0, 10.0), vec2(-90.0, 50.0));
|
||||
let moved = rect_of(&ctx, id);
|
||||
assert!(close_to(moved.min - start.min, vec2(-90.0, 50.0)), "{name}: {start:?} → {moved:?}");
|
||||
// A drag that starts below the heading (in the bottom margin) doesn't.
|
||||
drag(&ctx, &mut app, moved.left_bottom() + vec2(6.0, -4.0), vec2(80.0, -80.0));
|
||||
assert_eq!(rect_of(&ctx, id), moved, "{name}: dragging the body moved it");
|
||||
// Dragged far past the window's edge, it stops at the edge.
|
||||
drag(&ctx, &mut app, moved.left_top() + vec2(40.0, 10.0), vec2(5000.0, 5000.0));
|
||||
let edge = rect_of(&ctx, id);
|
||||
assert!(screen.contains_rect(edge.shrink(0.5)), "{name}: off screen at {edge:?}");
|
||||
assert!((edge.right() - screen.right()).abs() < 1.5 && (edge.bottom() - screen.bottom()).abs() < 1.5, "{name}: {edge:?}");
|
||||
// Closed and opened again (this session): where it was left.
|
||||
app.ui.dialog = None;
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
open(&mut app);
|
||||
settle(&ctx, &mut app);
|
||||
assert!(close_to(rect_of(&ctx, id).min - edge.min, Vec2::ZERO), "{name}: reopened at {:?}, left at {edge:?}", rect_of(&ctx, id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset Path's Joins is a dropdown of Miter, Round and Bevel (it was a text field), listed after
|
||||
/// Offset; choosing Round runs the command with round joins.
|
||||
#[test]
|
||||
fn offset_path_joins_is_a_dropdown() {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
let at = |texts: &[(String, Rect)], s: &str| texts.iter().find(|(t, _)| t == s).map(|(_, r)| *r);
|
||||
let (offset, joins, miter) = (at(&texts, "Offset:").unwrap(), at(&texts, "Joins:").unwrap(), at(&texts, "Miter Limit:").unwrap());
|
||||
assert!(offset.top() < joins.top() && joins.top() < miter.top(), "Offset, Joins, Miter Limit: {texts:?}");
|
||||
let current = at(&texts, "Miter").expect("Joins shows its choice");
|
||||
assert!(at(&texts, "miter").is_none(), "Joins is a text field: {texts:?}");
|
||||
// The list: Miter, Round, Bevel.
|
||||
let open = click(&ctx, &mut app, current.center());
|
||||
for choice in ["Round", "Bevel"] {
|
||||
assert!(at(&open, choice).is_some(), "{choice} isn't listed: {open:?}");
|
||||
}
|
||||
let round = at(&open, "Round").unwrap();
|
||||
click(&ctx, &mut app, round.center());
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["joins"], json!("round"));
|
||||
// OK: the same result as the command with round joins (not miter).
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let made = |app: &VectorcraftApp| {
|
||||
let st = app.session.doc().unwrap();
|
||||
format!("{:?}", st.doc.node(st.selection.objects[0]).unwrap().kind)
|
||||
};
|
||||
let direct = |joins: &str| {
|
||||
let mut app = app_with_selection();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("object.path.offsetPath", json!({"offset": 10, "joins": joins, "miterLimit": 4})).unwrap();
|
||||
made(&app)
|
||||
};
|
||||
assert_eq!(made(&app), direct("round"));
|
||||
assert_ne!(made(&app), direct("miter"));
|
||||
}
|
||||
|
||||
/// The other form dialogs' choices are dropdowns too: Average's and Shear's axis.
|
||||
#[test]
|
||||
fn form_dialog_axes_are_dropdowns() {
|
||||
for (id, shown) in [("path.average", "Both"), ("object.shear", "Horizontal"), ("object.reflect", "Vertical")] {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, id, json!({}));
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
assert!(texts.iter().any(|(t, _)| t == shown), "{id}: {texts:?}");
|
||||
assert!(!texts.iter().any(|(t, _)| *t == shown.to_lowercase()), "{id} has a text field: {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The axis choices are translated in the "axis" context: in Japanese the plain Horizontal and
|
||||
/// Vertical are the type orientations (horizontal and vertical writing).
|
||||
#[test]
|
||||
fn axis_choices_read_as_axes_in_every_complete_language() {
|
||||
use crate::i18n::{LANGUAGES, Lang, tr, tr_ctx};
|
||||
let ja = Lang::from_code("ja").unwrap();
|
||||
assert_eq!(tr_ctx(ja, "axis", "Horizontal"), "水平方向");
|
||||
assert_ne!(tr_ctx(ja, "axis", "Horizontal"), tr(ja, "Horizontal"));
|
||||
for l in LANGUAGES.iter().filter(|l| l.complete_menus) {
|
||||
for s in ["Horizontal", "Vertical", "Both"] {
|
||||
assert!(l.source.lines().any(|line| line.starts_with(&format!("axis\t{s}\t"))), "{}: no axis row for {s}", l.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset Path previews on the canvas; Cancel rolls the preview back.
|
||||
#[test]
|
||||
fn offset_path_previews() {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::<usize>();
|
||||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
let texts = if texts.is_empty() { frame(&ctx, &mut app, vec![]) } else { texts };
|
||||
let preview = texts.iter().find(|(t, _)| t == "Preview").map(|(_, r)| *r).expect("a Preview checkbox");
|
||||
click(&ctx, &mut app, preview.center());
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert_eq!(count(&app), 2, "previewed");
|
||||
cancel(&mut app);
|
||||
assert_eq!(count(&app), 1, "rolled back");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
/// The save prompt's rect after a few frames in a window of `physical` pixels at UI scale `zoom`.
|
||||
fn prompt(app: &mut VectorcraftApp, ctx: &egui::Context, physical: egui::Vec2, zoom: f32) -> (egui::Rect, egui::Rect) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
use crate::{VectorcraftApp, font_menu};
|
||||
|
||||
@@ -37,77 +37,66 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let sample = font_menu::sample_text(app);
|
||||
let mut close = false;
|
||||
let mut act: Option<&str> = None;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(tl!("Find Font"))
|
||||
.id(egui::Id::new("dialog-find-font"))
|
||||
.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: &mut Ui| {
|
||||
ui.set_width(380.0);
|
||||
ui.label(egui::RichText::new(tl!("Find Font")).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, &crate::i18n::fmt(tl!("Fonts in Document: {count}"), &[("count", &list.len().to_string())]));
|
||||
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_min_height(120.0);
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
for (i, f) in list.iter().enumerate() {
|
||||
let (note, color) = font_note(f);
|
||||
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
|
||||
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
|
||||
if ui.selectable_label(i == sel, text).clicked() {
|
||||
d.fields.insert("selected".into(), json!(i));
|
||||
}
|
||||
crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::new("dialog-find-font"), -40.0, 22, |ui: &mut Ui| {
|
||||
ui.set_width(380.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Find Font"));
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, &crate::i18n::fmt(tl!("Fonts in Document: {count}"), &[("count", &list.len().to_string())]));
|
||||
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_min_height(120.0);
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
for (i, f) in list.iter().enumerate() {
|
||||
let (note, color) = font_note(f);
|
||||
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
|
||||
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
|
||||
if ui.selectable_label(i == sel, text).clicked() {
|
||||
d.fields.insert("selected".into(), json!(i));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, tl!("Replace With Font"));
|
||||
let fam = d.str("family");
|
||||
ui.horizontal(|ui| {
|
||||
// Picks the font only: nothing is previewed on the document.
|
||||
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
|
||||
if let Some((f, style)) = font_menu::picked(app, pick) {
|
||||
d.fields.insert("family".into(), json!(f));
|
||||
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
|
||||
}
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
|
||||
// "(closest)" is ours; the font's style names are shown as they are.
|
||||
let closest = tl!("(closest)");
|
||||
let mut opts: Vec<&str> = vec![closest];
|
||||
opts.extend(styles.iter().map(String::as_str));
|
||||
let style = d.str("style");
|
||||
let cur = if style.is_empty() { closest } else { style.as_str() };
|
||||
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
|
||||
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
let has = sel < list.len();
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Find"))).clicked() {
|
||||
act = Some("find");
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Change"))).on_hover_text(tl!("In the selected objects")).clicked() {
|
||||
act = Some("change");
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Change All"))).clicked() {
|
||||
act = Some("changeAll");
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("Done")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, tl!("Replace With Font"));
|
||||
let fam = d.str("family");
|
||||
ui.horizontal(|ui| {
|
||||
// Picks the font only: nothing is previewed on the document.
|
||||
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
|
||||
if let Some((f, style)) = font_menu::picked(app, pick) {
|
||||
d.fields.insert("family".into(), json!(f));
|
||||
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
|
||||
}
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
|
||||
// "(closest)" is ours; the font's style names are shown as they are.
|
||||
let closest = tl!("(closest)");
|
||||
let mut opts: Vec<&str> = vec![closest];
|
||||
opts.extend(styles.iter().map(String::as_str));
|
||||
let style = d.str("style");
|
||||
let cur = if style.is_empty() { closest } else { style.as_str() };
|
||||
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
|
||||
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
let has = sel < list.len();
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Find"))).clicked() {
|
||||
act = Some("find");
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Change"))).on_hover_text(tl!("In the selected objects")).clicked() {
|
||||
act = Some("change");
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Change All"))).clicked() {
|
||||
act = Some("changeAll");
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("Done")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
if let (Some(a), Some(from)) = (act, list.get(sel)) {
|
||||
let r = match a {
|
||||
@@ -157,7 +146,7 @@ mod tests {
|
||||
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap();
|
||||
open(&mut app);
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
|
||||
@@ -2880,6 +2880,7 @@
|
||||
Zoom to Selection Zoom a la selección
|
||||
Zoom Tool Herramienta Zoom
|
||||
Zoom with Mouse Wheel Zoom con la rueda del mouse
|
||||
Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando
|
||||
{count} file(s) in {folder}{note}. Show the package folder? {count} archivo(s) en {folder}{note}. ¿Mostrar la carpeta del paquete?
|
||||
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
|
||||
@@ -3013,3 +3014,11 @@
|
||||
Version Versión
|
||||
% of all commits % de todos los commits
|
||||
Lines +/− Líneas +/−
|
||||
Joins Uniones
|
||||
Miter Inglete
|
||||
Bevel Bisel
|
||||
Offset Desplazamiento
|
||||
Axis Eje
|
||||
axis Horizontal Horizontal
|
||||
axis Vertical Vertical
|
||||
axis Both Ambos
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2553,6 +2553,7 @@
|
||||
Zoom Tool ズームツール
|
||||
Zoom to Selection 選択範囲にズーム
|
||||
Zoom with Mouse Wheel マウスホイールでズーム
|
||||
Scrub Numeric Fields by Dragging ドラッグで数値フィールドを調整
|
||||
[Default] is protected: New… starts an editable copy. [Default] は変更できません。「新規…」で編集できる複製を作ります。
|
||||
[Unnamed] [名称未設定]
|
||||
above (対象:次を超える画像)
|
||||
@@ -3002,3 +3003,11 @@
|
||||
Version バージョン
|
||||
% of all commits 全コミットに占める割合
|
||||
Lines +/− 行数 +/−
|
||||
Joins 角の形状
|
||||
Miter マイター
|
||||
Bevel ベベル
|
||||
Offset オフセット
|
||||
Axis 軸
|
||||
axis Horizontal 水平方向
|
||||
axis Vertical 垂直方向
|
||||
axis Both 両方
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2536,6 +2536,7 @@
|
||||
Zoom Tool Ferramenta Zoom
|
||||
Zoom to Selection Ampliar para a Seleção
|
||||
Zoom with Mouse Wheel Zoom com a Roda do Mouse
|
||||
Scrub Numeric Fields by Dragging Ajustar Campos Numéricos Arrastando
|
||||
[Default] is protected: New… starts an editable copy. [Padrão] é protegido: Novo… inicia uma cópia editável.
|
||||
[Unnamed] [Sem Nome]
|
||||
above acima
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2821,6 +2821,7 @@
|
||||
Zoom to Selection 缩放到所选对象
|
||||
Zoom Tool 缩放工具
|
||||
Zoom with Mouse Wheel 使用鼠标滚轮缩放
|
||||
Scrub Numeric Fields by Dragging 拖动以调整数值字段
|
||||
{count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 个文件{note}。要显示打包文件夹吗?
|
||||
{count} linked file(s) not found 找不到 {count} 个链接文件
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? 自存储此文档后,有 {count} 个链接图像已更改。要显示新版本吗?
|
||||
@@ -3260,3 +3261,11 @@
|
||||
Version 版本
|
||||
% of all commits 占全部提交的百分比
|
||||
Lines +/− 行数 +/−
|
||||
Joins 连接
|
||||
Miter 斜接
|
||||
Bevel 斜角
|
||||
Offset 偏移
|
||||
Axis 轴
|
||||
axis Horizontal 水平
|
||||
axis Vertical 垂直
|
||||
axis Both 两者
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2904,6 +2904,7 @@
|
||||
Zoom to Selection 縮放至選取範圍
|
||||
Zoom Tool 縮放工具
|
||||
Zoom with Mouse Wheel 使用滑鼠滾輪縮放
|
||||
Scrub Numeric Fields by Dragging 拖曳以調整數值欄位
|
||||
{count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 個檔案{note}。要顯示封裝資料夾嗎?
|
||||
{count} linked file(s) not found 找不到 {count} 個連結檔案
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? 自儲存此文件後,有 {count} 個連結影像已變更。要顯示新版本嗎?
|
||||
@@ -3033,3 +3034,11 @@
|
||||
Version 版本
|
||||
% of all commits 佔全部提交的比例
|
||||
Lines +/− 行數 +/−
|
||||
Joins 轉角
|
||||
Miter 尖角
|
||||
Bevel 斜角
|
||||
Offset 位移
|
||||
Axis 軸
|
||||
axis Horizontal 水平
|
||||
axis Vertical 垂直
|
||||
axis Both 兩者
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -38,6 +38,7 @@ pub mod prefs_dialog;
|
||||
pub mod print;
|
||||
pub mod recovery;
|
||||
pub mod render_worker;
|
||||
mod scrub;
|
||||
pub mod shortcut_editor;
|
||||
pub mod shortcuts;
|
||||
pub mod state;
|
||||
@@ -107,6 +108,8 @@ mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_saveext;
|
||||
#[cfg(test)]
|
||||
mod tests_scrub;
|
||||
#[cfg(test)]
|
||||
mod tests_selectall;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
@@ -331,6 +334,9 @@ pub struct VectorcraftApp {
|
||||
pub(crate) ime_marked: Option<String>,
|
||||
/// The IME must drop its marked text (see [`Self::take_ime_discard`]).
|
||||
pub(crate) ime_discard: bool,
|
||||
/// A numeric field is being scrubbed: the document's edits meanwhile are one undo step
|
||||
/// ([`scrub::begin_frame`]).
|
||||
scrub_group: bool,
|
||||
}
|
||||
|
||||
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`].
|
||||
@@ -394,6 +400,7 @@ impl VectorcraftApp {
|
||||
synthetic_modifiers: false,
|
||||
ime_marked: None,
|
||||
ime_discard: false,
|
||||
scrub_group: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,6 +871,7 @@ impl VectorcraftApp {
|
||||
return;
|
||||
}
|
||||
let t0 = now_ms();
|
||||
scrub::begin_frame(self, &ctx);
|
||||
font_menu::end_stale_preview(self, &ctx);
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
if self.ui.screen_mode < 2 {
|
||||
@@ -896,6 +904,7 @@ impl VectorcraftApp {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
self.ui_fonts.frame(&ctx);
|
||||
scrub::end_frame(self, &ctx);
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
let _ = json!(null);
|
||||
}
|
||||
|
||||
@@ -227,7 +227,9 @@ pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip));
|
||||
let l =
|
||||
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip));
|
||||
crate::scrub::note_label(ui, l.rect);
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,7 +89,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Indents and paragraph spacing are distances (General); type sizes follow Units ▸ Type.
|
||||
let unit = app.session.general_unit();
|
||||
let label = |ui: &mut Ui, s: &str, tip: &str| {
|
||||
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(s).size(11.5).strong().color(t.text))).on_hover_text(tip);
|
||||
let l = ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(s).size(11.5).strong().color(t.text))).on_hover_text(tip);
|
||||
crate::scrub::note_label(ui, l.rect);
|
||||
};
|
||||
egui::Grid::new("para-grid").num_columns(4).spacing([4.0, 4.0]).show(ui, |ui| {
|
||||
label(ui, "→|", tl!("Left Indent"));
|
||||
|
||||
@@ -299,7 +299,8 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
super::transform::constrain_link(app, ui);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
|
||||
let icon = icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
|
||||
crate::scrub::note_label(ui, icon.rect);
|
||||
// The bounding box's angle: a new value turns the selection to it.
|
||||
if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, 70.0) {
|
||||
app.run("object.rotate", json!({"angle": a, "absolute": true})).ok();
|
||||
@@ -345,7 +346,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
for (label, is_fill) in [(tl!("Fill"), true), (tl!("Stroke"), false)] {
|
||||
ui.horizontal(|ui| {
|
||||
super::paint_chip(app, ui, !is_fill, 22.0, false);
|
||||
ui.label(egui::RichText::new(label).size(12.0));
|
||||
widgets::field_label(ui, 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", weight, app.session.stroke_unit(), 70.0) {
|
||||
@@ -357,7 +358,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Opacity")).size(12.0));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("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)})).ok();
|
||||
|
||||
@@ -110,7 +110,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let hidden: bool = pstate(ui.ctx(), "stroke-hide-options");
|
||||
let label_w = 64.0;
|
||||
let row_label = |ui: &mut Ui, s: &str| {
|
||||
ui.add_sized(vec2(label_w, 24.0), egui::Label::new(egui::RichText::new(s).size(12.5).color(t.text)).halign(egui::Align::RIGHT));
|
||||
let l = ui.add_sized(vec2(label_w, 24.0), egui::Label::new(egui::RichText::new(s).size(12.5).color(t.text)).halign(egui::Align::RIGHT));
|
||||
crate::scrub::note_label(ui, l.rect);
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
row_label(ui, tl!("Weight:"));
|
||||
|
||||
@@ -139,7 +139,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let origin = rp.map(|p| json!([p.x, p.y]));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text(tl!("Rotate"));
|
||||
let icon = icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text(tl!("Rotate"));
|
||||
crate::scrub::note_label(ui, icon.rect);
|
||||
// The bounding box's angle: a new value turns the selection to it.
|
||||
let angle = bx.map_or(0.0, |b| b.angle);
|
||||
if let Some(a) = widgets::spin_plain(ui, "xfp-rot", angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS)
|
||||
@@ -148,7 +149,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin})).ok();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
|
||||
let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
|
||||
crate::scrub::note_label(ui, icon.rect);
|
||||
if let Some(a) = widgets::plain_field(ui, "xfp-shear", 0.0, "°", 1, 56.0)
|
||||
&& a != 0.0
|
||||
{
|
||||
|
||||
@@ -78,6 +78,7 @@ struct Applied {
|
||||
white_canvas: bool,
|
||||
threads: i32,
|
||||
tool_tips: bool,
|
||||
scrub: bool,
|
||||
}
|
||||
|
||||
/// Per frame: push UI-side preferences into egui / the renderer when they change.
|
||||
@@ -88,8 +89,13 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
{
|
||||
app.ui.brightness = b;
|
||||
}
|
||||
let want =
|
||||
Applied { brightness: app.ui.brightness, white_canvas: p.canvas_color == "white", threads: p.render_threads, tool_tips: p.show_tool_tips };
|
||||
let want = Applied {
|
||||
brightness: app.ui.brightness,
|
||||
white_canvas: p.canvas_color == "white",
|
||||
threads: p.render_threads,
|
||||
tool_tips: p.show_tool_tips,
|
||||
scrub: p.scrub_numeric_fields,
|
||||
};
|
||||
let id = egui::Id::new("dc-applied-prefs");
|
||||
let prev: Option<Applied> = ctx.data(|d| d.get_temp::<Option<Applied>>(id)).flatten();
|
||||
if prev != Some(want) {
|
||||
@@ -98,6 +104,7 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// due), whichever widget asks for one.
|
||||
let delay = if want.tool_tips { egui::style::Interaction::default().tooltip_delay } else { f32::INFINITY };
|
||||
ctx.global_style_mut(|s| s.interaction.tooltip_delay = delay);
|
||||
crate::scrub::set_enabled(ctx, want.scrub);
|
||||
if want.white_canvas {
|
||||
let mut t = Tokens::get(ctx);
|
||||
t.pasteboard = egui::Color32::WHITE;
|
||||
@@ -122,81 +129,67 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let (mut ok, mut cancel, mut reset) = (false, false, 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.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
let cat = d.str("__category");
|
||||
let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]);
|
||||
egui::Window::new(tl!("Preferences"))
|
||||
.id(egui::Id::new("dialog-preferences"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(760.0);
|
||||
ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
// Category list.
|
||||
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.set_width(196.0);
|
||||
ui.set_min_height(430.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for c in PREF_CATEGORIES {
|
||||
let sel = *c == cat;
|
||||
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
|
||||
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
|
||||
if ui.add(b).clicked() {
|
||||
d.fields.insert("__category".into(), json!(c));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
// Fields.
|
||||
crate::dialogs::modal::show(ctx, tl!("Preferences"), egui::Id::new("dialog-preferences"), -20.0, 20, |ui| {
|
||||
ui.set_width(760.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Preferences"));
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
// Category list.
|
||||
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||||
ui.set_width(196.0);
|
||||
ui.set_min_height(430.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(530.0);
|
||||
ui.label(egui::RichText::new(tl!(cat)).font(theme::semibold(14.0)).color(t.text_strong));
|
||||
ui.add_space(8.0);
|
||||
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
category_fields(ui, &mut d, cat);
|
||||
});
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for c in PREF_CATEGORIES {
|
||||
let sel = *c == cat;
|
||||
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
|
||||
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
|
||||
if ui.add(b).clicked() {
|
||||
d.fields.insert("__category".into(), json!(c));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::secondary_button(ui, tl!("Reset Preferences"))
|
||||
.on_hover_text(tl!("Restore every preference to its default (applied on OK)"))
|
||||
.clicked()
|
||||
{
|
||||
reset = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
|
||||
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new(tl!("Next"))).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
|
||||
}
|
||||
if ui.add_enabled(i > 0, egui::Button::new(tl!("Previous"))).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
|
||||
}
|
||||
// Fields.
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(530.0);
|
||||
ui.label(egui::RichText::new(tl!(cat)).font(theme::semibold(14.0)).color(t.text_strong));
|
||||
ui.add_space(8.0);
|
||||
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
category_fields(ui, &mut d, cat);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::secondary_button(ui, tl!("Reset Preferences"))
|
||||
.on_hover_text(tl!("Restore every preference to its default (applied on OK)"))
|
||||
.clicked()
|
||||
{
|
||||
reset = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
|
||||
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new(tl!("Next"))).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
|
||||
}
|
||||
if ui.add_enabled(i > 0, egui::Button::new(tl!("Previous"))).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if reset {
|
||||
for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() {
|
||||
d.fields.insert(k, v);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Scrubbing numeric fields (#400, beyond Illustrator; Photoshop's scrubby labels): a horizontal
|
||||
//! drag on a numeric field's label (`W:`, `Opacity:`, the Character panel's size icon) steps the
|
||||
//! field's value by one unit every [`PX_PER_STEP`] points, ten with Shift and a tenth with
|
||||
//! Ctrl/Cmd, as the arrow keys step a focused field; a click on the label focuses the field. The
|
||||
//! text box itself never scrubs, and a field without a label doesn't.
|
||||
//!
|
||||
//! The panels apply each value the drag passes as they apply a typed one, so the art follows the
|
||||
//! drag live; the app holds an undo group open meanwhile ([`begin_frame`], [`end_frame`]), so the
|
||||
//! drag is one undo step and Escape takes it back. Preferences › General › Scrub Numeric Fields by
|
||||
//! Dragging turns it off.
|
||||
|
||||
use egui::{Context, CursorIcon, Id, Key, Rect, Response, Sense, Ui};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Points of horizontal drag per step.
|
||||
pub const PX_PER_STEP: f32 = 2.0;
|
||||
/// The widest gap between a label and the field it names.
|
||||
const LABEL_GAP: f32 = 64.0;
|
||||
|
||||
/// Where the scrub in progress is kept (egui memory).
|
||||
fn state_id() -> Id {
|
||||
Id::new("numeric-field-scrub")
|
||||
}
|
||||
|
||||
/// Where the last label drawn waits for its field.
|
||||
fn label_id() -> Id {
|
||||
Id::new("numeric-field-label")
|
||||
}
|
||||
|
||||
fn enabled_id() -> Id {
|
||||
Id::new("numeric-field-scrub-enabled")
|
||||
}
|
||||
|
||||
/// Turn scrubbing on or off (General › Scrub Numeric Fields by Dragging; on until set).
|
||||
pub(crate) fn set_enabled(ctx: &Context, on: bool) {
|
||||
ctx.data_mut(|d| d.insert_temp(enabled_id(), on));
|
||||
}
|
||||
|
||||
fn enabled(ctx: &Context) -> bool {
|
||||
ctx.data(|d| d.get_temp(enabled_id())).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The scrub in progress.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Scrub {
|
||||
/// The zone dragged: a field or its label.
|
||||
zone: Id,
|
||||
/// The field's value when the drag started (in its units), which Escape puts back.
|
||||
start: f64,
|
||||
/// Drag not applied yet, in the field's units.
|
||||
pending: f64,
|
||||
/// The step `pending` counts in (it changes with Shift and Ctrl/Cmd).
|
||||
step: f64,
|
||||
/// A value was applied.
|
||||
moved: bool,
|
||||
/// Escape ended the drag.
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// Where the scrub of a numeric field stands this frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Phase {
|
||||
Idle,
|
||||
Dragging,
|
||||
/// Released this frame.
|
||||
Released,
|
||||
/// Escape ended it this frame.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub(crate) fn phase(ctx: &Context) -> Phase {
|
||||
let Some(s) = ctx.data(|d| d.get_temp::<Scrub>(state_id())) else { return Phase::Idle };
|
||||
if ctx.drag_stopped_id() == Some(s.zone) {
|
||||
if s.cancelled { Phase::Cancelled } else { Phase::Released }
|
||||
} else if ctx.is_being_dragged(s.zone) {
|
||||
Phase::Dragging
|
||||
} else {
|
||||
Phase::Idle
|
||||
}
|
||||
}
|
||||
|
||||
/// Before the panels draw: while a field is scrubbed, the edits its values make are one undo step.
|
||||
/// (The drag's first frame applies nothing, so the group is open before its first value.)
|
||||
pub(crate) fn begin_frame(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
if !app.scrub_group && matches!(phase(ctx), Phase::Dragging | Phase::Released) {
|
||||
app.session.begin_undo_group();
|
||||
app.scrub_group = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// After the panels: a finished drag closes its undo group; Escape undoes it.
|
||||
pub(crate) fn end_frame(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let phase = phase(ctx);
|
||||
if !app.scrub_group || phase == Phase::Dragging {
|
||||
return;
|
||||
}
|
||||
app.scrub_group = false;
|
||||
app.session.end_undo_group(phase == Phase::Cancelled);
|
||||
app.sync_views();
|
||||
}
|
||||
|
||||
/// A field's label drawn this pass, waiting for the field after it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Label {
|
||||
rect: Rect,
|
||||
pass: u64,
|
||||
}
|
||||
|
||||
/// Note the label (text or icon) drawn at `rect`: a numeric field drawn right after it on the
|
||||
/// same line takes it as its label, which scrubs the field.
|
||||
pub(crate) fn note_label(ui: &Ui, rect: Rect) {
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| d.insert_temp(label_id(), Label { rect, pass }));
|
||||
}
|
||||
|
||||
/// The label noted just before the field at `field`, on its line.
|
||||
fn take_label(ui: &Ui, field: Rect) -> Option<Rect> {
|
||||
let l = ui.data_mut(|d| {
|
||||
let l = d.get_temp::<Label>(label_id());
|
||||
d.remove::<Label>(label_id());
|
||||
l
|
||||
})?;
|
||||
let gap = field.left() - l.rect.right();
|
||||
(l.pass == ui.ctx().cumulative_pass_nr() && (-1.0..=LABEL_GAP).contains(&gap) && field.y_range().contains(l.rect.center().y)).then_some(l.rect)
|
||||
}
|
||||
|
||||
/// Scrub numeric field `field`, its box at `rect`, showing `text`: `value` in its units, rounded
|
||||
/// to `decimals` places. → The value a drag on the field's label steps it to, or the value it
|
||||
/// started at when Escape cancels the drag. A click on the label (or a press held without
|
||||
/// stepping) focuses the field with all of `text` selected. Nothing for a field without a label,
|
||||
/// disabled or showing no value (objects that differ).
|
||||
pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option<f64>, decimals: i32) -> Option<f64> {
|
||||
let label = take_label(ui, rect)?;
|
||||
let value = value.filter(|v| v.is_finite())?;
|
||||
if !enabled(ui.ctx()) || !ui.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
// On top of the label, which may sense clicks itself (selectable text).
|
||||
let resp = ui.interact(label, field.with("scrub-label"), Sense::click_and_drag());
|
||||
drag(ui, &resp, field, text, value, decimals)
|
||||
}
|
||||
|
||||
/// What a drag on `resp`, the label of field `field`, does this frame.
|
||||
fn drag(ui: &Ui, resp: &Response, field: Id, text: &str, value: f64, decimals: i32) -> Option<f64> {
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
if resp.clicked() {
|
||||
crate::widgets::focus_field(ui, field, text);
|
||||
return None;
|
||||
}
|
||||
if resp.drag_started() {
|
||||
// Typing elsewhere ends, as a click elsewhere ends it.
|
||||
ui.memory_mut(|m| m.stop_text_input());
|
||||
let s = Scrub { zone: resp.id, start: value, pending: 0.0, step: 0.0, moved: false, cancelled: false };
|
||||
ui.data_mut(|d| d.insert_temp(state_id(), s));
|
||||
}
|
||||
if !(resp.dragged() || resp.drag_stopped()) {
|
||||
return None;
|
||||
}
|
||||
let mut s = ui.data(|d| d.get_temp::<Scrub>(state_id())).filter(|s| s.zone == resp.id && !s.cancelled)?;
|
||||
// egui ends the drag on Escape: the field goes back to where it started.
|
||||
let out = if ui.input_mut(|i| i.consume_key(i.modifiers, Key::Escape)) {
|
||||
s.cancelled = true;
|
||||
s.moved.then_some(s.start)
|
||||
} else if resp.drag_stopped() {
|
||||
// Held without stepping: a slow click.
|
||||
if !s.moved {
|
||||
crate::widgets::focus_field(ui, field, text);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
step(ui, resp, &mut s, value, decimals)
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(state_id(), s));
|
||||
out
|
||||
}
|
||||
|
||||
/// The value this frame's drag steps `value` to, if it makes a whole step.
|
||||
fn step(ui: &Ui, resp: &Response, s: &mut Scrub, value: f64, decimals: i32) -> Option<f64> {
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let size = if mods.command {
|
||||
0.1
|
||||
} else if mods.shift {
|
||||
10.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// A count steps by whole numbers: Ctrl/Cmd makes it slower instead.
|
||||
let step = f64::max(size, 10f64.powi(-decimals));
|
||||
if step != s.step {
|
||||
(s.step, s.pending) = (step, 0.0);
|
||||
}
|
||||
s.pending += f64::from(resp.drag_delta().x / PX_PER_STEP) * size;
|
||||
// (Nudged off the float error of summed tenths.)
|
||||
let n = (s.pending / step + s.pending.signum() * 1e-9).trunc();
|
||||
// The drag's first frame only starts it: the app opens its undo group on the next.
|
||||
if n == 0.0 || resp.drag_started() {
|
||||
return None;
|
||||
}
|
||||
s.pending -= n * step;
|
||||
s.moved = true;
|
||||
Some(crate::widgets::round_to(value + n * step, decimals))
|
||||
}
|
||||
@@ -521,178 +521,167 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(tl!("Keyboard Shortcuts"))
|
||||
.id(egui::Id::new("dialog-shortcuts"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(640.0);
|
||||
ui.label(egui::RichText::new(tl!("Keyboard Shortcuts")).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
// Set row.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Set:")).color(t.text_dim));
|
||||
let cur = d.str("set");
|
||||
let mut opts: Vec<&str> = PRESETS.to_vec();
|
||||
if !PRESETS.contains(&cur.as_str()) {
|
||||
opts.push(CUSTOM);
|
||||
}
|
||||
// The sets listed are ours (translated); a set read from an imported file shows
|
||||
// its name as it is.
|
||||
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
|
||||
&& let Some(p) = preset(name)
|
||||
{
|
||||
ov = p;
|
||||
d.fields.insert("set".into(), json!(name));
|
||||
d.fields.insert("__message".into(), json!(""));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
if ui.button(tl!("Import…")).clicked() {
|
||||
d.fields.insert("__import".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Export…")).clicked() {
|
||||
d.fields.insert("__export".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Reset to Defaults")).clicked() {
|
||||
ov.clear();
|
||||
d.fields.insert("set".into(), json!(PRESETS[0]));
|
||||
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
// Tabs + search.
|
||||
ui.horizontal(|ui| {
|
||||
let tab = d.str("tab");
|
||||
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
|
||||
if ui.selectable_label(tab == id, egui::RichText::new(tl!(label)).font(theme::semibold(12.5))).clicked() {
|
||||
d.fields.insert("tab".into(), json!(id));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut q = d.str("query");
|
||||
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(200.0)).changed() {
|
||||
d.fields.insert("query".into(), json!(q));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let tools = d.str("tab") != "menu";
|
||||
let q = d.str("query").to_lowercase();
|
||||
let selected = d.str("__selected");
|
||||
let rec = d.str("__recording");
|
||||
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(360.0);
|
||||
ui.label(egui::RichText::new(if tools { tl!("Tool") } else { tl!("Command") }).color(t.text_dim).size(11.0));
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for e in entries().iter().filter(|e| e.is_tool == tools) {
|
||||
let sc = effective_in(&ov, &e.key);
|
||||
let matches = e.label.to_lowercase().contains(&q)
|
||||
|| tl!(&e.label).to_lowercase().contains(&q)
|
||||
|| e.group.to_lowercase().contains(&q)
|
||||
|| tl!(&e.group).to_lowercase().contains(&q)
|
||||
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
|
||||
if !q.is_empty() && !matches {
|
||||
continue;
|
||||
}
|
||||
let row = ui.horizontal(|ui| {
|
||||
let name = if e.is_tool || e.group.is_empty() {
|
||||
tl!(&e.label).to_string()
|
||||
} else {
|
||||
format!("{} › {}", tl!(&e.group), tl!(&e.label))
|
||||
};
|
||||
let r = ui
|
||||
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(360.0);
|
||||
ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate())
|
||||
})
|
||||
.inner;
|
||||
if r.clicked() {
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
let label = if rec == e.key {
|
||||
tl!("Press keys…").to_string()
|
||||
} else {
|
||||
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
|
||||
};
|
||||
let changed = ov.contains_key(&e.key);
|
||||
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
|
||||
let b = ui
|
||||
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
|
||||
.on_hover_text(tl!("Click, then press the new shortcut (Esc cancels)"));
|
||||
if b.clicked() {
|
||||
d.fields.insert("__recording".into(), json!(e.key));
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
});
|
||||
if scroll_to.as_deref() == Some(e.key.as_str()) {
|
||||
row.response.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Selected-row actions + message.
|
||||
ui.horizontal(|ui| {
|
||||
let sel = d.str("__selected");
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
|
||||
let _ = assign(&mut ov, &sel, None, true);
|
||||
d.fields.insert("set".into(), json!(CUSTOM));
|
||||
}
|
||||
if ui.button(tl!("Use Default")).clicked() {
|
||||
reset_one(&mut ov, &sel);
|
||||
}
|
||||
});
|
||||
let conflict = d.str("__conflict");
|
||||
if !conflict.is_empty() && ui.button(tl!("Go to Conflict")).clicked() {
|
||||
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
|
||||
d.fields.insert("tab".into(), json!(tab));
|
||||
d.fields.insert("query".into(), json!(""));
|
||||
d.fields.insert("__selected".into(), json!(conflict));
|
||||
d.fields.insert("__scrollTo".into(), json!(conflict));
|
||||
d.fields.insert("__conflict".into(), json!(""));
|
||||
}
|
||||
});
|
||||
let msg = d.str("__message");
|
||||
if !msg.is_empty() {
|
||||
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
|
||||
crate::dialogs::modal::show(ctx, tl!("Keyboard Shortcuts"), egui::Id::new("dialog-shortcuts"), -20.0, 20, |ui| {
|
||||
ui.set_width(640.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Keyboard Shortcuts"));
|
||||
ui.add_space(10.0);
|
||||
// Set row.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Set:")).color(t.text_dim));
|
||||
let cur = d.str("set");
|
||||
let mut opts: Vec<&str> = PRESETS.to_vec();
|
||||
if !PRESETS.contains(&cur.as_str()) {
|
||||
opts.push(CUSTOM);
|
||||
}
|
||||
let n = all_conflicts(&ov).len();
|
||||
if n > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tn(
|
||||
n as u64,
|
||||
"{n} shortcut is assigned more than once",
|
||||
"{n} shortcuts are assigned more than once",
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.size(11.0),
|
||||
);
|
||||
// The sets listed are ours (translated); a set read from an imported file shows
|
||||
// its name as it is.
|
||||
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
|
||||
&& let Some(p) = preset(name)
|
||||
{
|
||||
ov = p;
|
||||
d.fields.insert("set".into(), json!(name));
|
||||
d.fields.insert("__message".into(), json!(""));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
if ui.button(tl!("Import…")).clicked() {
|
||||
d.fields.insert("__import".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Export…")).clicked() {
|
||||
d.fields.insert("__export".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Reset to Defaults")).clicked() {
|
||||
ov.clear();
|
||||
d.fields.insert("set".into(), json!(PRESETS[0]));
|
||||
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
// Tabs + search.
|
||||
ui.horizontal(|ui| {
|
||||
let tab = d.str("tab");
|
||||
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
|
||||
if ui.selectable_label(tab == id, egui::RichText::new(tl!(label)).font(theme::semibold(12.5))).clicked() {
|
||||
d.fields.insert("tab".into(), json!(id));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut q = d.str("query");
|
||||
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(200.0)).changed() {
|
||||
d.fields.insert("query".into(), json!(q));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let tools = d.str("tab") != "menu";
|
||||
let q = d.str("query").to_lowercase();
|
||||
let selected = d.str("__selected");
|
||||
let rec = d.str("__recording");
|
||||
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(360.0);
|
||||
ui.label(egui::RichText::new(if tools { tl!("Tool") } else { tl!("Command") }).color(t.text_dim).size(11.0));
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for e in entries().iter().filter(|e| e.is_tool == tools) {
|
||||
let sc = effective_in(&ov, &e.key);
|
||||
let matches = e.label.to_lowercase().contains(&q)
|
||||
|| tl!(&e.label).to_lowercase().contains(&q)
|
||||
|| e.group.to_lowercase().contains(&q)
|
||||
|| tl!(&e.group).to_lowercase().contains(&q)
|
||||
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
|
||||
if !q.is_empty() && !matches {
|
||||
continue;
|
||||
}
|
||||
let row = ui.horizontal(|ui| {
|
||||
let name = if e.is_tool || e.group.is_empty() {
|
||||
tl!(&e.label).to_string()
|
||||
} else {
|
||||
format!("{} › {}", tl!(&e.group), tl!(&e.label))
|
||||
};
|
||||
let r = ui
|
||||
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(360.0);
|
||||
ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate())
|
||||
})
|
||||
.inner;
|
||||
if r.clicked() {
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
let label = if rec == e.key {
|
||||
tl!("Press keys…").to_string()
|
||||
} else {
|
||||
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
|
||||
};
|
||||
let changed = ov.contains_key(&e.key);
|
||||
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
|
||||
let b = ui
|
||||
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
|
||||
.on_hover_text(tl!("Click, then press the new shortcut (Esc cancels)"));
|
||||
if b.clicked() {
|
||||
d.fields.insert("__recording".into(), json!(e.key));
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
});
|
||||
if scroll_to.as_deref() == Some(e.key.as_str()) {
|
||||
row.response.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Selected-row actions + message.
|
||||
ui.horizontal(|ui| {
|
||||
let sel = d.str("__selected");
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
|
||||
let _ = assign(&mut ov, &sel, None, true);
|
||||
d.fields.insert("set".into(), json!(CUSTOM));
|
||||
}
|
||||
if ui.button(tl!("Use Default")).clicked() {
|
||||
reset_one(&mut ov, &sel);
|
||||
}
|
||||
});
|
||||
let conflict = d.str("__conflict");
|
||||
if !conflict.is_empty() && ui.button(tl!("Go to Conflict")).clicked() {
|
||||
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
|
||||
d.fields.insert("tab".into(), json!(tab));
|
||||
d.fields.insert("query".into(), json!(""));
|
||||
d.fields.insert("__selected".into(), json!(conflict));
|
||||
d.fields.insert("__scrollTo".into(), json!(conflict));
|
||||
d.fields.insert("__conflict".into(), json!(""));
|
||||
}
|
||||
});
|
||||
let msg = d.str("__message");
|
||||
if !msg.is_empty() {
|
||||
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
|
||||
}
|
||||
let n = all_conflicts(&ov).len();
|
||||
if n > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tn(
|
||||
n as u64,
|
||||
"{n} shortcut is assigned more than once",
|
||||
"{n} shortcuts are assigned more than once",
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or(json!({})));
|
||||
let import = d.fields.remove("__import").is_some();
|
||||
let export = d.fields.remove("__export").is_some();
|
||||
|
||||
@@ -190,6 +190,10 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// Followed before anything returns, so a key typed in a field isn't taken for a paste.
|
||||
let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events));
|
||||
// A numeric field being scrubbed has the keyboard: Escape cancels the drag.
|
||||
if crate::scrub::phase(ctx) != crate::scrub::Phase::Idle {
|
||||
return;
|
||||
}
|
||||
if app.ui.dialog.is_some() || app.ui.palette_open {
|
||||
if crate::shortcut_editor::is_recording(app) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Scrubbing numeric fields by dragging their label (#400): steps, modifiers, clamps, Escape, the
|
||||
//! preference, and one undo step in the app.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use egui::{CursorIcon, Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Unit;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{VectorcraftApp, scrub, widgets};
|
||||
|
||||
/// Headless frames of one labelled field: where its label and its box were last drawn.
|
||||
struct Row {
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
label: Cell<Rect>,
|
||||
field: Cell<Rect>,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn new() -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
Self { ctx, time: 0.0, label: Cell::new(Rect::NOTHING), field: Cell::new(Rect::NOTHING) }
|
||||
}
|
||||
|
||||
/// One frame with `events`, the keyboard holding `modifiers`; `draw` draws the field after
|
||||
/// the label "W:". → What the field returned and the pointer's cursor.
|
||||
fn frame(&mut self, events: Vec<Event>, modifiers: Modifiers, draw: &dyn Fn(&mut egui::Ui) -> Option<f64>) -> (Option<f64>, CursorIcon) {
|
||||
self.time += 0.05;
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0)));
|
||||
let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
let input = egui::RawInput { events, time: Some(self.time), screen_rect, focused: true, ..Default::default() };
|
||||
let mut got = None;
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
self.label.set(widgets::dim_label(ui, "W:").rect);
|
||||
let before = ui.cursor().min;
|
||||
got = draw(ui);
|
||||
self.field.set(Rect::from_min_max(before, ui.min_rect().max));
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
(got, out.platform_output.cursor_icon)
|
||||
}
|
||||
|
||||
/// Drag from `from` by `dx` points in `steps` moves (a frame each) with `modifiers` held, then
|
||||
/// release (`escape`: press Escape before releasing). → The values the field returned.
|
||||
fn drag(
|
||||
&mut self,
|
||||
from: Pos2,
|
||||
dx: f32,
|
||||
steps: usize,
|
||||
modifiers: Modifiers,
|
||||
escape: bool,
|
||||
draw: &dyn Fn(&mut egui::Ui) -> Option<f64>,
|
||||
) -> Vec<f64> {
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers };
|
||||
let mut got = vec![];
|
||||
let mut push = |r: (Option<f64>, CursorIcon)| got.extend(r.0);
|
||||
push(self.frame(vec![Event::PointerMoved(from)], modifiers, draw));
|
||||
push(self.frame(vec![button(from, true)], modifiers, draw));
|
||||
let mut at = from;
|
||||
for _ in 0..steps {
|
||||
at.x += dx / steps as f32;
|
||||
push(self.frame(vec![Event::PointerMoved(at)], modifiers, draw));
|
||||
}
|
||||
if escape {
|
||||
let esc = Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
push(self.frame(vec![esc], modifiers, draw));
|
||||
}
|
||||
push(self.frame(vec![button(at, false)], modifiers, draw));
|
||||
push(self.frame(vec![], Modifiers::NONE, draw));
|
||||
got
|
||||
}
|
||||
}
|
||||
|
||||
/// A W field in points over `width`, applying what it returns (as the panels do).
|
||||
fn w_field(width: &Cell<f64>) -> impl Fn(&mut egui::Ui) -> Option<f64> + '_ {
|
||||
|ui| widgets::num_field(ui, "w", Some(width.get()), Unit::Points, 80.0).inspect(|&v| width.set(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_label_steps_the_field_with_shift_and_ctrl() {
|
||||
for (mods, dx, want) in [
|
||||
// One unit per PX_PER_STEP points, ten with Shift, a tenth with Ctrl/Cmd; left goes down.
|
||||
(Modifiers::NONE, 40.0, 100.0 + 40.0 / scrub::PX_PER_STEP as f64),
|
||||
(Modifiers::NONE, -40.0, 100.0 - 40.0 / scrub::PX_PER_STEP as f64),
|
||||
(Modifiers::SHIFT, 40.0, 100.0 + 400.0 / scrub::PX_PER_STEP as f64),
|
||||
(Modifiers::COMMAND, 40.0, 100.0 + 4.0 / scrub::PX_PER_STEP as f64),
|
||||
] {
|
||||
let mut row = Row::new();
|
||||
let width = Cell::new(100.0);
|
||||
let draw = w_field(&width);
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
let got = row.drag(row.label.get().center(), dx, 4, mods, false, &draw);
|
||||
assert!((width.get() - want).abs() < 1e-9, "{mods:?} {dx}: {} (values {got:?})", width.get());
|
||||
assert!(got.len() > 1, "the values come live while dragging: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_the_label_shows_the_resize_cursor_and_clicking_it_focuses_the_field() {
|
||||
let mut row = Row::new();
|
||||
let width = Cell::new(100.0);
|
||||
let draw = w_field(&width);
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
let (_, cursor) = row.frame(vec![Event::PointerMoved(row.label.get().center())], Modifiers::NONE, &draw);
|
||||
assert_eq!(cursor, CursorIcon::ResizeHorizontal);
|
||||
// The text box keeps the text cursor.
|
||||
let (_, cursor) = row.frame(vec![Event::PointerMoved(row.field.get().center())], Modifiers::NONE, &draw);
|
||||
assert_eq!(cursor, CursorIcon::Text);
|
||||
// A click on the label (no drag) focuses the field, its text all selected.
|
||||
let at = row.label.get().center();
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
row.frame(vec![Event::PointerMoved(at), button(true), button(false)], Modifiers::NONE, &draw);
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
let id = row.ctx.memory(|m| m.focused());
|
||||
assert!(id.is_some(), "the field has the focus");
|
||||
let st = egui::TextEdit::load_state(&row.ctx, id.unwrap()).unwrap();
|
||||
let r = st.cursor.char_range().unwrap().as_sorted_char_range();
|
||||
assert_eq!((r.start.0, r.end.0), (0, "100 pt".len()));
|
||||
assert_eq!(width.get(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_text_box_does_not_scrub() {
|
||||
let mut row = Row::new();
|
||||
let width = Cell::new(100.0);
|
||||
let draw = w_field(&width);
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
let got = row.drag(row.field.get().center(), 40.0, 4, Modifiers::NONE, false, &draw);
|
||||
assert_eq!(width.get(), 100.0, "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_without_a_label_does_not_scrub() {
|
||||
let mut row = Row::new();
|
||||
let width = Cell::new(100.0);
|
||||
// The label is there, but the field is far from it: not its label.
|
||||
let draw = |ui: &mut egui::Ui| {
|
||||
ui.add_space(120.0);
|
||||
widgets::num_field(ui, "w", Some(width.get()), Unit::Points, 80.0).inspect(|&v| width.set(v))
|
||||
};
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
let (_, cursor) = row.frame(vec![Event::PointerMoved(row.label.get().center())], Modifiers::NONE, &draw);
|
||||
assert_ne!(cursor, CursorIcon::ResizeHorizontal);
|
||||
row.drag(row.label.get().center(), 40.0, 4, Modifiers::NONE, false, &draw);
|
||||
assert_eq!(width.get(), 100.0);
|
||||
}
|
||||
|
||||
/// The caller's limits hold, and dragging back changes the value at once (no dead zone past the
|
||||
/// limit); counts step by whole numbers, Ctrl/Cmd only slows them.
|
||||
#[test]
|
||||
fn scrubbing_respects_limits_and_precision() {
|
||||
let mut row = Row::new();
|
||||
let opacity = Cell::new(95.0);
|
||||
let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "o", opacity.get(), "%", 0, 60.0).inspect(|&v| opacity.set(v.clamp(0.0, 100.0)));
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
let at = row.label.get().center();
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
row.frame(vec![Event::PointerMoved(at), button(at, true)], Modifiers::NONE, &draw);
|
||||
for x in [10.0, 20.0, 30.0, 40.0] {
|
||||
row.frame(vec![Event::PointerMoved(at + vec2(x, 0.0))], Modifiers::NONE, &draw);
|
||||
}
|
||||
assert_eq!(opacity.get(), 100.0, "clamped");
|
||||
row.frame(vec![Event::PointerMoved(at + vec2(36.0, 0.0))], Modifiers::NONE, &draw);
|
||||
assert_eq!(opacity.get(), 98.0, "back down at once");
|
||||
row.frame(vec![button(at + vec2(36.0, 0.0), false)], Modifiers::NONE, &draw);
|
||||
|
||||
let mut row = Row::new();
|
||||
let count = Cell::new(3.0);
|
||||
let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "n", count.get(), "", 0, 60.0).inspect(|&v| count.set(v));
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
let got = row.drag(row.label.get().center(), 40.0, 4, Modifiers::COMMAND, false, &draw);
|
||||
assert_eq!(count.get(), 5.0, "{got:?}");
|
||||
assert!(got.iter().all(|v| v.fract() == 0.0), "{got:?}");
|
||||
|
||||
// Units: a millimetre field steps whole millimetres.
|
||||
let mut row = Row::new();
|
||||
let pt = Cell::new(Unit::Millimeters.to_pt(10.0));
|
||||
let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "mm", Some(pt.get()), Unit::Millimeters, 80.0).inspect(|&v| pt.set(v));
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
row.drag(row.label.get().center(), 20.0, 2, Modifiers::NONE, false, &draw);
|
||||
assert!((Unit::Millimeters.from_pt(pt.get()) - 20.0).abs() < 1e-9, "{}", Unit::Millimeters.from_pt(pt.get()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_puts_the_value_back_and_the_preference_turns_scrubbing_off() {
|
||||
let mut row = Row::new();
|
||||
let width = Cell::new(100.0);
|
||||
let draw = w_field(&width);
|
||||
row.frame(vec![], Modifiers::NONE, &draw);
|
||||
let got = row.drag(row.label.get().center(), 40.0, 4, Modifiers::NONE, true, &draw);
|
||||
assert_eq!(width.get(), 100.0, "{got:?}");
|
||||
assert!(got.len() > 1 && got.last() == Some(&100.0), "{got:?}");
|
||||
assert_eq!(scrub::phase(&row.ctx), scrub::Phase::Idle);
|
||||
|
||||
scrub::set_enabled(&row.ctx, false);
|
||||
let (_, cursor) = row.frame(vec![Event::PointerMoved(row.label.get().center())], Modifiers::NONE, &draw);
|
||||
assert_ne!(cursor, CursorIcon::ResizeHorizontal);
|
||||
row.drag(row.label.get().center(), 40.0, 4, Modifiers::NONE, false, &draw);
|
||||
assert_eq!(width.get(), 100.0);
|
||||
}
|
||||
|
||||
// ---------- in the app ----------
|
||||
|
||||
/// One frame of the whole window with `events`. → The rect of the first text drawn as `text`.
|
||||
fn app_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, text: &str) -> Option<Rect> {
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 900.0)));
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
fn find(shape: &egui::Shape, text: &str) -> Option<Rect> {
|
||||
match shape {
|
||||
egui::Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2())),
|
||||
egui::Shape::Vec(v) => v.iter().find_map(|s| find(s, text)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
out.shapes.iter().find_map(|s| find(&s.shape, text))
|
||||
}
|
||||
|
||||
fn app_with_rect() -> (VectorcraftApp, egui::Context) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
(app, egui::Context::default())
|
||||
}
|
||||
|
||||
fn width(app: &VectorcraftApp) -> f64 {
|
||||
app.session.transform_box(&app.session.doc().unwrap().selection.objects).unwrap().rect.width()
|
||||
}
|
||||
|
||||
fn undo_len(app: &VectorcraftApp) -> usize {
|
||||
app.session.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Drag the W label by `dx` in four moves (`escape`: Escape before the release).
|
||||
fn drag_w(app: &mut VectorcraftApp, ctx: &egui::Context, dx: f32, escape: bool) {
|
||||
let mut w = None;
|
||||
for _ in 0..4 {
|
||||
w = app_frame(app, ctx, vec![], "W:");
|
||||
}
|
||||
let at = w.expect("the W label is shown").center();
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
app_frame(app, ctx, vec![Event::PointerMoved(at)], "");
|
||||
app_frame(app, ctx, vec![button(at, true)], "");
|
||||
for i in 1..=4 {
|
||||
app_frame(app, ctx, vec![Event::PointerMoved(at + vec2(dx * i as f32 / 4.0, 0.0))], "");
|
||||
}
|
||||
if escape {
|
||||
app_frame(app, ctx, vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], "");
|
||||
}
|
||||
app_frame(app, ctx, vec![button(at + vec2(dx, 0.0), false)], "");
|
||||
app_frame(app, ctx, vec![], "");
|
||||
}
|
||||
|
||||
/// A scrub of the W label resizes the selection live and is one undo step; Escape takes it back
|
||||
/// without a step (and without deselecting); with the preference off the label doesn't scrub.
|
||||
#[test]
|
||||
fn scrubbing_w_in_the_app_is_one_undo_step() {
|
||||
let (mut app, ctx) = app_with_rect();
|
||||
let undo = undo_len(&app);
|
||||
drag_w(&mut app, &ctx, 40.0, false);
|
||||
assert!((width(&app) - (100.0 + 40.0 / scrub::PX_PER_STEP as f64)).abs() < 1e-6, "{}", width(&app));
|
||||
assert_eq!(undo_len(&app), undo + 1, "one undo step for the drag");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!((width(&app) - 100.0).abs() < 1e-6);
|
||||
|
||||
drag_w(&mut app, &ctx, 40.0, true);
|
||||
assert!((width(&app) - 100.0).abs() < 1e-6, "Escape: {}", width(&app));
|
||||
assert_eq!(undo_len(&app), undo, "no step");
|
||||
assert!(!app.session.doc().unwrap().selection.objects.is_empty(), "Escape didn't deselect");
|
||||
|
||||
app.run("prefs.set", json!({"key": "scrubNumericFields", "value": false})).unwrap();
|
||||
drag_w(&mut app, &ctx, 40.0, false);
|
||||
assert!((width(&app) - 100.0).abs() < 1e-6, "off: {}", width(&app));
|
||||
}
|
||||
@@ -4,8 +4,8 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKin
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::Unit;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{icons, scrub};
|
||||
|
||||
/// Square icon button; `selected` draws the pressed well.
|
||||
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response {
|
||||
@@ -87,8 +87,20 @@ pub fn divider(ui: &mut Ui) {
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// A label in the panels' text colour; before a numeric field it scrubs the field
|
||||
/// ([`scrub`]).
|
||||
pub fn dim_label(ui: &mut Ui, text: &str) -> Response {
|
||||
dim_name(ui, tl!(text))
|
||||
let resp = dim_name(ui, tl!(text));
|
||||
scrub::note_label(ui, resp.rect);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A field's label in other styles (`text` as given): before a numeric field it scrubs the field
|
||||
/// ([`scrub`]).
|
||||
pub fn field_label(ui: &mut Ui, text: impl Into<egui::WidgetText>) -> Response {
|
||||
let resp = ui.label(text);
|
||||
scrub::note_label(ui, resp.rect);
|
||||
resp
|
||||
}
|
||||
|
||||
/// [`dim_label`] for a name that is user or file data (a swatch, style, artboard or font name):
|
||||
@@ -110,10 +122,10 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
buf = shown.clone();
|
||||
}
|
||||
take_dialog_focus(ui, id, &buf);
|
||||
let resp = ui
|
||||
let (rect, resp) = ui
|
||||
.allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(width);
|
||||
egui::Frame::NONE
|
||||
let framed = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(CornerRadius::same(2))
|
||||
@@ -128,16 +140,26 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(t.text_strong),
|
||||
)
|
||||
})
|
||||
.inner
|
||||
});
|
||||
(framed.response.rect, framed.inner)
|
||||
})
|
||||
.inner;
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ step in the field's unit.
|
||||
// ↑/↓ step in the field's unit; a drag on its label scrubs it.
|
||||
let stepped = step_with_arrows(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.format(unit.to_pt(v)));
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
|
||||
let commit = resp.lost_focus() && buf != shown;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if commit { unit.parse(&buf) } else { stepped.map(|v| unit.to_pt(v)) }
|
||||
if commit { unit.parse(&buf) } else { stepped.or(scrubbed).map(|v| unit.to_pt(v)) }
|
||||
}
|
||||
|
||||
/// The places a stepped value in a unit field keeps (no 12.300000001).
|
||||
const STEP_DECIMALS: i32 = 3;
|
||||
|
||||
/// `v` rounded to `decimals` places.
|
||||
pub(crate) fn round_to(v: f64, decimals: i32) -> f64 {
|
||||
let scale = 10f64.powi(decimals.clamp(0, 6));
|
||||
(v * scale).round() / scale
|
||||
}
|
||||
|
||||
/// ↑/↓ in focused numeric field `resp` step the number `buf` shows (`read` parses it, `show`
|
||||
@@ -163,8 +185,8 @@ fn step_with_arrows(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&s
|
||||
if steps == 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Rounded as fields show it (no 12.300000001).
|
||||
let v = ((read(buf)? + steps) * 1000.0).round() / 1000.0;
|
||||
// Rounded as fields show it.
|
||||
let v = round_to(read(buf)? + steps, STEP_DECIMALS);
|
||||
*buf = show(v);
|
||||
ui.data_mut(|d| d.insert_temp(resp.id, buf.clone()));
|
||||
select_all(ui, resp.id, buf);
|
||||
@@ -184,6 +206,11 @@ pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) {
|
||||
if ui.is_sizing_pass() || ui.data_mut(|d| d.remove_temp::<bool>(dialog_focus_flag())).is_none() {
|
||||
return;
|
||||
}
|
||||
focus_field(ui, id, text);
|
||||
}
|
||||
|
||||
/// Give text field `id` the keyboard focus with all of `text` selected.
|
||||
pub(crate) fn focus_field(ui: &Ui, id: egui::Id, text: &str) {
|
||||
select_all(ui, id, text);
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
@@ -209,16 +236,17 @@ fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {
|
||||
}
|
||||
|
||||
/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line),
|
||||
/// its text kept under `id` while it has focus. Returns the text (as edited) and the response.
|
||||
fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response) {
|
||||
/// its text kept under `id` while it has focus. Returns the text (as edited), the text edit's
|
||||
/// response and the box.
|
||||
fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response, Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
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.to_string()) } else { shown.to_string() };
|
||||
let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32;
|
||||
let resp = ui
|
||||
let (rect, resp) = ui
|
||||
.allocate_ui_with_layout(vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(width);
|
||||
egui::Frame::NONE
|
||||
let framed = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(CornerRadius::same(2))
|
||||
@@ -233,12 +261,12 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(t.text_strong),
|
||||
)
|
||||
})
|
||||
.inner
|
||||
});
|
||||
(framed.response.rect, framed.inner)
|
||||
})
|
||||
.inner;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
(buf, resp)
|
||||
(buf, resp, rect)
|
||||
}
|
||||
|
||||
/// A recessed text field showing `value` (blank when `None`: the selection's values differ),
|
||||
@@ -247,7 +275,7 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize
|
||||
pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option<String> {
|
||||
let id = ui.id().with(id);
|
||||
let shown = value.unwrap_or_default();
|
||||
let (buf, resp) = recessed_text(ui, id, shown, width, rows);
|
||||
let (buf, resp, _) = recessed_text(ui, id, shown, width, rows);
|
||||
(resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string())
|
||||
}
|
||||
|
||||
@@ -257,6 +285,7 @@ pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&m
|
||||
let (r, _) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!(label), egui::FontId::proportional(12.5), t.text);
|
||||
scrub::note_label(ui, r);
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
@@ -288,14 +317,14 @@ pub fn mixed_field(
|
||||
vectorcraft_doc::parse_number(&bare.replace(['%', '°'], ""))
|
||||
};
|
||||
let shown = value.map(show).unwrap_or_default();
|
||||
let (mut buf, resp) = recessed_text(ui, id, &shown, width, 1);
|
||||
let (mut buf, resp, rect) = recessed_text(ui, id, &shown, width, 1);
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ step at the field's precision (a count ignores Ctrl/Cmd's tenth).
|
||||
let stepped = step_with_arrows(ui, &resp, &mut buf, read, show).map(|v| {
|
||||
let scale = 10f64.powi(decimals.min(6) as i32);
|
||||
(v * scale).round() / scale
|
||||
});
|
||||
if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.filter(|&v| Some(v) != value) }
|
||||
// ↑/↓ step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on its label
|
||||
// scrubs it.
|
||||
let decimals = decimals.min(6) as i32;
|
||||
let stepped = step_with_arrows(ui, &resp, &mut buf, read, show).map(|v| round_to(v, decimals));
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals);
|
||||
if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) }
|
||||
}
|
||||
|
||||
/// Draw a paint preview (swatch chip) into `rect`.
|
||||
@@ -1102,7 +1131,7 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
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 enabled = ui.is_enabled();
|
||||
let resp = egui::Frame::NONE
|
||||
let framed = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(CornerRadius::same(2))
|
||||
@@ -1116,15 +1145,16 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(if enabled { t.text_strong } else { t.text_disabled }),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
});
|
||||
let resp = framed.inner;
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if resp.lost_focus() && buf != shown {
|
||||
let s = buf.trim();
|
||||
if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) }
|
||||
} else {
|
||||
None
|
||||
scrubbed.map(|v| Some(unit.to_pt(v)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::state::{Dialog, DockTab, UiState};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -318,79 +318,66 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
let mut action: Option<(&str, Value)> = None;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
let manage = d.kind == "manageWorkspaces";
|
||||
egui::Window::new("Workspace")
|
||||
.id(egui::Id::new("dialog-workspace"))
|
||||
.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_width(340.0);
|
||||
ui.label(
|
||||
egui::RichText::new(if manage { tl!("Manage Workspaces") } else { tl!("New Workspace") }).font(theme::semibold(16.0)).color(t.text),
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
if manage {
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_min_height(140.0);
|
||||
ui.set_width(ui.available_width());
|
||||
for n in names(&app.ui) {
|
||||
let builtin = is_builtin(&n);
|
||||
let shown = crate::panels::label_or_name(&n, builtin);
|
||||
let text = if builtin { egui::RichText::new(shown).color(t.text_dim) } else { egui::RichText::new(shown).color(t.text) };
|
||||
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
|
||||
d.fields.insert("selected".into(), json!(n));
|
||||
d.fields.insert("name".into(), json!(n));
|
||||
}
|
||||
crate::dialogs::modal::show(ctx, "Workspace", egui::Id::new("dialog-workspace"), -40.0, 22, |ui| {
|
||||
ui.set_width(340.0);
|
||||
crate::dialogs::modal::heading(ui, if manage { tl!("Manage Workspaces") } else { tl!("New Workspace") });
|
||||
ui.add_space(12.0);
|
||||
if manage {
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_min_height(140.0);
|
||||
ui.set_width(ui.available_width());
|
||||
for n in names(&app.ui) {
|
||||
let builtin = is_builtin(&n);
|
||||
let shown = crate::panels::label_or_name(&n, builtin);
|
||||
let text = if builtin { egui::RichText::new(shown).color(t.text_dim) } else { egui::RichText::new(shown).color(t.text) };
|
||||
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
|
||||
d.fields.insert("selected".into(), json!(n));
|
||||
d.fields.insert("name".into(), json!(n));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let sel = d.str("selected");
|
||||
ui.horizontal(|ui| {
|
||||
let mut name = d.str("name");
|
||||
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
|
||||
d.fields.insert("name".into(), json!(name));
|
||||
}
|
||||
if ui.button(tl!("New")).on_hover_text(tl!("Save the current layout under this name")).clicked() {
|
||||
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
|
||||
}
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button(tl!("Rename")).clicked() {
|
||||
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
|
||||
}
|
||||
if ui.button(tl!("Delete")).clicked() {
|
||||
action = Some(("window.workspace.delete", json!({"name": sel})));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
|
||||
let mut name = d.str("name");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
|
||||
if r.changed() {
|
||||
d.fields.insert("name".into(), json!(name));
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if !manage && widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let sel = d.str("selected");
|
||||
ui.horizontal(|ui| {
|
||||
let mut name = d.str("name");
|
||||
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
|
||||
d.fields.insert("name".into(), json!(name));
|
||||
}
|
||||
if ui.button(tl!("New")).on_hover_text(tl!("Save the current layout under this name")).clicked() {
|
||||
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
|
||||
}
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button(tl!("Rename")).clicked() {
|
||||
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
|
||||
}
|
||||
if ui.button(tl!("Delete")).clicked() {
|
||||
action = Some(("window.workspace.delete", json!({"name": sel})));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
|
||||
let mut name = d.str("name");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
|
||||
if r.changed() {
|
||||
d.fields.insert("name".into(), json!(name));
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if !manage && widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if !manage && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
ok = true;
|
||||
}
|
||||
|
||||
+3
-1
@@ -1147,7 +1147,9 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
|
||||
The preferences for the view need the desktop app (`vectorcraft-cli mcp --connect`): `zoomWithMouseWheel` (the
|
||||
wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; the control channel's
|
||||
`ui.wheel` turns the wheel), `zoomToSelection` (on: Zoom In and Zoom Out centre the selection), `showToolTips`,
|
||||
`anchorSize` (1–7), `gridColor`, `gridStyle`, `gridsInBack`, `guideColor`, `guideStyle` and `recentFontsCount`.
|
||||
`anchorSize` (1–7), `gridColor`, `gridStyle`, `gridsInBack`, `guideColor`, `guideStyle`, `recentFontsCount` and
|
||||
`scrubNumericFields` (on: a horizontal drag on a numeric field's label steps the field, one undo step per drag; the
|
||||
control channel's `ui.drag` scrubs).
|
||||
|
||||
## Type preferences
|
||||
|
||||
|
||||
Reference in new issue
Block a user