mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #2613 from dotCooCoo/feat/links-shift-range
Links: Shift-click selects a range of links
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
//! Links panel: every image in the document, linked files with their status (missing, modified)
|
||||
//! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects
|
||||
//! its image; the buttons Relink, Go To, Update and Edit Original act on the selected rows; Link
|
||||
//! Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find
|
||||
//! Linked Files: `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort,
|
||||
//! thumbnail size and Show in Folder.
|
||||
//! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows
|
||||
//! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink,
|
||||
//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the
|
||||
//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files:
|
||||
//! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort, thumbnail size
|
||||
//! and Show in Folder.
|
||||
//!
|
||||
//! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI
|
||||
//! commands: they hand the file to the system).
|
||||
@@ -245,6 +247,31 @@ fn status_tip(r: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on the row of image `id` among the shown `rows`: plain selects the image alone,
|
||||
/// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked
|
||||
/// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A
|
||||
/// Shift-click while that row is not shown, or after the last click was in another document, acts
|
||||
/// as a click without Shift.
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) {
|
||||
let Some(uid) = app.session.active().map(|st| st.uid) else { return };
|
||||
let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor");
|
||||
let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id));
|
||||
if m.shift
|
||||
&& let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid)
|
||||
&& let (Some(i), Some(j)) = (at(a), at(id))
|
||||
{
|
||||
let range: Vec<u64> = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect();
|
||||
app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok();
|
||||
return;
|
||||
}
|
||||
set_pstate(ctx, "links-anchor", Some((uid, id)));
|
||||
if m.command {
|
||||
app.run("select.toggle", json!({ "id": id })).ok();
|
||||
} else {
|
||||
app.run("select.set", json!({ "ids": [id] })).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let o = options(ui.ctx());
|
||||
@@ -257,7 +284,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let thumbs = super::layers::ThumbSource::of(app, st, &doc);
|
||||
let selected = selected_images(app);
|
||||
let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice);
|
||||
let mut clicked: Option<(u64, bool, bool)> = None;
|
||||
let mut clicked: Option<(u64, egui::Modifiers, bool)> = None;
|
||||
widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(110.0);
|
||||
ui.set_width(ui.available_width());
|
||||
@@ -317,21 +344,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
);
|
||||
let resp = resp.on_hover_text(status_tip(r));
|
||||
if resp.double_clicked() {
|
||||
clicked = Some((id, false, true));
|
||||
clicked = Some((id, egui::Modifiers::NONE, true));
|
||||
} else if resp.clicked() {
|
||||
clicked = Some((id, ui.input(|i| i.modifiers.shift || i.modifiers.command), false));
|
||||
clicked = Some((id, ui.input(|i| i.modifiers), false));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
match clicked {
|
||||
Some((id, _, true)) => go_to(app, id),
|
||||
Some((id, true, _)) => {
|
||||
app.run("select.toggle", json!({ "id": id })).ok();
|
||||
}
|
||||
Some((id, false, _)) => {
|
||||
app.run("select.set", json!({ "ids": [id] })).ok();
|
||||
}
|
||||
Some((id, m, false)) => click(app, ui.ctx(), rows, id, m),
|
||||
None => {}
|
||||
}
|
||||
if o.info {
|
||||
|
||||
@@ -56,6 +56,27 @@ fn panel(app: &mut VectorcraftApp) -> String {
|
||||
painted_text(app, super::links::show)
|
||||
}
|
||||
|
||||
/// Click the row named `name` in the panel with `mods` held (frames at `time` and just after: a
|
||||
/// second apart, two clicks are not a double-click).
|
||||
fn click_row(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, mods: egui::Modifiers, time: f64) {
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>, time: f64| {
|
||||
let events = std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect();
|
||||
super::tests_appearance::frame_raw(ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, super::links::show)
|
||||
};
|
||||
let at = super::tests_appearance::text_rect(&frame(app, vec![], time), name).center();
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: mods };
|
||||
for (i, e) in [egui::Event::PointerMoved(at), button(true), button(false)].into_iter().enumerate() {
|
||||
frame(app, vec![e], time + 0.01 * (i + 1) as f64);
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected objects' ids, in ascending order.
|
||||
fn selected(app: &VectorcraftApp) -> Vec<u64> {
|
||||
let mut ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_lists_the_images_with_link_info_for_the_selected_one() {
|
||||
let dir = Folder::new("panel");
|
||||
@@ -75,6 +96,42 @@ fn the_panel_lists_the_images_with_link_info_for_the_selected_one() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift-click selects the images of the rows from the last row clicked without Shift; Ctrl/Cmd-click
|
||||
/// adds or removes one image, and Ctrl/Cmd-Shift-click adds the rows' images to the selection.
|
||||
#[test]
|
||||
fn shift_click_selects_a_range_of_links_and_ctrl_click_toggles_one() {
|
||||
let dir = Folder::new("range");
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
for (i, name) in ["a.png", "b.png", "c.png", "d.png"].into_iter().enumerate() {
|
||||
let path = dir.file(name, &png(20, 20));
|
||||
app.run("file.place", json!({"path": path, "at": [50 + 80 * i, 100]})).unwrap();
|
||||
}
|
||||
let list = app.run("links.list", json!({})).unwrap();
|
||||
let rows: Vec<(String, u64)> =
|
||||
list["links"].as_array().unwrap().iter().map(|r| (r["name"].as_str().unwrap().to_string(), r["id"].as_u64().unwrap())).collect();
|
||||
assert_eq!(rows.len(), 4, "{list}");
|
||||
let id = |rows_at: &[usize]| {
|
||||
let mut ids: Vec<u64> = rows_at.iter().map(|&i| rows[i].1).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
click_row(&mut app, &ctx, &rows[0].0, egui::Modifiers::NONE, 1.0);
|
||||
assert_eq!(selected(&app), id(&[0]));
|
||||
click_row(&mut app, &ctx, &rows[2].0, egui::Modifiers::SHIFT, 2.0);
|
||||
assert_eq!(selected(&app), id(&[0, 1, 2]), "Shift-click selects the rows from the one clicked before");
|
||||
click_row(&mut app, &ctx, &rows[1].0, egui::Modifiers::COMMAND, 3.0);
|
||||
assert_eq!(selected(&app), id(&[0, 2]), "Ctrl/Cmd-click removes one image");
|
||||
click_row(&mut app, &ctx, &rows[3].0, egui::Modifiers::SHIFT | egui::Modifiers::COMMAND, 4.0);
|
||||
assert_eq!(selected(&app), id(&[0, 1, 2, 3]), "Ctrl/Cmd-Shift-click adds the rows from the one clicked before");
|
||||
click_row(&mut app, &ctx, &rows[0].0, egui::Modifiers::SHIFT, 5.0);
|
||||
assert_eq!(selected(&app), id(&[0, 1]), "Shift-click replaces the selection with the rows");
|
||||
click_row(&mut app, &ctx, &rows[3].0, egui::Modifiers::NONE, 6.0);
|
||||
assert_eq!(selected(&app), id(&[3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_document_shows_how_to_add_images() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -621,6 +621,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
|
||||
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.
|
||||
- In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one.
|
||||
- Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`).
|
||||
- Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499).
|
||||
- A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes as a copied `.pdf` file does, and one saved as PostScript pastes as a copied `.eps` file does.
|
||||
|
||||
Reference in New Issue
Block a user