Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068a934f6a | ||
|
|
079580257a | ||
|
|
f81c0f51d7 | ||
|
|
01a65c44ab | ||
|
|
11c3a99bfb |
No files matched your search
+2
-1
@@ -142,7 +142,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph Design, Column and Marker artwork remain open.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. A second value axis (Left/Right/Both, separate scales), Graph Design, Column and Marker artwork remain open.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
@@ -179,6 +179,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- 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).
|
||||
- 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`).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
@@ -69,6 +69,15 @@ impl GraphKind {
|
||||
GraphKind::Radar => "Radar",
|
||||
}
|
||||
}
|
||||
/// Whether a series of this type can be drawn in a graph of type `graph` (Combine different graph types): the
|
||||
/// column, stacked column, line and area types share a vertical value axis, the bar types a horizontal one.
|
||||
/// Scatter, pie and radar lay their data out on their own and take no other types.
|
||||
pub fn combines_with(self, graph: GraphKind) -> bool {
|
||||
use GraphKind::*;
|
||||
let vertical = |k| matches!(k, Column | StackedColumn | Line | Area);
|
||||
let horizontal = |k| matches!(k, Bar | StackedBar);
|
||||
self == graph || (vertical(self) && vertical(graph)) || (horizontal(self) && horizontal(graph))
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
|
||||
@@ -95,6 +104,10 @@ pub struct GraphSpec {
|
||||
/// entries keep the generator's own fill and stroke.
|
||||
pub series_paints: Vec<SeriesPaint>,
|
||||
pub kind: GraphKind,
|
||||
/// Graph types chosen for single series (Combine different graph types), by series index; `None` and missing
|
||||
/// entries draw as [`Self::kind`].
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_kinds: Vec<Option<GraphKind>>,
|
||||
/// The plot rectangle (the area the graph tool dragged), document points.
|
||||
pub rect: Rect,
|
||||
pub series: Vec<String>,
|
||||
@@ -133,6 +146,11 @@ fn edge_to_edge_before_the_option() -> bool {
|
||||
}
|
||||
|
||||
impl GraphSpec {
|
||||
/// The type series `s` is drawn as: its own type when it combines with the graph's, else the graph's.
|
||||
pub fn series_kind(&self, s: usize) -> GraphKind {
|
||||
self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind)
|
||||
}
|
||||
|
||||
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
|
||||
/// of [`MAX_GRAPH_SERIES`] cells).
|
||||
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
|
||||
@@ -159,6 +177,7 @@ impl Default for GraphSpec {
|
||||
Self {
|
||||
series_paints: vec![],
|
||||
kind: GraphKind::Column,
|
||||
series_kinds: vec![],
|
||||
rect: Rect::new(0.0, 0.0, 200.0, 150.0),
|
||||
series: vec![],
|
||||
categories: vec![],
|
||||
|
||||
+348
-88
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, type?, columnWidth?: %, clusterWidth?: %, legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?} change the graph type and options (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0); no options → the current ones",
|
||||
"{id?, type?, seriesIndexes?: [index], columnWidth?: %, clusterWidth?: %, legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0); no options → the current ones",
|
||||
has_selection,
|
||||
set_type
|
||||
),
|
||||
@@ -323,7 +323,9 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let cells = g.cells();
|
||||
let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten();
|
||||
let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0);
|
||||
let stacked = matches!(g.kind, GraphKind::StackedColumn | GraphKind::StackedBar | GraphKind::Area);
|
||||
// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own.
|
||||
let kinds: Vec<GraphKind> = (0..nser).map(|s| g.series_kind(s)).collect();
|
||||
let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind);
|
||||
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
|
||||
|
||||
match g.kind {
|
||||
@@ -400,12 +402,19 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let scatter = g.kind == GraphKind::Scatter;
|
||||
let (mut xlo, mut xhi) = (f64::MAX, f64::MIN);
|
||||
for c in 0..ncat {
|
||||
if stacked {
|
||||
let pos: f64 = (0..nser).map(|s| val(c, s).max(0.0)).sum();
|
||||
let neg: f64 = (0..nser).map(|s| val(c, s).min(0.0)).sum();
|
||||
hi = hi.max(pos);
|
||||
lo = lo.min(neg);
|
||||
} else if scatter {
|
||||
if !scatter {
|
||||
// Stacked columns (or bars) stack together, and so do areas; other series count one value each.
|
||||
for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] {
|
||||
let members = || (0..nser).filter(|s| stack.contains(&kind(*s)));
|
||||
hi = hi.max(members().map(|s| val(c, s).max(0.0)).sum());
|
||||
lo = lo.min(members().map(|s| val(c, s).min(0.0)).sum());
|
||||
}
|
||||
for v in (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line)).filter_map(|s| cell(c, s))
|
||||
{
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
} else {
|
||||
for s in (0..nser).step_by(2) {
|
||||
if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) {
|
||||
hi = hi.max(y);
|
||||
@@ -414,11 +423,6 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
xhi = xhi.max(x);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for v in (0..nser).filter_map(|s| cell(c, s)) {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (lo, hi, step) = value_axis(g, lo, hi);
|
||||
@@ -450,11 +454,14 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let span = if horizontal { r.height() } else { r.width() };
|
||||
let cat_w = span / ncat as f64;
|
||||
let cat_start = |c: usize| if horizontal { r.y0 + c as f64 * cat_w } else { r.x0 + c as f64 * cat_w };
|
||||
// Area bands always span the plot; line graphs only with Edge-to-Edge Lines.
|
||||
let points_mode = g.kind == GraphKind::Area || (g.kind == GraphKind::Line && g.edge_to_edge);
|
||||
let cat_mid = |c: usize| {
|
||||
if points_mode && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 }
|
||||
// Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every
|
||||
// series puts its points: at the edges when all of them span the plot, else at the category centres.
|
||||
let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge);
|
||||
let mid = |c: usize, edges: bool| {
|
||||
if edges && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 }
|
||||
};
|
||||
let label_edges = kinds.iter().all(|k| spans(*k));
|
||||
let cat_mid = |c: usize| mid(c, label_edges);
|
||||
if !scatter {
|
||||
for c in 0..ncat {
|
||||
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
|
||||
@@ -499,78 +506,83 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
}
|
||||
let ax = b.group("Axes", axes);
|
||||
b.out.push(ax);
|
||||
match g.kind {
|
||||
GraphKind::Column | GraphKind::Bar => {
|
||||
let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 1.0);
|
||||
let slot = cluster / nser as f64;
|
||||
let bar = slot * (g.column_width / 100.0).clamp(0.01, 1.0);
|
||||
for c in 0..ncat {
|
||||
let c0 = cat_start(c) + (cat_w - cluster) / 2.0;
|
||||
for (s, items) in series.iter_mut().enumerate() {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let a = c0 + slot * s as f64 + (slot - bar) / 2.0;
|
||||
let (v0, v1) = (vpos(0.0f64.clamp(lo, hi)), vpos(v));
|
||||
let rect = if horizontal {
|
||||
Rect::new(v0.min(v1), a, v0.max(v1), a + bar)
|
||||
} else {
|
||||
Rect::new(a, v0.min(v1), a + bar, v0.max(v1))
|
||||
};
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.path(shapes::rectangle(rect), fill, stroke, w));
|
||||
// Columns (or bars) of one category side by side; stacked columns take one slot of that cluster, or, with
|
||||
// no plain columns, a width of their own.
|
||||
let columns: Vec<usize> = (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar)).collect();
|
||||
let stacks: Vec<usize> = (0..nser).filter(|s| matches!(kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect();
|
||||
let slots = columns.len() + usize::from(!stacks.is_empty());
|
||||
let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 1.0);
|
||||
let slot = cluster / slots.max(1) as f64;
|
||||
let bar = slot * (g.column_width / 100.0).clamp(0.01, 1.0);
|
||||
let bar_rect = |a: f64, w: f64, v0: f64, v1: f64| {
|
||||
if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) }
|
||||
};
|
||||
for c in 0..ncat {
|
||||
let c0 = cat_start(c) + (cat_w - cluster) / 2.0;
|
||||
for (i, &s) in columns.iter().enumerate() {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
let n = b.path(shapes::rectangle(bar_rect(a, bar, vpos(0.0f64.clamp(lo, hi)), vpos(v))), fill, stroke, w);
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
}
|
||||
let (a, w) = if columns.is_empty() {
|
||||
let w = (cat_w * (g.column_width / 100.0).clamp(0.01, 1.0) * (g.cluster_width / 100.0).clamp(0.01, 1.0) * 1.2).min(cat_w);
|
||||
(cat_start(c) + (cat_w - w) / 2.0, w)
|
||||
} else {
|
||||
(c0 + slot * columns.len() as f64 + (slot - bar) / 2.0, bar)
|
||||
};
|
||||
let (mut pos, mut neg) = (0.0, 0.0);
|
||||
for &s in &stacks {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let base = if v >= 0.0 { &mut pos } else { &mut neg };
|
||||
let (v0, v1) = (vpos(*base), vpos(*base + v));
|
||||
*base += v;
|
||||
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
let n = b.path(shapes::rectangle(bar_rect(a, w, v0, v1)), fill, stroke, sw);
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cumulative area bands, each from the previous area total up to its own.
|
||||
let mut below: Vec<f64> = vec![0.0; ncat];
|
||||
for s in (0..nser).filter(|s| kind(*s) == GraphKind::Area) {
|
||||
let above: Vec<f64> = below.iter().enumerate().map(|(c, b)| b + val(c, s)).collect();
|
||||
// Mixed with series at the category centres, areas take their points there too.
|
||||
let at = |c: usize, v: &[f64]| Point::new(mid(c, label_edges), vpos(v.get(c).copied().unwrap_or(0.0)));
|
||||
let mut pts: Vec<Point> = (0..ncat).map(|c| at(c, &above)).collect();
|
||||
pts.extend((0..ncat).rev().map(|c| at(c, &below)));
|
||||
let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
let n = b.path(polyline(&pts, true), fill, stroke, w);
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
below = above;
|
||||
}
|
||||
for s in (0..nser).filter(|s| kind(*s) == GraphKind::Line) {
|
||||
let pts: Vec<Option<Point>> =
|
||||
(0..ncat).map(|c| cell(c, s).map(|v| Point::new(mid(c, g.edge_to_edge && label_edges), vpos(v)))).collect();
|
||||
if g.connect_points {
|
||||
for run in runs(&pts) {
|
||||
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
|
||||
let n = b.path(polyline(&run, false), fill, stroke, w);
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
GraphKind::StackedColumn | GraphKind::StackedBar => {
|
||||
let bar = cat_w * (g.column_width / 100.0).clamp(0.01, 1.0) * (g.cluster_width / 100.0).clamp(0.01, 1.0) * 1.2;
|
||||
let bar = bar.min(cat_w);
|
||||
for c in 0..ncat {
|
||||
let a = cat_start(c) + (cat_w - bar) / 2.0;
|
||||
let (mut pos, mut neg) = (0.0, 0.0);
|
||||
for (s, items) in series.iter_mut().enumerate() {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let base = if v >= 0.0 { &mut pos } else { &mut neg };
|
||||
let (v0, v1) = (vpos(*base), vpos(*base + v));
|
||||
*base += v;
|
||||
let rect = if horizontal {
|
||||
Rect::new(v0.min(v1), a, v0.max(v1), a + bar)
|
||||
} else {
|
||||
Rect::new(a, v0.min(v1), a + bar, v0.max(v1))
|
||||
};
|
||||
let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
items.push(b.path(shapes::rectangle(rect), fill, stroke, w));
|
||||
if g.mark_points {
|
||||
for p in pts.iter().flatten() {
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
let n = b.path(marker(*p, 5.0), fill, stroke, w);
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
GraphKind::Line => {
|
||||
for (s, items) in series.iter_mut().enumerate() {
|
||||
let pts: Vec<Option<Point>> = (0..ncat).map(|c| cell(c, s).map(|v| Point::new(cat_mid(c), vpos(v)))).collect();
|
||||
if g.connect_points {
|
||||
for run in runs(&pts) {
|
||||
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
|
||||
items.push(b.path(polyline(&run, false), fill, stroke, w));
|
||||
}
|
||||
}
|
||||
if g.mark_points {
|
||||
for p in pts.iter().flatten() {
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.path(marker(*p, 5.0), fill, stroke, w));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GraphKind::Area => {
|
||||
// Cumulative bands, each from the previous total up to its own.
|
||||
let mut below: Vec<f64> = vec![0.0; ncat];
|
||||
for (s, items) in series.iter_mut().enumerate() {
|
||||
let above: Vec<f64> = (0..ncat).map(|c| below[c] + val(c, s)).collect();
|
||||
let mut pts: Vec<Point> = (0..ncat).map(|c| Point::new(cat_mid(c), vpos(above[c]))).collect();
|
||||
pts.extend((0..ncat).rev().map(|c| Point::new(cat_mid(c), vpos(below[c]))));
|
||||
let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
items.push(b.path(polyline(&pts, true), fill, stroke, w));
|
||||
below = above;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,7 +602,15 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
labels.push(b.text(Point::new(x + 12.0, y + 7.5), &label, Justify::Left));
|
||||
}
|
||||
}
|
||||
for (s, items) in series.into_iter().enumerate() {
|
||||
// Areas at the back, lines and points in front of columns; one graph type keeps the series order.
|
||||
let depth = |s: usize| match kind(s) {
|
||||
GraphKind::Area => 0,
|
||||
GraphKind::Line => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let mut series: Vec<(usize, Vec<Arc<Node>>)> = series.into_iter().enumerate().collect();
|
||||
series.sort_by_key(|(s, _)| depth(*s));
|
||||
for (s, items) in series {
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -779,16 +799,73 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.setType";
|
||||
let id = target(s, p, C)?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
let keys = ["type", "columnWidth", "clusterWidth", "legend", "markPoints", "connectPoints", "edgeToEdge", "ticks", "axisMin", "axisMax"];
|
||||
// The series to retype: `seriesIndexes`, else, with no `id`, a selection made only of this graph's series (Group
|
||||
// Selection). Anything else selected, the graph itself for one, is the whole graph.
|
||||
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
let picked: Vec<usize> = match p.get("seriesIndexes") {
|
||||
Some(v) => {
|
||||
let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(C, "`seriesIndexes` is a non-empty list of series indexes"))?;
|
||||
if p.get("type").is_none() {
|
||||
return Err(bad(C, "`seriesIndexes` needs a `type`"));
|
||||
}
|
||||
a.iter()
|
||||
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(C, format!("no series {i}"))))
|
||||
.collect::<Result<_>>()?
|
||||
}
|
||||
None if p.get("id").is_none() => {
|
||||
let st = s.doc()?;
|
||||
let members = series_members(&st.doc);
|
||||
let picked: Option<Vec<usize>> = st.selection.objects.iter().map(|n| members.get(n).filter(|r| r.graph == id).map(|r| r.index)).collect();
|
||||
let mut out = picked.unwrap_or_default();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
let keys = [
|
||||
"type",
|
||||
"seriesIndexes",
|
||||
"columnWidth",
|
||||
"clusterWidth",
|
||||
"legend",
|
||||
"markPoints",
|
||||
"connectPoints",
|
||||
"edgeToEdge",
|
||||
"ticks",
|
||||
"axisMin",
|
||||
"axisMax",
|
||||
];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
// With series picked, the type they share, so the dialog's OK keeps it.
|
||||
let kinds: Vec<GraphKind> = picked.iter().map(|i| spec.series_kind(*i)).collect();
|
||||
let kind = kinds.first().copied().filter(|k| kinds.iter().all(|x| x == k)).unwrap_or(spec.kind);
|
||||
return Ok(json!({
|
||||
"type": spec.kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
|
||||
"type": kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
|
||||
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks,
|
||||
"axisMin": spec.axis_min, "axisMax": spec.axis_max,
|
||||
}));
|
||||
}
|
||||
// Series picked with Group Selection stay selected through the regeneration (their groups are new nodes).
|
||||
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
spec.kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?;
|
||||
let kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?;
|
||||
if picked.is_empty() {
|
||||
spec.kind = kind;
|
||||
} else {
|
||||
if !kind.combines_with(spec.kind) {
|
||||
return Err(bad(C, format!("a {} series can't go in a {} graph", kind.label(), spec.kind.label())));
|
||||
}
|
||||
spec.series_kinds.resize(count, None);
|
||||
for i in picked {
|
||||
if let Some(k) = spec.series_kinds.get_mut(i) {
|
||||
*k = (kind != spec.kind).then_some(kind);
|
||||
}
|
||||
}
|
||||
while spec.series_kinds.last().is_some_and(Option::is_none) {
|
||||
spec.series_kinds.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
spec.column_width = f64_or(p, "columnWidth", spec.column_width).clamp(1.0, 1000.0);
|
||||
spec.cluster_width = f64_or(p, "clusterWidth", spec.cluster_width).clamp(1.0, 100.0);
|
||||
@@ -804,8 +881,19 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
spec.axis_max = v.as_f64();
|
||||
}
|
||||
let label = format!("{} Graph", spec.kind.label());
|
||||
s.edit("Graph Type", |d, _| {
|
||||
s.edit("Graph Type", |d, sel| {
|
||||
regenerate(d, id, spec)?;
|
||||
if !reselect.is_empty() {
|
||||
let groups: Vec<NodeId> = d
|
||||
.node(id)
|
||||
.and_then(Node::children)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| c.series_index.is_some_and(|i| reselect.contains(&i)))
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
sel.set(groups);
|
||||
}
|
||||
if let Some(n) = d.node_mut(id)
|
||||
&& n.name.as_deref().is_some_and(|nm| nm.ends_with(" Graph"))
|
||||
{
|
||||
@@ -1316,4 +1404,176 @@ mod tests {
|
||||
assert_eq!(v["blanks"], json!([[1, 0], [2, 1]]));
|
||||
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none());
|
||||
}
|
||||
|
||||
fn spec(s: &Session, id: NodeId) -> GraphSpec {
|
||||
s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Bounds of a series' marks, without its legend swatch (the last child).
|
||||
fn marks(s: &Session, id: NodeId, index: u32) -> Vec<vectorcraft_geom::Rect> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let ch = series(&n, index).children().unwrap().to_vec();
|
||||
ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_given_its_own_type_is_drawn_as_that_type_in_front_of_the_columns() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let one_column = marks(&s, id, 0)[0].width();
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
|
||||
let g = spec(&s, id);
|
||||
assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Column, GraphKind::Column, GraphKind::Line));
|
||||
// The columns left take the whole cluster; the line runs through the category centres, one marker a value.
|
||||
let cols = marks(&s, id, 0);
|
||||
assert_eq!(cols.len(), 3);
|
||||
assert!((cols[0].width() - 2.0 * one_column).abs() < 1e-6, "{} vs {one_column}", cols[0].width());
|
||||
let line = marks(&s, id, 1);
|
||||
assert_eq!(line.len(), 4, "a line and three markers");
|
||||
assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6);
|
||||
// The line series is drawn after (in front of) the column series.
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
|
||||
assert_eq!(order, [0, 1]);
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap();
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
|
||||
assert_eq!(order, [1, 0]);
|
||||
// Back to the graph's own type: nothing is stored for that series.
|
||||
assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]);
|
||||
// One undo per change.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line), Some(GraphKind::Line)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_selecting_a_series_retypes_only_that_series() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let grp = series(&n, 1).id;
|
||||
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
|
||||
// The dialog opens on the series' type, and its OK (every option sent back) keeps it.
|
||||
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column");
|
||||
s.execute("graph.setType", &json!({"type": "area"})).unwrap();
|
||||
let g = spec(&s, id);
|
||||
assert_eq!((g.kind, g.series_kind(1)), (GraphKind::Column, GraphKind::Area));
|
||||
let mut all = s.execute("graph.setType", &json!({})).unwrap();
|
||||
assert_eq!(all["type"], "area");
|
||||
all["ticks"] = json!(4);
|
||||
s.execute("graph.setType", &all).unwrap();
|
||||
assert_eq!(spec(&s, id).series_kind(1), GraphKind::Area);
|
||||
// The series stays selected through each change.
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
|
||||
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]);
|
||||
// A series and something outside it selected, or an explicit `id`: the whole graph.
|
||||
s.execute("select.set", &json!({"ids": [grp.0, id.0]})).unwrap();
|
||||
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column");
|
||||
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
|
||||
assert_eq!(s.execute("graph.setType", &json!({"id": id.0})).unwrap()["type"], "column");
|
||||
// The whole graph selected: the graph's type changes, the series keeps its own.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
|
||||
let g = spec(&s, id);
|
||||
assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_that_do_not_share_a_value_axis_are_refused_or_ignored() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
for ty in ["pie", "radar", "scatter", "bar"] {
|
||||
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": ty})).is_err(), "{ty}");
|
||||
}
|
||||
for bad in [json!([2]), json!([]), json!([-1]), json!(["x"]), json!([1e30]), json!({}), json!(1)] {
|
||||
assert!(s.execute("graph.setType", &json!({"seriesIndexes": bad, "type": "line"})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1]})).is_err(), "no type");
|
||||
assert!(spec(&s, id).series_kinds.is_empty());
|
||||
// A series type the graph's new type can't take is drawn as the graph's type, and kept for when it can.
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
|
||||
s.execute("graph.setType", &json!({"type": "pie"})).unwrap();
|
||||
let g = spec(&s, id);
|
||||
assert_eq!(g.series_kind(1), GraphKind::Pie);
|
||||
s.execute("graph.setType", &json!({"type": "column"})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_kind(1), GraphKind::Line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_columns_take_one_slot_and_the_value_axis_covers_their_total() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",a,b,c\nQ1,10,20,30\nQ2,10,20,30";
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "stackedColumn", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line"})).unwrap();
|
||||
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
|
||||
assert!((a[0].x0 - b[0].x0).abs() < 1e-6 && (a[0].y0 - b[0].y1).abs() < 1e-6, "b sits on a");
|
||||
// a + b stack to 30, the line's highest value is 30: the axis ends at 30 and the stack reaches the top.
|
||||
assert!((b[0].y0 - 0.0).abs() < 1e-6, "{:?}", b[0]);
|
||||
// With a plain column too, the stack is the cluster's last slot, beside it.
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "column"})).unwrap();
|
||||
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
|
||||
assert!((a[0].width() - b[0].width()).abs() < 1e-6 && b[0].x0 > a[0].x1, "{:?} {:?}", a[0], b[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn areas_mixed_with_columns_put_their_points_at_the_category_centres() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap();
|
||||
let (area, cols) = (marks(&s, id, 0)[0], marks(&s, id, 1));
|
||||
assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}");
|
||||
// The area is drawn behind the columns.
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
|
||||
assert_eq!(order, [0, 1]);
|
||||
// An area graph alone still spans the plot.
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap();
|
||||
assert!((marks(&s, id, 0)[0].x0 - 100.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_graphs_take_stacked_bar_series() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5";
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err());
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap();
|
||||
let (a, b, c) = (marks(&s, id, 0), marks(&s, id, 1), marks(&s, id, 2));
|
||||
// b and c stack along x in the cluster's second slot, below a's bar; c, negative, runs left of the zero line.
|
||||
assert!((a[0].height() - b[0].height()).abs() < 1e-6 && b[0].y0 > a[0].y1, "{:?} {:?}", a[0], b[0]);
|
||||
assert!((b[0].y0 - c[0].y0).abs() < 1e-6 && (c[0].x1 - b[0].x0).abs() < 1e-6, "{:?} {:?}", b[0], c[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_types_survive_save_and_open_and_older_files_have_none() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
|
||||
let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]);
|
||||
let g: GraphSpec = serde_json::from_value(json!({"kind": "line", "rows": [[1.0, 2.0]]})).unwrap();
|
||||
assert!(g.series_kinds.is_empty());
|
||||
assert!(serde_json::to_value(&g).unwrap().get("seriesKinds").is_none());
|
||||
// A stored type the graph can't take, or past its series, is drawn as the graph's type.
|
||||
let g: GraphSpec = serde_json::from_value(json!({"kind": "pie", "seriesKinds": ["line", null, "column"], "rows": [[1.0]]})).unwrap();
|
||||
assert_eq!((g.series_kind(0), g.series_kind(5)), (GraphKind::Pie, GraphKind::Pie));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
cmd!(
|
||||
"links.updateChanged",
|
||||
"Update Changed Links",
|
||||
[],
|
||||
None,
|
||||
"{} read again the active document's linked files that changed on disk since they were last seen (size or modification time), whatever Preferences → File Handling → Update Links says; the desktop app looks every two seconds and follows that preference. Files seen for the first time are only remembered. One undo step → {updated: [ids], ask: [], modified: []}",
|
||||
has_doc,
|
||||
crate::link_watch::update_changed
|
||||
),
|
||||
cmd!(
|
||||
"links.relink",
|
||||
"Relink",
|
||||
@@ -158,6 +167,11 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]).
|
||||
pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
|
||||
groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect()
|
||||
}
|
||||
|
||||
/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when
|
||||
/// given), then, without `ids`, the ones only symbols and patterns show.
|
||||
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod cmd;
|
||||
pub mod file_access;
|
||||
pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod link_watch;
|
||||
pub mod steps;
|
||||
mod tooling;
|
||||
pub mod units;
|
||||
@@ -873,6 +874,11 @@ pub struct Session {
|
||||
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
|
||||
/// it.
|
||||
pub search_threads: Option<usize>,
|
||||
/// Each open document's linked files' size and modification time when last seen (linked
|
||||
/// files changing while the document is open, [`link_watch`]).
|
||||
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
|
||||
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
|
||||
pub link_scan: Option<link_watch::LinkScan>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -928,6 +934,8 @@ impl Session {
|
||||
font_search: None,
|
||||
search_rules: None,
|
||||
search_threads: None,
|
||||
link_stamps: Default::default(),
|
||||
link_scan: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1506,6 +1514,8 @@ mod tests_links;
|
||||
#[cfg(test)]
|
||||
mod tests_linkspanel;
|
||||
#[cfg(test)]
|
||||
mod tests_linkwatch;
|
||||
#[cfg(test)]
|
||||
mod tests_liquify;
|
||||
#[cfg(test)]
|
||||
mod tests_live;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Linked files that change while their document is open (another app saved them: PhotoCraft,
|
||||
//! LightCraft, Photoshop, a placed VectorCraft document): Preferences › File Handling › Update
|
||||
//! Links decides, as it does when a document opens. Automatically reads them again
|
||||
//! (`links.update`, an undo step), Ask When Modified offers to, Manually leaves them modified in
|
||||
//! the Links panel.
|
||||
//!
|
||||
//! The active document's linked files are compared by size and modification time with what they
|
||||
//! were when last seen. The desktop app takes the stamps every two seconds and when its window
|
||||
//! comes back to the front, on a worker thread so a file on a slow network share never holds up
|
||||
//! the interface ([`Session::start_link_scan`], [`Session::poll_link_scan`]);
|
||||
//! `links.updateChanged` takes them on the spot and reads the changed files again (CLI, agents).
|
||||
//!
|
||||
//! Stamps are kept per document: one in the background is looked at when it comes to the front.
|
||||
//! A file seen for the first time is only remembered (one modified while its document was closed
|
||||
//! is handled when the document opens); one that can't be read yet (another app is still writing
|
||||
//! it) keeps its old stamp, so the next look tries again. A change is acted on once: undoing an
|
||||
//! update, or answering No, leaves the link modified until the file changes again.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A file's size and modification time (nanoseconds since 1970).
|
||||
pub type Stamp = (u64, u128);
|
||||
|
||||
/// Stamps of a document's linked files taken on a worker thread: (document uid, [(file,
|
||||
/// object ids, stamp)]).
|
||||
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stamp(path: &str) -> Option<Stamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some((meta.len(), modified.as_nanos()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn stamp(_: &str) -> Option<Stamp> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The linked files of `d` with the ids of the objects showing them.
|
||||
pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
|
||||
crate::cmd::links::linked_groups(d)
|
||||
}
|
||||
|
||||
/// `links.updateChanged`: stamp the active document's linked files now and read the changed
|
||||
/// ones again, whatever Update Links says.
|
||||
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
|
||||
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
s.apply_link_stamps(uid, now, true)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Start stamping the active document's linked files on a worker thread, unless a look is
|
||||
/// still running (or the build has no files to look at: the web app).
|
||||
pub fn start_link_scan(&mut self) {
|
||||
if self.link_scan.is_some() || cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let Some(st) = self.active() else { return };
|
||||
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let slot: LinkScan = Arc::default();
|
||||
let out = slot.clone();
|
||||
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
|
||||
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
self.link_scan = Some(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a finished look (`None` while there is none, or it is still running): see
|
||||
/// [`Session::apply_link_stamps`].
|
||||
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
|
||||
let (uid, stamps) = self.link_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
self.link_scan = None;
|
||||
Some(self.apply_link_stamps(uid, stamps, false))
|
||||
}
|
||||
|
||||
/// Compare the stamps of document `uid`'s linked files with the ones last seen and act on
|
||||
/// the changed ones as Update Links says (`update`: read them again regardless). Returns
|
||||
/// `{updated: [ids], ask: [ids], modified: [ids]}`: read again, to offer (Ask When Modified),
|
||||
/// or left modified (Manually).
|
||||
pub fn apply_link_stamps(&mut self, uid: u64, now: Vec<(String, Vec<u64>, Option<Stamp>)>, update: bool) -> Result<Value> {
|
||||
let none = || json!({"updated": [], "ask": [], "modified": []});
|
||||
// The document went to the back meanwhile: it's looked at when it's in front again.
|
||||
if self.active().map(|d| d.uid) != Some(uid) {
|
||||
return Ok(none());
|
||||
}
|
||||
// This document's last stamps; those of closed documents are dropped.
|
||||
let open: HashSet<u64> = self.documents().iter().map(|d| d.uid).collect();
|
||||
let mut before: HashMap<String, Stamp> = HashMap::new();
|
||||
self.link_stamps.retain(|(u, p), s| {
|
||||
if *u == uid {
|
||||
before.insert(p.clone(), *s);
|
||||
false
|
||||
} else {
|
||||
open.contains(u)
|
||||
}
|
||||
});
|
||||
let mut changed: Vec<(String, Vec<u64>)> = Vec::new();
|
||||
for (path, ids, stamp) in now {
|
||||
match (before.get(&path), stamp) {
|
||||
// Not there right now (an app saving by delete and rename): keep the last stamp.
|
||||
(Some(old), None) => {
|
||||
self.link_stamps.insert((uid, path), *old);
|
||||
}
|
||||
(old, Some(new)) => {
|
||||
if old.is_some_and(|o| *o != new) {
|
||||
changed.push((path.clone(), ids));
|
||||
}
|
||||
self.link_stamps.insert((uid, path), new);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Ok(none());
|
||||
}
|
||||
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect();
|
||||
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
|
||||
match mode {
|
||||
"automatically" => {
|
||||
let r = self.execute("links.update", &json!({"ids": ids}))?;
|
||||
// Not readable yet (still being written): the next look tries again.
|
||||
let unread: BTreeSet<u64> = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
for (path, ids) in &changed {
|
||||
if ids.iter().any(|i| unread.contains(i))
|
||||
&& let Some(old) = before.get(path)
|
||||
{
|
||||
self.link_stamps.insert((uid, path.clone()), *old);
|
||||
}
|
||||
}
|
||||
Ok(json!({"updated": r["updated"], "ask": [], "modified": []}))
|
||||
}
|
||||
"askWhenModified" => Ok(json!({"updated": [], "ask": ids, "modified": []})),
|
||||
_ => Ok(json!({"updated": [], "ask": [], "modified": ids})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Linked files changing while their document is open (`link_watch`): `links.updateChanged`,
|
||||
//! and the background look following Preferences → File Handling → Update Links.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session};
|
||||
|
||||
/// Write `bytes` to `path` so its modification time moves even on coarse file systems.
|
||||
fn save(path: &str, bytes: &[u8]) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn colour(s: &Session) -> [u8; 3] {
|
||||
centre_colour(&s.doc().unwrap().doc)
|
||||
}
|
||||
|
||||
/// One background look, waited for.
|
||||
fn look(s: &mut Session) -> Value {
|
||||
s.start_link_scan();
|
||||
let t0 = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(r) = s.poll_link_scan() {
|
||||
return r.unwrap();
|
||||
}
|
||||
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_changed_reads_changed_files_again_once() {
|
||||
let dir = Folder::new("watch-update");
|
||||
let path = dir.file("art.png");
|
||||
std::fs::write(&path, png(40, 40, RED)).unwrap();
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &path);
|
||||
// First look: remembered, nothing to read.
|
||||
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
|
||||
save(&path, &png(40, 40, BLUE));
|
||||
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0]));
|
||||
assert_eq!(colour(&s), BLUE);
|
||||
// Undoing it is respected: the link stays modified until the file changes again.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(colour(&s), RED);
|
||||
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
|
||||
assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_look_follows_update_links() {
|
||||
for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] {
|
||||
let dir = Folder::new(&format!("watch-{mode}"));
|
||||
let path = dir.file("art.png");
|
||||
std::fs::write(&path, png(40, 40, RED)).unwrap();
|
||||
let mut s = session();
|
||||
s.prefs.update_links = mode.into();
|
||||
let id = place(&mut s, &path);
|
||||
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
|
||||
save(&path, &png(40, 40, BLUE));
|
||||
let r = look(&mut s);
|
||||
assert_eq!(r[key], json!([id.0]), "{mode}: {r}");
|
||||
assert_eq!(colour(&s), if mode == "automatically" { BLUE } else { RED }, "{mode}");
|
||||
// Acted on once: the next look finds nothing new.
|
||||
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
|
||||
assert!(s.link_scan.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_still_being_written_is_tried_again() {
|
||||
let dir = Folder::new("watch-partial");
|
||||
let path = dir.file("art.png");
|
||||
std::fs::write(&path, png(40, 40, RED)).unwrap();
|
||||
let mut s = session();
|
||||
s.prefs.update_links = "automatically".into();
|
||||
let id = place(&mut s, &path);
|
||||
look(&mut s);
|
||||
// Another app has only written the start of the file so far.
|
||||
let full = png(40, 40, BLUE);
|
||||
save(&path, &full[..16]);
|
||||
assert_eq!(look(&mut s)["updated"], json!([]));
|
||||
assert_eq!(colour(&s), RED, "the last good pixels stay");
|
||||
save(&path, &full);
|
||||
assert_eq!(look(&mut s)["updated"], json!([id.0]));
|
||||
assert_eq!(colour(&s), BLUE);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
|
||||
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
|
||||
//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are
|
||||
//! offered for update (`links.update`). Files changed while the app was in the background are
|
||||
//! checked when its window comes back to the front ([`after_focus`]).
|
||||
//! offered for update (`links.update`). Files another app changes while the document is open are
|
||||
//! offered the same way ([`ask_update_changed`], from [`vectorcraft_engine::link_watch`]).
|
||||
//!
|
||||
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
|
||||
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty)
|
||||
@@ -61,6 +61,16 @@ fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>) {
|
||||
|
||||
/// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again.
|
||||
fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
ask(app, ids, tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"));
|
||||
}
|
||||
|
||||
/// The same offer for linked files another app changed while the document is open
|
||||
/// ([`vectorcraft_engine::link_watch`]).
|
||||
pub(crate) fn ask_update_changed(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
ask(app, ids, tl!("{count} linked image(s) changed on disk. Show the new versions?"));
|
||||
}
|
||||
|
||||
fn ask(app: &mut VectorcraftApp, ids: Vec<Value>, question: &str) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -68,46 +78,10 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
app.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", ids.len()));
|
||||
return;
|
||||
}
|
||||
let detail = crate::i18n::fmt(
|
||||
tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"),
|
||||
&[("count", &ids.len().to_string())],
|
||||
);
|
||||
let detail = crate::i18n::fmt(question, &[("count", &ids.len().to_string())]);
|
||||
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
|
||||
}
|
||||
|
||||
/// A linked file as last seen: its path, and its size and modification time when it exists.
|
||||
pub(crate) type FileStamp = (String, Option<(u64, Option<std::time::SystemTime>)>);
|
||||
|
||||
/// The window came back to the front (#925): linked files of the active document changed
|
||||
/// meanwhile are read again with Update Links: Automatically, offered for update with Ask When
|
||||
/// Modified (once per change of a file), and left to the Links panel with Manually. Nothing is
|
||||
/// asked while a dialog is open.
|
||||
pub fn after_focus(app: &mut VectorcraftApp) {
|
||||
if cfg!(target_arch = "wasm32") || app.session.prefs.update_links == "manually" || app.ui.dialog.is_some() || app.session.active().is_none() {
|
||||
return;
|
||||
}
|
||||
let Ok(r) = app.session.execute("links.check", &json!({})) else { return };
|
||||
let rows: Vec<Value> = r["links"].as_array().into_iter().flatten().filter(|l| l["status"] == "modified").cloned().collect();
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
if app.session.prefs.update_links == "automatically" {
|
||||
// A failure shows in the status bar, as the Links panel's Update Link does.
|
||||
let _ = app.run("links.update", json!({ "ids": ids(&rows) }));
|
||||
return;
|
||||
}
|
||||
// The file each link reads (one found elsewhere after a move), its size and when it was last
|
||||
// changed (two changes can share a time on file systems that keep it coarsely).
|
||||
let stamp = |l: &Value| -> FileStamp {
|
||||
let path = l["found"].as_str().or(l["path"].as_str()).unwrap_or_default().to_string();
|
||||
let meta = std::fs::metadata(&path).ok();
|
||||
let at = meta.as_ref().map(|m| (m.len(), m.modified().ok()));
|
||||
(path, at)
|
||||
};
|
||||
let new: Vec<Value> = rows.into_iter().filter(|l| app.links_asked.insert(stamp(l))).collect();
|
||||
ask_update(app, ids(&new));
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
||||
@@ -296,58 +270,4 @@ mod tests {
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #925: a linked file changed while the app was in the background follows Update Links when
|
||||
/// the window comes back to the front.
|
||||
#[test]
|
||||
fn a_link_changed_in_the_background_follows_update_links_on_focus() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-focus-links-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let pic = dir.join("a.png");
|
||||
// Each version a different size, so no two share a length and modification time.
|
||||
let mut version = 0;
|
||||
let mut change = || {
|
||||
version += 1;
|
||||
let mut out = vec![];
|
||||
image::RgbaImage::from_pixel(100 + version, 50, image::Rgba([40 * version as u8, 0, 0, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(&pic, out).unwrap();
|
||||
};
|
||||
change();
|
||||
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
|
||||
let modified = |app: &mut VectorcraftApp| app.run("links.check", json!({})).unwrap()["modified"].as_u64().unwrap();
|
||||
// The window coming to the front asks for the check.
|
||||
let mut raw = egui::RawInput { events: vec![egui::Event::WindowFocused(true)], ..Default::default() };
|
||||
app.raw_input_hook(&mut raw);
|
||||
assert!(app.links_check);
|
||||
// Ask When Modified (the default): asked once per change.
|
||||
change();
|
||||
after_focus(&mut app);
|
||||
let d = app.ui.dialog.take().expect("asks");
|
||||
assert_eq!(d.str("__command"), "links.update");
|
||||
after_focus(&mut app);
|
||||
assert!(app.ui.dialog.is_none(), "not twice for the same change");
|
||||
change();
|
||||
after_focus(&mut app);
|
||||
assert!(app.ui.dialog.is_some(), "a new change asks again");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(modified(&mut app), 0);
|
||||
// Automatically: read again without asking.
|
||||
app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap();
|
||||
change();
|
||||
after_focus(&mut app);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(modified(&mut app), 0);
|
||||
// Manually: left to the Links panel.
|
||||
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
|
||||
change();
|
||||
after_focus(&mut app);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(modified(&mut app), 1);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -516,6 +516,9 @@
|
||||
Layers Vrstvy
|
||||
Libraries Knihovny
|
||||
Links Propojení
|
||||
{count} linked image(s) changed on disk. Show the new versions? Na disku se změnilo {count} propojených obrázků. Zobrazit nové verze?
|
||||
Update Changed Links Aktualizovat změněná propojení
|
||||
@msg Updated {_1} linked file(s) changed on disk Aktualizováno propojených souborů změněných na disku: {_1}
|
||||
Magic Wand Kouzelná hůlka
|
||||
Navigator Navigátor
|
||||
Pattern Options Možnosti vzorku
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3003,6 +3003,7 @@
|
||||
Untitled Sin título
|
||||
Update Link Actualizar vínculo
|
||||
Update Links Actualizar vínculos
|
||||
Update Changed Links Actualizar vínculos cambiados
|
||||
Update Modified Links Actualizar vínculos modificados
|
||||
Updated {} image(s) Se actualizaron {} imagen(es)
|
||||
UPPERCASE MAYÚSCULAS
|
||||
@@ -3138,6 +3139,7 @@
|
||||
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
|
||||
fonts not copied: {fonts} fuentes no copiadas: {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
|
||||
{count} linked image(s) changed on disk. Show the new versions? {count} imagen(es) vinculada(s) cambiaron en el disco. ¿Mostrar las versiones nuevas?
|
||||
{count} more rules: Export writes every one. {count} reglas más: Exportar las escribe todas.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Se omitieron {count} objetos sin nombre: ponles nombre o activa Objetos sin nombre en el menú del panel.
|
||||
{effect} is already applied {effect} ya está aplicado
|
||||
@@ -3788,6 +3790,7 @@ axis Both Ambos
|
||||
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
|
||||
@msg Updated {_1} linked file(s) changed on disk Se actualizaron {_1} archivo(s) vinculado(s) que cambiaron en el disco
|
||||
@msg {_1}: not saved, so not closed {_1}: no se guardó, así que no se cerró
|
||||
@msg {bpp}-bit bitmaps can't be read no se pueden leer mapas de bits de {bpp} bits
|
||||
@msg {command} didn't create an object {command} no creó ningún objeto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3000,6 +3000,7 @@
|
||||
Untitled Sans titre
|
||||
Update Link Mettre à jour le lien
|
||||
Update Links Mettre à jour les liens
|
||||
Update Changed Links Mettre à jour les liens modifiés
|
||||
Update Modified Links Mettre à jour les liens modifiés
|
||||
Updated {} image(s) {} image(s) mise(s) à jour
|
||||
UPPERCASE MAJUSCULES
|
||||
@@ -3135,6 +3136,7 @@
|
||||
{count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s)
|
||||
fonts not copied: {fonts} polices non copiées : {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ?
|
||||
{count} linked image(s) changed on disk. Show the new versions? {count} image(s) liée(s) modifiée(s) sur le disque. Afficher les nouvelles versions ?
|
||||
{count} more rules: Export writes every one. {count} règles de plus : Exporter les écrit toutes.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objets sans nom omis : nommez-les ou activez Objets sans nom dans le menu du panneau.
|
||||
{effect} is already applied {effect} est déjà appliqué
|
||||
@@ -3785,6 +3787,7 @@ axis Both Les deux
|
||||
@msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions
|
||||
@msg Updated {_1} linked file(s) changed on disk {_1} fichier(s) lié(s) modifié(s) sur le disque mis à jour
|
||||
@msg {_1}: not saved, so not closed {_1} : non enregistré, donc non fermé
|
||||
@msg {bpp}-bit bitmaps can't be read impossible de lire les images bitmap en {bpp} bits
|
||||
@msg {command} didn't create an object {command} n’a créé aucun objet
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3003,6 +3003,7 @@
|
||||
Untitled Senza titolo
|
||||
Update Link Aggiorna collegamento
|
||||
Update Links Aggiorna collegamenti
|
||||
Update Changed Links Aggiorna collegamenti modificati
|
||||
Update Modified Links Aggiorna collegamenti modificati
|
||||
Updated {} image(s) Immagini aggiornate: {}
|
||||
UPPERCASE MAIUSCOLO
|
||||
@@ -3138,6 +3139,7 @@
|
||||
{count} linked file(s) not found File collegati non trovati: {count}
|
||||
fonts not copied: {fonts} font non copiati: {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni?
|
||||
{count} linked image(s) changed on disk. Show the new versions? Immagini collegate modificate su disco: {count}. Vuoi mostrare le nuove versioni?
|
||||
{count} more rules: Export writes every one. Altre {count} regole: Esporta le scrive tutte.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} oggetti senza nome esclusi: assegna loro un nome o attiva Oggetti senza nome nel menu del pannello.
|
||||
{effect} is already applied L'effetto {effect} è già applicato
|
||||
@@ -3788,6 +3790,7 @@ axis Both Entrambi
|
||||
@msg {_1} is already used by {_2} {_1} è già usato da {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show file collegati non leggibili qui: {_1}; vengono mostrate le loro anteprime
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions immagini collegate modificate su disco: {_1}; Aggiorna collegamenti mostra le nuove versioni
|
||||
@msg Updated {_1} linked file(s) changed on disk File collegati modificati su disco aggiornati: {_1}
|
||||
@msg {_1}: not saved, so not closed {_1}: non salvato, quindi non chiuso
|
||||
@msg {bpp}-bit bitmaps can't be read impossibile leggere bitmap a {bpp} bit
|
||||
@msg {command} didn't create an object {command} non ha creato alcun oggetto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2668,6 +2668,7 @@
|
||||
Unshift シフトを戻す
|
||||
Update Link リンクを更新
|
||||
Update Links リンクを更新
|
||||
Update Changed Links 変更されたリンクを更新
|
||||
Update Modified Links 変更されたリンクを更新
|
||||
Use メニュー
|
||||
Use Artboards アートボードを使用
|
||||
@@ -2777,6 +2778,7 @@
|
||||
{count} linked file(s) not found リンクファイルが {count} 個見つかりません
|
||||
fonts not copied: {fonts} コピーされなかったフォント:{fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか?
|
||||
{count} linked image(s) changed on disk. Show the new versions? {count} 個のリンク画像がディスク上で変更されました。新しいバージョンを表示しますか?
|
||||
{count} more rules: Export writes every one. ほかに {count} 個のルールがあります。書き出しにはすべて含まれます。
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 名前のないオブジェクト {count} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。
|
||||
{effect} is already applied {effect} はすでに適用されています
|
||||
@@ -3719,6 +3721,7 @@ theme Appearance 外観
|
||||
@msg `{tool}` isn't a Liquify tool `{tool}` はゆがみツールではありません
|
||||
@msg {_1} linked file(s) can't be read here: their previews show この環境では {_1} 個のリンクファイルを読み込めません: 代わりにプレビューを表示しています
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます
|
||||
@msg Updated {_1} linked file(s) changed on disk ディスク上で変更された {_1} 個のリンクファイルを更新しました
|
||||
@msg pick a colour, gradient or pattern to save as a swatch スウォッチとして保存するカラー、グラデーション、またはパターンを選択してください
|
||||
@msg Packaged {n} file(s) in {folder}{note} {n} 個のファイルを {folder} にパッケージしました{note}
|
||||
@msg Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2483,6 +2483,9 @@
|
||||
Unshift Desfazer Mudança de Cor
|
||||
Update Link Atualizar Link
|
||||
Update Links Atualizar Links
|
||||
Update Changed Links Atualizar Links alterados
|
||||
{count} linked image(s) changed on disk. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) no disco. Mostrar as novas versões?
|
||||
@msg Updated {_1} linked file(s) changed on disk {_1} arquivo(s) vinculado(s) alterado(s) no disco atualizado(s)
|
||||
Update Modified Links Atualizar Links Modificados
|
||||
Use Use
|
||||
Use Artboards Usar Mesas de Trabalho
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2999,6 +2999,7 @@
|
||||
Untitled Без названия
|
||||
Update Link Обновить связь
|
||||
Update Links Обновить связи
|
||||
Update Changed Links Обновить измененные связи
|
||||
Update Modified Links Обновить изменённые связи
|
||||
Updated {} image(s) Обновлено изображений: {}
|
||||
UPPERCASE ПРОПИСНЫЕ
|
||||
@@ -3134,6 +3135,7 @@
|
||||
{count} linked file(s) not found Не найдено связанных файлов: {count}
|
||||
fonts not copied: {fonts} шрифты не скопированы: {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии?
|
||||
{count} linked image(s) changed on disk. Show the new versions? Связанных изображений ({count}) изменились на диске. Показать новые версии?
|
||||
{count} more rules: Export writes every one. Ещё правил: {count}: экспорт записывает все.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Пропущено объектов без имени: {count}: назовите их или включите «Объекты без имени» в меню панели.
|
||||
{effect} is already applied {effect} уже применён
|
||||
@@ -3784,6 +3786,7 @@ axis Both Оба
|
||||
@msg {_1} is already used by {_2} {_1} уже используется {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show здесь невозможно прочитать связанных файлов: {_1}: отображаются их предварительные просмотры
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions связанных изображений ({_1}) изменились на диске: «Обновить связи» покажет новые версии
|
||||
@msg Updated {_1} linked file(s) changed on disk Обновлено связанных файлов, измененных на диске: {_1}
|
||||
@msg {_1}: not saved, so not closed {_1}: не сохранён, поэтому не закрыт
|
||||
@msg {bpp}-bit bitmaps can't be read растровые изображения с глубиной {bpp} бит не читаются
|
||||
@msg {command} didn't create an object {command} не создал объект
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2995,6 +2995,7 @@
|
||||
Untitled Без назви
|
||||
Update Link Оновити зв’язок
|
||||
Update Links Оновити зв’язки
|
||||
Update Changed Links Оновити змінені зв’язки
|
||||
Update Modified Links Оновити змінені зв’язки
|
||||
Updated {} image(s) Оновлено зображень: {}
|
||||
UPPERCASE ВЕЛИКІ ЛІТЕРИ
|
||||
@@ -3130,6 +3131,7 @@
|
||||
{count} linked file(s) not found не знайдено пов’язаних файлів: {count}
|
||||
fonts not copied: {fonts} шрифти не скопійовано: {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії?
|
||||
{count} linked image(s) changed on disk. Show the new versions? пов’язані зображення ({count}) змінилися на диску. Показати нові версії?
|
||||
{count} more rules: Export writes every one. ще правил: {count}: експорт записує все.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. пропущено об’єкти без імені: {count}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі.
|
||||
{effect} is already applied {effect} вже застосований
|
||||
@@ -3771,6 +3773,7 @@ axis Both Обидві
|
||||
@msg {_1} is already used by {_2} {_1} вже використовується {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show тут неможливо прочитати пов’язані файли: {_1}: відображаються їх попередні перегляди
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions пов’язаних зображень ({_1}) змінилися на диску: «Оновити зв’язки» покаже нові версії
|
||||
@msg Updated {_1} linked file(s) changed on disk Оновлено пов’язаних файлів, змінених на диску: {_1}
|
||||
@msg {_1}: not saved, so not closed {_1}: не збережено, тому не закрито
|
||||
@msg {bpp}-bit bitmaps can't be read растрові зображення з глибиною {bpp} біт не читаються
|
||||
@msg {command} didn't create an object {command} не створив об’єкт
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -2893,6 +2893,9 @@
|
||||
Untitled 未命名
|
||||
Update Link 更新链接
|
||||
Update Links 更新链接
|
||||
Update Changed Links 更新已更改的链接
|
||||
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 个链接图像已在磁盘上更改。要显示新版本吗?
|
||||
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 个在磁盘上更改的链接文件
|
||||
Update Modified Links 更新已修改的链接
|
||||
Updated {} image(s) 已更新 {} 张图像
|
||||
UPPERCASE 大写
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2984,6 +2984,9 @@
|
||||
Untitled 未命名
|
||||
Update Link 更新連結
|
||||
Update Links 更新連結
|
||||
Update Changed Links 更新已變更的連結
|
||||
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 個連結影像已在磁碟上變更。要顯示新版本嗎?
|
||||
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 個在磁碟上變更的連結檔案
|
||||
Update Modified Links 更新已修改的連結
|
||||
Updated {} image(s) 已更新 {} 張影像
|
||||
UPPERCASE 全部大寫
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+51
-15
@@ -371,12 +371,6 @@ pub struct VectorcraftApp {
|
||||
/// The look for fonts installed or removed while the app was in the background, running
|
||||
/// ([`Self::refresh_installed_fonts`]): whether they were.
|
||||
font_check: Option<std::sync::mpsc::Receiver<bool>>,
|
||||
/// The window came back to the front: check the active document's linked files next frame
|
||||
/// ([`dialogs::missing_links::after_focus`]).
|
||||
pub(crate) links_check: bool,
|
||||
/// The changed linked files already offered for update (path, size and modification time), so
|
||||
/// coming back to the window doesn't ask about the same change twice.
|
||||
pub(crate) links_asked: std::collections::HashSet<dialogs::missing_links::FileStamp>,
|
||||
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
|
||||
pub(crate) paste_chord: shortcuts::PasteChord,
|
||||
/// Saves and exports running in the background (Preferences → File Handling).
|
||||
@@ -474,8 +468,6 @@ impl VectorcraftApp {
|
||||
clipboard_probe: None,
|
||||
picks: picks::Picks::default(),
|
||||
font_check: None,
|
||||
links_check: false,
|
||||
links_asked: Default::default(),
|
||||
paste_chord: Default::default(),
|
||||
background: Default::default(),
|
||||
recovery: Default::default(),
|
||||
@@ -757,6 +749,54 @@ impl VectorcraftApp {
|
||||
std::mem::take(&mut self.ime_discard)
|
||||
}
|
||||
|
||||
/// Linked files another app changed while their document is open: every two seconds, and as
|
||||
/// soon as the window comes back to the front, stamp the active document's linked files on a
|
||||
/// worker thread and act on the changed ones as Preferences › File Handling › Update Links
|
||||
/// says ([`vectorcraft_engine::link_watch`]). No look starts while a dialog is open, so an
|
||||
/// Ask When Modified question never goes unseen.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn tick_link_updates(&mut self, ctx: &egui::Context, now: f64) {
|
||||
if let Some(r) = self.session.poll_link_scan() {
|
||||
match r {
|
||||
Ok(r) => {
|
||||
let n = |k: &str| r[k].as_array().map_or(0, Vec::len);
|
||||
if n("updated") > 0 {
|
||||
self.status(format!("Updated {} linked file(s) changed on disk", n("updated")));
|
||||
}
|
||||
if n("ask") > 0 {
|
||||
dialogs::missing_links::ask_update_changed(self, r["ask"].as_array().cloned().unwrap_or_default());
|
||||
}
|
||||
if n("modified") > 0 {
|
||||
self.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", n("modified")));
|
||||
}
|
||||
}
|
||||
Err(e) => self.status(e.to_string()),
|
||||
}
|
||||
}
|
||||
let (last_key, focus_key) = (egui::Id::new("linkWatch.lastScan"), egui::Id::new("linkWatch.focused"));
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
|
||||
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
|
||||
if self.ui.dialog.is_some() || self.session.active().is_none() {
|
||||
return;
|
||||
}
|
||||
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY);
|
||||
if now - last >= 2.0 || (focused && !was_focused) {
|
||||
// Walking the document's links costs a pass over it: only when a look is due.
|
||||
// `start_link_scan` starts none for a document without links, which then doesn't
|
||||
// wake the app for looks of its own (any frame still looks once one is due).
|
||||
ctx.data_mut(|d| d.insert_temp(last_key, now));
|
||||
self.session.start_link_scan();
|
||||
let links = self.session.link_scan.is_some();
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("linkWatch.links"), links));
|
||||
}
|
||||
if self.session.link_scan.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
} else if ctx.data(|d| d.get_temp::<bool>(egui::Id::new("linkWatch.links"))).unwrap_or(false) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(2000));
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a transient status message.
|
||||
pub fn status(&mut self, s: impl Into<String>) {
|
||||
self.ui.status = s.into();
|
||||
@@ -889,11 +929,10 @@ impl VectorcraftApp {
|
||||
self.system_paste = wanted && self.system_clipboard_pasteable();
|
||||
}
|
||||
self.poll_font_check(ctx);
|
||||
if std::mem::take(&mut self.links_check) {
|
||||
dialogs::missing_links::after_focus(self);
|
||||
}
|
||||
picks::poll(self, ctx);
|
||||
background::poll(self);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.tick_link_updates(ctx, now);
|
||||
if !self.background.jobs.is_empty() {
|
||||
// Keep the status bar's progress moving and pick the result up when it arrives.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
@@ -1025,10 +1064,7 @@ impl VectorcraftApp {
|
||||
match e {
|
||||
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
|
||||
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
|
||||
egui::Event::WindowFocused(true) => {
|
||||
self.refresh_installed_fonts();
|
||||
self.links_check = true;
|
||||
}
|
||||
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user