Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17bac2c64 | ||
|
|
01ad745e86 | ||
|
|
d8f6bacddb | ||
|
|
fc74389b5a | ||
|
|
0891ffbf51 | ||
|
|
861a0759b0 | ||
|
|
992f6a0a4d | ||
|
|
53e39884fc | ||
|
|
abdc26d8d1 | ||
|
|
56c975465c | ||
|
|
2dcac5b48b | ||
|
|
dc39146d0d | ||
|
|
147fc113c3 |
No files matched your search
+1
-1
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ use std::sync::{Arc, OnceLock};
|
||||
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
|
||||
use eframe::wgpu::{self, Backend, DeviceType, PowerPreference};
|
||||
|
||||
/// The adapters a restart leaves out, those the window failed on, as comma-separated
|
||||
/// `backend:vendor:device` keys (`Vulkan:1002:164e`). Set by the app itself when it starts again.
|
||||
/// The adapters a restart leaves out, those the window failed on, as comma-separated [`key`]s
|
||||
/// (`Vulkan:1002:164e`, `Metal:Intel Iris Pro Graphics`). Set by the app itself when it starts
|
||||
/// again.
|
||||
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
|
||||
|
||||
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
|
||||
@@ -36,7 +37,7 @@ const HELP: &str =
|
||||
/// Frames the UI ran before the failure, below which it was still starting up. The failure in
|
||||
/// #502 came on the first or second frame; later, the adapter has proved itself, and a lost
|
||||
/// window is something else (such as the compositor restarting).
|
||||
const STARTUP_FRAMES: u64 = 10;
|
||||
pub const STARTUP_FRAMES: u64 = 10;
|
||||
|
||||
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
|
||||
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
|
||||
@@ -60,7 +61,7 @@ pub fn power_preference(pref: Option<&str>, env: Option<PowerPreference>) -> Pow
|
||||
/// One adapter, as [`adapter_order`] sees it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Candidate {
|
||||
/// Identifies the adapter across a restart: `backend:vendor:device` ([`key`]).
|
||||
/// Identifies the adapter across a restart ([`key`]).
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub backend: Backend,
|
||||
@@ -73,7 +74,7 @@ impl Candidate {
|
||||
fn new(adapter: &wgpu::Adapter, surface: Option<&wgpu::Surface<'_>>) -> Self {
|
||||
let info = adapter.get_info();
|
||||
Self {
|
||||
key: key(info.backend, info.vendor, info.device),
|
||||
key: key(info.backend, info.vendor, info.device, &info.name),
|
||||
presents: surface.is_none_or(|s| adapter.is_surface_supported(s)),
|
||||
name: info.name,
|
||||
backend: info.backend,
|
||||
@@ -83,8 +84,15 @@ impl Candidate {
|
||||
}
|
||||
|
||||
/// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them).
|
||||
fn key(backend: Backend, vendor: u32, device: u32) -> String {
|
||||
format!("{backend:?}:{vendor:04x}:{device:04x}")
|
||||
/// Metal reports no ids (`0000:0000` for every GPU), so an adapter without them is `backend:name`
|
||||
/// (`Metal:Intel Iris Pro Graphics`): otherwise leaving out the one that failed would leave out
|
||||
/// every GPU of a dual-GPU Mac (#651). Commas, which separate [`SKIP_ENV`]'s keys, become spaces.
|
||||
fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String {
|
||||
if vendor == 0 && device == 0 {
|
||||
format!("{backend:?}:{}", name.replace(',', " ").trim())
|
||||
} else {
|
||||
format!("{backend:?}:{vendor:04x}:{device:04x}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The order to try `candidates` in (their indices): only those that present to the window and
|
||||
@@ -307,6 +315,27 @@ mod tests {
|
||||
|
||||
/// A restart leaves out the adapter the window failed on, so Power Saving on the machine of
|
||||
/// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try.
|
||||
/// A dual-GPU Mac reports both GPUs under Metal with no PCI ids (#651): their keys still
|
||||
/// differ, so when the integrated GPU fails, the restart renders on the discrete one.
|
||||
#[test]
|
||||
fn gpus_without_pci_ids_are_told_apart_by_name() {
|
||||
let mac = |name: &str, device_type| Candidate {
|
||||
key: key(Backend::Metal, 0, 0, name),
|
||||
name: name.into(),
|
||||
backend: Backend::Metal,
|
||||
device_type,
|
||||
presents: true,
|
||||
};
|
||||
let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)];
|
||||
assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics"));
|
||||
let first = adapter_order(&c, PowerPreference::LowPower, None, &[]);
|
||||
assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics");
|
||||
let skip = parse_skip(&c[first[0]].key);
|
||||
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, None, &skip)), ["NVIDIA GeForce GT 750M"]);
|
||||
// A comma in a name can't split the list.
|
||||
assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_tries_the_next_adapter() {
|
||||
let c = issue_502();
|
||||
@@ -433,8 +462,8 @@ mod tests {
|
||||
fn skipped_adapters_parse_from_a_comma_separated_list() {
|
||||
assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]);
|
||||
assert!(parse_skip("").is_empty());
|
||||
assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e), "Vulkan:1002:164e");
|
||||
assert_eq!(key(Backend::Gl, 0x10de, 0x2f04), "Gl:10de:2f04");
|
||||
assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e");
|
||||
assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -43,10 +43,13 @@ struct App {
|
||||
graphics_loss: GraphicsLoss,
|
||||
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
|
||||
graphics_lost: bool,
|
||||
/// Frames the UI has run (see [`end_before_teardown`]).
|
||||
frames: u64,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.frames = ctx.cumulative_frame_nr();
|
||||
if let Some(why) = self.graphics_loss.take() {
|
||||
// eframe can't give a window a new device: the user saves and starts again.
|
||||
self.graphics_lost = self.app.graphics_lost(&why);
|
||||
@@ -87,6 +90,7 @@ impl eframe::App for App {
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.app);
|
||||
end_before_teardown(self.frames);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
@@ -94,6 +98,22 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
|
||||
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
|
||||
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
|
||||
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
|
||||
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
|
||||
/// failure then starts the app again on another adapter (#651).
|
||||
#[allow(unused_variables)]
|
||||
fn end_before_teardown(frames: u64) {
|
||||
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
|
||||
if frames >= gpu::STARTUP_FRAMES {
|
||||
log::info!("quitting");
|
||||
log::logger().flush();
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
|
||||
/// can't be opened is reported in the status bar and on stderr; the others still open.
|
||||
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
|
||||
@@ -423,6 +443,11 @@ fn main() -> std::process::ExitCode {
|
||||
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.native_adapter_selector = Some(gpu::selector(power, startup.clone()));
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
|
||||
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
|
||||
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
|
||||
create.instance_descriptor.flags =
|
||||
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
}
|
||||
options
|
||||
};
|
||||
@@ -490,7 +515,7 @@ fn main() -> std::process::ExitCode {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
open_files(&mut app, files);
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false }))
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -147,6 +147,8 @@ fn auto_height(t: &mut TextObject) -> bool {
|
||||
let col_w = if cols > 1 { ((b.width() - t.area.gutter.max(0.0) * (cols - 1) as f64) / cols as f64).max(1.0) } else { b.width() };
|
||||
with_size(&mut probe, col_w, tall);
|
||||
probe.area.columns = 1;
|
||||
// Measured from the top: aligned in the tall probe the lines would sit far below.
|
||||
probe.area.vertical_align = vectorcraft_doc::VerticalAlign::Top;
|
||||
let lay = vectorcraft_text::layout(db, &probe);
|
||||
let inset = t.area.inset.max(0.0);
|
||||
let Some(bottom) = lay.lines.iter().map(|l| l.baseline + l.descent).filter(|y| y.is_finite()).reduce(f64::max) else { return false };
|
||||
|
||||
@@ -1586,6 +1586,8 @@ mod tests_swatchlib;
|
||||
#[cfg(test)]
|
||||
mod tests_targeting;
|
||||
#[cfg(test)]
|
||||
mod tests_textcombos;
|
||||
#[cfg(test)]
|
||||
mod tests_textedit;
|
||||
#[cfg(test)]
|
||||
mod tests_textimport;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! The text engine's newer features combined through commands: Auto Size and Shrink Text to Fit
|
||||
//! with inline graphics, per-paragraph attributes, the paragraph composer and vertical
|
||||
//! alignment; editing and undo across them; and a save round trip with all of them on.
|
||||
//! (`vectorcraft_text`'s `tests_combos` covers the layout side.)
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::text::INLINE_CHAR;
|
||||
use vectorcraft_doc::{AreaFit, Composer, Justify, NodeKind, TextKind, TextObject, VerticalAlign};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
|
||||
const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it.";
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A symbol `name`: a circle `d` points across, its instance deleted.
|
||||
fn circle_symbol(s: &mut Session, name: &str, d: f64) {
|
||||
s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": d, "height": d})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": name})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// Area type at (40, 40), `w` × `h`, size 12, with `extra` area options; selected.
|
||||
fn area(s: &mut Session, w: f64, h: f64, text: &str, extra: Value) -> NodeId {
|
||||
let mut a = json!({"width": w, "height": h});
|
||||
if let (Some(a), Some(e)) = (a.as_object_mut(), extra.as_object()) {
|
||||
a.extend(e.clone());
|
||||
}
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": a, "text": text})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(s: &Session, id: NodeId) -> Rect {
|
||||
match text(s, id).kind {
|
||||
TextKind::Area { frame } => frame.bounds().unwrap(),
|
||||
k => panic!("not area type: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn lay(t: &TextObject) -> vectorcraft_text::TextLayout {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
|
||||
}
|
||||
|
||||
/// Auto Size's frame is tight: the text fits, and a point less overflows.
|
||||
fn assert_tight(s: &Session, id: NodeId, what: &str) {
|
||||
let t = text(s, id);
|
||||
assert!(!lay(&t).overflow, "{what}: the frame holds the text");
|
||||
let b = frame(s, id);
|
||||
let mut less = t.clone();
|
||||
less.area.fit = AreaFit::None;
|
||||
less.kind = TextKind::Area {
|
||||
frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(b.x0, b.y0, b.x1, b.y1 - 1.0), 0.1)),
|
||||
};
|
||||
assert!(lay(&less).overflow, "{what}: a point less overflows ({b:?})");
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session) {
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn query(s: &mut Session) -> Value {
|
||||
s.execute("text.areaOptions", &json!({})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_height_x_inline_graphics() {
|
||||
let mut s = session();
|
||||
circle_symbol(&mut s, "Tap", 10.0);
|
||||
let id = area(&mut s, 200.0, 300.0, "Tap: add one mana.", json!({"fit": "autoHeight"}));
|
||||
let one = frame(&s, id);
|
||||
assert_tight(&s, id, "one line");
|
||||
// Art taller than the line makes the line, and so the frame, taller: in the same undo step.
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 3})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
|
||||
let tall = frame(&s, id);
|
||||
assert!(tall.height() > one.height() + 5.0, "{one:?} → {tall:?}");
|
||||
assert_tight(&s, id, "tall art");
|
||||
// Enough art to wrap: another line.
|
||||
for _ in 0..12 {
|
||||
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 1})).unwrap();
|
||||
}
|
||||
assert!(lay(&text(&s, id)).lines.len() > 1);
|
||||
assert_tight(&s, id, "wrapped art");
|
||||
// Deleting the art shrinks it back; undo restores each step exactly.
|
||||
let wrapped = frame(&s, id);
|
||||
let all = text(&s, id).plain_text().rfind(INLINE_CHAR).unwrap() + INLINE_CHAR.len_utf8();
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": all})).unwrap();
|
||||
assert_eq!(text(&s, id).plain_text(), "Tap: add one mana.");
|
||||
assert_eq!(frame(&s, id), one);
|
||||
undo(&mut s);
|
||||
assert_eq!(frame(&s, id), wrapped);
|
||||
for _ in 0..13 {
|
||||
undo(&mut s);
|
||||
}
|
||||
assert_eq!(frame(&s, id), one);
|
||||
redo(&mut s);
|
||||
assert_eq!(frame(&s, id), tall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_height_x_paragraph_attrs() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 200.0, 300.0, "One\nTwo", json!({"fit": "autoHeight"}));
|
||||
let h0 = frame(&s, id).height();
|
||||
// Space before on the second paragraph only: the frame grows by exactly that.
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 30, "start": 4, "end": 4})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!((t.para_at(0).space_before, t.para_at(1).space_before), (0.0, 30.0));
|
||||
let h1 = frame(&s, id).height();
|
||||
assert!((h1 - h0 - 30.0).abs() < 1e-6, "{h0} → {h1}");
|
||||
assert_tight(&s, id, "space before");
|
||||
// Return in the spaced paragraph: the new one inherits the spacing, the frame follows.
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 7, "end": 7, "insert": "\nThree"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.paragraph_styles().iter().map(|p| p.space_before).collect::<Vec<_>>(), [0.0, 30.0, 30.0]);
|
||||
assert!(frame(&s, id).height() > h1 + 30.0);
|
||||
assert_tight(&s, id, "three paragraphs");
|
||||
// Merging paragraphs: the first one's attributes win, the frame shrinks.
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 3, "end": 4})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.paragraph_styles().iter().map(|p| p.space_before).collect::<Vec<_>>(), [0.0, 30.0]);
|
||||
assert_tight(&s, id, "merged");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert!((frame(&s, id).height() - h1).abs() < 1e-9);
|
||||
undo(&mut s);
|
||||
assert!((frame(&s, id).height() - h0).abs() < 1e-9);
|
||||
assert!(text(&s, id).paras.is_empty(), "back to one style for all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_height_x_composer() {
|
||||
// Switching the composer of one paragraph rebreaks it; Auto Size follows either way.
|
||||
let mut s = session();
|
||||
let story = format!("{STORY} {STORY}\n{STORY}");
|
||||
let id = area(&mut s, 130.0, 100.0, &story, json!({"fit": "autoHeight"}));
|
||||
assert_tight(&s, id, "every-line");
|
||||
for composer in ["singleLine", "everyLine", "singleLine"] {
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": composer, "start": 0, "end": 0})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.para_at(1).composer, Composer::EveryLine, "the second paragraph keeps its own");
|
||||
assert_tight(&s, id, composer);
|
||||
}
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine"})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"id": id.0, "justify": "justifyAll"})).unwrap();
|
||||
assert!(text(&s, id).paragraph_styles().iter().all(|p| p.composer == Composer::SingleLine && p.justify == Justify::JustifyAll));
|
||||
assert_tight(&s, id, "justified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_height_x_vertical_align() {
|
||||
// An Auto Size frame has no space to spare: every alignment sets the lines as Top does, and
|
||||
// the frame is as tall as with Top.
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 160.0, 100.0, STORY, json!({"fit": "autoHeight"}));
|
||||
let top_frame = frame(&s, id);
|
||||
let top = lay(&text(&s, id));
|
||||
for a in ["center", "bottom", "justify"] {
|
||||
s.execute("text.areaOptions", &json!({"verticalAlign": a})).unwrap();
|
||||
assert_eq!(frame(&s, id), top_frame, "{a}");
|
||||
let l = lay(&text(&s, id));
|
||||
for (li, l0) in l.lines.iter().zip(&top.lines) {
|
||||
assert!((li.baseline - l0.baseline).abs() < 1e-6, "{a}: {} vs {}", li.baseline, l0.baseline);
|
||||
}
|
||||
assert_tight(&s, id, a);
|
||||
}
|
||||
// Editing with the alignment on keeps the frame tight.
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": format!("{STORY} ")})).unwrap();
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Justify);
|
||||
assert_tight(&s, id, "longer, justified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_text_x_inline_graphics_and_paragraphs() {
|
||||
let mut s = session();
|
||||
circle_symbol(&mut s, "Dot", 8.0);
|
||||
let id = area(&mut s, 160.0, 60.0, "Short rules.\nFlavor.", json!({"fit": "shrinkText", "fitMinPercent": 20}));
|
||||
assert_eq!(query(&mut s)["fitScale"], 1.0);
|
||||
// Inline art and per-paragraph spacing push it over; shrinking makes it fit.
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 12, "start": 13, "end": 13})).unwrap();
|
||||
for i in 0..10 {
|
||||
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Dot", "scale": 1.0 + i as f64 * 0.2})).unwrap();
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": "Add "})).unwrap();
|
||||
}
|
||||
let q = query(&mut s);
|
||||
assert_eq!(q["overflow"], false, "{q}");
|
||||
let f = q["fitScale"].as_f64().unwrap();
|
||||
assert!((0.2..1.0).contains(&f), "{f}");
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.paragraph_count(), 2);
|
||||
assert_eq!(t.para_at(1).space_before, 12.0, "inserting in the first paragraph keeps the second's attributes");
|
||||
assert_eq!(t.runs[0].style.size, 12.0, "the stored size stays");
|
||||
let l = lay(&t);
|
||||
assert_eq!(l.inlines.len(), 10);
|
||||
assert!(l.inlines.iter().all(|i| i.bounds.height() < 12.0 * 2.8 * f + 1e-6));
|
||||
// Undo walks the fit back up.
|
||||
for _ in 0..20 {
|
||||
undo(&mut s);
|
||||
}
|
||||
assert_eq!(query(&mut s)["fitScale"], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_attrs_x_inline_editing() {
|
||||
// An inline graphic opening a paragraph belongs to it: inserting it keeps the paragraph's
|
||||
// attributes, deleting it with the paragraph break merges into the paragraph before.
|
||||
let mut s = session();
|
||||
circle_symbol(&mut s, "Tap", 10.0);
|
||||
let id = area(&mut s, 300.0, 200.0, "Rules\nFlavor", json!({}));
|
||||
s.execute("text.setStyle", &json!({"id": id.0, "justify": "center", "start": 6, "end": 6})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine", "start": 6, "end": 6})).unwrap();
|
||||
s.execute("text.insertInline", &json!({"id": id.0, "at": 6, "symbol": "Tap"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.plain_text(), format!("Rules\n{INLINE_CHAR}Flavor"));
|
||||
assert_eq!((t.para_at(0).justify, t.para_at(1).justify), (Justify::Auto, Justify::Center));
|
||||
assert_eq!(t.para_at(1).composer, Composer::SingleLine);
|
||||
// The art's line is centred.
|
||||
let l = lay(&t);
|
||||
let g = &l.glyphs[l.inlines[0].glyph];
|
||||
let line = &l.lines[g.line];
|
||||
assert!(((line.x0 + line.x1) * 0.5 - 150.0).abs() < 1e-6, "centred in the 300 pt frame: {line:?}");
|
||||
assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6);
|
||||
// Return just before the art: the new paragraph (holding the art) continues the centred one.
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 6, "end": 6, "insert": "Text\n"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.paragraph_styles().iter().map(|p| p.justify).collect::<Vec<_>>(), [Justify::Auto, Justify::Center, Justify::Center]);
|
||||
undo(&mut s);
|
||||
// Delete the break and the art together: one paragraph, the first one's attributes.
|
||||
let end = 6 + INLINE_CHAR.len_utf8();
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 5, "end": end})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.plain_text(), "RulesFlavor");
|
||||
assert!(t.paras.is_empty() && t.para.justify == Justify::Auto && t.para.composer == Composer::EveryLine);
|
||||
assert!(t.runs.iter().all(|r| r.inline.is_none()));
|
||||
undo(&mut s);
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.plain_text(), format!("Rules\n{INLINE_CHAR}Flavor"));
|
||||
assert_eq!(t.para_at(1).justify, Justify::Center);
|
||||
assert_eq!(t.runs.iter().filter(|r| r.inline.is_some()).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_x_inline_graphics_in_the_document() {
|
||||
// The art the renderer draws (the layout's inline placements) moves with centred lines.
|
||||
let mut s = session();
|
||||
circle_symbol(&mut s, "Tap", 10.0);
|
||||
let id = area(&mut s, 300.0, 200.0, "Add one mana.", json!({}));
|
||||
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap"})).unwrap();
|
||||
let top = lay(&text(&s, id));
|
||||
s.execute("text.areaOptions", &json!({"verticalAlign": "center"})).unwrap();
|
||||
let l = lay(&text(&s, id));
|
||||
let d = l.lines[0].baseline - top.lines[0].baseline;
|
||||
assert!(d > 50.0, "{d}");
|
||||
assert!((l.inlines[0].bounds.y0 - top.inlines[0].bounds.y0 - d).abs() < 1e-6, "{:?} vs {:?}", l.inlines[0].bounds, top.inlines[0].bounds);
|
||||
// The cached bounds (hit testing, selection) include the moved art.
|
||||
let cached = text(&s, id).cached_bounds.unwrap();
|
||||
assert!(cached.contains(l.inlines[0].bounds.center()), "{cached:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_feature_saves_and_reopens() {
|
||||
let mut s = session();
|
||||
circle_symbol(&mut s, "Tap", 10.0);
|
||||
let id = area(
|
||||
&mut s,
|
||||
220.0,
|
||||
70.0,
|
||||
&format!(": Add one mana of any color. {STORY}\n{STORY}"),
|
||||
json!({"verticalAlign": "center", "fit": "shrinkText", "fitMinPercent": 30}),
|
||||
);
|
||||
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 0.8, "shift": 0.5})).unwrap();
|
||||
let flavor = text(&s, id).plain_text().find('\n').unwrap() + 1;
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 8, "composer": "singleLine", "start": flavor, "end": flavor})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"id": id.0, "justify": "justifyAll", "start": flavor, "end": flavor})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"id": id.0, "tracking": 20})).unwrap();
|
||||
let t = text(&s, id);
|
||||
let l = lay(&t);
|
||||
assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale);
|
||||
assert_eq!(l.inlines.len(), 1);
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
let NodeKind::Text(t2) = &back.node(id).unwrap().kind else { panic!("not text") };
|
||||
let mut t2 = (**t2).clone();
|
||||
// Resolved art bounds and layout caches aren't saved: resolve them as opening does.
|
||||
back.resolve_text_inline(&mut t2);
|
||||
assert_eq!(t2.runs, t.runs);
|
||||
assert_eq!(t2.paragraph_styles(), t.paragraph_styles());
|
||||
assert_eq!(t2.area, t.area);
|
||||
let l2 = lay(&t2);
|
||||
assert_eq!((l2.fit_scale, l2.lines.len()), (l.fit_scale, l.lines.len()));
|
||||
assert_eq!(l2.inlines.iter().map(|i| i.bounds).collect::<Vec<_>>(), l.inlines.iter().map(|i| i.bounds).collect::<Vec<_>>());
|
||||
}
|
||||
@@ -206,14 +206,17 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) ->
|
||||
Some(best.unwrap_or_else(|| at(lo)))
|
||||
}
|
||||
|
||||
/// `t` with every run's size, explicit leading and baseline shift scaled by `f` (auto leading
|
||||
/// follows the size; paragraph spacing stays).
|
||||
/// `t` with every run's size, explicit leading and baseline shift scaled by `f`, inline art's own
|
||||
/// shift included (auto leading and the art's size follow the size; paragraph spacing stays).
|
||||
fn scaled(t: &TextObject, f: f64) -> TextObject {
|
||||
let mut s = t.clone();
|
||||
for r in &mut s.runs {
|
||||
r.style.size *= f;
|
||||
r.style.leading = r.style.leading.map(|l| l * f);
|
||||
r.style.baseline_shift *= f;
|
||||
if let Some(a) = &mut r.inline {
|
||||
a.baseline_shift *= f;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -595,6 +598,15 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, re
|
||||
g.origin.y += d;
|
||||
}
|
||||
}
|
||||
// Inline art moves with its glyph's line.
|
||||
for i in &mut cx.out.inlines {
|
||||
let d = cx.out.glyphs.get(i.glyph).and_then(|g| offs.get(g.line)).copied().unwrap_or(0.0);
|
||||
if d != 0.0 {
|
||||
let m = Affine::translate((0.0, d));
|
||||
i.xf = m * i.xf;
|
||||
i.bounds = m.transform_rect_bbox(i.bounds);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let laid_out = |out: &TextLayout| out.lines.last().map_or(0, |l| l.end);
|
||||
@@ -624,12 +636,17 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, re
|
||||
return;
|
||||
}
|
||||
let before = laid_out(&cx.out);
|
||||
let prev = (std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::replace(&mut cx.out.overflow, false));
|
||||
let prev = (
|
||||
std::mem::take(&mut cx.out.glyphs),
|
||||
std::mem::take(&mut cx.out.lines),
|
||||
std::mem::take(&mut cx.out.inlines),
|
||||
std::mem::replace(&mut cx.out.overflow, false),
|
||||
);
|
||||
flow(cx, paras, t, Some(regions));
|
||||
if laid_out(&cx.out) < before {
|
||||
// Text no longer fits (lines got narrower, or flow around a wrap object): undo the
|
||||
// pass and try a smaller step.
|
||||
(cx.out.glyphs, cx.out.lines, cx.out.overflow) = prev;
|
||||
(cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.overflow) = prev;
|
||||
for (r, (shift, gap)) in regions.iter_mut().zip(saved) {
|
||||
r.shift = shift;
|
||||
r.gap = gap;
|
||||
|
||||
@@ -502,6 +502,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_combos;
|
||||
#[cfg(test)]
|
||||
mod tests_embed;
|
||||
#[cfg(test)]
|
||||
mod tests_fit;
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
//! The text engine's newer features combined, two at a time and all together: ligatures under
|
||||
//! tracking, the paragraph composer, area type vertical alignment, Shrink Text to Fit,
|
||||
//! per-paragraph attributes and inline graphics. Each feature has its own tests; these check that
|
||||
//! one doesn't undo or skip another (as vertical alignment once left inline art behind).
|
||||
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
use vectorcraft_doc::{AreaFit, CharStyle, Composer, InlineArt, Justify, TextKind, TextRun, VerticalAlign};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \
|
||||
The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing and letter spacing.";
|
||||
|
||||
/// Words full of `fi` (Source Serif 4 ligates it).
|
||||
const FI: &str = "fine fish finish first in fifty fields of fireflies, a fitting finale for the final fiddler";
|
||||
|
||||
/// Two lines that single-line and every-line composers break differently (see
|
||||
/// `every_line_composer_balances_ragged_lines`).
|
||||
const RAG: &str = "Destroy all creatures with toughness X or less.";
|
||||
|
||||
fn db() -> &'static FontDb {
|
||||
FontDb::global()
|
||||
}
|
||||
|
||||
fn sans(size: f64) -> CharStyle {
|
||||
CharStyle { size, ..CharStyle::default() }
|
||||
}
|
||||
|
||||
fn serif(size: f64, tracking: f64) -> CharStyle {
|
||||
CharStyle { font_family: "Source Serif 4".into(), size, tracking, ..CharStyle::default() }
|
||||
}
|
||||
|
||||
fn run(text: &str, style: CharStyle) -> TextRun {
|
||||
TextRun::new(text, style)
|
||||
}
|
||||
|
||||
/// A square symbol, `scale` × the run's size tall, raised `shift` points.
|
||||
fn art(scale: f64, shift: f64) -> InlineArt {
|
||||
InlineArt { scale, baseline_shift: shift, bounds: Some(Rect::new(-5.0, -5.0, 5.0, 5.0)), ..InlineArt::new("sym") }
|
||||
}
|
||||
|
||||
fn sym(scale: f64, shift: f64, style: CharStyle) -> TextRun {
|
||||
TextRun::inline(art(scale, shift), style)
|
||||
}
|
||||
|
||||
/// Area type in `frame` holding `runs`.
|
||||
fn area(runs: Vec<TextRun>, frame: Rect) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "", sans(12.0));
|
||||
t.runs = runs;
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t
|
||||
}
|
||||
|
||||
/// `t` as Shrink Text to Fit lays it out at factor `f`, without fitting: sizes, explicit leading
|
||||
/// and baseline shifts scaled (inline art's own shift too), paragraph spacing kept.
|
||||
fn scaled_by(t: &TextObject, f: f64) -> TextObject {
|
||||
let mut s = t.clone();
|
||||
s.area.fit = AreaFit::None;
|
||||
for r in &mut s.runs {
|
||||
r.style.size *= f;
|
||||
r.style.leading = r.style.leading.map(|l| l * f);
|
||||
r.style.baseline_shift *= f;
|
||||
if let Some(a) = &mut r.inline {
|
||||
a.baseline_shift *= f;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn line_texts(t: &TextObject, l: &TextLayout) -> Vec<String> {
|
||||
let text = t.plain_text();
|
||||
l.lines.iter().map(|li| text[li.start..li.end].trim_end().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Ligature clusters: glyphs standing for more than one character.
|
||||
fn ligatures(l: &TextLayout) -> usize {
|
||||
l.glyphs.iter().filter(|g| g.len > 1 && !g.outline.elements().is_empty()).count()
|
||||
}
|
||||
|
||||
/// Every inline graphic drawn once, where its glyph is: left edge on the glyph's origin, the
|
||||
/// scaled height, centred half the (scaled) cap height above the glyph's baseline plus its own
|
||||
/// (scaled) shift.
|
||||
fn assert_inlines_on_their_glyphs(t: &TextObject, l: &TextLayout, what: &str) {
|
||||
let want = t.runs.iter().filter(|r| r.inline.is_some()).count();
|
||||
assert_eq!(l.inlines.len(), want, "{what}: one placement per inline run");
|
||||
let mut glyphs: Vec<usize> = l.inlines.iter().map(|i| i.glyph).collect();
|
||||
glyphs.dedup();
|
||||
assert_eq!(glyphs.len(), want, "{what}: no inline drawn twice");
|
||||
for i in &l.inlines {
|
||||
let g = &l.glyphs[i.glyph];
|
||||
assert_eq!(g.run, i.run, "{what}");
|
||||
let r = &t.runs[i.run];
|
||||
let a = r.inline.as_ref().unwrap();
|
||||
let size = r.style.size * l.fit_scale;
|
||||
let face = db().face(&r.style.font_family, &r.style.font_style).unwrap();
|
||||
let cap = face.cap_height * size / face.upem;
|
||||
let b = i.bounds;
|
||||
assert!((b.height() - a.scale * size).abs() < 1e-6, "{what}: height {} vs {}", b.height(), a.scale * size);
|
||||
assert!((b.x0 - g.origin.x).abs() < 1e-6, "{what}: {b:?} vs origin {:?}", g.origin);
|
||||
let centre = g.origin.y - cap * 0.5 - a.baseline_shift * l.fit_scale;
|
||||
assert!((b.center().y - centre).abs() < 1e-6, "{what}: centre {} vs {centre} (line {})", b.center().y, g.line);
|
||||
assert!(l.lines[g.line].glyph_start <= i.glyph && i.glyph < l.lines[g.line].glyph_end, "{what}");
|
||||
assert!(l.bounds.contains(b.center()), "{what}: layout bounds {:?} miss {b:?}", l.bounds);
|
||||
}
|
||||
}
|
||||
|
||||
/// Space left below the last line of a one-cell frame.
|
||||
fn slack(l: &TextLayout, frame: Rect) -> f64 {
|
||||
let last = l.lines.last().unwrap();
|
||||
frame.y1 - (last.baseline + last.descent)
|
||||
}
|
||||
|
||||
fn baselines(l: &TextLayout) -> Vec<f64> {
|
||||
l.lines.iter().map(|li| li.baseline).collect()
|
||||
}
|
||||
|
||||
// ---------- ligatures under tracking × … ----------
|
||||
|
||||
#[test]
|
||||
fn ligatures_x_composer() {
|
||||
// Justified (where every-line spreads word space) and ragged, with tracking inside the limits
|
||||
// (kept) and letterspaced (dropped), whichever composer breaks the lines.
|
||||
for justify in [Justify::Left, Justify::JustifyLeft] {
|
||||
for (tracking, kept) in [(0.0, true), (20.0, true), (100.0, false)] {
|
||||
let mut counts = vec![];
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
let mut t = area(vec![run(&FI.repeat(3), serif(12.0, tracking))], Rect::new(0.0, 0.0, 170.0, 1000.0));
|
||||
t.para.justify = justify;
|
||||
t.para.composer = composer;
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.lines.len() > 3, "several lines to compose");
|
||||
counts.push(ligatures(&l));
|
||||
}
|
||||
assert_eq!(counts[0], counts[1], "{justify:?} {tracking}: composers shape alike");
|
||||
assert_eq!(counts[0] > 0, kept, "{justify:?} {tracking}: {counts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_x_vertical_align() {
|
||||
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
|
||||
for tracking in [20.0, 100.0] {
|
||||
let mut t = area(vec![run(FI, serif(12.0, tracking))], frame);
|
||||
let top = layout(db(), &t);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
t.area.vertical_align = a;
|
||||
let l = layout(db(), &t);
|
||||
let gids = |l: &TextLayout| l.glyphs.iter().map(|g| (g.gid, g.byte)).collect::<Vec<_>>();
|
||||
assert_eq!(gids(&l), gids(&top), "{tracking} {a:?}: moving lines doesn't reshape them");
|
||||
assert!(l.lines[0].baseline > top.lines[0].baseline + 1.0 || a == VerticalAlign::Justify, "{a:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_x_shrink_text() {
|
||||
// Tracking is in 1/1000 em, so scaling the size doesn't move it across the limits.
|
||||
let frame = Rect::new(0.0, 0.0, 150.0, 60.0);
|
||||
for (tracking, features, kept) in [(40.0, vec![], true), (100.0, vec![], false), (100.0, vec!["liga".to_string()], true)] {
|
||||
let st = CharStyle { features, ..serif(14.0, tracking) };
|
||||
let mut t = area(vec![run(&FI.repeat(2), st)], frame);
|
||||
t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow && l.fit_scale < 1.0, "{tracking}: {}", l.fit_scale);
|
||||
assert_eq!(ligatures(&l) > 0, kept, "{tracking}");
|
||||
assert_eq!(ligatures(&l), ligatures(&layout(db(), &scaled_by(&t, l.fit_scale))), "{tracking}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_x_paragraph_attrs() {
|
||||
// Paragraph attributes don't touch shaping: a centred, indented, every-line paragraph keeps
|
||||
// its ligatures and a letterspaced run in the next paragraph loses them.
|
||||
let mut t = area(vec![run(&format!("{FI}\n"), serif(12.0, 10.0)), run(FI, serif(12.0, 120.0))], Rect::new(0.0, 0.0, 240.0, 600.0));
|
||||
let mut second = t.para.clone();
|
||||
second.justify = Justify::Center;
|
||||
second.left_indent = 20.0;
|
||||
second.space_before = 12.0;
|
||||
t.set_paragraph_styles(vec![t.para.clone(), second]);
|
||||
let l = layout(db(), &t);
|
||||
let split = FI.len() + 1;
|
||||
let ligs = |first: bool| l.glyphs.iter().filter(|g| g.len > 1 && (g.byte < split) == first).count();
|
||||
assert!(ligs(true) > 0, "tracking 10 keeps them");
|
||||
assert_eq!(ligs(false), 0, "tracking 120 drops them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_x_inline_graphics() {
|
||||
// An inline graphic between ligated words neither breaks their ligatures nor takes them: its
|
||||
// own letterspaced run tracks the art without touching its neighbours.
|
||||
let st = serif(14.0, 0.0);
|
||||
let runs = vec![run("fine fish ", st.clone()), sym(1.0, 0.0, serif(14.0, 200.0)), run(" first fifty", st.clone())];
|
||||
let t = area(runs, Rect::new(0.0, 0.0, 400.0, 100.0));
|
||||
let l = layout(db(), &t);
|
||||
let without = area(vec![run("fine fish ", st.clone()), run(" first fifty", st.clone())], Rect::new(0.0, 0.0, 400.0, 100.0));
|
||||
assert_eq!(ligatures(&l), ligatures(&layout(db(), &without)), "the same ligatures as without the art");
|
||||
assert!(ligatures(&l) >= 4, "fi in fine, fish, first and fifty");
|
||||
assert_inlines_on_their_glyphs(&t, &l, "between ligatures");
|
||||
let g = &l.glyphs[l.inlines[0].glyph];
|
||||
assert!((g.advance - (14.0 + 200.0 / 1000.0 * 14.0)).abs() < 1e-6, "art width plus its tracking: {}", g.advance);
|
||||
}
|
||||
|
||||
// ---------- composer × … ----------
|
||||
|
||||
#[test]
|
||||
fn composer_x_vertical_align() {
|
||||
let w = natural_width(RAG, 12.0) * 0.9;
|
||||
let frame = Rect::new(0.0, 0.0, w, 200.0);
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
let mut t = area(vec![run(RAG, sans(12.0))], frame);
|
||||
t.para.composer = composer;
|
||||
let top = layout(db(), &t);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
t.area.vertical_align = a;
|
||||
let l = layout(db(), &t);
|
||||
assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{composer:?} {a:?}: the same breaks");
|
||||
let want = match a {
|
||||
VerticalAlign::Center => slack(&top, frame) * 0.5,
|
||||
_ => slack(&top, frame),
|
||||
};
|
||||
let moved = l.lines.last().unwrap().baseline - top.lines.last().unwrap().baseline;
|
||||
assert!((moved - want).abs() < 1e-6, "{composer:?} {a:?}: last line moved {moved}, want {want}");
|
||||
}
|
||||
}
|
||||
// And the two composers still differ there.
|
||||
let breaks = |c: Composer| {
|
||||
let mut t = area(vec![run(RAG, sans(12.0))], frame);
|
||||
t.para.composer = c;
|
||||
t.area.vertical_align = VerticalAlign::Center;
|
||||
line_texts(&t, &layout(db(), &t))
|
||||
};
|
||||
assert_ne!(breaks(Composer::SingleLine), breaks(Composer::EveryLine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_shrink_text() {
|
||||
// Each composer shrinks to the largest size at which its own breaks fit.
|
||||
for justify in [Justify::Left, Justify::JustifyLeft] {
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
let mut t = area(vec![run(COPY, sans(14.0))], Rect::new(0.0, 0.0, 180.0, 90.0));
|
||||
t.para.composer = composer;
|
||||
t.para.justify = justify;
|
||||
t.area.fit = AreaFit::ShrinkText { min_percent: 20.0 };
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow && l.fit_scale < 1.0, "{justify:?} {composer:?}");
|
||||
let size = 14.0 * l.fit_scale;
|
||||
assert!(layout(db(), &scaled_by(&t, (size + 0.1) / 14.0)).overflow, "{justify:?} {composer:?}: maximal at {size}");
|
||||
let same = layout(db(), &scaled_by(&t, l.fit_scale));
|
||||
assert_eq!(line_texts(&t, &same), line_texts(&t, &l), "{justify:?} {composer:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_paragraph_attrs() {
|
||||
// Each paragraph breaks with its own composer.
|
||||
let w = natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0;
|
||||
let mut t = area(vec![run(&format!("{RAG}\n{RAG}\n{RAG}"), sans(12.0))], Rect::new(0.0, 0.0, w, 400.0));
|
||||
let p = |c: Composer| vectorcraft_doc::ParaStyle { composer: c, ..t.para.clone() };
|
||||
t.set_paragraph_styles(vec![p(Composer::SingleLine), p(Composer::EveryLine), p(Composer::SingleLine)]);
|
||||
let l = layout(db(), &t);
|
||||
let greedy = ["Destroy all creatures with toughness X or", "less."];
|
||||
let every = ["Destroy all creatures with toughness X", "or less."];
|
||||
let want: Vec<&str> = [greedy, every, greedy].concat();
|
||||
assert_eq!(line_texts(&t, &l), want);
|
||||
// The object-wide override still wins over every paragraph.
|
||||
let forced = layout_with(db(), &t, &LayoutOptions { composer: Some(Composer::SingleLine), ..Default::default() });
|
||||
assert_eq!(line_texts(&t, &forced), [greedy, greedy, greedy].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_inline_graphics() {
|
||||
// An inline graphic is a word's worth of box to the composers: each one breaks around it as
|
||||
// around the letter it replaces, and the art follows its glyph to whichever line it lands on.
|
||||
let st = sans(12.0);
|
||||
let runs = |x: TextRun| vec![run("Destroy all creatures with toughness ", st.clone()), x, run(" or less.", st.clone())];
|
||||
let with_art = runs(sym(0.7, 0.0, st.clone()));
|
||||
let mut probe = area(with_art.clone(), Rect::new(0.0, 0.0, 10_000.0, 100.0));
|
||||
probe.runs.truncate(2);
|
||||
probe.runs.push(run(" or", st.clone()));
|
||||
let w = layout(db(), &probe).lines[0].x1 + 2.0;
|
||||
let sym_text = INLINE.to_string();
|
||||
for (composer, first, second) in [
|
||||
(Composer::SingleLine, format!("Destroy all creatures with toughness {sym_text} or"), "less."),
|
||||
(Composer::EveryLine, format!("Destroy all creatures with toughness {sym_text}"), "or less."),
|
||||
] {
|
||||
let mut t = area(with_art.clone(), Rect::new(0.0, 0.0, w, 200.0));
|
||||
t.para.composer = composer;
|
||||
let l = layout(db(), &t);
|
||||
assert_eq!(line_texts(&t, &l), [first.as_str(), second], "{composer:?}");
|
||||
assert_inlines_on_their_glyphs(&t, &l, &format!("{composer:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
const INLINE: char = vectorcraft_doc::text::INLINE_CHAR;
|
||||
|
||||
// ---------- vertical alignment × … ----------
|
||||
|
||||
#[test]
|
||||
fn vertical_align_x_shrink_text() {
|
||||
let frame = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let mut t = area(vec![run(©.repeat(2), sans(14.0))], frame);
|
||||
t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
let top = layout(db(), &t);
|
||||
assert!(!top.overflow && top.fit_scale < 1.0);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom] {
|
||||
t.area.vertical_align = a;
|
||||
let l = layout(db(), &t);
|
||||
assert_eq!(l.fit_scale, top.fit_scale, "{a:?}: aligning doesn't change the fit");
|
||||
assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{a:?}");
|
||||
let want = if a == VerticalAlign::Center { slack(&top, frame) * 0.5 } else { slack(&top, frame) };
|
||||
for (b, b0) in baselines(&l).iter().zip(baselines(&top)) {
|
||||
assert!((b - b0 - want).abs() < 1e-6, "{a:?}: {b} vs {b0} + {want}");
|
||||
}
|
||||
if a == VerticalAlign::Bottom {
|
||||
assert!(slack(&l, frame).abs() < 1e-6, "the shrunk block sits on the bottom");
|
||||
}
|
||||
}
|
||||
// Still overflowing at the minimum: aligned like any full frame (the same lines, moved by
|
||||
// less than a line, inside the frame), at the same scale.
|
||||
let frame = Rect::new(0.0, 0.0, 120.0, 40.0);
|
||||
let mut t = area(vec![run(©.repeat(4), sans(14.0))], frame);
|
||||
t.area.fit = AreaFit::ShrinkText { min_percent: 90.0 };
|
||||
let top = layout(db(), &t);
|
||||
assert!(top.overflow && (top.fit_scale - 0.9).abs() < 1e-9);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
t.area.vertical_align = a;
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.overflow && l.fit_scale == top.fit_scale, "{a:?}");
|
||||
assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{a:?}");
|
||||
let first = &top.lines[0];
|
||||
assert!(l.lines[0].baseline - first.baseline < first.ascent + first.descent, "{a:?}");
|
||||
assert!(slack(&l, frame) >= -1e-6, "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_x_paragraph_attrs() {
|
||||
// Per-paragraph spacing and alignment survive the move: lines keep their x and their
|
||||
// paragraph gaps, and the block lands where the alignment puts it.
|
||||
let frame = Rect::new(0.0, 0.0, 260.0, 500.0);
|
||||
let mut t = area(vec![run(&format!("{COPY}\n{COPY}\n{COPY}"), sans(11.0))], frame);
|
||||
let mut mid = t.para.clone();
|
||||
mid.justify = Justify::Center;
|
||||
mid.space_before = 18.0;
|
||||
mid.left_indent = 24.0;
|
||||
let mut last = t.para.clone();
|
||||
last.justify = Justify::Right;
|
||||
last.space_before = 6.0;
|
||||
t.set_paragraph_styles(vec![t.para.clone(), mid, last]);
|
||||
let top = layout(db(), &t);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom] {
|
||||
t.area.vertical_align = a;
|
||||
let l = layout(db(), &t);
|
||||
assert_eq!(line_texts(&t, &l), line_texts(&t, &top));
|
||||
let d = l.lines[0].baseline - top.lines[0].baseline;
|
||||
assert!(d > 1.0, "{a:?}");
|
||||
for (li, l0) in l.lines.iter().zip(&top.lines) {
|
||||
assert!((li.baseline - l0.baseline - d).abs() < 1e-6, "{a:?}: every line moves alike");
|
||||
assert!((li.x0 - l0.x0).abs() < 1e-6 && (li.x1 - l0.x1).abs() < 1e-6, "{a:?}: x stays");
|
||||
}
|
||||
for (g, g0) in l.glyphs.iter().zip(&top.glyphs) {
|
||||
assert!((g.origin.x - g0.origin.x).abs() < 1e-6 && (g.origin.y - g0.origin.y - d).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
// Justify: spacing is shared out on top of each paragraph's own space before, never less.
|
||||
t.area.vertical_align = VerticalAlign::Justify;
|
||||
let l = layout(db(), &t);
|
||||
assert!(slack(&l, frame).abs() < 1e-6, "justified to the bottom");
|
||||
for (pair, pair0) in l.lines.windows(2).zip(top.lines.windows(2)) {
|
||||
assert!(pair[1].baseline - pair[0].baseline >= pair0[1].baseline - pair0[0].baseline - 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_x_inline_graphics() {
|
||||
// Inline art moves with its line however the lines are aligned, in one cell or several, in a
|
||||
// rectangle or a frame the text flows into again.
|
||||
let st = sans(12.0);
|
||||
let mut runs = vec![];
|
||||
for i in 0..6 {
|
||||
runs.push(run(&format!("{} ", ©[i * 20..i * 20 + 20]), st.clone()));
|
||||
runs.push(sym(1.0 + i as f64 * 0.1, i as f64 - 2.0, st.clone()));
|
||||
if i == 2 {
|
||||
runs.push(run("\n", st.clone()));
|
||||
}
|
||||
}
|
||||
let mut tri = BezPath::new();
|
||||
tri.move_to((0.0, 0.0));
|
||||
tri.line_to((600.0, 0.0));
|
||||
tri.line_to((300.0, 400.0));
|
||||
tri.close_path();
|
||||
for (name, frame, columns) in [
|
||||
("rect", Rect::new(0.0, 0.0, 160.0, 400.0).to_path(0.1), 1),
|
||||
("columns", Rect::new(0.0, 0.0, 400.0, 300.0).to_path(0.1), 2),
|
||||
("triangle", tri, 1),
|
||||
] {
|
||||
for a in VerticalAlign::ALL {
|
||||
let mut t = area(runs.clone(), Rect::ZERO);
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) };
|
||||
t.area.columns = columns;
|
||||
t.area.vertical_align = a;
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow, "{name} {a:?}");
|
||||
assert_inlines_on_their_glyphs(&t, &l, &format!("{name} {a:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Shrink Text to Fit × … ----------
|
||||
|
||||
#[test]
|
||||
fn shrink_text_x_paragraph_attrs() {
|
||||
// Shrinking scales the type, not each paragraph's spacing and indents: the fitted layout is
|
||||
// the unfitted one at the scaled size.
|
||||
let mut t = area(vec![run(&format!("{COPY}\n{COPY}"), sans(13.0))], Rect::new(0.0, 0.0, 220.0, 160.0));
|
||||
let mut second = t.para.clone();
|
||||
second.space_before = 14.0;
|
||||
second.left_indent = 30.0;
|
||||
second.first_line_indent = 12.0;
|
||||
second.justify = Justify::JustifyLeft;
|
||||
second.composer = Composer::SingleLine;
|
||||
t.set_paragraph_styles(vec![t.para.clone(), second]);
|
||||
t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow && l.fit_scale < 1.0);
|
||||
let same = layout(db(), &scaled_by(&t, l.fit_scale));
|
||||
assert_eq!(baselines(&l), baselines(&same));
|
||||
assert_eq!(line_texts(&t, &l), line_texts(&t, &same));
|
||||
// The second paragraph's indents are unscaled points.
|
||||
let split = COPY.len() + 1;
|
||||
let first_of_second = l.lines.iter().find(|li| li.start == split).unwrap();
|
||||
assert!((first_of_second.x0 - 42.0).abs() < 1e-6, "{}", first_of_second.x0);
|
||||
assert!(l.lines.iter().filter(|li| li.start > split).all(|li| (li.x0 - 30.0).abs() < 1e-6));
|
||||
// A paragraph's space before alone can make the text overflow; shrinking still finds a fit.
|
||||
let mut tall = t.clone();
|
||||
tall.edit_paras(Some(1..2), |p| p.space_before = 100.0);
|
||||
let l = layout(db(), &tall);
|
||||
assert!(!l.overflow && l.fit_scale < layout(db(), &t).fit_scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_text_x_inline_graphics() {
|
||||
// Inline art shrinks with the type, its shift included, and stays centred on the cap height.
|
||||
let st = sans(14.0);
|
||||
let mut runs = vec![];
|
||||
for i in 0..8 {
|
||||
runs.push(run(©[i * 25..i * 25 + 25], st.clone()));
|
||||
runs.push(sym(1.2, 1.5, st.clone()));
|
||||
}
|
||||
let mut t = area(runs, Rect::new(0.0, 0.0, 200.0, 80.0));
|
||||
t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale);
|
||||
assert_inlines_on_their_glyphs(&t, &l, "shrunk");
|
||||
let same = layout(db(), &scaled_by(&t, l.fit_scale));
|
||||
for (i, i0) in l.inlines.iter().zip(&same.inlines) {
|
||||
assert!((i.bounds.y0 - i0.bounds.y0).abs() < 1e-6 && (i.bounds.x0 - i0.bounds.x0).abs() < 1e-6, "{:?} vs {:?}", i.bounds, i0.bounds);
|
||||
}
|
||||
// Large art makes lines taller; shrinking still fits it.
|
||||
for r in &mut t.runs {
|
||||
if let Some(a) = &mut r.inline {
|
||||
a.scale = 3.0;
|
||||
}
|
||||
}
|
||||
let big = layout(db(), &t);
|
||||
assert!(!big.overflow && big.fit_scale < l.fit_scale);
|
||||
assert_inlines_on_their_glyphs(&t, &big, "shrunk, big art");
|
||||
}
|
||||
|
||||
// ---------- paragraph attributes × inline graphics ----------
|
||||
|
||||
#[test]
|
||||
fn paragraph_attrs_x_inline_graphics() {
|
||||
// Inline art in paragraphs of each alignment and indent sits on its glyph, and an inline
|
||||
// graphic opening a paragraph takes that paragraph's first-line indent.
|
||||
let st = sans(12.0);
|
||||
let runs = vec![
|
||||
run("Left aligned ", st.clone()),
|
||||
sym(1.0, 0.0, st.clone()),
|
||||
run(" text.\n", st.clone()),
|
||||
sym(1.0, 0.0, st.clone()),
|
||||
run(" centred with art first\nright ", st.clone()),
|
||||
sym(1.5, -1.0, st.clone()),
|
||||
run(" aligned\nfull justified text with ", st.clone()),
|
||||
sym(1.0, 0.0, st.clone()),
|
||||
run(" in the middle of a long line that wraps", st.clone()),
|
||||
];
|
||||
let mut t = area(runs, Rect::new(0.0, 0.0, 200.0, 400.0));
|
||||
let base = t.para.clone();
|
||||
let with = |j: Justify, first: f64| vectorcraft_doc::ParaStyle { justify: j, first_line_indent: first, left_indent: 6.0, ..base.clone() };
|
||||
t.set_paragraph_styles(vec![base.clone(), with(Justify::Center, 0.0), with(Justify::Right, 0.0), with(Justify::JustifyLeft, 18.0)]);
|
||||
let l = layout(db(), &t);
|
||||
assert_inlines_on_their_glyphs(&t, &l, "paragraphs");
|
||||
let opening = &l.glyphs[l.inlines[1].glyph];
|
||||
let line = &l.lines[opening.line];
|
||||
assert!((opening.origin.x - line.x0).abs() < 1e-6, "the art opens its line");
|
||||
assert!(((line.x0 + line.x1) * 0.5 - (6.0 + 200.0) * 0.5).abs() < 1e-6, "the centred paragraph: {line:?}");
|
||||
let right = &l.lines[l.glyphs[l.inlines[2].glyph].line];
|
||||
assert!((right.x1 - 200.0).abs() < 1e-6, "{right:?}");
|
||||
let full = l.lines.iter().find(|li| li.start == t.plain_text().find("full").unwrap()).unwrap();
|
||||
assert!((full.x0 - 24.0).abs() < 1e-6, "left indent + first-line indent: {full:?}");
|
||||
}
|
||||
|
||||
// ---------- all of them ----------
|
||||
|
||||
/// One area type object with everything on: two paragraphs with their own attributes (the first
|
||||
/// ragged every-line with inline symbols and lightly tracked ligatures, the second single-line,
|
||||
/// justified and spaced away), centred vertically, shrunk to fit.
|
||||
#[test]
|
||||
fn every_feature_at_once() {
|
||||
let rules = serif(14.0, 15.0);
|
||||
let flavor = CharStyle { font_style: "Italic".into(), ..serif(14.0, 0.0) };
|
||||
let mut runs = vec![];
|
||||
for (i, words) in
|
||||
["Sacrifice a fine fish", "First strike. When this finishes fighting, draw a card", "Fifty fireflies fill the field"].iter().enumerate()
|
||||
{
|
||||
runs.push(sym(0.8, 0.0, rules.clone()));
|
||||
runs.push(run(&format!(": {words}. "), rules.clone()));
|
||||
if i == 1 {
|
||||
runs.push(sym(0.8, 0.5, rules.clone()));
|
||||
runs.push(run(" Then fight again.", rules.clone()));
|
||||
}
|
||||
}
|
||||
runs.push(run("\nThe finest fishers find their fortune in the fjords, if they first fix their nets.", flavor));
|
||||
let frame = Rect::new(0.0, 0.0, 240.0, 110.0);
|
||||
let mut t = area(runs, frame);
|
||||
let mut flav = t.para.clone();
|
||||
flav.space_before = 10.0;
|
||||
flav.justify = Justify::JustifyLeft;
|
||||
flav.composer = Composer::SingleLine;
|
||||
t.para.composer = Composer::EveryLine;
|
||||
t.set_paragraph_styles(vec![t.para.clone(), flav]);
|
||||
t.area.fit = AreaFit::ShrinkText { min_percent: 30.0 };
|
||||
|
||||
let top = layout(db(), &t);
|
||||
t.area.vertical_align = VerticalAlign::Center;
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale);
|
||||
assert_eq!(l.fit_scale, top.fit_scale);
|
||||
// Shrunk: the same as laying out the scaled text, then centring it.
|
||||
let mut unfit = scaled_by(&t, l.fit_scale);
|
||||
unfit.area.vertical_align = VerticalAlign::Top;
|
||||
let plain = layout(db(), &unfit);
|
||||
assert_eq!(line_texts(&t, &l), line_texts(&t, &plain));
|
||||
let d = slack(&plain, frame) * 0.5;
|
||||
assert!(d > 0.0);
|
||||
for (b, b0) in baselines(&l).iter().zip(baselines(&plain)) {
|
||||
assert!((b - b0 - d).abs() < 1e-6, "centred: {b} vs {b0} + {d}");
|
||||
}
|
||||
// Every symbol on its glyph; ligatures kept at tracking 15.
|
||||
assert_inlines_on_their_glyphs(&t, &l, "everything");
|
||||
assert_eq!(l.inlines.len(), 4);
|
||||
assert!(ligatures(&l) >= 6, "{}", ligatures(&l));
|
||||
// The flavor paragraph: justified (all but its last line fill the measure) and spaced away.
|
||||
let split = t.plain_text().find('\n').unwrap() + 1;
|
||||
let flavor_lines: Vec<&LineInfo> = l.lines.iter().filter(|li| li.start >= split).collect();
|
||||
assert!(flavor_lines.len() >= 2);
|
||||
for li in &flavor_lines[..flavor_lines.len() - 1] {
|
||||
assert!((li.x1 - li.x0 - 240.0).abs() < 0.5, "justified: {li:?}");
|
||||
}
|
||||
// Deterministic.
|
||||
let again = layout(db(), &t);
|
||||
assert_eq!(baselines(&again), baselines(&l));
|
||||
assert_eq!(again.inlines.iter().map(|i| i.bounds).collect::<Vec<_>>(), l.inlines.iter().map(|i| i.bounds).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// The natural width of `s` set in one line at `size`.
|
||||
fn natural_width(s: &str, size: f64) -> f64 {
|
||||
let l = layout(db(), &TextObject::point(Point::ZERO, s, sans(size)));
|
||||
l.lines[0].x1 - l.lines[0].x0
|
||||
}
|
||||
@@ -296,3 +296,33 @@ fn top_to_top_leading_spaces_inline_art_by_its_run() {
|
||||
let (tall0, tall1) = lines(3.0);
|
||||
assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_art_moves_with_vertically_aligned_lines() {
|
||||
use vectorcraft_doc::text::{AreaOptions, VerticalAlign};
|
||||
// A rectangle (lines shift) and a triangle (text flows again lower down).
|
||||
let rect = Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1);
|
||||
let mut tri = kurbo::BezPath::new();
|
||||
tri.move_to((0.0, 0.0));
|
||||
tri.line_to((800.0, 0.0));
|
||||
tri.line_to((400.0, 400.0));
|
||||
tri.close_path();
|
||||
for frame in [rect, tri] {
|
||||
for align in [VerticalAlign::Center, VerticalAlign::Bottom] {
|
||||
let mut t = text("A", art(1.0), "B", 20.0);
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) };
|
||||
t.area = AreaOptions { vertical_align: align, ..AreaOptions::default() };
|
||||
let l = layout(db(), &t);
|
||||
assert_eq!(l.inlines.len(), 1, "{align:?}");
|
||||
let g = inline_glyph(&l);
|
||||
let line = &l.lines[g.line];
|
||||
assert!(line.baseline > 100.0, "{align:?}: not aligned, baseline {}", line.baseline);
|
||||
// Still centred on the cap height above its (moved) baseline.
|
||||
let face = db().face("Source Sans 3", "Regular").unwrap();
|
||||
let cap = face.cap_height * 20.0 / face.upem;
|
||||
let b = l.inlines[0].bounds;
|
||||
assert!((b.center().y - (line.baseline - cap * 0.5)).abs() < 1e-6, "{align:?}: {b:?} baseline {}", line.baseline);
|
||||
assert!((b.x0 - g.origin.x).abs() < 1e-6, "{align:?}: {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,17 +516,19 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
prev.is_some_and(|prev| prev != m)
|
||||
});
|
||||
|
||||
// Zoom around the pointer, or scroll ([`wheel`]).
|
||||
// Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also
|
||||
// slide.
|
||||
if resp.hovered() {
|
||||
if (factor - 1.0).abs() > 1e-6 {
|
||||
if let (Some(p), Some(vm)) = (hover, app.view_mut()) {
|
||||
let before = xf.to_doc(p);
|
||||
vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0);
|
||||
let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() };
|
||||
let after = nx.to_doc(p);
|
||||
vm.center += before - after;
|
||||
}
|
||||
} else if scroll != egui::Vec2::ZERO
|
||||
if (factor - 1.0).abs() > 1e-6
|
||||
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
|
||||
{
|
||||
let before = xf.to_doc(p);
|
||||
vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0);
|
||||
let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() };
|
||||
let after = nx.to_doc(p);
|
||||
vm.center += before - after;
|
||||
}
|
||||
if scroll != egui::Vec2::ZERO
|
||||
&& let Some(vm) = app.view_mut()
|
||||
{
|
||||
let d = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }.delta_to_doc(scroll);
|
||||
@@ -673,13 +675,15 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
|
||||
const WHEEL_ZOOM: f64 = 0.01;
|
||||
|
||||
/// What the wheel and a pinch did over the canvas this frame: a zoom factor (about the pointer)
|
||||
/// and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
|
||||
/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame:
|
||||
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
|
||||
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
|
||||
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
|
||||
/// per wheel line and page. `alt_turn`: the last wheel turn was an Alt-wheel one (kept by the
|
||||
/// caller across frames).
|
||||
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn: &mut bool) -> (f64, egui::Vec2) {
|
||||
// Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead).
|
||||
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta);
|
||||
if !wheel_zooms {
|
||||
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
|
||||
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
|
||||
@@ -688,14 +692,14 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn
|
||||
}
|
||||
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
|
||||
return if *alt_turn && scroll != egui::Vec2::ZERO {
|
||||
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), egui::Vec2::ZERO)
|
||||
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan)
|
||||
} else {
|
||||
(zoom, scroll)
|
||||
(zoom, scroll + pan)
|
||||
};
|
||||
}
|
||||
// The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom.
|
||||
let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta));
|
||||
let mut scroll = egui::Vec2::ZERO;
|
||||
let mut scroll = pan;
|
||||
for e in &i.events {
|
||||
match e {
|
||||
egui::Event::MouseWheel { unit, delta, modifiers, .. } => {
|
||||
@@ -1719,6 +1723,7 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
app.run("file.open", json!({})).ok();
|
||||
}
|
||||
});
|
||||
recent(app, ui);
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
@@ -1739,6 +1744,35 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
crate::community::links(app, ui);
|
||||
}
|
||||
|
||||
/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and
|
||||
/// folder; a click opens it. Nothing when there are none.
|
||||
fn recent(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
const SHOWN: usize = 6;
|
||||
let files: Vec<String> = crate::io::recent_files(app).iter().take(SHOWN).cloned().collect();
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new(tl!("Recent Files")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(8.0);
|
||||
for path in files {
|
||||
let p = std::path::Path::new(&path);
|
||||
let name = p.file_name().map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned());
|
||||
let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
crate::icons::icon(ui, "history", 16.0, t.icon);
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(&name).color(t.accent).size(13.0)).frame(false)).on_hover_text(&path);
|
||||
ui.add(egui::Label::new(egui::RichText::new(&folder).size(12.0).color(t.text_dim)).truncate());
|
||||
if r.clicked()
|
||||
&& let Err(e) = crate::io::open_path(app, &path)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
|
||||
vectorcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f);
|
||||
}
|
||||
@@ -2658,6 +2692,36 @@ mod tests {
|
||||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
|
||||
}
|
||||
|
||||
/// Two fingers dragged together on a touch screen pan the canvas with them, under either
|
||||
/// Zoom with Mouse Wheel setting, and draw nothing (#449).
|
||||
#[test]
|
||||
fn two_fingers_dragged_together_pan_the_canvas() {
|
||||
for wheel_zooms in [false, true] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let c = app.canvas_rect.unwrap().center();
|
||||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||||
let fingers = |phase, d: egui::Vec2| vec![touch(1, phase, c - vec2(40.0, 0.0) + d), touch(2, phase, c + vec2(40.0, 0.0) + d)];
|
||||
let before = *app.view().unwrap();
|
||||
// egui-winit moves the pointer with the first finger, as here.
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]);
|
||||
frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, Vec2::ZERO));
|
||||
for k in 1..=3 {
|
||||
frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, vec2(10.0, 8.0) * k as f32));
|
||||
}
|
||||
frame(&mut app, &ctx, fingers(egui::TouchPhase::End, vec2(30.0, 24.0)));
|
||||
let after = *app.view().unwrap();
|
||||
let moved = (after.center - before.center) * after.zoom;
|
||||
assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "no zoom: {wheel_zooms}");
|
||||
assert!((moved.x + 30.0).abs() < 0.5 && (moved.y + 24.0).abs() < 0.5, "the content follows the fingers ({wheel_zooms}): {moved:?}");
|
||||
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 0, "nothing drawn");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_space_zooms_and_space_pans_whatever_the_tool() {
|
||||
use egui::{Event, Modifiers};
|
||||
|
||||
@@ -190,7 +190,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(640.0);
|
||||
ui.set_min_height(HEIGHT - 32.0);
|
||||
presets(app, ui, &mut d);
|
||||
b.create |= presets(app, ui, &mut d);
|
||||
});
|
||||
});
|
||||
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::same(18)).show(ui, |ui| {
|
||||
@@ -205,8 +205,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
b.finish(app, ctx, d);
|
||||
}
|
||||
|
||||
/// The category tabs and the chosen category's preset cards.
|
||||
fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
/// The category tabs and the chosen category's preset cards: a click chooses one, a double-click
|
||||
/// also creates it (true then).
|
||||
fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let names: Vec<&str> = newdoc::category_names().collect();
|
||||
let cat = names.iter().position(|n| n.eq_ignore_ascii_case(&d.str("category"))).unwrap_or(0);
|
||||
@@ -227,20 +228,24 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
if list.is_empty() {
|
||||
let hint = if cat == 1 { tl!("Presets you save with the Save Preset button appear here.") } else { tl!("Documents you create appear here.") };
|
||||
ui.label(egui::RichText::new(hint).color(t.text_dim));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let mut create = false;
|
||||
egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(HEIGHT - 110.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
|
||||
let (w, h, preset) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"));
|
||||
for s in &list {
|
||||
let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h;
|
||||
if preset_card(ui, s, selected).clicked() {
|
||||
let card = preset_card(ui, s, selected);
|
||||
if card.clicked() || card.double_clicked() {
|
||||
apply(d, s);
|
||||
}
|
||||
create |= card.double_clicked();
|
||||
}
|
||||
});
|
||||
});
|
||||
create
|
||||
}
|
||||
|
||||
/// Is `name` a built-in preset's? Those are ours (translated); Recent and Saved hold the names of
|
||||
@@ -345,7 +350,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
|
||||
// Close and Create at the bottom right.
|
||||
ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0));
|
||||
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
b.create = widgets::primary_button(ui, tl!("Create")).clicked();
|
||||
b.create |= widgets::primary_button(ui, tl!("Create")).clicked();
|
||||
ui.add_space(8.0);
|
||||
b.close = widgets::secondary_button(ui, tl!("Close")).clicked();
|
||||
});
|
||||
@@ -558,7 +563,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.horizontal(|ui| {
|
||||
templates = widgets::secondary_button(ui, tl!("Templates…")).on_hover_text(tl!("New from Template")).clicked();
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
b.create = widgets::primary_button(ui, tl!("Create Document")).clicked();
|
||||
b.create |= widgets::primary_button(ui, tl!("Create Document")).clicked();
|
||||
ui.add_space(8.0);
|
||||
// Cancel goes back to New Document.
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
@@ -610,6 +615,51 @@ mod tests {
|
||||
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A double-click on a preset card creates the document from it (#663); a click only chooses it.
|
||||
#[test]
|
||||
fn a_double_clicked_preset_creates_the_document() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.newDialog", json!({})).unwrap();
|
||||
set(&mut app, "category", json!("Mobile"));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// Frames 50 ms apart, so two clicks a frame apart make a double-click.
|
||||
let mut time = 0.0;
|
||||
let mut run = |app: &mut VectorcraftApp, events| {
|
||||
time += 0.05;
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
};
|
||||
run(&mut app, vec![]);
|
||||
// The first card of the category, found by its size line.
|
||||
let card = |app: &mut VectorcraftApp, run: &mut dyn FnMut(&mut VectorcraftApp, Vec<egui::Event>) -> egui::FullOutput| {
|
||||
let out = run(app, vec![]);
|
||||
let mut texts = vec![];
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
|
||||
texts.into_iter().find(|(t, _)| t.starts_with("Phone 393")).map(|(_, r)| r.center()).unwrap()
|
||||
};
|
||||
let at = card(&mut app, &mut run);
|
||||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
run(&mut app, vec![egui::Event::PointerMoved(at), press(true)]);
|
||||
run(&mut app, vec![press(false)]);
|
||||
assert!(app.ui.dialog.is_some() && app.session.documents().is_empty(), "a click only chooses it");
|
||||
assert!(field(&app, "preset").as_str().is_some_and(|p| p == "Phone 393×852"), "{:?}", field(&app, "preset"));
|
||||
run(&mut app, vec![press(true)]);
|
||||
run(&mut app, vec![press(false)]);
|
||||
assert!(app.ui.dialog.is_none(), "the double-click created it: {:?}", app.ui.status);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_fills_the_details_and_create_makes_the_document() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -90,3 +90,46 @@ fn cmd_n_opens_the_new_document_dialog() {
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("newDocument"));
|
||||
assert_eq!(app.session.documents().len(), 1, "no document is made until the dialog's OK");
|
||||
}
|
||||
|
||||
/// One Home screen frame (no document open) with `events`: the texts drawn and where.
|
||||
fn home_texts(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, egui::Rect)> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
|
||||
texts
|
||||
}
|
||||
|
||||
/// The Home screen lists the first recent files (#663), each by name and folder; a click opens it,
|
||||
/// and one that can't be opened says so. With none, there is no Recent Files section.
|
||||
#[test]
|
||||
fn home_lists_recent_files_and_a_click_opens_one() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let has = |texts: &[(String, egui::Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
|
||||
assert!(!has(&home_texts(&mut app, &ctx, vec![]), "Recent Files"), "none: no section");
|
||||
let dir = std::env::temp_dir().join("vectorcraft-home-recent");
|
||||
let missing = dir.join("Gone.svg").to_string_lossy().into_owned();
|
||||
app.ui.recent_files = (0..9).map(|i| dir.join(format!("Drawing {i}.svg")).to_string_lossy().into_owned()).collect();
|
||||
app.ui.recent_files.insert(0, missing);
|
||||
let texts = home_texts(&mut app, &ctx, vec![]);
|
||||
assert!(has(&texts, "Recent Files") && has(&texts, "Gone.svg") && has(&texts, "Drawing 4.svg"), "{texts:?}");
|
||||
assert!(!has(&texts, "Drawing 5.svg"), "the first six only");
|
||||
assert!(has(&texts, &dir.to_string_lossy()), "with its folder");
|
||||
let at = texts.iter().find(|(t, _)| t == "Gone.svg").map(|(_, r)| r.center()).unwrap();
|
||||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
home_texts(&mut app, &ctx, vec![egui::Event::PointerMoved(at), press(true)]);
|
||||
home_texts(&mut app, &ctx, vec![press(false)]);
|
||||
assert!(app.session.documents().is_empty());
|
||||
assert!(!app.ui.status.is_empty(), "a file that can't be opened says so");
|
||||
}
|
||||
+2
-1
@@ -43,11 +43,12 @@ The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (
|
||||
- **Automatic on Windows and macOS** is power saving: the system shows frames from any GPU, and presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some hybrid laptops (#306).
|
||||
- **Automatic on Linux and the BSDs** keeps the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502).
|
||||
- 0.5.0 saved `powerSaving`, its default, for everyone; it reads as `automatic`.
|
||||
- **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking.
|
||||
- **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`, #651). The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking.
|
||||
- **Environment variables** for when the choice still goes wrong, all read at startup and stronger than the preference:
|
||||
- `WGPU_POWER_PREF`: `low`, `high` or `none` (the system's order).
|
||||
- `WGPU_ADAPTER_NAME`: part of an adapter's name, any case (`WGPU_ADAPTER_NAME=nvidia`, `=radv`, `=intel`); the names are in the log.
|
||||
- `WGPU_BACKEND`: the backends to use, such as `vulkan`, `dx12`, `metal` or `gl`.
|
||||
- `WGPU_VALIDATION_INDIRECT_CALL=1`: turns wgpu's check of indirect draw arguments back on. VectorCraft draws nothing indirectly and turns it off, since the compute shader it needs fails to compile on some drivers (#651).
|
||||
- On Linux with Mesa, `MESA_VK_DEVICE_SELECT=10de:2f04!` (vendor:device as `vulkaninfo --summary` or `lspci -nn` show it) leaves only that GPU to Vulkan, and `DRI_PRIME=1` picks the other GPU.
|
||||
- Help › About and the control channel's `ui.inspect` (`graphicsAdapter`) show the adapter in use, and the app logs it at startup.
|
||||
|
||||
|
||||
Reference in new issue
Block a user