Compare commits

...
Author SHA1 Message Date
BFlatandClaude Opus 5.5 f16403a851 Fix #293: the dock's double arrow collapses its panels to icons
The » at the top of the right dock was only painted. Its header strip is
now a click target (hover highlight, "Collapse to Icons" tooltip) that
runs the new window.collapseDock UI command: the Properties | Layers |
Libraries group hides and its three panels become icons at the top of
the icon column, under a « strip ("Expand Panels") that expands them
again. While collapsed, those icons and window.panel pop the panel out
next to the column like the other icon panels (dock-width flyout, Layers
and Properties given a height that keeps them on screen); expanding with
one popped out shows its tab.

The flyout now follows the icon column's real left edge (recorded each
frame) instead of assuming a 300-point dock, so it also tracks a resized
or collapsed dock. The collapsed state is kept with the UI preferences
and in user workspaces; the built-in workspaces expand the dock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:21:59 +07:00
BFlat b64dcc02df Merge pull request #329 from storytold/fix/issue-306
Fix #306: render on the power-saving GPU by default, with a preference for the discrete one
2026-10-07 21:21:15 +07:00
BFlatandClaude Opus 5.5 406c27ebd8 Fix #306: render on the power-saving GPU by default, with a preference for the discrete one
On hybrid-graphics laptops (an integrated GPU driving the display plus a
discrete one), egui-wgpu's default power preference is HighPerformance, so
VectorCraft rendered on the discrete GPU and its frames were copied to the
display through the integrated one. On an Asus ZenBook with Intel Iris + Arc
the window flickered whenever the mouse moved over it (#306, #305).

The canvas is rasterized on the CPU (vello_cpu) and the GPU only composites
the canvas texture and draws the UI, so the integrated GPU is plenty. The
desktop app now asks wgpu for the power-saving adapter by default.
Single-GPU machines are unaffected.

- Preferences > Performance > Graphics Processor (`gpuPreference`:
  powerSaving, the default, or highPerformance), with a note that it
  applies after a restart. The app reads it from the saved preferences
  before the window opens. WGPU_POWER_PREF still overrides it.
- Help > About and `ui.inspect` (`graphicsAdapter`) name the adapter in
  use, and it is logged at startup, for future GPU bug reports.
- Docs (README, docs/development.md) and ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:10:17 +07:00
BFlat dfe7f1a311 Merge pull request #326 from storytold/fix/issue-296
Fix #296: variable fonts list and draw every named style
2026-10-07 21:09:48 +07:00
BFlatandClaude Opus 5.5 079f9a8b9a Fix #296: variable fonts list and draw every named style
The font database made one style per font file, so a variable font
such as Figtree[wght].ttf listed only its default instance (Light).
Faces now also yield their fvar named instances, each a style of the
family named by its subfamily name (the default instance is the face
itself; unnamed or duplicate instances are left out; axes and
instances read are capped). The system font scan reads the fvar table
beside the name table, so installed instances are listed before the
file is loaded and found by name and by PostScript name.

An instance face carries its axis settings: skrifa draws its outlines
and reads its metrics and advances at that location, harfrust shapes
with a ShaperInstance, so layout, the canvas, Create Outlines and hit
testing all use the instance. PDF export embeds it through krilla's
Font::new_variable, SVG @font-face through subsetter's
subset_with_variations: both write a static font of that instance.

Tests build a small variable font in code (test_fonts, via
write-fonts; no font file is committed) and check listing, resolving,
glyphs and advances, PDF and SVG export and damaged fvar/gvar tables;
Bahnschrift and Segoe UI Variable are checked where installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:01:26 +07:00
BFlat 8e40794153 Merge pull request #325 from storytold/fix/issue-290
Fix #290: hovering another menu title switches the open menu
2026-10-07 20:54:33 +07:00
BFlatandClaude Opus 5.5 ee7a7ebfac Fix #290: hovering another menu title switches the open menu
On the in-window menu bar (Windows and Linux) each top-level menu needed
its own click: with File open, moving the pointer onto Edit or Object did
nothing. Now, as on a native menu bar, while one menu is open a moving
pointer that reaches another title opens that menu instead (ported from
PhotoCraft). A pointer resting on a title doesn't switch, and a tall menu
that covers the bar doesn't switch to the title underneath it. The native
macOS menu bar is unchanged.

Also adds a regression test for #292: the Simplify, Offset Path, Move,
Rotate, Split Into Grid and Average dialogs are 366 pt wide in a 1600 pt
window (already fixed by #239).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:32:03 +07:00
BFlat 0d4d125f9b Merge pull request #323 from storytold/fix/issue-291
Fix #291: live corners stay circular when a rectangle is scaled unevenly
2026-10-07 20:28:06 +07:00
33 changed files with 1407 additions and 171 deletions

No files matched your search

Generated
+1
View File
@@ -4553,6 +4553,7 @@ dependencies = [
"subsetter",
"vectorcraft-doc",
"vectorcraft-geom",
"write-fonts",
]
[[package]]
+4
View File
@@ -158,6 +158,10 @@ Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/cr
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default.
To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance**
and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
### Use it from Claude Code and other agents
Register the MCP server with Claude Code:
+7 -5
View File
@@ -42,7 +42,7 @@ since 2026-10-01.
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); Windows/Linux packaging and accessibility are pending |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
### Where we're lacking (in priority order)
@@ -95,12 +95,13 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **UI:** Illustrator 2026 layout restyled to measured values:
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
@@ -119,7 +120,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales.
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
@@ -141,6 +142,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, 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.
- **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 the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use.
- **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`).
## Milestones and estimates
@@ -154,7 +156,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
@@ -190,7 +192,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
+64 -1
View File
@@ -241,6 +241,27 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// The power preference wgpu picks the window's graphics adapter with (#306): the
/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
/// GPU both preferences pick it.
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
use eframe::wgpu::PowerPreference;
match (env, pref) {
(Some(p), _) => p,
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
(None, _) => PowerPreference::LowPower,
}
}
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
}
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
/// macOS keeps its traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
@@ -262,7 +283,9 @@ fn main() -> eframe::Result {
}
let saved = read_prefs();
let saved_window = saved.as_ref().and_then(|ui| ui.window);
let options = eframe::NativeOptions {
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size(window::DEFAULT_SIZE)
@@ -276,6 +299,9 @@ fn main() -> eframe::Result {
.with_app_id("ai.storyteller.vectorcraft"),
..Default::default()
};
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.power_preference = power;
}
eframe::run_native(
"VectorCraft",
options,
@@ -297,6 +323,11 @@ fn main() -> eframe::Result {
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
app.session.recovery.set_default_folder(recovery);
}
if let Some(rs) = &cc.wgpu_render_state {
let summary = adapter_summary(&rs.adapter.get_info());
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
app.graphics_adapter = Some(summary);
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
@@ -316,3 +347,35 @@ fn main() -> eframe::Result {
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use eframe::wgpu::PowerPreference;
#[test]
fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
assert_eq!(power_preference(None, None), PowerPreference::LowPower);
assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
// A value this version doesn't know (a newer or damaged preference file) is the default.
assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
// WGPU_POWER_PREF wins over the preference, either way.
assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
}
/// The preference the engine saves is the one the app reads back before the window opens.
#[test]
fn the_saved_engine_preference_is_found() {
let mut prefs = vectorcraft_engine::Prefs::default();
let saved = prefs.to_json();
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
prefs.gpu_preference = "highPerformance".into();
let saved = prefs.to_json();
let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
}
}
+3
View File
@@ -82,6 +82,8 @@ pub const UNITS: &[(&str, &str)] = &[
("feet", "Feet"),
];
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup.
pub const GPU_PREFERENCES: &[(&str, &str)] = &[("powerSaving", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
@@ -265,6 +267,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
// Performance
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
p!("gpuPreference", "Performance", "GPU Performance", "Graphics Processor", choice(GPU_PREFERENCES)),
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
+7
View File
@@ -397,6 +397,12 @@ pub struct Prefs {
// Performance
pub gpu_performance: bool,
pub animated_zoom: bool,
/// Which graphics processor the desktop app asks for at startup (it takes effect after a
/// restart): `powerSaving` (the integrated GPU on hybrid-graphics laptops) or `highPerformance`
/// (the discrete one). The canvas is rasterized on the CPU and only composited on the GPU, so
/// the integrated GPU is plenty; presenting from the discrete GPU through the integrated one
/// made some hybrid laptops flicker (#306). Single-GPU machines are unaffected.
pub gpu_preference: String,
pub history_states: u32,
pub real_time_drawing: bool,
/// Rasterizer worker threads; -1 = automatic.
@@ -598,6 +604,7 @@ impl Default for Prefs {
interface_language: s("auto"),
gpu_performance: true,
animated_zoom: true,
gpu_preference: s("powerSaving"),
history_states: 500,
real_time_drawing: true,
render_threads: -1,
+30
View File
@@ -122,3 +122,33 @@ fn prefs_serde_round_trip_and_tolerates_missing_fields() {
assert_eq!(partial.keyboard_increment, 4.0);
assert_eq!(partial.corner_radius, 12.0);
}
/// Performance › Graphics Processor (#306): power saving by default, set by value or label, kept
/// through a save/load round trip, and a bad value is an error that changes nothing.
#[test]
fn gpu_preference_defaults_to_power_saving_and_validates() {
let mut s = Session::new();
assert_eq!(s.prefs.gpu_preference, "powerSaving");
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving"));
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "highPerformance");
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "powerSaving");
for bad in [json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}");
assert_eq!(s.prefs.gpu_preference, "powerSaving");
}
let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() };
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back.gpu_preference, "highPerformance");
// Preference files written before the preference existed get the default.
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
assert_eq!(old.gpu_preference, "powerSaving");
let l = s.execute("prefs.list", &json!({})).unwrap();
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone();
assert_eq!(row["category"], "Performance");
assert_eq!(row["options"], json!(["powerSaving", "highPerformance"]));
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "powerSaving");
}
+2
View File
@@ -32,4 +32,6 @@ sha2 = { version = "0.10", default-features = false }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
serde_json = { workspace = true }
+5 -1
View File
@@ -1444,7 +1444,11 @@ impl Exporter<'_> {
let name = format!("{} {}", face.family, face.style);
let font = match face.embedding() {
Embedding::Subset => {
let font = krilla::text::Font::new(face.file_data().to_vec().into(), face.face_index()).map(|f| (f, face.units_per_em()));
// A named instance of a variable font is embedded instanced (its own outlines and
// widths, not the default instance's).
let coords: Vec<(krilla::text::Tag, f32)> = face.variations().iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
let font =
krilla::text::Font::new_variable(face.file_data().to_vec().into(), face.face_index(), &coords).map(|f| (f, face.units_per_em()));
if font.is_none() {
self.warn(format!("the font “{name}” can't be embedded: its text is exported as outlines"));
}
+22
View File
@@ -110,6 +110,28 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
}
}
/// A named instance of a variable font is real text in that instance: its embedded subset draws
/// the instance's glyphs where the outlines would be, not the default instance's (#296).
#[test]
fn variable_font_instances_are_real_text_in_their_own_glyphs() {
use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font};
FontDb::global().add_font(variable_font().unwrap());
let mut widths = vec![];
for s in ["Regular", "Bold"] {
let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: s.into(), ..style(48.0) };
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
let r = pdf(&d, false);
assert!(r.warnings.is_empty(), "{s}: {:?}", r.warnings);
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s}: real text");
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
let off =
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(off < 0.5, "{s}: {real:?} vs {outlined:?}");
widths.push(real.width());
}
assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}");
}
#[test]
fn gradients_on_real_text_stay_where_they_were() {
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+2
View File
@@ -30,6 +30,8 @@ svgtypes = "0.16"
[dev-dependencies]
# Reading embedded font subsets back.
skrifa = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
resvg = { version = "0.45", default-features = false }
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
@@ -216,6 +216,31 @@ fn embedded_fonts_hold_only_the_glyphs_used() {
assert!(!export_full(&d, &ExportOptions { embed_fonts: true, outline_text: true, ..Default::default() }, None).svg.contains("@font-face"));
}
/// A named instance of a variable font embeds as a static font of that instance: the variable
/// file would show its default instance in browsers (#296).
#[test]
fn variable_font_instances_embed_as_themselves() {
use vectorcraft_text::test_fonts::{VARIABLE_FAMILY, variable_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(variable_font().unwrap());
let mut d = Document::new(300.0, 200.0);
add(&mut d, text(Point::new(20.0, 60.0), &[("ll", style(VARIABLE_FAMILY, "Regular")), ("ll", style(VARIABLE_FAMILY, "Bold"))]));
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
let fonts = embedded(&o.svg);
assert_eq!(fonts.iter().map(|f| f.1).collect::<Vec<_>>(), [400, 700], "{:?}", o.warnings);
let mut advances = vec![];
for ((_, w, data), style) in fonts.iter().zip(["Regular", "Bold"]) {
let f = skrifa::FontRef::new(data).unwrap();
assert!(f.fvar().is_err() && f.gvar().is_err(), "{w}: a static font");
let gid = f.cmap().unwrap().map_codepoint('l').unwrap();
let adv = f.hmtx().unwrap().advance(gid).unwrap();
let face = db.face(VARIABLE_FAMILY, style).unwrap();
assert_eq!(f64::from(adv), face.advance(face.glyph_for('l')).round(), "{style}");
advances.push(adv);
}
assert!(advances[1] > advances[0], "{advances:?}");
}
/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original
/// name) with OS/2 `fsType` set to `fs_type`.
fn licensed_font(family: &str, fs_type: u16) -> Vec<u8> {
+13 -2
View File
@@ -15,5 +15,16 @@ kurbo = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
log = { workspace = true }
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer.
subsetter = { version = "0.2", default-features = false }
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer
# (which turns on `variable-fonts` too: named instances of variable fonts are instanced).
subsetter = { version = "0.2", default-features = false, features = ["variable-fonts"] }
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
# Apache-2.0, already in the build through subsetter.
write-fonts = { version = "0.48", optional = true }
[features]
# `vectorcraft_text::test_fonts` for other crates' tests (dev-dependencies only).
test-fonts = ["dep:write-fonts"]
[dev-dependencies]
write-fonts = "0.48"
+33 -10
View File
@@ -60,19 +60,29 @@ impl FontFace {
/// The face as a font file to embed: the glyphs of `chars` alone when its licence allows
/// subsetting, else the whole face. Ligatures, kerning and other layout features are left out
/// of subsets (viewers draw each character's own glyph). `None` when the licence forbids
/// embedding or the font can't be read.
/// of subsets (viewers draw each character's own glyph). A named instance of a variable font
/// ([`FontFace::variations`]) is embedded as a static font of that instance (the variable file
/// would show its default instance), CFF2 outlines becoming TrueType ones. `None` when the
/// licence forbids embedding or the font can't be read.
pub fn embed(&self, chars: &[char]) -> Option<EmbeddedFont> {
let font = self.skrifa()?;
let cff = [b"CFF ", b"CFF2"].iter().any(|t| font.table_data(Tag::new(t)).is_some());
let whole = || Some(EmbeddedFont { data: sfnt(font.table_directory.sfnt_version(), tables(&font))?, cff, subset: false });
let instance = !self.variations().is_empty();
match self.embedding() {
Embedding::Forbidden => None,
// Every glyph of the instance, keeping their ids.
Embedding::Whole if instance => {
let all: Vec<char> = font.charmap().mappings().filter_map(|(c, _)| char::from_u32(c)).collect();
subset(self, &font, &all, true).map(|(data, cff)| EmbeddedFont { data, cff, subset: false })
}
Embedding::Whole => whole(),
Embedding::Subset => match subset(self, &font, chars) {
Some(data) => Some(EmbeddedFont { data, cff, subset: true }),
// Outlines the subsetter can't read (CFF2): what the licence allows anyway.
None => whole(),
Embedding::Subset => match subset(self, &font, chars, false) {
Some((data, cff)) => Some(EmbeddedFont { data, cff, subset: true }),
// Outlines the subsetter can't read: what the licence allows anyway (a named
// instance can't be embedded whole, which would show the default instance).
None if !instance => whole(),
None => None,
},
}
}
@@ -83,10 +93,17 @@ fn tables<'a>(font: &skrifa::FontRef<'a>) -> Vec<(Tag, Cow<'a, [u8]>)> {
font.table_directory.table_records().iter().filter_map(|r| font.table_data(r.tag()).map(|d| (r.tag(), Cow::Borrowed(d.as_bytes())))).collect()
}
/// `face` reduced to the glyphs of `chars`, with a `cmap` mapping them and the face's `OS/2`.
fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option<Vec<u8>> {
/// `face` reduced to the glyphs of `chars` (every glyph, keeping their ids, when `all`), with a
/// `cmap` mapping them and the face's `OS/2`; a named instance instanced. Returns the font and
/// whether its outlines are CFF ones.
fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char], all: bool) -> Option<(Vec<u8>, bool)> {
let charmap = font.charmap();
let mut remapper = subsetter::GlyphRemapper::new();
if all {
for gid in 0..font.maxp().ok()?.num_glyphs() {
remapper.remap(gid);
}
}
let mut map: Vec<(u32, u16)> = chars
.iter()
.filter_map(|c| {
@@ -96,14 +113,20 @@ fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option
.collect();
map.sort_unstable();
map.dedup_by_key(|m| m.0);
let data = subsetter::subset(face.data(), face.index(), &remapper).ok()?;
let data = if face.variations().is_empty() {
subsetter::subset(face.data(), face.index(), &remapper).ok()?
} else {
let coords: Vec<(subsetter::Tag, f32)> = face.variations().iter().map(|(t, v)| (subsetter::Tag::new(t), *v)).collect();
subsetter::subset_with_variations(face.data(), face.index(), &coords, &remapper).ok()?
};
let sub = skrifa::FontRef::new(&data).ok()?;
let cff = [b"CFF ", b"CFF2"].iter().any(|t| sub.table_data(Tag::new(t)).is_some());
let mut t: Vec<(Tag, Cow<'_, [u8]>)> = tables(&sub).into_iter().filter(|(tag, _)| *tag != CMAP && *tag != OS2).collect();
t.push((CMAP, Cow::Owned(cmap(&map)?)));
if let Some(os2) = font.table_data(OS2) {
t.push((OS2, Cow::Owned(os2_for(os2.as_bytes(), &map))));
}
sfnt(sub.table_directory.sfnt_version(), t)
Some((sfnt(sub.table_directory.sfnt_version(), t)?, cff))
}
/// The face's `OS/2` table with its first and last character fitted to the subset's.
+206 -54
View File
@@ -7,11 +7,11 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::instance::{Location, LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
use skrifa::{GlyphId, MetadataProvider, Tag};
/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue).
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
@@ -28,6 +28,14 @@ static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
];
/// An axis setting of a variable font: the axis tag (`wght`) and its value in user units (700).
pub type Variation = ([u8; 4], f32);
/// Caps on what a (possibly damaged) variable font can make the database read: axes per font and
/// named instances listed per face.
const MAX_AXES: usize = 64;
const MAX_INSTANCES: usize = 512;
enum FontBytes {
Static(&'static [u8]),
Owned(Arc<Vec<u8>>),
@@ -60,6 +68,13 @@ pub struct FontFace {
pub(crate) cap_height: f64,
pub(crate) x_height: f64,
pub(crate) shaper: harfrust::ShaperData,
/// The axis settings of the variable font's named instance this face is (user units: `wght`
/// 700); empty for a static face or a variable font's default instance.
variations: Vec<Variation>,
/// [`Self::variations`] in the font's normalized design space (outlines and metrics).
location: Location,
/// [`Self::variations`] for the shaper (advances, kerning, feature variations).
pub(crate) instance: Option<harfrust::ShaperInstance>,
}
impl std::fmt::Debug for FontFace {
@@ -85,6 +100,17 @@ impl FontFace {
pub(crate) fn index(&self) -> u32 {
self.index
}
/// Where in a variable font's design space the face's outlines and metrics are taken (the
/// default location for a static face).
pub(crate) fn location(&self) -> LocationRef<'_> {
LocationRef::from(&self.location)
}
/// The axis settings of the variable font's named instance this face is (`wght` 700 for
/// Bold), in user units; empty for a static face and a variable font's default instance.
/// The face's file holds the whole variable font: exporters that embed it pass these on.
pub fn variations(&self) -> &[Variation] {
&self.variations
}
/// Unique id of this face within the process.
pub fn id(&self) -> u32 {
self.id
@@ -112,7 +138,7 @@ impl FontFace {
/// Advance width of glyph `gid` in font units.
pub fn advance(&self, gid: u32) -> f64 {
self.skrifa()
.and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid)))
.and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid)))
.map(|a| a as f64)
.unwrap_or(self.upem * 0.5)
}
@@ -147,8 +173,18 @@ impl FontFace {
#[derive(Debug, Default)]
struct CatalogFamily {
name: String,
/// (style, file) of each face.
faces: Vec<(String, PathBuf)>,
faces: Vec<CatalogFace>,
}
/// One style of an installed family: a face, or a named instance of a variable font.
#[derive(Debug)]
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
struct CatalogFace {
style: String,
path: PathBuf,
/// Weight and italic as the style lists them (by its name, or a named instance's axes).
weight: f32,
italic: bool,
}
/// The installed fonts by ASCII-lowercased family name (lookups ignore ASCII case, as
@@ -344,26 +380,103 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
Some((family, style))
}
/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`.
fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
/// One style a font file offers: one of its faces, or a named instance of a variable font
/// (Figtree's `Figtree[wght].ttf` holds Light, the default, and Regular to Black as instances).
#[derive(Clone, Debug)]
pub(crate) struct FaceStyle {
family: String,
style: String,
keys: FaceKeys,
/// A named instance's axis settings (user units); empty for the face itself.
variations: Vec<Variation>,
/// A named instance's weight (its `wght` setting); `None` for the face itself (its OS/2 table
/// says) or an instance of a font without a weight axis.
weight: Option<f32>,
/// A named instance is italic (by its `ital` or `slnt` setting, or its name).
italic: bool,
}
/// The styles of a face: the face itself, then its named instances when it is a variable font.
pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
let Some((family, style)) = face_names(f) else { return vec![] };
let mut keys = FaceKeys::of(f, &family, &style);
let instances = named_instances(f, &family, &style, &mut keys);
let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false };
std::iter::once(face).chain(instances).collect()
}
/// The named instances of a variable font face (its `fvar` table) as styles of the face's family,
/// named by their subfamily names, without the default instance (the face itself) and without
/// names already taken. The default instance's name, when it isn't the face's style name (a face
/// `Regular` whose default instance is `Book`), names the face too (`keys`). Damaged tables give
/// fewer instances or none; the counts read are capped.
fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mut FaceKeys) -> Vec<FaceStyle> {
let axes = f.axes();
if axes.is_empty() || axes.len() > MAX_AXES {
return vec![];
}
let tags: Vec<Tag> = axes.iter().map(|a| a.tag()).collect();
let defaults: Vec<f32> = axes.iter().map(|a| a.default_value()).collect();
// Instances without a PostScript name of their own are named `<prefix>-<style>` (Adobe
// Technical Note #5902), as PDF and `.ai` files name them.
let prefix = name(f, &[StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX]).unwrap_or_else(|| family.to_string());
let mut taken = vec![norm(style)];
let mut out = Vec::new();
for inst in f.named_instances().iter().take(MAX_INSTANCES) {
let coords: Vec<f32> = inst.user_coords().take(MAX_AXES).collect();
if coords.len() != tags.len() || coords.iter().any(|c| !c.is_finite()) {
continue;
}
let Some(style) = name(f, &[inst.subfamily_name_id()]) else { continue };
let ns = norm(&style);
if ns.is_empty() || taken.contains(&ns) {
continue;
}
// The default instance is the face itself.
if coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3) {
keys.styles.push(ns.clone());
keys.postscript.push(norm(&format!("{prefix}{style}")));
taken.push(ns);
continue;
}
taken.push(ns);
let axis = |tag: &[u8; 4]| tags.iter().zip(&coords).find(|(t, _)| t.to_be_bytes() == *tag).map(|(_, v)| *v);
let weight = axis(b"wght").map(|w| w.clamp(1.0, 1000.0));
let italic = style_italic(&style) || axis(b"ital").is_some_and(|v| v >= 0.5) || axis(b"slnt").is_some_and(|v| v != 0.0);
let mut styles: Vec<String> = std::iter::once(style.clone()).chain(all_names(f, inst.subfamily_name_id())).map(|s| norm(&s)).collect();
let mut postscript: Vec<String> = inst.postscript_name_id().map(|id| all_names(f, id)).unwrap_or_default().iter().map(|n| norm(n)).collect();
postscript.push(norm(&format!("{prefix}{style}")));
for v in [&mut styles, &mut postscript] {
v.retain(|k| !k.is_empty());
v.sort();
v.dedup();
}
let keys = FaceKeys { families: keys.families.clone(), styles, legacy: vec![], postscript };
let variations = tags.iter().zip(&coords).map(|(t, v)| (t.to_be_bytes(), *v)).collect();
out.push(FaceStyle { family: family.to_string(), style, keys, variations, weight, italic });
}
out
}
/// Parse every face in `data` (a font file or collection) and the named instances of its variable
/// ones. Returns each style with the index of its face.
fn enumerate_faces(data: &[u8]) -> Vec<(u32, FaceStyle)> {
let count = match FileRef::new(data) {
Ok(FileRef::Font(_)) => 1,
Ok(FileRef::Collection(c)) => c.len(),
Err(_) => 0,
};
(0..count)
.filter_map(|i| {
let (family, style) = face_names(&skrifa::FontRef::from_index(data, i).ok()?)?;
Some((i, family, style))
})
.filter_map(|i| Some((i, skrifa::FontRef::from_index(data, i).ok()?)))
.flat_map(|(i, f)| face_styles(&f).into_iter().map(move |s| (i, s)))
.collect()
}
/// (family, style, PostScript name) of every face in the font file at `path`, reading only its
/// table directories and `name` tables: a scan opens hundreds of font files, many of them
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
/// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them
/// megabytes long.
#[cfg(not(target_arch = "wasm32"))]
fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> {
fn file_face_names(path: &Path) -> Vec<FaceStyle> {
use std::io::{Read, Seek, SeekFrom};
/// Caps on what a (possibly damaged) file can make the scan read.
const MAX_FACES: u32 = 256;
@@ -390,29 +503,55 @@ fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> {
let dir = read_at(start.into(), 12)?;
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
let records = read_at(u64::from(start) + 12, tables * 16)?;
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?;
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
if len > MAX_NAME_TABLE {
return None;
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?;
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
if len > MAX_NAME_TABLE {
return None;
}
read_at(offset.into(), len as usize)
};
let name = table(b"name")?;
// A variable font's named instances are styles too.
let fvar = table(b"fvar");
// A font holding just these tables, to read them as the font itself would.
let mut tables: Vec<(&[u8; 4], &[u8])> = vec![(b"name", &name)];
if let Some(fvar) = &fvar {
tables.push((b"fvar", fvar));
}
let table = read_at(offset.into(), len as usize)?;
// A one-table font holding just the `name` table, to read it as the font itself would.
let mut font = Vec::with_capacity(28 + table.len());
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]);
font.extend_from_slice(b"name");
for v in [0, 28, len] {
font.extend_from_slice(&u32::to_be_bytes(v));
}
font.extend_from_slice(&table);
let f = skrifa::FontRef::new(&font).ok()?;
let (family, style) = face_names(&f)?;
let keys = FaceKeys::of(&f, &family, &style);
Some((family, style, keys))
let font = sfnt_of(&tables)?;
Some(face_styles(&skrifa::FontRef::new(&font).ok()?))
})
.flatten()
.collect()
}
/// A font file holding `tables` (tag, data), sorted by tag as table directories are.
#[cfg(any(test, not(target_arch = "wasm32")))]
pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
let mut tables = tables.to_vec();
tables.sort_by_key(|t| *t.0);
let n = u16::try_from(tables.len()).ok()?;
let mut font = Vec::new();
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
for v in [n, 0, 0, 0] {
font.extend_from_slice(&v.to_be_bytes());
}
let mut offset = 12 + 16 * tables.len();
for (tag, data) in &tables {
font.extend_from_slice(*tag);
for v in [0, u32::try_from(offset).ok()?, u32::try_from(data.len()).ok()?] {
font.extend_from_slice(&v.to_be_bytes());
}
offset += data.len().next_multiple_of(4);
}
for (_, data) in &tables {
font.extend_from_slice(data);
font.resize(font.len().next_multiple_of(4), 0);
}
Some(font)
}
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
pub fn system_font_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
@@ -448,23 +587,31 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
dirs
}
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option<std::path::PathBuf>) -> Option<FontFace> {
fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::path::PathBuf>) -> Option<FontFace> {
let data: &[u8] = match &bytes {
FontBytes::Static(b) => b,
FontBytes::Owned(v) => v.as_slice(),
};
let f = skrifa::FontRef::from_index(data, index).ok()?;
let m = f.metrics(Size::unscaled(), LocationRef::default());
let FaceStyle { family, style, keys, variations, weight, italic } = spec;
// A named instance: outlines, metrics and advances at its axis settings.
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
let m = f.metrics(Size::unscaled(), &location);
let a = f.attributes();
let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?);
let keys = FaceKeys::of(&f, &family, &style);
let hb = harfrust::FontRef::from_index(data, index).ok()?;
let shaper = harfrust::ShaperData::new(&hb);
let instance =
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
Some(FontFace {
keys,
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
weight: a.weight.value(),
italic: !matches!(a.style, skrifa::attribute::Style::Normal),
weight: weight.unwrap_or(a.weight.value()),
italic: italic || !matches!(a.style, skrifa::attribute::Style::Normal),
variations,
location,
instance,
upem: m.units_per_em.max(1) as f64,
ascent: m.ascent as f64,
descent: -(m.descent as f64),
@@ -523,8 +670,8 @@ impl FontDb {
// first: fallbacks for Japanese text after the requested and bundled fonts.
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
for (i, family, style) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) {
for (i, spec) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, spec, None) {
faces.push(Arc::new(f));
}
}
@@ -631,9 +778,9 @@ impl FontDb {
let mut v: Vec<(bool, f32, String)> =
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
for (style, _) in &c.faces {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) {
v.push((style_italic(style), style_weight(style), style.clone()));
for cf in &c.faces {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) {
v.push((cf.italic, cf.weight, cf.style.clone()));
}
}
}
@@ -652,11 +799,11 @@ impl FontDb {
fn add_font_from(&self, bytes: Vec<u8>, path: Option<&Path>) -> usize {
let data = Arc::new(bytes);
let mut added = 0;
for (i, family, style) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
for (i, spec) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) {
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(Path::to_path_buf)) {
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) {
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
added += 1;
}
@@ -722,7 +869,7 @@ impl FontDb {
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
for (family, style, keys) in file_face_names(&p) {
for FaceStyle { family, style, keys, weight, italic, .. } in file_face_names(&p) {
for (k, a) in Alias::of(&family, &style, &keys) {
aliases.entry(k).or_default().push(a);
}
@@ -734,7 +881,8 @@ impl FontDb {
if entry.name.is_empty() {
entry.name = family;
}
entry.faces.push((style, p.clone()));
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
n += 1;
}
}
@@ -824,7 +972,7 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool {
let mut paths: Vec<PathBuf> =
self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|(_, p)| p.clone()).collect()).unwrap_or_default();
self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|cf| cf.path.clone()).collect()).unwrap_or_default();
paths.sort();
paths.dedup();
let mut any = false;
@@ -867,7 +1015,7 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
fn is_cataloged(&self, family: &str, style: &str) -> bool {
let ns = norm(style);
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|(s, _)| norm(s) == ns))
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|cf| norm(&cf.style) == ns))
}
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
@@ -901,6 +1049,11 @@ impl FontDb {
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
return Some((*f).clone());
}
// Another of a face's style names: in another language, or a variable font's default
// instance's name.
if let Some(f) = cands.iter().find(|f| f.keys.styles.contains(&ns)) {
return Some((*f).clone());
}
let (tw, ti) = (style_weight(style), style_italic(style));
cands
.iter()
@@ -958,7 +1111,7 @@ impl FontDb {
return true;
}
}
let mut paths: Vec<PathBuf> = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())).collect();
let mut paths: Vec<PathBuf> = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|cf| cf.path.clone())).collect();
paths.sort();
paths.dedup();
for p in paths {
@@ -966,8 +1119,7 @@ impl FontDb {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit =
enumerate_faces(&data).iter().any(|(i, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
let hit = enumerate_faces(&data).iter().any(|(i, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
if hit && self.add_font_from(data, Some(&p)) > 0 && covered(self) {
return true;
}
@@ -986,7 +1138,7 @@ impl FontDb {
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
+4
View File
@@ -18,6 +18,8 @@ mod fontdb;
pub mod hyphen;
mod layout;
mod shape;
#[cfg(any(test, feature = "test-fonts"))]
pub mod test_fonts;
pub mod thread;
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
@@ -385,4 +387,6 @@ mod tests_sysfonts;
#[cfg(test)]
mod tests_typo;
#[cfg(test)]
mod tests_variable;
#[cfg(test)]
mod tests_vertical;
+3 -3
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
use skrifa::instance::Size;
use vectorcraft_doc::CharStyle;
use crate::features::OtFeatures;
@@ -247,7 +247,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).build();
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
@@ -271,7 +271,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
// Fallback: nominal glyphs and hmtx advances, no shaping.
if let Some(f) = face.skrifa() {
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default());
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
let chars: Vec<char> = if upper { c.to_uppercase().collect() } else { vec![c] };
+112
View File
@@ -0,0 +1,112 @@
//! Fonts built in code for tests, so no font file is committed: a small variable font (#296).
//! Compiled for this crate's tests and, with the `test-fonts` feature, for other crates' tests.
use skrifa::instance::{LocationRef, Size};
use skrifa::raw::TableProvider;
use skrifa::raw::tables::glyf::Glyph;
use skrifa::{GlyphId, MetadataProvider};
use write_fonts::FontBuilder;
use write_fonts::tables::fvar::{AxisInstanceArrays, Fvar, InstanceRecord, VariationAxisRecord};
use write_fonts::tables::gvar::{GlyphDelta, GlyphDeltas, GlyphVariations, Gvar, Tent};
use write_fonts::types::{F2Dot14, Fixed, NameId, Tag};
/// The family of [`variable_font`].
pub const VARIABLE_FAMILY: &str = "Varitest Sans";
/// The characters [`variable_font`] widens towards its heaviest weight (simple glyphs in Source Sans
/// 3; the composite ones, like `H`, would need their components varied).
pub const VARIABLE_CHARS: &str = "lIn";
/// How much wider [`VARIABLE_CHARS`] are at the heaviest weight (outlines and advances).
pub const VARIABLE_WIDEN: f32 = 0.25;
/// The bundled Source Sans 3 Regular as a variable font of family [`VARIABLE_FAMILY`]: one weight
/// axis (400–700, default 400, the face itself being Regular) whose maximum makes the glyphs of
/// [`VARIABLE_CHARS`] [`VARIABLE_WIDEN`] wider (outlines and advances, through `gvar`), with the
/// named instances Normal (the default, the face Regular), SemiBold (550) and Bold (700), plus an instance without
/// a name and one named like another (`BOLD`), which style lists leave out. `None` when it can't
/// be built.
pub fn variable_font() -> Option<Vec<u8>> {
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let (glyf, loca) = (font.glyf().ok()?, font.loca(None).ok()?);
let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default());
let charmap = font.charmap();
let widened: Vec<GlyphId> = VARIABLE_CHARS.chars().filter_map(|c| charmap.map(c)).collect();
let mut variations = Vec::new();
for g in 0..u32::from(font.maxp().ok()?.num_glyphs()) {
let gid = write_fonts::types::GlyphId::new(g);
let id = GlyphId::new(g);
let deltas = if widened.contains(&id) {
let Ok(Some(Glyph::Simple(s))) = loca.get_glyf(id, &glyf) else { return None };
let wider = |x: f32| (x * VARIABLE_WIDEN).round() as i16;
let mut d: Vec<GlyphDelta> = s.points().map(|p| GlyphDelta::required(wider(f32::from(p.x)), 0)).collect();
// The phantom points: left side bearing, advance, top, bottom.
let advance = wider(metrics.advance_width(id)?);
d.extend([(0, 0), (advance, 0), (0, 0), (0, 0)].map(|(x, y)| GlyphDelta::required(x, y)));
vec![GlyphDeltas::new(vec![Tent::new(F2Dot14::from_f32(1.0), None)], d)]
} else {
vec![]
};
variations.push(GlyphVariations::new(gid, deltas));
}
let gvar = Gvar::new(variations, 1).ok()?;
let axis = VariationAxisRecord {
axis_tag: Tag::new(b"wght"),
min_value: Fixed::from_i32(400),
default_value: Fixed::from_i32(400),
max_value: Fixed::from_i32(700),
flags: 0,
axis_name_id: NameId::new(256),
};
let instance = |name: u16, wght: i32| InstanceRecord {
subfamily_name_id: NameId::new(name),
flags: 0,
coordinates: vec![Fixed::from_i32(wght)],
post_script_name_id: None,
};
let instances = vec![instance(257, 400), instance(258, 550), instance(259, 700), instance(260, 600), instance(261, 650)];
let fvar = Fvar::new(AxisInstanceArrays::new(vec![axis], instances));
let name = name_table(&[
(1, VARIABLE_FAMILY),
(2, "Regular"),
(4, "Varitest Sans Regular"),
(6, "VaritestSans-Regular"),
(256, "Weight"),
(257, "Normal"),
(258, "SemiBold"),
(259, "Bold"),
(261, "BOLD"),
])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
builder.add_table(&fvar).ok()?.add_table(&gvar).ok()?;
// The rest of Source Sans 3's tables as they are.
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// A `name` table of Windows English (US) records (name id, string).
pub fn name_table(records: &[(u16, &str)]) -> Option<Vec<u8>> {
let mut records = records.to_vec();
records.sort_by_key(|r| r.0);
let n = u16::try_from(records.len()).ok()?;
let (mut head, mut strings) = (Vec::new(), Vec::new());
for v in [0, n, 6 + 12 * n] {
head.extend_from_slice(&v.to_be_bytes());
}
for (id, s) in records {
let bytes: Vec<u8> = s.encode_utf16().flat_map(u16::to_be_bytes).collect();
for v in [3, 1, 0x0409, id, u16::try_from(bytes.len()).ok()?, u16::try_from(strings.len()).ok()?] {
head.extend_from_slice(&v.to_be_bytes());
}
strings.extend(bytes);
}
head.extend(strings);
Some(head)
}
+248
View File
@@ -0,0 +1,248 @@
//! Variable fonts: each named instance is a style of its own, listed, resolved, shaped, drawn and
//! embedded at its axis settings (#296: Figtree listed only Light, its default instance).
use kurbo::{Point, Shape};
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
use skrifa::raw::TableProvider;
use vectorcraft_doc::CharStyle;
use super::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY as FAMILY, VARIABLE_WIDEN, name_table, variable_font};
use super::*;
fn font() -> Vec<u8> {
variable_font().expect("the test font builds")
}
/// A database of the bundled fonts and the test variable font.
fn db() -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(font()), 3, "Regular, SemiBold and Bold");
db
}
/// The width of `c`'s outline in font units.
fn ink_width(db: &FontDb, face: &FontFace, c: char) -> f64 {
db.outline(face, face.glyph_for(c)).bounding_box().width()
}
fn text(family: &str, style: &str, s: &str) -> TextObject {
TextObject::point(Point::ZERO, s, CharStyle { font_family: family.into(), font_style: style.into(), size: 100.0, ..CharStyle::default() })
}
#[test]
fn named_instances_are_styles_of_the_family() {
let db = db();
// The default instance is the face itself; the unnamed and the twice-named ones are left out.
assert_eq!(db.styles(FAMILY), ["Regular", "SemiBold", "Bold"]);
let bold = db.face(FAMILY, "Bold").unwrap();
assert_eq!((bold.family.as_str(), bold.style.as_str(), bold.weight, bold.italic), (FAMILY, "Bold", 700.0, false));
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
let regular = db.face(FAMILY, "Regular").unwrap();
assert!(regular.variations().is_empty() && regular.weight == 400.0);
assert_eq!(db.face(FAMILY, "SemiBold").unwrap().weight, 550.0);
// Fonts added again add nothing.
assert_eq!(db.add_font(font()), 0);
}
#[test]
fn an_instance_is_found_by_its_names_and_by_weight() {
let db = db();
let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.style.clone(), m)).unwrap();
assert_eq!(m(FAMILY, "Bold"), ("Bold".into(), FontMatch::Exact));
assert_eq!(m("varitest sans", "semi bold"), ("SemiBold".into(), FontMatch::Exact), "names are normalized");
// The default instance's own name names the face.
assert_eq!(m(FAMILY, "Normal"), ("Regular".into(), FontMatch::Exact));
assert!(!db.styles(FAMILY).contains(&"Normal".to_string()));
// A style the font hasn't: the closest weight.
assert_eq!(m(FAMILY, "Black"), ("Bold".into(), FontMatch::Style));
assert_eq!(m(FAMILY, "Medium"), ("SemiBold".into(), FontMatch::Style));
// PostScript names (as PDF and .ai files name fonts): `<family>-<style>` without spaces.
assert_eq!(m("VaritestSans-Bold", "Regular"), ("Bold".into(), FontMatch::Exact));
assert_eq!(db.find_postscript("VaritestSans-SemiBold").unwrap().style, "SemiBold");
}
#[test]
fn an_instance_draws_and_measures_its_own_glyphs() {
let db = db();
let (regular, semi, bold) = (db.face(FAMILY, "Regular").unwrap(), db.face(FAMILY, "SemiBold").unwrap(), db.face(FAMILY, "Bold").unwrap());
let widen = f64::from(VARIABLE_WIDEN);
for c in VARIABLE_CHARS.chars() {
let g = regular.glyph_for(c);
assert_eq!(bold.glyph_for(c), g);
// Advances (gvar phantom points) and outlines: a quarter wider at Bold, half that at SemiBold.
let (r, b, s) = (regular.advance(g), bold.advance(g), semi.advance(g));
assert!((b - r * (1.0 + widen)).abs() <= 1.0, "{c}: advance {r} -> {b}");
assert!((s - r * (1.0 + widen / 2.0)).abs() <= 1.0, "{c}: SemiBold advance {s}");
let (ri, bi) = (ink_width(&db, &regular, c), ink_width(&db, &bold, c));
assert!((bi - ri * (1.0 + widen)).abs() <= 2.0, "{c}: outline {ri} -> {bi}");
}
// Characters the axis doesn't change stay as they are.
let o = regular.glyph_for('o');
assert_eq!(regular.advance(o), bold.advance(o));
assert_eq!(db.outline(&regular, o).to_svg(), db.outline(&bold, o).to_svg());
// Laid out (shaped with the instance): the glyphs come from the Bold face, advance as it does
// and are drawn with its outlines (what the canvas, Create Outlines and hit tests use).
let (lr, lb) = (layout(&db, &text(FAMILY, "Regular", VARIABLE_CHARS)), layout(&db, &text(FAMILY, "Bold", VARIABLE_CHARS)));
assert!(lb.glyphs.iter().all(|g| g.font_id == bold.id()));
let width = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>();
let expected: f64 = VARIABLE_CHARS.chars().map(|c| bold.advance(bold.glyph_for(c))).sum::<f64>() * 100.0 / bold.units_per_em();
assert!((width(&lb) - expected).abs() < 0.5, "shaped {} vs {expected}", width(&lb));
assert!(width(&lb) > width(&lr) * (1.0 + widen * 0.9));
let ink = |l: &TextLayout| l.to_bezpath().bounding_box().width();
assert!(ink(&lb) > ink(&lr) * (1.0 + widen * 0.9), "{} vs {}", ink(&lb), ink(&lr));
}
#[test]
fn an_embedded_instance_is_a_static_font_of_that_instance() {
let db = db();
let bold = db.face(FAMILY, "Bold").unwrap();
let e = bold.embed(&['l', 'o']).unwrap();
assert!(e.subset && !e.cff);
let f = skrifa::FontRef::new(&e.data).unwrap();
assert!(f.fvar().is_err() && f.gvar().is_err(), "no variations left");
let g = f.charmap().map('l').unwrap();
let adv = f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(g).unwrap();
assert!((f64::from(adv) - bold.advance(bold.glyph_for('l'))).abs() <= 1.0, "the Bold advance: {adv}");
// The default face embeds as before.
let regular = db.face(FAMILY, "Regular").unwrap();
let data = regular.embed(&['l']).unwrap().data;
let r = skrifa::FontRef::new(&data).unwrap();
let rl = r.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(r.charmap().map('l').unwrap()).unwrap();
assert!((f64::from(rl) - regular.advance(regular.glyph_for('l'))).abs() <= 1.0 && rl < adv, "{rl} vs {adv}");
}
#[test]
fn installed_variable_fonts_list_their_instances_before_loading() {
let dir = std::env::temp_dir().join(format!("vc-varfonts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Varitest[wght].ttf"), font()).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
assert_eq!(db.styles(FAMILY), ["Regular", "SemiBold", "Bold"]);
assert!(!db.is_loaded(FAMILY), "listed from the name and fvar tables alone");
assert_eq!(db.by_postscript_name("VaritestSans-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
let (bold, m) = db.resolve(FAMILY, "Bold").unwrap();
assert_eq!((bold.style.as_str(), m), ("Bold", FontMatch::Exact));
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
assert!(bold.path().is_some());
}
/// Damaged or hostile `fvar` tables give fewer styles, never a panic, and the face still works.
#[test]
fn damaged_variation_tables_never_panic() {
let name = name_table(&[(1, "Broken Var"), (2, "Regular"), (256, "W")]).unwrap();
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
// Header: version 1.0, axes offset 16, reserved, axis count, axis size 20, instance count,
// instance size.
let header = |axes: u16, axis_size: u16, instances: u16, instance_size: u16| {
let mut h = be(&[0x0001_0000]);
for v in [16, 2, axes, axis_size, instances, instance_size] {
h.extend(v.to_be_bytes());
}
h
};
let axis = |tag: &[u8; 4], min: i32, def: i32, max: i32| {
let mut a = tag.to_vec();
a.extend(be(&[(min << 16) as u32, (def << 16) as u32, (max << 16) as u32]));
a.extend([0, 0, 1, 0]);
a
};
let mut damaged: Vec<Vec<u8>> = vec![
vec![],
vec![0; 3],
header(0xffff, 20, 0xffff, 8),
[header(1, 20, 0xffff, 8), axis(b"wght", 100, 400, 900)].concat(),
[header(1, 20, 3, 0xffff), axis(b"wght", 100, 400, 900)].concat(),
[header(1, 0, 3, 8), axis(b"wght", 100, 400, 900)].concat(),
[header(200, 20, 1, 804), vec![0; 4000]].concat(),
];
// Many instances (all named): the list is capped.
let mut many = [header(1, 20, 2000, 8), axis(b"wght", 0, 0, 30000)].concat();
for i in 0..2000u32 {
many.extend(be(&[256 << 16, (i + 1) << 16]));
}
damaged.push(many);
for fvar in &damaged {
let f = fontdb::sfnt_of(&[(b"name", &name), (b"fvar", fvar)]).unwrap();
let styles = fontdb::face_styles(&skrifa::FontRef::new(&f).unwrap());
assert!(!styles.is_empty() && styles.len() <= 2, "{} styles", styles.len());
}
// A real font with a damaged fvar (and its gvar) still loads and lays out.
let good = font();
let at = |tag: &[u8; 4]| {
let tables = u16::from_be_bytes([good[4], good[5]]) as usize;
let r = (0..tables).map(|i| 12 + 16 * i).find(|r| &good[*r..*r + 4] == tag).unwrap();
let off = u32::from_be_bytes(good[r + 8..r + 12].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(good[r + 12..r + 16].try_into().unwrap()) as usize;
off..off + len
};
let (fvar, gvar) = (at(b"fvar"), at(b"gvar"));
// Deterministic byte damage across both tables.
let mut seed = 0x2545_f491_u32;
for round in 0..300 {
let mut bad = good.clone();
for _ in 0..1 + round % 8 {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let range = if seed.is_multiple_of(2) { fvar.clone() } else { gvar.clone() };
let i = range.start + (seed as usize >> 3) % range.len();
bad[i] = (seed >> 24) as u8;
}
let db = FontDb::with_font_dirs(vec![]);
db.add_font(bad);
for style in db.styles(FAMILY) {
let l = layout(&db, &text(FAMILY, &style, "lHio"));
assert!(l.glyphs.len() <= 8, "{style}: {} glyphs", l.glyphs.len());
}
}
}
/// Windows ships variable system fonts: Bahnschrift (weight and width axes) lists its named
/// instances and draws them. Skipped where it isn't installed.
#[test]
fn bahnschrift_lists_and_draws_its_instances() {
let db = FontDb::with_font_dirs(system_font_dirs());
if !db.has_family("Bahnschrift") {
eprintln!("skipped: Bahnschrift is not installed");
return;
}
let styles = db.styles("Bahnschrift");
eprintln!("Bahnschrift: {styles:?}");
for s in ["Light", "Regular", "SemiBold", "Bold"] {
assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}");
}
let light = db.face("Bahnschrift", "Light").unwrap();
let bold = db.face("Bahnschrift", "Bold").unwrap();
assert_eq!((light.style.as_str(), bold.style.as_str()), ("Light", "Bold"));
assert!(bold.weight > light.weight, "{} vs {}", bold.weight, light.weight);
let (lw, bw) = (ink_width(&db, &light, 'l'), ink_width(&db, &bold, 'l'));
assert!(bw > lw * 1.05, "the Bold l is wider: {lw} -> {bw}");
let w = |s: &str| layout(&db, &text("Bahnschrift", s, "Hamburgefonstiv")).to_bezpath().bounding_box().width();
assert!(w("Bold") > w("Light"), "{} vs {}", w("Bold"), w("Light"));
}
/// Segoe UI Variable (Windows 11; optical size and weight axes). Skipped where it isn't installed.
#[test]
fn segoe_ui_variable_lists_its_instances() {
let db = FontDb::with_font_dirs(system_font_dirs());
let families: Vec<String> = db.families().into_iter().filter(|f| f.starts_with("Segoe UI Variable")).collect();
if families.is_empty() {
eprintln!("skipped: Segoe UI Variable is not installed");
return;
}
for f in &families {
let styles = db.styles(f);
eprintln!("{f}: {styles:?}");
assert!(styles.len() > 1, "{f}: {styles:?}");
}
if families.iter().any(|f| f == "Segoe UI Variable") {
let (light, bold) = (db.face("Segoe UI Variable", "Light Text").unwrap(), db.face("Segoe UI Variable", "Bold Text").unwrap());
assert_eq!((light.style.as_str(), bold.style.as_str()), ("Light Text", "Bold Text"));
assert!(bold.weight > light.weight && !bold.variations().is_empty());
let w = |f: &FontFace| ink_width(&db, f, 'm');
assert!(w(&bold) > w(&light), "{} vs {}", w(&bold), w(&light));
}
}
+1
View File
@@ -83,6 +83,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
"activeDocument": app.session.active_index(),
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
"graphicsAdapter": app.graphics_adapter,
// Saves and exports still being written in the background (Background Save / Export).
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
})
+5
View File
@@ -37,6 +37,11 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
))
.size(11.0),
);
// The GPU the window renders with, for bug reports (Preferences › Performance picks it).
if let Some(adapter) = &app.graphics_adapter {
ui.add_space(6.0);
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Graphics: {adapter}"), &[("adapter", adapter.as_str())])).size(11.0));
}
});
app.ui.about = open;
}
+15
View File
@@ -274,6 +274,21 @@ fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() {
assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width());
}
#[test]
fn menu_parameter_dialogs_are_compact() {
// #292: Object › Path › Simplify (and its neighbours) spanned the whole window.
for id in ["object.path.simplify", "object.path.offsetPath", "object.move", "object.rotate", "object.path.splitIntoGrid", "path.average"] {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
assert!(app.ui.dialog.is_some(), "{id} opened no dialog");
let wide = dialog_rect(&mut app, 1600.0);
eprintln!("{id}: {:.0} × {:.0} in a 1600-point window", wide.width(), wide.height());
assert!(wide.width() < 420.0, "{id} is {:.0} wide in a 1600-point window", wide.width());
}
}
/// A list mixing built-in labels with names translates only the built-in entries: names that
/// happen to be catalog keys ("Black", "Regular") are shown as they are.
#[test]
+336 -74
View File
@@ -1,109 +1,200 @@
//! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column
//! whose panels pop out to the left.
//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed
//! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column
//! then expands it again.
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2};
use serde_json::json;
use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, icons, panels, widgets};
const ICON_COL: f32 = 38.0;
/// Height of the strip along the top of a dock column that carries its double arrow.
const HEADER: f32 = 14.0;
/// The tabbed group's width until the dock has been laid out (its default size).
const DOCK_WIDTH: f32 = 300.0;
/// Top of a popped-out panel.
const FLYOUT_TOP: f32 = 110.0;
/// Where the icon column's left edge was drawn last (egui temp memory, one frame old).
fn column_left_id() -> egui::Id {
egui::Id::new("dock-icon-column-left")
}
/// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while
/// one of its panels is popped out shows that panel's tab instead.
pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
app.ui.dock_collapsed = collapsed;
app.ui.dock = true;
if !collapsed && let Some(tab) = app.ui.open_panel.as_deref().and_then(DockTab::from_id) {
app.ui.dock_tab = tab;
app.ui.open_panel = None;
}
}
/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons,
/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked.
fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
let arrow = Rect::from_min_size(hdr.right_top() + vec2(-16.0, 1.0), vec2(12.0, 12.0));
if resp.hovered() {
ui.painter().rect_filled(arrow, CornerRadius::same(2), t.hover);
}
let icon = if collapsed { "chevrons-left" } else { "chevrons-right" };
icons::paint(ui, icon, arrow.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text });
resp.on_hover_text(if collapsed { tl!("Expand Panels") } else { tl!("Collapse to Icons") }).clicked()
}
/// Run `window.collapseDock` for a click on a double arrow.
fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
// It only fails on bad params, which this never sends; show it all the same.
if let Err(e) = app.run("window.collapseDock", json!({ "collapsed": collapsed })) {
app.ui.status = e;
}
}
/// One icon of the column: a click pops its panel out (or puts it away).
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, label: &str, icon: &str) {
let open = app.ui.open_panel.as_deref() == Some(id);
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Main tabbed group.
egui::Panel::right("dock")
.resizable(true)
.default_size(300.0)
.size_range(230.0..=520.0)
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
// Tab strip.
let (hdr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
icons::paint(ui, "chevrons-right", egui::Rect::from_min_size(hdr.right_top() + vec2(-14.0, 2.0), vec2(10.0, 10.0)), t.text);
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let mut x = strip.left();
for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] {
let active = app.ui.dock_tab == tab;
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
if active {
ui.painter().rect_filled(r, 0.0, t.panel);
// Main tabbed group, unless it is collapsed to icons.
if !app.ui.dock_collapsed {
egui::Panel::right("dock")
.resizable(true)
.default_size(DOCK_WIDTH)
.size_range(230.0..=520.0)
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
if collapse_header(ui, false) {
toggle(app, true);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if resp.clicked() {
app.ui.dock_tab = tab;
// Tab strip.
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let mut x = strip.left();
for tab in DockTab::ALL {
let (_, label, _) = tab.info();
let active = app.ui.dock_tab == tab;
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
if active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if resp.clicked() {
app.ui.dock_tab = tab;
}
x = r.right();
}
x = r.right();
}
let menu_id = match app.ui.dock_tab {
DockTab::Properties => "properties",
DockTab::Layers => "layers",
DockTab::Libraries => "libraries",
};
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
DockTab::Properties => {
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
}
DockTab::Layers => panels::layers::show(app, ui),
DockTab::Libraries => panels::libraries(app, ui),
let menu_id = app.ui.dock_tab.info().0;
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
DockTab::Properties => {
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
}
DockTab::Layers => panels::layers::show(app, ui),
DockTab::Libraries => panels::libraries(app, ui),
});
});
});
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock).
egui::Panel::right("icon_column")
}
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A
// collapsed group's panels head it, under the « that expands them again.
let collapsed = app.ui.dock_collapsed;
let column = egui::Panel::right("icon_column")
.resizable(false)
.exact_size(ICON_COL)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(4, 6)).stroke(Stroke::new(1.5, t.border)))
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
// The icons scroll in a window too short for them.
widgets::strip_scroll(ui, "icon_column", |ui| {
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
if gi > 0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
}
for id in group.iter() {
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
let open = app.ui.open_panel.as_deref() == Some(*id);
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
if collapsed && collapse_header(ui, true) {
toggle(app, false);
}
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
// The icons scroll in a window too short for them.
widgets::strip_scroll(ui, "icon_column", |ui| {
if collapsed {
for tab in DockTab::ALL {
let (id, label, icon) = tab.info();
panel_icon(app, ui, id, label, icon);
}
}
}
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
if gi > 0 || collapsed {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter()
.line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
}
for id in group.iter() {
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
panel_icon(app, ui, id, label, icon);
}
}
});
});
});
let left = column.response.rect.left();
ui.ctx().data_mut(|d| d.insert_temp(column_left_id(), left));
}
/// An icon panel popped out next to the icon column.
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
/// while the dock is collapsed.
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(id) = app.ui.open_panel.clone() else { return };
let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return };
let tab = DockTab::from_id(&id);
let label = match tab {
Some(tab) if app.ui.dock_collapsed => tab.info().1,
// Expanded, the group shows its panels in the dock itself.
Some(_) => return,
None => match ICON_PANELS.iter().find(|p| p.0 == id) {
Some((_, label, _)) => *label,
None => return,
},
};
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
// Pinned by its right edge, 6 points left of the icon column: a panel wider than its 256
// points grows towards the canvas rather than over the panel icons.
let right = screen.right() - ICON_COL - 300.0 - 6.0;
// Pinned by its right edge, 6 points left of the icon column wherever the dock's width (or its
// collapsed state) put it: content wider than the flyout grows it towards the canvas rather
// than over the panel icons. Without a dock on screen it keeps to the window's right edge.
let column = if app.ui.dock && app.ui.screen_mode < 3 {
ctx.data(|d| d.get_temp::<f32>(column_left_id()))
.filter(|x| x.is_finite() && *x > screen.left() && *x <= screen.right())
.unwrap_or_else(|| screen.right() - ICON_COL - if app.ui.dock_collapsed { 0.0 } else { DOCK_WIDTH })
} else {
screen.right()
};
let right = column - 6.0;
// The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one
// that keeps the flyout on screen.
// The tabbed group's panels keep the width the dock gives them; icon panels are 256 points.
let width = if tab.is_some() { DOCK_WIDTH } else { 256.0 };
let tall = (screen.height() - FLYOUT_TOP - 26.0 - 20.0 - 40.0).clamp(160.0, 560.0);
let mut open = true;
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
area.fixed_pos(egui::pos2(right, 110.0)).show(ctx, |ui| {
area.fixed_pos(egui::pos2(right, FLYOUT_TOP)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
ui.set_width(256.0);
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
ui.set_width(width);
let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
let tab = egui::Rect::from_min_size(
let title = egui::Rect::from_min_size(
strip.min,
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
);
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
@@ -113,8 +204,26 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
panels::panel_menu(app, ui, &id, menu_r);
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(236.0);
panels::show_icon_panel(app, ui, &id);
ui.set_width(width - 20.0);
match tab {
Some(DockTab::Properties) => {
// At least `tall` when the sections are taller: the flyout's area keeps the
// size its content needs, and egui sizes a new area at most 400 points high,
// so a scroll area that only took the room it was given never grew past it.
egui::ScrollArea::vertical()
.id_salt("props-flyout")
.max_height(tall)
.min_scrolled_height(tall)
.auto_shrink([false, true])
.show(ui, |ui| panels::properties::show(app, ui));
}
Some(DockTab::Layers) => {
ui.set_max_height(tall);
panels::layers::show(app, ui);
}
Some(DockTab::Libraries) => panels::libraries(app, ui),
None => panels::show_icon_panel(app, ui, &id),
}
});
});
});
@@ -179,4 +288,157 @@ mod tests {
}
assert!(icons.last().unwrap().bottom() <= 400.0, "scrolled into view: {:?}", icons.last());
}
const SCREEN: egui::Vec2 = vec2(1600.0, 1000.0);
/// A frame of the dock and its flyout on a 1600 × 1000 window.
struct Harness {
app: VectorcraftApp,
ctx: egui::Context,
time: f64,
}
impl Harness {
fn new() -> Self {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut h = Self { app, ctx, time: 0.0 };
h.settle();
h
}
fn frame(&mut self, events: Vec<egui::Event>) {
self.time += 0.05;
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN);
let input = egui::RawInput { time: Some(self.time), events, screen_rect: Some(screen), ..Default::default() };
let app = &mut self.app;
self.ctx
.run_ui(input, |ui| {
show(app, ui);
floating_panel(app, ui.ctx());
})
.textures_delta
.clear();
}
fn settle(&mut self) {
for _ in 0..3 {
self.frame(vec![]);
}
}
fn click(&mut self, at: egui::Pos2) {
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
self.frame(vec![egui::Event::PointerMoved(at), button(true)]);
self.frame(vec![button(false)]);
self.settle();
}
/// The icon column's left edge as last drawn.
fn column_left(&self) -> f32 {
self.ctx.data(|d| d.get_temp::<f32>(column_left_id())).unwrap()
}
/// The panel icons of the column, top to bottom.
fn icons(&self) -> Vec<Rect> {
widget_rects(&self.ctx, vec2(30.0, 30.0))
}
/// The double arrow at the top right of the window (the dock's, or the collapsed column's).
fn arrow(&self) -> egui::Pos2 {
egui::pos2(SCREEN.x - 10.0, HEADER / 2.0)
}
}
fn icon_panel_count() -> usize {
ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum()
}
#[test]
fn the_double_arrow_collapses_the_dock_to_icons_and_back() {
let mut h = Harness::new();
assert!(!h.app.ui.dock_collapsed);
// Expanded, the group (at least its 230-point minimum) is right of the column.
let expanded = |left: f32| left <= SCREEN.x - ICON_COL - 230.0;
assert!(expanded(h.column_left()), "expanded: the group is right of the column: {}", h.column_left());
assert_eq!(h.icons().len(), icon_panel_count());
h.click(h.arrow());
assert!(h.app.ui.dock_collapsed, "the » collapses the dock");
assert!(h.column_left() > SCREEN.x - ICON_COL - 2.0, "collapsed: only the icon column is left: {}", h.column_left());
let icons = h.icons();
assert_eq!(icons.len(), icon_panel_count() + 3, "Properties, Layers and Libraries became icons");
assert!(icons[0].top() > HEADER, "the icons sit under the « strip");
h.click(h.arrow());
assert!(!h.app.ui.dock_collapsed, "the « expands the dock again");
assert!(expanded(h.column_left()), "the group is back: {}", h.column_left());
assert_eq!(h.icons().len(), icon_panel_count());
}
#[test]
fn a_collapsed_panel_pops_out_left_of_the_column() {
let mut h = Harness::new();
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
h.settle();
// Properties, Layers, Libraries head the column.
let layers = h.icons()[1];
h.click(layers.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
h.settle();
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
let column = h.column_left();
assert!(rect.right() <= column, "the flyout {rect:?} stays left of the column at {column}");
assert!(rect.left() >= 0.0 && rect.bottom() <= SCREEN.y, "the flyout {rect:?} is on screen");
assert!(rect.height() > 200.0, "Layers gets room for its list: {rect:?}");
// Its » puts it away.
h.click(egui::pos2(rect.right() - 13.0, rect.top() + 13.0));
assert_eq!(h.app.ui.open_panel, None, "the flyout's » closes it");
// Expanding with a panel popped out shows its tab.
h.click(layers.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
h.click(h.arrow());
assert!(!h.app.ui.dock_collapsed);
assert_eq!((h.app.ui.open_panel.as_deref(), h.app.ui.dock_tab), (None, DockTab::Layers));
// Properties pops out as wide as the dock and shows its sections, not a sliver, also as
// the first flyout of a session (a fresh harness: the flyouts share one area, which
// would otherwise keep the size the Layers flyout gave it).
let mut h = Harness::new();
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
h.app.run("window.panel", json!({"panel": "properties"})).unwrap();
h.settle();
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
assert!(rect.width() >= DOCK_WIDTH && rect.right() <= h.column_left(), "Properties flyout {rect:?}");
// Taller than the 400 points egui sizes a new area at: its sections (Align, Quick
// Actions) aren't cut off below Appearance.
assert!(rect.height() > 450.0 && rect.bottom() <= SCREEN.y, "Properties flyout {rect:?}");
}
#[test]
fn the_collapse_command_sets_or_toggles_the_dock() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
assert_eq!(app.run("window.collapseDock", json!({})).unwrap(), json!(true));
assert!(app.ui.dock_collapsed && crate::menus::checked(&app, "window.collapseDock", &json!({})) == Some(true));
assert_eq!(app.run("window.collapseDock", json!({"collapsed": true})).unwrap(), json!(true));
assert!(app.run("window.collapseDock", json!({"collapsed": "yes"})).is_err());
// Window › Layers pops Layers out of its icon while collapsed.
app.run("window.panel", json!({"panel": "layers"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true));
assert_eq!(app.run("window.collapseDock", json!({})).unwrap(), json!(false));
assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_tab), (None, DockTab::Layers));
app.run("window.panel", json!({"panel": "properties"})).unwrap();
assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_tab), (None, DockTab::Properties));
// Saved with the preferences and with a user workspace; the built-in ones expand it.
app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
assert!(saved.sanitized().dock_collapsed);
app.run("window.workspace.new", json!({"name": "Icons"})).unwrap();
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
assert!(!app.ui.dock_collapsed, "Essentials shows the panels");
app.run("window.workspace", json!({"name": "Icons"})).unwrap();
assert!(app.ui.dock_collapsed, "the user workspace keeps them collapsed");
}
}
+3
View File
@@ -569,3 +569,6 @@
Make Compound Path Vytvořit složenou cestu
Release Compound Path Uvolnit složenou cestu
Select All Vybrat vše
Collapse Panels to Icons Sbalit panely na ikony
Collapse to Icons Sbalit na ikony
Expand Panels Rozbalit panely
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -569,3 +569,6 @@
Make Compound Path 複合パスを作成
Release Compound Path 複合パスを解除
Select All すべてを選択
Collapse Panels to Icons パネルをアイコンに折りたたむ
Collapse to Icons アイコンに折りたたむ
Expand Panels パネルを展開
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -148,6 +148,7 @@
Appearance Panel 外觀面板
Appearance panel 外觀面板
Applies when the linked file is relinked or updated. 重新連結或更新連結檔案時套用。
Applies the next time VectorCraft starts. 下次啟動 VectorCraft 時生效。
Apply Brush 套用筆刷
Apply Character Style 套用字元樣式
Apply Effect 套用效果
@@ -361,6 +362,8 @@
CMYK CMYK
CMYK Color CMYK 色彩
CMYK colours print with their own values; off, they go through the colour settings too. CMYK 顏色以其本身的數值列印;關閉時,也會經過色彩設定處理。
Collapse Panels to Icons 將面板收合為圖示
Collapse to Icons 收合為圖示
Collate 逐份列印
Collect for Export 收集以供匯出
Collect in New Layer 收集至新圖層
@@ -815,6 +818,7 @@
Expand Gradient Mesh 展開漸層網格
Expand Gradient To 展開漸層為
Expand Options 展開選項
Expand Panels 展開面板
Expand Repeat 展開重複
Expand Shape 展開形狀
Expand: 展開:
@@ -1017,6 +1021,8 @@
Graphic Styles 繪圖樣式
Graphic Styles Options 繪圖樣式選項
Graphics 圖形
Graphics Processor 圖形處理器
Graphics: {adapter} 圖形:{adapter}
Gravity 重力
Grays 灰色
Grays and Neutrals 灰色與中性色
@@ -1073,6 +1079,7 @@
Hide Transparency Grid 隱藏透明度格點
High 高
High (300 ppi) 高 (300 ppi)
High Performance (discrete) 高效能 (獨立顯示卡)
Highlight Alternate Glyphs 醒目提示替代字符
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
Highlight Substituted Fonts 醒目提示被取代的字體
@@ -1775,6 +1782,7 @@
Polygon Properties: 多邊形內容:
Polygon Tool 多邊形工具
Portrait 直向
Power Saving (integrated) 節能 (內建顯示晶片)
Portuguese 葡萄牙文
Position 位置
Postcard 明信片
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -293,6 +293,9 @@ pub struct VectorcraftApp {
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
pub custom_titlebar: bool,
/// The graphics adapter the window renders with ("name (backend)"), as the host reports it:
/// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown.
pub graphics_adapter: Option<String>,
/// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details.
pub place: place::PlaceState,
/// The Paste commands can paste from the system clipboard alone: it holds something to paste
@@ -367,6 +370,7 @@ impl VectorcraftApp {
canvas_rect: None,
hover_doc: None,
custom_titlebar: false,
graphics_adapter: None,
place: Default::default(),
system_paste: false,
system_paste_at: f64::NEG_INFINITY,
+161 -20
View File
@@ -172,6 +172,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.taskBar", "Contextual Task Bar", "", "{}"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
(
"window.collapseDock",
"Collapse Panels to Icons",
"",
"{collapsed?: bool} collapse the dock's Properties | Layers | Libraries group to icons (true), expand it (false) or toggle (omitted), as the double arrow at the top of the dock does; returns the new state",
),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"),
("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"),
@@ -895,27 +901,35 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
app.ui.control_bar = on;
Ok(json!(on))
}
"window.collapseDock" => {
let collapsed = match p.get("collapsed") {
None | Some(Value::Null) => !app.ui.dock_collapsed,
Some(Value::Bool(b)) => *b,
Some(_) => return Some(Err("collapsed must be true or false".into())),
};
crate::dock::set_collapsed(app, collapsed);
Ok(json!(collapsed))
}
"window.panel" => {
let panel = s("panel").unwrap_or_default();
match panel.as_str() {
"properties" => {
app.ui.dock_tab = DockTab::Properties;
Ok(Value::Null)
}
"layers" => {
app.ui.dock_tab = DockTab::Layers;
Ok(Value::Null)
}
"libraries" => {
app.ui.dock_tab = DockTab::Libraries;
Ok(Value::Null)
}
p if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
match (panel.as_str(), DockTab::from_id(&panel)) {
// A collapsed dock pops the panel out of its icon, like the icon panels.
(p, Some(tab)) if app.ui.dock_collapsed => {
app.ui.dock_tab = tab;
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
app.ui.dock = true;
Ok(json!({"open": app.ui.open_panel}))
}
other => Err(format!("unknown panel `{other}`")),
(_, Some(tab)) => {
app.ui.dock_tab = tab;
Ok(Value::Null)
}
(p, None) if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
app.ui.dock = true;
Ok(json!({"open": app.ui.open_panel}))
}
(other, None) => Err(format!("unknown panel `{other}`")),
}
}
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
@@ -1195,13 +1209,12 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"window.taskBar" => app.ui.task_bar,
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
match panel {
"properties" => app.ui.dock_tab == DockTab::Properties,
"layers" => app.ui.dock_tab == DockTab::Layers,
"libraries" => app.ui.dock_tab == DockTab::Libraries,
match DockTab::from_id(panel) {
Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab,
_ => app.ui.open_panel.as_deref() == Some(panel),
}
}
"window.collapseDock" => app.ui.dock_collapsed,
"window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()),
"window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness),
"view.slices.lock" => app.session.slices_locked(),
@@ -2343,14 +2356,16 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
let tree = menu_tree();
let end = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 {
egui::RichText::new(tl!(title)).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(tl!(title)).size(13.0).color(t.text)
};
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
}
switch_on_hover(ui.ctx(), &titles);
ui.cursor().min.x
})
.inner;
@@ -2360,6 +2375,39 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
end
}
/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no
/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must
/// not switch to the title under it).
fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool {
title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id)
}
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
/// title opens that menu instead (egui alone needs a click on each title).
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) {
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
return;
};
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
let Some(p) = ctx.pointer_hover_pos() else {
return;
};
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) else {
return;
};
if i != open
&& let Some(id) = ids.get(i)
{
egui::Popup::open_id(ctx, *id);
ctx.request_repaint();
}
}
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
/// it scrolls when it is taller than the window.
fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
@@ -2954,6 +3002,99 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
mod tests {
use super::*;
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
/// (rect, id of the title's popup).
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(egui::Rect, egui::Id)> {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
let mut layer = None;
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
layer = Some(ui.layer_id());
menu_bar(app, ui);
});
out.textures_delta.clear();
let layer = layer.unwrap();
let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(layer)
.filter(|w| w.sense.senses_click() && w.rect.top() < 30.0)
// `egui::Popup::default_response_id` of the title's response.
.map(|w| (w.rect, w.id.with("popup")))
.collect()
});
titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left()));
titles
}
fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec<usize> {
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
}
#[test]
fn hovering_another_title_switches_the_open_menu() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
bar_frame(&mut app, &ctx, vec![]);
let titles = bar_frame(&mut app, &ctx, vec![]);
assert_eq!(titles.len(), menu_tree().len());
// 0 is the app menu, then File, Edit, Object.
let (file, edit, object) = (titles[1].0.center(), titles[2].0.center(), titles[3].0.center());
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut t = bar_frame(app, &ctx, events);
for _ in 0..2 {
t = bar_frame(app, &ctx, vec![]);
}
t
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
// Nothing open: hovering a title opens nothing.
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert!(open_titles(&ctx, &t).is_empty());
// Click File, then move onto Edit and on to Object: each opens in turn, alone.
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
frames(&mut app, vec![button(file, true)]);
let t = frames(&mut app, vec![button(file, false)]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a click opens File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Edit opens it and closes File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
assert_eq!(open_titles(&ctx, &t), vec![3], "hovering Object opens it and closes Edit");
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
// stays open while the pointer stays still over Object.
egui::Popup::open_id(&ctx, t[1].1);
let t = frames(&mut app, vec![]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a still pointer leaves the open menu alone");
}
#[test]
fn a_popup_covering_a_title_does_not_switch_menus() {
let ctx = egui::Context::default();
let p = egui::pos2(90.0, 12.0);
let mut reaches = (true, false);
// The popup becomes hit-testable once egui has laid it out: draw a few frames.
for _ in 0..3 {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
let ctx = ui.ctx().clone();
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::new("covered"), egui::Sense::click());
let q = p + egui::vec2(0.0, 100.0);
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::new("free"), egui::Sense::click());
egui::Area::new(egui::Id::new("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
ui.allocate_space(egui::vec2(20.0, 20.0));
});
reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q));
});
out.textures_delta.clear();
}
assert_eq!(reaches, (false, true));
}
#[test]
fn names_in_menus_are_not_interface_labels() {
for (id, label) in [
+36
View File
@@ -279,6 +279,10 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
d.fields.insert(sp.key.into(), json!(opts[i].0));
}
});
if sp.key == "gpuPreference" {
// The window's graphics device is chosen once, when the app starts.
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
}
}
PrefKind::Color => {
labeled(ui, sp.label, |ui| {
@@ -393,6 +397,38 @@ mod tests {
assert_eq!(a.session.prefs.ui_brightness, "light");
}
/// Performance › Graphics Processor (#306): shown with its restart note, applied by OK and
/// saved with the UI state under the key the desktop app reads before the window opens.
#[test]
fn graphics_processor_preference_shows_and_persists() {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let mut a = app();
open(&mut a, Some("Performance"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// The window sizes itself on the first frame and draws on the second.
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
assert!(shown.iter().any(|t| t.starts_with("Graphics Processor")), "{shown:?}");
assert!(shown.iter().any(|t| t == "Power Saving (integrated)"), "{shown:?}");
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
a.ui.dialog.as_mut().unwrap().fields.insert("gpuPreference".into(), json!("highPerformance"));
confirm(&mut a).unwrap();
assert_eq!(a.session.prefs.gpu_preference, "highPerformance");
let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap();
assert_eq!(saved["engine_prefs"]["gpuPreference"], json!("highPerformance"));
}
#[test]
fn every_category_renders_fields() {
for c in PREF_CATEGORIES {
+25 -1
View File
@@ -76,6 +76,24 @@ pub enum DockTab {
Libraries,
}
impl DockTab {
/// The tab's panel id (`window.panel`), English label and icon (when the dock is collapsed).
pub fn info(self) -> (&'static str, &'static str, &'static str) {
match self {
DockTab::Properties => ("properties", "Properties", "dc-options"),
DockTab::Layers => ("layers", "Layers", "layers"),
DockTab::Libraries => ("libraries", "Libraries", "library"),
}
}
pub const ALL: [DockTab; 3] = [DockTab::Properties, DockTab::Layers, DockTab::Libraries];
/// The tab whose panel id is `id`.
pub fn from_id(id: &str) -> Option<DockTab> {
DockTab::ALL.into_iter().find(|t| t.info().0 == id)
}
}
/// Panels that live as collapsed icons in the dock (Essentials Classic).
pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("color", "Color", "palette"),
@@ -219,7 +237,12 @@ pub struct UiState {
pub legacy_language: Option<String>,
pub brightness: Brightness,
pub dock_tab: DockTab,
/// Icon panel currently popped out of the collapsed column.
/// The dock's tabbed group (Properties | Layers | Libraries) is collapsed to icons at the top of
/// the icon column (the dock's double arrow, `window.collapseDock`).
#[serde(default)]
pub dock_collapsed: bool,
/// Icon panel currently popped out of the collapsed column (also `properties`, `layers` or
/// `libraries` while the dock is collapsed).
pub open_panel: Option<String>,
pub control_bar: bool,
pub toolbar: bool,
@@ -358,6 +381,7 @@ impl Default for UiState {
legacy_language: None,
brightness: Brightness::MediumDark,
dock_tab: DockTab::Properties,
dock_collapsed: false,
open_panel: None,
control_bar: false,
toolbar: true,
+4
View File
@@ -23,6 +23,8 @@ pub struct Workspace {
pub task_bar: bool,
pub dock: bool,
pub dock_tab: DockTab,
/// The dock's tabbed group is collapsed to icons (built-in workspaces: expanded).
pub dock_collapsed: bool,
pub open_panel: Option<String>,
pub status_bar: bool,
}
@@ -115,6 +117,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
task_bar: ui.task_bar,
dock: ui.dock,
dock_tab: ui.dock_tab,
dock_collapsed: ui.dock_collapsed,
open_panel: ui.open_panel.clone(),
status_bar: ui.status_bar,
}
@@ -130,6 +133,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.task_bar = w.task_bar;
ui.dock = w.dock;
ui.dock_tab = w.dock_tab;
ui.dock_collapsed = w.dock_collapsed;
ui.open_panel = w.open_panel.clone();
ui.status_bar = w.status_bar;
ui.flyout = None;
+7
View File
@@ -180,6 +180,13 @@ a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Col
Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run
`window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items.
Collapsing the dock: `window.collapseDock {collapsed?}` (the » at the top of the dock; omitted toggles) hides the
Properties | Layers | Libraries group and puts its three panels as icons at the top of the icon column, under a «
that expands them again. While collapsed, those icons and `window.panel {panel: "properties"|"layers"|"libraries"}`
pop the panel out next to the column like the other icon panels (`ui.dock_collapsed`, `ui.open_panel` in
`ui.inspect`); expanding with one popped out shows its tab. The state is saved with the preferences and in user
workspaces; the built-in workspaces expand the dock.
Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection
(fields `preset`: a preset name, setting it loads that preset's options; the option keys of
`object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,
+8
View File
@@ -24,6 +24,14 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`.
- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).
## Desktop graphics processor
The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI. The desktop app (`apps/vectorcraft/src/main.rs`) therefore asks wgpu for the **power-saving** adapter by default, which is the integrated GPU on hybrid-graphics laptops. Presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some laptops (#306). A machine with one GPU gets that GPU either way.
- **Preferences › Performance › Graphics Processor** (`gpuPreference`: `powerSaving` or `highPerformance`) picks the other one. The adapter is chosen when the window opens, so a change applies after a restart.
- The `WGPU_POWER_PREF` environment variable (`low`, `high` or `none`) overrides the preference.
- Help › About and the control channel's `ui.inspect` (`graphicsAdapter`) show the adapter in use, and the app logs it at startup.
## Fonts: craft-fonts (optional build input)
Font files are never committed to this repository. Fonts shared by the Crafting Apps live in