Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2497713779 | ||
|
|
79a67e2642 | ||
|
|
d9a2cfe7f9 | ||
|
|
37d4477362 |
No files matched your search
+1
-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.
|
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-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ mod window;
|
|||||||
use vectorcraft_engine::Session;
|
use vectorcraft_engine::Session;
|
||||||
use vectorcraft_engine::cmd::fileio;
|
use vectorcraft_engine::cmd::fileio;
|
||||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||||
|
use vectorcraft_ui_egui::picks::PickRequest;
|
||||||
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
@@ -185,9 +186,22 @@ fn save_prefs(app: &VectorcraftApp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A native file dialog showing `pick`'s file types, folder and suggested name.
|
/// The window file dialogs belong to.
|
||||||
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
|
type Parent = Option<std::sync::Arc<winit::window::Window>>;
|
||||||
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
|
|
||||||
|
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
|
||||||
|
/// shows it over the window and keeps it in front; macOS shows them as before).
|
||||||
|
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
|
||||||
|
let d = match parent {
|
||||||
|
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
|
||||||
|
_ => rfd::FileDialog::new(),
|
||||||
|
};
|
||||||
|
let pick = match request {
|
||||||
|
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
|
||||||
|
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
|
||||||
|
PickRequest::Folder => return d,
|
||||||
|
};
|
||||||
|
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts));
|
||||||
let d = match &pick.folder {
|
let d = match &pick.folder {
|
||||||
Some(folder) => d.set_directory(folder),
|
Some(folder) => d.set_directory(folder),
|
||||||
None => d,
|
None => d,
|
||||||
@@ -195,6 +209,28 @@ fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
|
|||||||
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||||
|
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||||
|
let paths = match request {
|
||||||
|
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||||
|
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||||
|
PickRequest::Save(pick) => {
|
||||||
|
// The Templates folder may not exist yet.
|
||||||
|
if let Some(folder) = &pick.folder {
|
||||||
|
let _ = std::fs::create_dir_all(folder);
|
||||||
|
}
|
||||||
|
dialog.save_file().into_iter().collect()
|
||||||
|
}
|
||||||
|
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
|
||||||
|
};
|
||||||
|
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the dialog for `request` over `parent` now → the paths picked.
|
||||||
|
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
|
||||||
|
show_dialog(file_dialog(&request, parent), &request)
|
||||||
|
}
|
||||||
|
|
||||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||||
fn reveal(path: &str) -> Result<(), String> {
|
fn reveal(path: &str) -> Result<(), String> {
|
||||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||||
@@ -230,25 +266,31 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||||||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn services() -> Services {
|
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
|
||||||
|
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
|
||||||
|
/// offers to kill the window as not responding (#592).
|
||||||
|
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||||
|
let dialog = file_dialog(&request, parent);
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("file-dialog".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// The app gone meanwhile has no use for the answer.
|
||||||
|
let _ = tx.send(show_dialog(dialog, &request));
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
Some(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn services(parent: Parent) -> Services {
|
||||||
|
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
|
||||||
Services {
|
Services {
|
||||||
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
|
||||||
pick_open_multi: Some(Box::new(|| {
|
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
|
||||||
fileio::place_filters()
|
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
|
||||||
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
|
// Windows and macOS dialogs run the window's events while they are open; Linux's don't.
|
||||||
.pick_files()
|
start_pick: cfg!(all(unix, not(target_os = "macos")))
|
||||||
.unwrap_or_default()
|
.then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
|
||||||
.into_iter()
|
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
|
||||||
.collect()
|
|
||||||
})),
|
|
||||||
pick_save: Some(Box::new(|pick: &FilePick| {
|
|
||||||
// The Templates folder may not exist yet.
|
|
||||||
if let Some(folder) = &pick.folder {
|
|
||||||
let _ = std::fs::create_dir_all(folder);
|
|
||||||
}
|
|
||||||
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
|
|
||||||
})),
|
|
||||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||||
write: Some(Box::new(write_file)),
|
write: Some(Box::new(write_file)),
|
||||||
// Background Save and Export write from a worker thread.
|
// Background Save and Export write from a worker thread.
|
||||||
@@ -266,7 +308,7 @@ fn services() -> Services {
|
|||||||
reveal: Some(Box::new(reveal)),
|
reveal: Some(Box::new(reveal)),
|
||||||
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
|
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
|
||||||
open_file: Some(Box::new(open_file)),
|
open_file: Some(Box::new(open_file)),
|
||||||
pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
|
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
|
||||||
// File → Print: the system's printers and print queue.
|
// File → Print: the system's printers and print queue.
|
||||||
print: Some(Box::new(printing::SystemPrint)),
|
print: Some(Box::new(printing::SystemPrint)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -397,7 +439,7 @@ fn main() -> std::process::ExitCode {
|
|||||||
"VectorCraft",
|
"VectorCraft",
|
||||||
options,
|
options,
|
||||||
Box::new(move |cc| {
|
Box::new(move |cc| {
|
||||||
let mut app = VectorcraftApp::new(Session::new(), services());
|
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
|
||||||
load_prefs(&mut app, saved);
|
load_prefs(&mut app, saved);
|
||||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||||
if let Some(w) = cc.winit_window() {
|
if let Some(w) = cc.winit_window() {
|
||||||
|
|||||||
@@ -384,6 +384,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||||||
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||||
task_bar(app, ui, &xf);
|
task_bar(app, ui, &xf);
|
||||||
}
|
}
|
||||||
|
if app.ui.screen_mode < 3 {
|
||||||
|
crate::free_transform::show(app, ui, xf.rect);
|
||||||
|
}
|
||||||
// Cursor.
|
// Cursor.
|
||||||
if resp.hovered() {
|
if resp.hovered() {
|
||||||
let m = ui.input(|i| i.modifiers);
|
let m = ui.input(|i| i.modifiers);
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
|||||||
"pinned": app.ui.task_bar_place.pinned,
|
"pinned": app.ui.task_bar_place.pinned,
|
||||||
"rect": crate::canvas::task_bar_rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
"rect": crate::canvas::task_bar_rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||||||
},
|
},
|
||||||
|
// The Free Transform tool's widget, where it shows.
|
||||||
|
"freeTransformWidget": crate::free_transform::rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||||||
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
|
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
|
||||||
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||||
"window": [r.width(), r.height()],
|
"window": [r.width(), r.height()],
|
||||||
|
|||||||
@@ -424,11 +424,8 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
|||||||
let pick = app.services.pick_folder.is_some();
|
let pick = app.services.pick_folder.is_some();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 });
|
form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 });
|
||||||
if pick
|
if pick && widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked() {
|
||||||
&& widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked()
|
crate::picks::folder_field(app, d, "folder");
|
||||||
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick())
|
|
||||||
{
|
|
||||||
d.fields.insert("folder".into(), json!(f));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
form::check(ui, d, "openLocation", tl!("Open Location after Export"));
|
form::check(ui, d, "openLocation", tl!("Open Location after Export"));
|
||||||
|
|||||||
@@ -145,10 +145,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(a) = act
|
if let Some(a) = act {
|
||||||
&& let Err(e) = run(app, d, a, &preset.name)
|
let r = if matches!(a, Action::Import) {
|
||||||
{
|
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||||
app.status(e);
|
let current = preset.name.clone();
|
||||||
|
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||||
|
} else {
|
||||||
|
run(app, d, a, &preset.name)
|
||||||
|
};
|
||||||
|
if let Err(e) = r {
|
||||||
|
app.status(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -181,7 +188,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() };
|
let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() };
|
||||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
|
||||||
let r = app.run("flattener.presets.import", json!({ "path": path }))?;
|
let r = app.run("flattener.presets.import", json!({ "path": path }))?;
|
||||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||||
select(d, first);
|
select(d, first);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||||||
p if !p.is_empty() => p,
|
p if !p.is_empty() => p,
|
||||||
_ => {
|
_ => {
|
||||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
|
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
|
||||||
app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?
|
crate::picks::open(app, &pick).ok_or("cancelled")?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?;
|
out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?;
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ pub fn cancel(app: &mut VectorcraftApp) {
|
|||||||
/// Apply the open dialog (OK).
|
/// Apply the open dialog (OK).
|
||||||
pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
|
pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
|
||||||
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
||||||
(spec(&d.kind).confirm)(app, &d)
|
// A file dialog it shows off the UI thread confirms the dialog as it is again.
|
||||||
|
crate::picks::as_entry(app, || crate::picks::Entry::Confirm(Box::new(d.clone())), |app| (spec(&d.kind).confirm)(app, &d))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||||
|
|||||||
@@ -70,11 +70,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||||||
if !downloads(app) {
|
if !downloads(app) {
|
||||||
widgets::label_row(ui, tl!("Location:"), LABEL, |ui| {
|
widgets::label_row(ui, tl!("Location:"), LABEL, |ui| {
|
||||||
form::text(ui, d, "folder", 230.0);
|
form::text(ui, d, "folder", 230.0);
|
||||||
if let Some(pick) = app.services.pick_folder.as_mut()
|
if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() {
|
||||||
&& ui.button(tl!("Choose…")).clicked()
|
crate::picks::folder_field(app, d, "folder");
|
||||||
&& let Some(f) = pick()
|
|
||||||
{
|
|
||||||
d.fields.insert("folder".into(), json!(f));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|||||||
@@ -102,10 +102,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||||||
details(ui, &t, preset, builtin);
|
details(ui, &t, preset, builtin);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if let Some(a) = act
|
if let Some(a) = act {
|
||||||
&& let Err(e) = run(app, d, a, &preset.name)
|
let r = if matches!(a, Action::Import) {
|
||||||
{
|
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||||
app.status(e);
|
let current = preset.name.clone();
|
||||||
|
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||||
|
} else {
|
||||||
|
run(app, d, a, &preset.name)
|
||||||
|
};
|
||||||
|
if let Err(e) = r {
|
||||||
|
app.status(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -169,7 +176,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() };
|
let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() };
|
||||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
|
||||||
let r = app.run("pdf.preset.import", json!({ "path": path }))?;
|
let r = app.run("pdf.preset.import", json!({ "path": path }))?;
|
||||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||||
select(d, first);
|
select(d, first);
|
||||||
|
|||||||
@@ -150,10 +150,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
let name = preset.name.clone();
|
let name = preset.name.clone();
|
||||||
if let Some(a) = act
|
if let Some(a) = act {
|
||||||
&& let Err(e) = run(app, d, a, &name)
|
let r = if matches!(a, Action::Import) {
|
||||||
{
|
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||||
app.status(e);
|
let current = name.clone();
|
||||||
|
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||||
|
} else {
|
||||||
|
run(app, d, a, &name)
|
||||||
|
};
|
||||||
|
if let Err(e) = r {
|
||||||
|
app.status(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -187,7 +194,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() };
|
let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() };
|
||||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
|
||||||
let r = app.run("perspective.presets.import", json!({ "path": path }))?;
|
let r = app.run("perspective.presets.import", json!({ "path": path }))?;
|
||||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||||
select(d, first);
|
select(d, first);
|
||||||
|
|||||||
@@ -111,10 +111,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if let Some(a) = act
|
if let Some(a) = act {
|
||||||
&& let Err(e) = run(app, d, a, &name)
|
let r = if matches!(a, Action::Import) {
|
||||||
{
|
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||||
app.status(e);
|
let current = name.clone();
|
||||||
|
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||||
|
} else {
|
||||||
|
run(app, d, a, &name)
|
||||||
|
};
|
||||||
|
if let Err(e) = r {
|
||||||
|
app.status(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -149,7 +156,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() };
|
let pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() };
|
||||||
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
|
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
|
||||||
let r = app.run("print.presets.import", json!({ "path": path }))?;
|
let r = app.run("print.presets.import", json!({ "path": path }))?;
|
||||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||||
select(d, first);
|
select(d, first);
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! The Free Transform widget (#598): while the Free Transform tool is active, a small floating box
|
||||||
|
//! at the canvas's top left with Constrain (as holding Shift: proportional scaling, moves and
|
||||||
|
//! rotations by 45°) over the tool's three modes, Free Transform, Perspective Distort and Free
|
||||||
|
//! Distort. It sets the tool's options through `tool.setOption` (`constrain`, `mode`), as agents
|
||||||
|
//! do; the modifier keys keep working while dragging.
|
||||||
|
|
||||||
|
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::VectorcraftApp;
|
||||||
|
use crate::theme::Tokens;
|
||||||
|
|
||||||
|
/// The tool the widget belongs to.
|
||||||
|
const TOOL: &str = "freeTransform";
|
||||||
|
/// A button's side.
|
||||||
|
const BUTTON: f32 = 28.0;
|
||||||
|
|
||||||
|
/// What a button shows.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Glyph {
|
||||||
|
/// An icon of the set.
|
||||||
|
Icon(&'static str),
|
||||||
|
/// A box narrower at the top than at the bottom, its corners marked.
|
||||||
|
Perspective,
|
||||||
|
/// A box with corners pulled every which way, its corners marked.
|
||||||
|
Distort,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool's modes as its `mode` option names them, with their buttons.
|
||||||
|
fn modes() -> [(&'static str, Glyph, &'static str); 3] {
|
||||||
|
[
|
||||||
|
("free", Glyph::Icon("dc-free-transform"), tl!("Free Transform")),
|
||||||
|
("perspective", Glyph::Perspective, tl!("Perspective Distort")),
|
||||||
|
("distort", Glyph::Distort, tl!("Free Distort")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the widget was last drawn (none while it isn't shown).
|
||||||
|
pub(crate) fn rect(ctx: &egui::Context) -> Option<Rect> {
|
||||||
|
let id = egui::Id::new(TOOL);
|
||||||
|
ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the widget in `canvas` (the canvas's area inside the rulers) while the Free Transform
|
||||||
|
/// tool is active.
|
||||||
|
pub(crate) fn show(app: &mut VectorcraftApp, ui: &Ui, canvas: Rect) {
|
||||||
|
if app.session.tool_id() != TOOL {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let t = Tokens::get(ui.ctx());
|
||||||
|
let opts = app.session.tool_options();
|
||||||
|
let mode = opts["mode"].as_str().unwrap_or("free").to_string();
|
||||||
|
let constrain = opts["constrain"].as_bool().unwrap_or(false);
|
||||||
|
let mut set: Option<(&str, Value)> = None;
|
||||||
|
egui::Area::new(egui::Id::new(TOOL)).order(egui::Order::Middle).fixed_pos(canvas.min + vec2(10.0, 10.0)).show(ui.ctx(), |ui| {
|
||||||
|
egui::Frame::NONE
|
||||||
|
.fill(t.panel)
|
||||||
|
.stroke(Stroke::new(1.0, t.button_border))
|
||||||
|
.corner_radius(CornerRadius::same(4))
|
||||||
|
.inner_margin(egui::Margin::same(3))
|
||||||
|
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(60) })
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = 2.0;
|
||||||
|
// Constrain shapes only what the Free Transform mode does.
|
||||||
|
if button(ui, Glyph::Icon("link"), tl!("Constrain"), constrain, mode == "free") {
|
||||||
|
set = Some(("constrain", json!(!constrain)));
|
||||||
|
}
|
||||||
|
let (line, _) = ui.allocate_exact_size(vec2(BUTTON, 3.0), Sense::hover());
|
||||||
|
ui.painter().hline(line.x_range().shrink(4.0), line.center().y, Stroke::new(1.0, t.button_border));
|
||||||
|
for (name, glyph, tip) in modes() {
|
||||||
|
if button(ui, glyph, tip, mode == name, true) && mode != name {
|
||||||
|
set = Some(("mode", json!(name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if let Some((key, value)) = set
|
||||||
|
&& let Err(e) = app.run("tool.setOption", json!({ "key": key, "value": value }))
|
||||||
|
{
|
||||||
|
app.status(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A square button showing `glyph`, highlighted when `on`, greyed and inert unless `enabled`.
|
||||||
|
/// Returns whether it was clicked.
|
||||||
|
fn button(ui: &mut Ui, glyph: Glyph, tip: &str, on: bool, enabled: bool) -> bool {
|
||||||
|
let t = Tokens::get(ui.ctx());
|
||||||
|
let (rect, resp) = ui.allocate_exact_size(vec2(BUTTON, BUTTON), if enabled { Sense::click() } else { Sense::hover() });
|
||||||
|
let bg = if on && enabled {
|
||||||
|
t.tool_active
|
||||||
|
} else if enabled && resp.hovered() {
|
||||||
|
t.hover
|
||||||
|
} else {
|
||||||
|
Color32::TRANSPARENT
|
||||||
|
};
|
||||||
|
ui.painter().rect_filled(rect, CornerRadius::same(3), bg);
|
||||||
|
let color = match (enabled, on) {
|
||||||
|
(false, _) => t.text_dim,
|
||||||
|
(true, true) => t.text,
|
||||||
|
(true, false) => t.icon,
|
||||||
|
};
|
||||||
|
paint(ui, glyph, rect.shrink(6.0), color);
|
||||||
|
resp.on_hover_text(tip).clicked() && enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw `glyph` in `r`.
|
||||||
|
fn paint(ui: &Ui, glyph: Glyph, r: Rect, color: Color32) {
|
||||||
|
let at = |x: f32, y: f32| pos2(r.left() + x * r.width(), r.top() + y * r.height());
|
||||||
|
let quad: [Pos2; 4] = match glyph {
|
||||||
|
Glyph::Icon(name) => return crate::icons::paint(ui, name, r, color),
|
||||||
|
Glyph::Perspective => [at(0.3, 0.12), at(0.7, 0.12), at(0.95, 0.88), at(0.05, 0.88)],
|
||||||
|
Glyph::Distort => [at(0.08, 0.2), at(0.78, 0.06), at(0.94, 0.9), at(0.2, 0.76)],
|
||||||
|
};
|
||||||
|
let (p, fill) = (ui.painter(), Tokens::get(ui.ctx()).panel);
|
||||||
|
p.add(egui::Shape::closed_line(quad.to_vec(), Stroke::new(1.3, color)));
|
||||||
|
for c in quad {
|
||||||
|
let mark = Rect::from_center_size(c, vec2(3.5, 3.5));
|
||||||
|
p.rect_filled(mark, 0.0, fill);
|
||||||
|
p.rect_stroke(mark, 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
use vectorcraft_engine::Session;
|
||||||
|
use vectorcraft_geom::Point;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::canvas::Xf;
|
||||||
|
|
||||||
|
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||||
|
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||||||
|
ctx.run_ui(raw, |ui| crate::canvas::show(app, ui)).textures_delta.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A press and release at `at`, then a frame to settle.
|
||||||
|
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
|
||||||
|
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||||
|
frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]);
|
||||||
|
frame(app, ctx, vec![button(false)]);
|
||||||
|
frame(app, ctx, vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options(app: &VectorcraftApp) -> (String, bool) {
|
||||||
|
let o = app.session.tool_options();
|
||||||
|
(o["mode"].as_str().unwrap_or_default().to_string(), o["constrain"].as_bool().unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widget shows with the Free Transform tool only; its buttons set the mode and Constrain
|
||||||
|
/// (inert in the distort modes); a corner dragged in Free Distort moves that corner alone.
|
||||||
|
#[test]
|
||||||
|
fn the_widget_sets_the_mode_and_a_corner_drag_then_distorts() {
|
||||||
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||||
|
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||||
|
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap()["id"].clone();
|
||||||
|
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
app.select_tool("selection");
|
||||||
|
frame(&mut app, &ctx, vec![]);
|
||||||
|
assert!(rect(&ctx).is_none(), "not with the Selection tool");
|
||||||
|
app.select_tool(TOOL);
|
||||||
|
frame(&mut app, &ctx, vec![]);
|
||||||
|
frame(&mut app, &ctx, vec![]);
|
||||||
|
let w = rect(&ctx).expect("the widget shows");
|
||||||
|
let canvas = app.canvas_rect.unwrap();
|
||||||
|
assert!(canvas.contains(w.min) && w.left() - canvas.left() < 20.0 && w.top() - canvas.top() < 20.0, "{w:?} in {canvas:?}");
|
||||||
|
// Constrain, a rule, then Free Transform, Perspective Distort and Free Distort.
|
||||||
|
let at = |i: usize| {
|
||||||
|
let rule = if i > 0 { 3.0 + 2.0 } else { 0.0 };
|
||||||
|
pos2(w.center().x, w.top() + 4.0 + BUTTON / 2.0 + i as f32 * (BUTTON + 2.0) + rule)
|
||||||
|
};
|
||||||
|
assert_eq!(options(&app), ("free".into(), false));
|
||||||
|
click(&mut app, &ctx, at(0));
|
||||||
|
assert_eq!(options(&app), ("free".into(), true), "Constrain on");
|
||||||
|
click(&mut app, &ctx, at(3));
|
||||||
|
assert_eq!(options(&app), ("distort".into(), true));
|
||||||
|
click(&mut app, &ctx, at(0));
|
||||||
|
assert_eq!(options(&app), ("distort".into(), true), "Constrain is inert in Free Distort");
|
||||||
|
click(&mut app, &ctx, at(2));
|
||||||
|
assert_eq!(options(&app).0, "perspective");
|
||||||
|
click(&mut app, &ctx, at(3));
|
||||||
|
// Free Distort: the top-left corner dragged alone.
|
||||||
|
let xf = Xf::new(canvas, app.view().unwrap());
|
||||||
|
let (from, to) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(80.0, 90.0)));
|
||||||
|
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||||
|
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||||||
|
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) / 2.0)]);
|
||||||
|
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||||
|
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||||
|
frame(&mut app, &ctx, vec![]);
|
||||||
|
let st = app.session.active().unwrap();
|
||||||
|
let node = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap();
|
||||||
|
let pts: Vec<Point> = node.path_data().unwrap().anchors().map(|(.., a)| a.p).collect();
|
||||||
|
let near = |q: Point| pts.iter().any(|p| p.distance(q) < 0.5);
|
||||||
|
assert!(
|
||||||
|
near(Point::new(80.0, 90.0)) && near(Point::new(200.0, 100.0)) && near(Point::new(200.0, 160.0)) && near(Point::new(100.0, 160.0)),
|
||||||
|
"{pts:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(app.session.tool_id(), TOOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -634,3 +634,7 @@
|
|||||||
Clipboard Handling Práce se schránkou
|
Clipboard Handling Práce se schránkou
|
||||||
Appearance of Black Vzhled černé
|
Appearance of Black Vzhled černé
|
||||||
Devices Zařízení
|
Devices Zařízení
|
||||||
|
A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm
|
||||||
|
Constrain Omezit
|
||||||
|
Free Transform Volná transformace
|
||||||
|
Perspective Distort Perspektivní deformace
|
||||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3598,3 +3598,7 @@ axis Both Ambos
|
|||||||
Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento.
|
Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento.
|
||||||
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
|
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
|
||||||
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.
|
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.
|
||||||
|
A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él
|
||||||
|
Constrain Restringir
|
||||||
|
Free Transform Transformación libre
|
||||||
|
Perspective Distort Distorsión de perspectiva
|
||||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3598,3 +3598,7 @@ axis Both Entrambi
|
|||||||
Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento.
|
Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento.
|
||||||
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
|
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
|
||||||
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.
|
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.
|
||||||
|
A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì
|
||||||
|
Constrain Vincola
|
||||||
|
Free Transform Trasformazione libera
|
||||||
|
Perspective Distort Distorsione prospettica
|
||||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3060,3 +3060,7 @@ theme Appearance 外観
|
|||||||
Off: an editable copy of its art, embedded in this document. オフ:アートの編集可能なコピーをこのドキュメントに埋め込みます。
|
Off: an editable copy of its art, embedded in this document. オフ:アートの編集可能なコピーをこのドキュメントに埋め込みます。
|
||||||
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
|
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
|
||||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。
|
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。
|
||||||
|
A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください
|
||||||
|
Constrain 制限
|
||||||
|
Free Transform 自由変形
|
||||||
|
Perspective Distort 遠近変形
|
||||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2648,3 +2648,7 @@
|
|||||||
Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento.
|
Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento.
|
||||||
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
|
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
|
||||||
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.
|
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.
|
||||||
|
A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro
|
||||||
|
Constrain Restringir
|
||||||
|
Free Transform Transformação Livre
|
||||||
|
Perspective Distort Distorção de Perspectiva
|
||||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3596,3 +3596,7 @@ axis Both Оба
|
|||||||
Off: an editable copy of its art, embedded in this document. Выкл.: редактируемая копия его рисунка, внедрённая в этот документ.
|
Off: an editable copy of its art, embedded in this document. Выкл.: редактируемая копия его рисунка, внедрённая в этот документ.
|
||||||
Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения.
|
Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения.
|
||||||
VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла.
|
VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла.
|
||||||
|
A file dialog is open: choose there first Открыто окно выбора файла: сначала сделайте выбор в нём
|
||||||
|
Constrain Ограничить
|
||||||
|
Free Transform Свободное трансформирование
|
||||||
|
Perspective Distort Искажение перспективы
|
||||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3315,3 +3315,6 @@ axis Both 两者
|
|||||||
Off: an editable copy of its art, embedded in this document. 关闭:将其图稿的可编辑副本嵌入此文档。
|
Off: an editable copy of its art, embedded in this document. 关闭:将其图稿的可编辑副本嵌入此文档。
|
||||||
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
|
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
|
||||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。
|
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。
|
||||||
|
A file dialog is open: choose there first 文件对话框已打开:请先在其中选择
|
||||||
|
Constrain 约束
|
||||||
|
Perspective Distort 透视扭曲
|
||||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3090,3 +3090,7 @@ axis Both 兩者
|
|||||||
Off: an editable copy of its art, embedded in this document. 關閉:將其圖稿的可編輯副本內嵌在此文件中。
|
Off: an editable copy of its art, embedded in this document. 關閉:將其圖稿的可編輯副本內嵌在此文件中。
|
||||||
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
|
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
|
||||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。
|
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。
|
||||||
|
A file dialog is open: choose there first 檔案對話方塊已開啟:請先在其中選擇
|
||||||
|
Constrain 強制
|
||||||
|
Free Transform 任意變形
|
||||||
|
Perspective Distort 透視扭曲
|
||||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -72,7 +72,7 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O
|
|||||||
|
|
||||||
/// A path from the open dialog, or "cancelled".
|
/// A path from the open dialog, or "cancelled".
|
||||||
fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||||||
app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
|
crate::picks::open(app, pick).ok_or_else(|| "cancelled".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Object › Plug-ins › Install Plug-in…: installs the `.wasm` at `path`, else a picked one (the
|
/// Object › Plug-ins › Install Plug-in…: installs the `.wasm` at `path`, else a picked one (the
|
||||||
@@ -164,7 +164,7 @@ fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String
|
|||||||
if is_web(app) {
|
if is_web(app) {
|
||||||
return Ok(pick.name.clone());
|
return Ok(pick.name.clone());
|
||||||
}
|
}
|
||||||
let picked = app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or("cancelled")?;
|
let picked = crate::picks::save(app, pick).ok_or("cancelled")?;
|
||||||
Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true))
|
Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub mod dock;
|
|||||||
pub mod find_font;
|
pub mod find_font;
|
||||||
pub mod floating;
|
pub mod floating;
|
||||||
pub mod font_menu;
|
pub mod font_menu;
|
||||||
|
mod free_transform;
|
||||||
pub mod graphics;
|
pub mod graphics;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod icon_data;
|
pub mod icon_data;
|
||||||
@@ -36,6 +37,7 @@ pub mod menus;
|
|||||||
pub mod native_menu;
|
pub mod native_menu;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
pub mod panels;
|
pub mod panels;
|
||||||
|
pub mod picks;
|
||||||
pub mod place;
|
pub mod place;
|
||||||
pub mod prefs_dialog;
|
pub mod prefs_dialog;
|
||||||
pub mod print;
|
pub mod print;
|
||||||
@@ -95,6 +97,8 @@ mod tests_pathtype;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_pdfoutput;
|
mod tests_pdfoutput;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_picks;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_place;
|
mod tests_place;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_plugins;
|
mod tests_plugins;
|
||||||
@@ -233,6 +237,10 @@ pub struct Services {
|
|||||||
pub print: Option<Box<dyn print::PrintService>>,
|
pub print: Option<Box<dyn print::PrintService>>,
|
||||||
/// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then.
|
/// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then.
|
||||||
pub native_menu: Option<native_menu::NativeMenu>,
|
pub native_menu: Option<native_menu::NativeMenu>,
|
||||||
|
/// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window
|
||||||
|
/// and the compositor finds it not answering): what asked runs again with the answer
|
||||||
|
/// ([`picks`]). Without it they are shown in line.
|
||||||
|
pub start_pick: Option<picks::StartPick>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached canvas raster.
|
/// Cached canvas raster.
|
||||||
@@ -347,6 +355,8 @@ pub struct VectorcraftApp {
|
|||||||
/// The thread that checks the system clipboard for `system_paste` instead
|
/// The thread that checks the system clipboard for `system_paste` instead
|
||||||
/// ([`Services::clipboard_probe`]).
|
/// ([`Services::clipboard_probe`]).
|
||||||
clipboard_probe: Option<clipboard_probe::Probe>,
|
clipboard_probe: Option<clipboard_probe::Probe>,
|
||||||
|
/// File dialogs shown off the UI thread and what runs again with their answers.
|
||||||
|
pub(crate) picks: picks::Picks,
|
||||||
/// The look for fonts installed or removed while the app was in the background, running
|
/// The look for fonts installed or removed while the app was in the background, running
|
||||||
/// ([`Self::refresh_installed_fonts`]): whether they were.
|
/// ([`Self::refresh_installed_fonts`]): whether they were.
|
||||||
font_check: Option<std::sync::mpsc::Receiver<bool>>,
|
font_check: Option<std::sync::mpsc::Receiver<bool>>,
|
||||||
@@ -426,6 +436,7 @@ impl VectorcraftApp {
|
|||||||
system_paste: false,
|
system_paste: false,
|
||||||
system_paste_at: f64::NEG_INFINITY,
|
system_paste_at: f64::NEG_INFINITY,
|
||||||
clipboard_probe: None,
|
clipboard_probe: None,
|
||||||
|
picks: picks::Picks::default(),
|
||||||
font_check: None,
|
font_check: None,
|
||||||
paste_chord: Default::default(),
|
paste_chord: Default::default(),
|
||||||
background: Default::default(),
|
background: Default::default(),
|
||||||
@@ -489,6 +500,16 @@ impl VectorcraftApp {
|
|||||||
|
|
||||||
/// Run a UI or engine command by id. The single entry point for every frontend path.
|
/// Run a UI or engine command by id. The single entry point for every frontend path.
|
||||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||||
|
// A file dialog it shows off the UI thread runs it again with the path picked.
|
||||||
|
if self.picks.is_entry_free() {
|
||||||
|
let entry = picks::Entry::Command(id.to_string(), params.clone());
|
||||||
|
return picks::as_entry(self, move || entry, |app| app.run_now(id, params));
|
||||||
|
}
|
||||||
|
self.run_now(id, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::run`] it, inside what asks for file dialogs.
|
||||||
|
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||||
self.run_count = self.run_count.wrapping_add(1);
|
self.run_count = self.run_count.wrapping_add(1);
|
||||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||||
return r;
|
return r;
|
||||||
@@ -790,6 +811,7 @@ impl VectorcraftApp {
|
|||||||
self.system_paste = wanted && self.system_clipboard_pasteable();
|
self.system_paste = wanted && self.system_clipboard_pasteable();
|
||||||
}
|
}
|
||||||
self.poll_font_check(ctx);
|
self.poll_font_check(ctx);
|
||||||
|
picks::poll(self, ctx);
|
||||||
background::poll(self);
|
background::poll(self);
|
||||||
if !self.background.jobs.is_empty() {
|
if !self.background.jobs.is_empty() {
|
||||||
// Keep the status bar's progress moving and pick the result up when it arrives.
|
// Keep the status bar's progress moving and pick the result up when it arrives.
|
||||||
|
|||||||
@@ -185,17 +185,23 @@ fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option<Vec<NodeId
|
|||||||
/// folder picker: the last folder Export for Screens used, else the Desktop); the web downloads
|
/// folder picker: the last folder Export for Screens used, else the Desktop); the web downloads
|
||||||
/// them, several as one zip.
|
/// them, several as one zip.
|
||||||
fn export(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
|
fn export(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
|
||||||
|
// The folder picked off the UI thread exports them then.
|
||||||
|
crate::picks::button(app, move |app| export_now(app, ids.clone(), files_each));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_now(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
|
||||||
let mut p = json!({ "assets": ids });
|
let mut p = json!({ "assets": ids });
|
||||||
if io::is_web(app) {
|
if io::is_web(app) {
|
||||||
p["zip"] = json!(ids.len() * files_each > 1);
|
p["zip"] = json!(ids.len() * files_each > 1);
|
||||||
} else {
|
} else {
|
||||||
let saved = app.session.active().map(|st| st.doc.export_settings.clone()).unwrap_or_default();
|
let saved = app.session.active().map(|st| st.doc.export_settings.clone()).unwrap_or_default();
|
||||||
let folder = match app.services.pick_folder.as_mut() {
|
let folder = if crate::picks::can(app, &crate::picks::PickRequest::Folder) {
|
||||||
Some(pick) => match pick() {
|
match crate::picks::folder(app) {
|
||||||
Some(f) => f,
|
Some(f) => f,
|
||||||
None => return,
|
None => return,
|
||||||
},
|
}
|
||||||
None => match saved
|
} else {
|
||||||
|
match saved
|
||||||
.get("folder")
|
.get("folder")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.filter(|f| !f.is_empty())
|
.filter(|f| !f.is_empty())
|
||||||
@@ -204,7 +210,7 @@ fn export(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
|
|||||||
{
|
{
|
||||||
Some(f) => f,
|
Some(f) => f,
|
||||||
None => return app.status("choose a folder to export to".to_string()),
|
None => return app.status("choose a folder to export to".to_string()),
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
p["folder"] = json!(folder);
|
p["folder"] = json!(folder);
|
||||||
p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true));
|
p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true));
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option<String>)
|
|||||||
}
|
}
|
||||||
path.or_else(|| {
|
path.or_else(|| {
|
||||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() };
|
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() };
|
||||||
app.services.pick_open.as_mut().and_then(|f| f(&pick))
|
crate::picks::open(app, &pick)
|
||||||
})
|
})
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.ok_or_else(|| "cancelled".into())
|
.ok_or_else(|| "cancelled".into())
|
||||||
|
|||||||
@@ -116,15 +116,23 @@ fn report(app: &mut VectorcraftApp, r: Result<Value, String>, done: impl FnOnce(
|
|||||||
|
|
||||||
/// Relink images `ids` to a picked file.
|
/// Relink images `ids` to a picked file.
|
||||||
pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
||||||
|
crate::picks::button(app, move |app| relink_picked(app, ids.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relink_picked(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
||||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
|
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
|
||||||
let Some(path) = app.services.pick_open.as_mut().and_then(|f| f(&pick)) else { return };
|
let Some(path) = crate::picks::open(app, &pick) else { return };
|
||||||
let r = app.run("links.relink", json!({ "ids": ids, "path": path }));
|
let r = app.run("links.relink", json!({ "ids": ids, "path": path }));
|
||||||
report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len)));
|
report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Relink images `ids` (none: every missing one) to the files of their names in a picked folder.
|
/// Relink images `ids` (none: every missing one) to the files of their names in a picked folder.
|
||||||
fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
||||||
let Some(folder) = app.services.pick_folder.as_mut().and_then(|f| f()) else { return };
|
crate::picks::button(app, move |app| relink_to_picked_folder(app, ids.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
|
||||||
|
let Some(folder) = crate::picks::folder(app) else { return };
|
||||||
let ids = Some(ids).filter(|i| !i.is_empty());
|
let ids = Some(ids).filter(|i| !i.is_empty());
|
||||||
let r = app.run("links.relink", json!({ "ids": ids, "folder": folder }));
|
let r = app.run("links.relink", json!({ "ids": ids, "folder": folder }));
|
||||||
report(app, r, |v| {
|
report(app, r, |v| {
|
||||||
@@ -154,9 +162,12 @@ pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named(name))) else { return };
|
let name = name.to_string();
|
||||||
let r = app.run("links.unembed", json!({ "id": id, "path": path }));
|
crate::picks::button(app, move |app| {
|
||||||
report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default()));
|
let Some(path) = crate::picks::save(app, &crate::FilePick::named(&name)) else { return };
|
||||||
|
let r = app.run("links.unembed", json!({ "id": id, "path": path }));
|
||||||
|
report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The UI commands `links.editOriginal` (open the linked file in its app) and `links.reveal`
|
/// The UI commands `links.editOriginal` (open the linked file in its app) and `links.reveal`
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! File dialogs that leave the window running (#592).
|
||||||
|
//!
|
||||||
|
//! The app asks for a path where it needs one (Open, Save As, Export, Place, Relink, presets…)
|
||||||
|
//! and goes on with it at once, so a dialog shown on the UI thread holds the window until it
|
||||||
|
//! closes. On Windows and macOS the dialog runs the window's events meanwhile; on Linux nothing
|
||||||
|
//! does, so a Wayland compositor finds the window not answering and offers to kill it. There the
|
||||||
|
//! host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now,
|
||||||
|
//! and when the dialog answers, what asked (the command, the dialog's OK, a panel's button: the
|
||||||
|
//! *entry*) runs again and gets the picked path at once.
|
||||||
|
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::state::Dialog;
|
||||||
|
use crate::{FilePick, VectorcraftApp};
|
||||||
|
|
||||||
|
/// A file dialog to show.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum PickRequest {
|
||||||
|
/// An open dialog for one file.
|
||||||
|
Open(FilePick),
|
||||||
|
/// An open dialog for files to place.
|
||||||
|
OpenMany,
|
||||||
|
/// A save dialog.
|
||||||
|
Save(FilePick),
|
||||||
|
/// A folder picker.
|
||||||
|
Folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paths a dialog shown off the UI thread picked (none: cancelled).
|
||||||
|
pub type PickAnswer = Vec<String>;
|
||||||
|
|
||||||
|
/// Shows a dialog off the UI thread; its answer arrives on the receiver. `None` when it can't, and
|
||||||
|
/// the dialog is then shown in line.
|
||||||
|
pub type StartPick = Box<dyn FnMut(PickRequest) -> Option<Receiver<PickAnswer>>>;
|
||||||
|
|
||||||
|
/// What runs again with the picked path.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) enum Entry {
|
||||||
|
/// A command (`app.run`).
|
||||||
|
Command(String, Value),
|
||||||
|
/// A dialog's OK (`dialogs::confirm`), on the dialog as it was.
|
||||||
|
Confirm(Box<Dialog>),
|
||||||
|
/// A panel's or a dialog's button.
|
||||||
|
Call(Rc<dyn Fn(&mut VectorcraftApp)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dialog shown off the UI thread, waiting for its answer.
|
||||||
|
struct Waiting {
|
||||||
|
request: PickRequest,
|
||||||
|
answer: Receiver<PickAnswer>,
|
||||||
|
entry: Entry,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's file dialogs ([`VectorcraftApp`]'s `picks`).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Picks {
|
||||||
|
/// What is running now and asks for a path, the outermost one.
|
||||||
|
entry: Option<Entry>,
|
||||||
|
waiting: Option<Waiting>,
|
||||||
|
/// A picked answer for the entry running again: the dialog it asks for gets it.
|
||||||
|
answer: Option<(PickRequest, PickAnswer)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Picks {
|
||||||
|
/// Nothing asking for file dialogs is running.
|
||||||
|
pub(crate) fn is_entry_free(&self) -> bool {
|
||||||
|
self.entry.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` as `entry`: a dialog it asks for off the UI thread runs `entry` again when it
|
||||||
|
/// answers. Inside another entry, that one runs again instead.
|
||||||
|
pub(crate) fn as_entry<R>(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R {
|
||||||
|
if app.picks.entry.is_some() {
|
||||||
|
return f(app);
|
||||||
|
}
|
||||||
|
app.picks.entry = Some(entry());
|
||||||
|
let r = f(app);
|
||||||
|
app.picks.entry = None;
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`as_entry`] for a panel's or a dialog's button that `f` handles.
|
||||||
|
pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) {
|
||||||
|
let f = Rc::new(f);
|
||||||
|
let again = f.clone();
|
||||||
|
as_entry(app, move || Entry::Call(again), |app| f(app));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dialog body's action `f` on `d` (the open dialog's copy) that asks for a file dialog: shown
|
||||||
|
/// off the UI thread, its answer runs `f` again on the open dialog, when it is still of that kind.
|
||||||
|
pub(crate) fn in_dialog(
|
||||||
|
app: &mut VectorcraftApp,
|
||||||
|
d: &mut Dialog,
|
||||||
|
f: impl Fn(&mut VectorcraftApp, &mut Dialog) -> Result<(), String> + 'static,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let f = Rc::new(f);
|
||||||
|
let (again, kind) = (f.clone(), d.kind.clone());
|
||||||
|
let entry = move || {
|
||||||
|
Entry::Call(Rc::new(move |app: &mut VectorcraftApp| {
|
||||||
|
let Some(mut d) = app.ui.dialog.take() else { return };
|
||||||
|
let r = if d.kind == kind { again(app, &mut d) } else { Ok(()) };
|
||||||
|
app.ui.dialog = Some(d);
|
||||||
|
if let Err(e) = r {
|
||||||
|
app.status(e);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
as_entry(app, entry, |app| f(app, d))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dialog body's folder button: the folder picked into field `key` of `d` ([`in_dialog`]).
|
||||||
|
pub(crate) fn folder_field(app: &mut VectorcraftApp, d: &mut Dialog, key: &'static str) {
|
||||||
|
let r = in_dialog(app, d, move |app, d| {
|
||||||
|
if let Some(f) = folder(app) {
|
||||||
|
d.fields.insert(key.into(), serde_json::json!(f));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
// Picking a folder doesn't fail.
|
||||||
|
let _ = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path from an open dialog for `pick` (none: cancelled, or shown off the UI thread for now).
|
||||||
|
pub(crate) fn open(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String> {
|
||||||
|
ask(app, PickRequest::Open(pick.clone()))?.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path from a save dialog for `pick`.
|
||||||
|
pub(crate) fn save(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String> {
|
||||||
|
ask(app, PickRequest::Save(pick.clone()))?.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder from the folder picker.
|
||||||
|
pub(crate) fn folder(app: &mut VectorcraftApp) -> Option<String> {
|
||||||
|
ask(app, PickRequest::Folder)?.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths from the open dialog for files to place (none: cancelled).
|
||||||
|
pub(crate) fn open_many(app: &mut VectorcraftApp) -> Vec<String> {
|
||||||
|
ask(app, PickRequest::OpenMany).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the host shows `request`'s kind of dialog at all.
|
||||||
|
pub(crate) fn can(app: &VectorcraftApp, request: &PickRequest) -> bool {
|
||||||
|
let s = &app.services;
|
||||||
|
match request {
|
||||||
|
PickRequest::Open(_) => s.pick_open.is_some(),
|
||||||
|
PickRequest::OpenMany => s.pick_open_multi.is_some() || s.pick_open.is_some(),
|
||||||
|
PickRequest::Save(_) => s.pick_save.is_some(),
|
||||||
|
PickRequest::Folder => s.pick_folder.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paths `request`'s dialog picked: the answer kept for it, else shown off the UI thread (no
|
||||||
|
/// paths for now), else shown in line.
|
||||||
|
fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option<PickAnswer> {
|
||||||
|
// One for another dialog is dropped: the entry took another way this time.
|
||||||
|
if let Some((asked, answer)) = app.picks.answer.take()
|
||||||
|
&& asked == request
|
||||||
|
{
|
||||||
|
return Some(answer);
|
||||||
|
}
|
||||||
|
if !can(app, &request) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let (Some(entry), Some(start)) = (app.picks.entry.clone(), app.services.start_pick.as_mut()) {
|
||||||
|
if app.picks.waiting.is_some() {
|
||||||
|
app.status(tl!("A file dialog is open: choose there first"));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(answer) = start(request.clone()) {
|
||||||
|
app.picks.waiting = Some(Waiting { request, answer, entry });
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let s = &mut app.services;
|
||||||
|
let one = |p: Option<String>| Some(p.into_iter().collect());
|
||||||
|
match &request {
|
||||||
|
PickRequest::Open(pick) => one(s.pick_open.as_mut().and_then(|f| f(pick))),
|
||||||
|
PickRequest::Save(pick) => one(s.pick_save.as_mut().and_then(|f| f(pick))),
|
||||||
|
PickRequest::Folder => one(s.pick_folder.as_mut().and_then(|f| f())),
|
||||||
|
PickRequest::OpenMany => match (s.pick_open_multi.as_mut(), s.pick_open.as_mut()) {
|
||||||
|
(Some(many), _) => Some(many()),
|
||||||
|
(None, Some(f)) => one(f(&FilePick::default())),
|
||||||
|
(None, None) => None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each frame: when the dialog shown off the UI thread answers with paths, run its entry again
|
||||||
|
/// with them. While it is open, look again shortly.
|
||||||
|
pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||||
|
let Some(w) = &app.picks.waiting else { return };
|
||||||
|
let answer = match w.answer.try_recv() {
|
||||||
|
Ok(answer) => answer,
|
||||||
|
Err(TryRecvError::Empty) => {
|
||||||
|
// What asked gave up for now: no "cancelled" while the dialog is open.
|
||||||
|
if app.ui.status == "cancelled" {
|
||||||
|
app.ui.status.clear();
|
||||||
|
}
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The dialog's thread is gone: as cancelled.
|
||||||
|
Err(TryRecvError::Disconnected) => vec![],
|
||||||
|
};
|
||||||
|
let Some(Waiting { request, entry, .. }) = app.picks.waiting.take() else { return };
|
||||||
|
if answer.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.picks.answer = Some((request, answer));
|
||||||
|
rerun(app, entry);
|
||||||
|
// An entry that took another way this time leaves the answer unused.
|
||||||
|
app.picks.answer = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `entry` again.
|
||||||
|
fn rerun(app: &mut VectorcraftApp, entry: Entry) {
|
||||||
|
let r = match entry {
|
||||||
|
Entry::Command(id, params) => app.run(&id, params).map(|_| ()),
|
||||||
|
Entry::Confirm(d) => {
|
||||||
|
app.ui.dialog = Some(*d);
|
||||||
|
crate::dialogs::confirm(app).map(|_| ())
|
||||||
|
}
|
||||||
|
Entry::Call(f) => {
|
||||||
|
f(app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = r {
|
||||||
|
app.status(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,10 +135,10 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> {
|
|||||||
f();
|
f();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) {
|
let paths = if app.services.pick_open_multi.is_some() {
|
||||||
(Some(f), _) => f(),
|
crate::picks::open_many(app)
|
||||||
(None, Some(f)) => f(&crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect(),
|
} else {
|
||||||
(None, None) => vec![],
|
crate::picks::open(app, &crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect()
|
||||||
};
|
};
|
||||||
if paths.is_empty() {
|
if paths.is_empty() {
|
||||||
return Err("cancelled".into());
|
return Err("cancelled".into());
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
|||||||
"shortcuts.export" => {
|
"shortcuts.export" => {
|
||||||
let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides);
|
let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides);
|
||||||
let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default();
|
let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default();
|
||||||
let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named("VectorCraft Shortcuts.json"))));
|
let path = s("path").or_else(|| crate::picks::save(app, &crate::FilePick::named("VectorCraft Shortcuts.json")));
|
||||||
match path {
|
match path {
|
||||||
Some(path) => match app.services.write.as_mut() {
|
Some(path) => match app.services.write.as_mut() {
|
||||||
Some(w) => w(&path, &bytes).map(|_| json!({"path": path})),
|
Some(w) => w(&path, &bytes).map(|_| json!({"path": path})),
|
||||||
@@ -477,7 +477,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
|||||||
let data = if let Some(v) = p.get("data") {
|
let data = if let Some(v) = p.get("data") {
|
||||||
Ok(v.clone())
|
Ok(v.clone())
|
||||||
} else {
|
} else {
|
||||||
let path = s("path").or_else(|| app.services.pick_open.as_mut().and_then(|f| f(&crate::FilePick::default())));
|
let path = s("path").or_else(|| crate::picks::open(app, &crate::FilePick::default()));
|
||||||
match (path, app.services.read.as_ref()) {
|
match (path, app.services.read.as_ref()) {
|
||||||
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
|
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
|
||||||
(None, _) => Err("cancelled".into()),
|
(None, _) => Err("cancelled".into()),
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! File dialogs shown off the UI thread (Linux, #592): what asked runs again with the answer.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use vectorcraft_engine::Session;
|
||||||
|
|
||||||
|
use crate::picks::{self, PickRequest};
|
||||||
|
use crate::state::Dialog;
|
||||||
|
use crate::{FilePick, Services, VectorcraftApp};
|
||||||
|
|
||||||
|
const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="10" height="10"/></svg>"#;
|
||||||
|
|
||||||
|
/// A dialog asked for, and where its answer goes.
|
||||||
|
type Asked = (PickRequest, Sender<Vec<String>>);
|
||||||
|
|
||||||
|
/// The dialogs showing.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct Shown(Rc<RefCell<Vec<Asked>>>);
|
||||||
|
|
||||||
|
impl Shown {
|
||||||
|
fn requests(&self) -> Vec<PickRequest> {
|
||||||
|
self.0.borrow().iter().map(|(r, _)| r.clone()).collect()
|
||||||
|
}
|
||||||
|
/// The last dialog shown answers `paths`.
|
||||||
|
fn answer(&self, paths: &[&str]) {
|
||||||
|
let (_, tx) = self.0.borrow_mut().pop().expect("a dialog is showing");
|
||||||
|
tx.send(paths.iter().map(|p| p.to_string()).collect()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A desktop app whose dialogs show off the UI thread (none in line), reading [`SVG`] from any path
|
||||||
|
/// and writing files to `written`.
|
||||||
|
fn app() -> (VectorcraftApp, Shown, Rc<RefCell<Vec<String>>>) {
|
||||||
|
let (shown, written) = (Shown::default(), Rc::new(RefCell::new(vec![])));
|
||||||
|
let (s, w) = (shown.clone(), written.clone());
|
||||||
|
let in_line = |_: &FilePick| -> Option<String> { panic!("no dialog on the UI thread") };
|
||||||
|
let services = Services {
|
||||||
|
pick_open: Some(Box::new(in_line)),
|
||||||
|
pick_save: Some(Box::new(in_line)),
|
||||||
|
pick_folder: Some(Box::new(|| panic!("no dialog on the UI thread"))),
|
||||||
|
read: Some(Box::new(|_: &str| Ok(SVG.to_vec()))),
|
||||||
|
write: Some(Box::new(move |path: &str, _: &[u8]| {
|
||||||
|
w.borrow_mut().push(path.to_string());
|
||||||
|
Ok(())
|
||||||
|
})),
|
||||||
|
start_pick: Some(Box::new(move |request: PickRequest| {
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
s.0.borrow_mut().push((request, tx));
|
||||||
|
Some(rx)
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
(VectorcraftApp::new(Session::new(), services), shown, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll(app: &mut VectorcraftApp) {
|
||||||
|
picks::poll(app, &egui::Context::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_open_opens_what_its_dialog_picks_when_it_answers() {
|
||||||
|
let (mut app, shown, _) = app();
|
||||||
|
// The dialog shows; File › Open gives up for now, without a "cancelled".
|
||||||
|
assert!(app.run("file.open", json!({})).is_err());
|
||||||
|
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
|
||||||
|
poll(&mut app);
|
||||||
|
assert!(app.session.documents().is_empty() && app.ui.status.is_empty(), "{}", app.ui.status);
|
||||||
|
// Another dialog waits for this one.
|
||||||
|
assert!(app.run("file.open", json!({})).is_err());
|
||||||
|
assert_eq!(shown.requests().len(), 1);
|
||||||
|
assert!(app.ui.status.contains("file dialog is open"), "{}", app.ui.status);
|
||||||
|
shown.answer(&["/art/a.svg"]);
|
||||||
|
poll(&mut app);
|
||||||
|
assert_eq!(app.session.documents().len(), 1);
|
||||||
|
assert_eq!(app.session.active().and_then(|d| d.path.clone()).as_deref(), Some("/art/a.svg"));
|
||||||
|
// Cancelled: nothing happens, and dialogs show again.
|
||||||
|
assert!(app.run("file.open", json!({})).is_err());
|
||||||
|
shown.answer(&[]);
|
||||||
|
poll(&mut app);
|
||||||
|
assert_eq!(app.session.documents().len(), 1);
|
||||||
|
assert!(app.run("file.open", json!({})).is_err());
|
||||||
|
assert_eq!(shown.requests().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_as_goes_on_with_the_path_picked() {
|
||||||
|
let (mut app, shown, written) = app();
|
||||||
|
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||||
|
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||||
|
assert!(app.run("file.saveAs", json!({})).is_err());
|
||||||
|
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]));
|
||||||
|
shown.answer(&["/art/poster.svg"]);
|
||||||
|
poll(&mut app);
|
||||||
|
// SVG Options asks next, and its OK writes the file there.
|
||||||
|
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"));
|
||||||
|
crate::dialogs::confirm(&mut app).unwrap();
|
||||||
|
assert_eq!(*written.borrow(), ["/art/poster.svg"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dialogs_folder_button_fills_its_field_when_the_picker_answers() {
|
||||||
|
let (mut app, shown, _) = app();
|
||||||
|
let mut d = Dialog::new("exportForScreens", json!({"folder": ""}));
|
||||||
|
app.ui.dialog = Some(d.clone());
|
||||||
|
picks::folder_field(&mut app, &mut d, "folder");
|
||||||
|
assert_eq!(shown.requests(), [PickRequest::Folder]);
|
||||||
|
shown.answer(&["/out"]);
|
||||||
|
poll(&mut app);
|
||||||
|
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("folder")).as_deref(), Some("/out"));
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
|
|||||||
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||||
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
|
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
|
||||||
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
|
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
|
||||||
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) |
|
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) |
|
||||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
||||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||||
@@ -214,6 +214,13 @@ Bar Position (`window.taskBar.reset`), which unpins it and puts it back under th
|
|||||||
Panel. As in Illustrator, neither the position nor the pin is saved: each launch starts with the bar under the
|
Panel. As in Illustrator, neither the position nor the pin is saved: each launch starts with the bar under the
|
||||||
selection.
|
selection.
|
||||||
|
|
||||||
|
The Free Transform widget: while the Free Transform tool (E) is active, a small box at the canvas's top left holds
|
||||||
|
Constrain over the tool's three modes, Free Transform, Perspective Distort and Free Distort. The buttons set the tool's
|
||||||
|
options, as `tool.setOption {key: "mode", value: "free"|"perspective"|"distort"}` and `{key: "constrain", value}` do:
|
||||||
|
Constrain acts as Shift held (proportional scaling, moves and rotations by 45°) and does nothing in the distort modes,
|
||||||
|
where it is greyed. The modifier keys still work while dragging (Cmd on a corner distorts it freely, Cmd+Alt+Shift in
|
||||||
|
perspective, Cmd on a side shears).
|
||||||
|
|
||||||
Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel
|
Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel
|
||||||
inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A
|
inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A
|
||||||
floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;
|
floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;
|
||||||
|
|||||||
Reference in new issue
Block a user