Properties: Fill and Stroke; Shift+F7 opens Align; Alt+wheel zooms

Discord feedback.

- Properties panel: the Appearance section's Fill and Stroke rows now use the
  Control bar's widgets: the chips with their chevron and swatch/mixer
  popover, the underlined Stroke link that opens the Stroke panel as a
  popover (stroke::link, shared with the Control bar) and the weight spinner
  with its presets (stroke::weight_field, which also follows the Type tool's
  selected characters). With nothing selected the Document view shows them
  too, as the Control bar does: they set up the next object drawn.
- Window menu panel keys as in Illustrator: Brushes F5, Layers F7, Align
  Shift+F7, Transform Shift+F8, Info Cmd+F8, Pathfinder Cmd+Shift+F9,
  Symbols Cmd+Shift+F11, Character Cmd+T, Paragraph Cmd+Alt+T, Tabs
  Cmd+Shift+T, OpenType Cmd+Alt+Shift+T (beside the existing Color, Color
  Guide, Appearance, Graphic Styles, Stroke, Gradient, Transparency and
  Attributes keys). The dock's tabs (Properties, Layers, Libraries) join the
  panel shortcut table and the Keyboard Shortcuts editor through
  state::all_panels, which window.panel's name lookup and the catalog's
  label list use too.
- Alt-wheel (Option on the Mac) zooms about the pointer whatever General >
  Zoom with Mouse Wheel says: egui spreads a notch over a few frames, and the
  rest of an Alt turn scrolled once Alt was up (also over the control
  channel's ui.wheel); the turn's own modifiers now decide. Pinch zoom is
  covered by the test too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 16:46:34 +07:00
1 parent a9b28cc22a
commit c5b5dbb6ad
12 files changed
+221 -60

No files matched your search

+3 -3
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -105,7 +105,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- 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, Remove Anchor Points when anchors are selected, 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.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 38 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…).
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 38 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
@@ -139,7 +139,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **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 (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
+47 -18
View File
@@ -445,8 +445,14 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
let alt_id = egui::Id::new("canvas-alt-wheel");
let alt_before = ui.data(|d| d.get_temp(alt_id)).unwrap_or(false);
let mut alt_turn = alt_before;
let (pointer, m, space, (factor, scroll)) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height())));
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut alt_turn)));
if alt_turn != alt_before {
ui.data_mut(|d| d.insert_temp(alt_id, alt_turn));
}
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
@@ -627,14 +633,24 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
const WHEEL_ZOOM: f64 = 0.01;
/// What the wheel and a pinch did over the canvas this frame: a zoom factor (about the pointer)
/// and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel zoom.
/// With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel zooms, Shift-wheel scrolls up
/// and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points per wheel line and page.
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32) -> (f64, egui::Vec2) {
/// and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
/// per wheel line and page. `alt_turn`: the last wheel turn was an Alt-wheel one (kept by the
/// caller across frames).
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn: &mut bool) -> (f64, egui::Vec2) {
if !wheel_zooms {
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a smoothed scroll.
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
*alt_turn = m.alt && !m.command;
}
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
return if i.modifiers.alt && scroll.y != 0.0 { (zoom * (f64::from(scroll.y) * WHEEL_ZOOM).exp(), egui::Vec2::ZERO) } else { (zoom, scroll) };
return if *alt_turn && scroll != egui::Vec2::ZERO {
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), egui::Vec2::ZERO)
} else {
(zoom, scroll)
};
}
// The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom.
let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta));
@@ -2064,7 +2080,8 @@ mod tests {
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
/// scrolls up and down and Cmd/Ctrl-wheel sideways.
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
/// trackpad pinch zoom about the pointer either way.
#[test]
fn zoom_with_mouse_wheel_preference() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -2080,10 +2097,19 @@ mod tests {
run(&mut app);
let rect = app.canvas_rect.unwrap();
let at = rect.center() + vec2(60.0, 40.0);
// One notch up at `at`: → (zoom ratio, scroll in points, how far the point under `at` moved).
let turn = |app: &mut VectorcraftApp, shift: bool, cmd: bool| {
// One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how
// far the point under `at` moved).
let turn = |app: &mut VectorcraftApp, mods: &str| {
let before = *app.view().unwrap();
crate::tests_synthetic::control(app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "shift": shift, "cmd": cmd}));
if mods == "pinch" {
app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]);
} else {
let mut p = json!({"x": at.x, "y": at.y, "dy": 1});
for m in mods.split('+').filter(|m| !m.is_empty()) {
p[m] = json!(true);
}
crate::tests_synthetic::control(app, &ctx, "ui.wheel", p);
}
// egui spreads a notch over a few frames.
for _ in 0..40 {
run(app);
@@ -2092,16 +2118,19 @@ mod tests {
let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at));
(after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved)
};
let (zoom, scroll, _) = turn(&mut app, false, false);
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6;
let (zoom, scroll, _) = turn(&mut app, "");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})");
let (zoom, _, moved) = turn(&mut app, false, true);
assert!(zoom > 1.01 && moved < 1e-6, "off: Cmd-wheel zooms in about the pointer ({zoom}, {moved})");
assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer");
assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer");
assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer");
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
let (zoom, _, moved) = turn(&mut app, false, false);
assert!(zoom > 1.01 && moved < 1e-6, "on: the wheel zooms in about the pointer ({zoom}, {moved})");
let (zoom, scroll, _) = turn(&mut app, true, false);
assert!(zooms_in(turn(&mut app, "")), "on: the wheel zooms in about the pointer");
assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel");
assert!(zooms_in(turn(&mut app, "pinch")), "on: and a pinch");
let (zoom, scroll, _) = turn(&mut app, "shift");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: Shift-wheel scrolls up ({zoom}, {scroll:?})");
let (zoom, scroll, _) = turn(&mut app, false, true);
let (zoom, scroll, _) = turn(&mut app, "cmd");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
}
+1 -2
View File
@@ -153,8 +153,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::panels::paint_chip(app, ui, true, 22.0, true);
// The link opens the Stroke panel as a popover under it; then the weight spinner
// (with presets) and the width profile.
let link = ui.link(egui::RichText::new(tl!("Stroke:")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options"));
stroke_panel::popover(app, &link);
stroke_panel::link(app, ui, tl!("Stroke:"));
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
app.run("stroke.set", json!({"profile": id})).ok();
+3 -7
View File
@@ -11,7 +11,7 @@ use vectorcraft_engine::cmd::fileio::SaveMode;
use crate::VectorcraftApp;
use crate::io;
use crate::state::{DockTab, ICON_PANELS, next_zoom};
use crate::state::{DockTab, next_zoom};
use crate::theme::{self, Brightness, Tokens};
use crate::widgets;
@@ -762,10 +762,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`).
fn normalize_panel(input: &str) -> Option<&'static str> {
let name = input.trim();
let tabs = DockTab::ALL.into_iter().map(|t| (t.info().0, t.info().1));
tabs.chain(ICON_PANELS.iter().map(|&(id, label, _)| (id, label)))
.find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label))
.map(|(id, _)| id)
crate::state::all_panels().find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)).map(|(id, _)| id)
}
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@@ -3229,8 +3226,7 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
out.insert(c.label.to_string());
out.extend(c.menu.iter().map(|m| m.to_string()));
}
out.extend(ICON_PANELS.iter().map(|p| p.1.to_string()));
out.extend(["Properties", "Layers", "Libraries"].map(str::to_string));
out.extend(crate::state::all_panels().map(|(_, label)| label.to_string()));
out.extend(vectorcraft_tools::catalog::all_tools().map(|t| t.label.to_string()));
out.extend(crate::toolbar::BASIC.iter().map(|c| c.0.to_string()));
out.extend(vectorcraft_color::BlendMode::ALL.iter().map(|m| m.label().to_string()));
+22 -15
View File
@@ -194,6 +194,9 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
divider(ui);
section_header(ui, tl!("Appearance"));
fill_stroke_rows(app, ui);
divider(ui);
section_header(ui, tl!("Rulers & Grids"));
ui.horizontal(|ui| {
let v = app.ui.view.clone();
@@ -342,21 +345,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(n) = first_selected(app) else { return };
section_header(ui, tl!("Appearance"));
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
for (label, is_fill) in [(tl!("Fill"), true), (tl!("Stroke"), false)] {
ui.horizontal(|ui| {
super::paint_chip(app, ui, !is_fill, 22.0, false);
widgets::field_label(ui, egui::RichText::new(label).size(12.0));
if !is_fill {
ui.add_space(8.0);
if let Some(w) = widgets::num_field(ui, "ap-w", weight, app.session.stroke_unit(), 70.0) {
app.run("stroke.set", json!({"weight": w})).ok();
}
let more = widgets::icon_button(ui, "ellipsis", tl!("Stroke options"), false, 22.0);
super::stroke::popover(app, &more);
}
});
}
fill_stroke_rows(app, ui);
ui.horizontal(|ui| {
widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0));
ui.add_space(8.0);
@@ -377,6 +366,24 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
/// The Fill and Stroke rows, with the Control bar's widgets: the chips (a click opens the
/// swatches, Shift-click the mixer), the Stroke link that opens the Stroke panel as a popover
/// and the weight spinner with its presets. With nothing selected they set up the next object.
fn fill_stroke_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
ui.horizontal(|ui| {
super::paint_chip(app, ui, false, 22.0, true);
ui.label(egui::RichText::new(tl!("Fill")).size(12.0).color(t.text));
});
ui.horizontal(|ui| {
super::paint_chip(app, ui, true, 22.0, true);
super::stroke::link(app, ui, tl!("Stroke"));
ui.add_space(8.0);
super::stroke::weight_field(app, ui, "ap-w", weight, 90.0);
});
}
pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(n) = first_selected(app) else {
dim_label(ui, tl!("Select a text object"));
+6 -4
View File
@@ -119,10 +119,12 @@ pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weig
}
}
/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties
/// panel's Stroke links), which a click on it toggles.
pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) {
widgets::popover(resp, resp.clicked(), |ui| {
/// The underlined Stroke link (`label`: the Control bar's "Stroke:", the Properties panel's
/// "Stroke"): a click toggles the Stroke panel in a popover under it.
pub(crate) fn link(app: &mut VectorcraftApp, ui: &mut Ui, label: &str) {
let t = Tokens::get(ui.ctx());
let resp = ui.link(egui::RichText::new(label).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options"));
widgets::popover(&resp, resp.clicked(), |ui| {
ui.set_width(260.0);
show(app, ui);
});
@@ -40,6 +40,33 @@ fn weights_and_dashes_show_in_the_stroke_unit_everywhere() {
assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar");
}
/// The Properties panel's Stroke row is the Control bar's: the Stroke link opens the Stroke panel
/// as a popover and the weight spinner lists the presets (Discord feedback).
#[test]
fn the_properties_stroke_link_and_weight_presets() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = app_with_rect();
let t = texts(&ctx, &mut app, properties::show);
assert!(!shows(&t, "Cap:"), "closed: {t:?}");
click(&ctx, &mut app, text_rect(&t, "Stroke").center(), properties::show);
let t = texts(&ctx, &mut app, properties::show);
assert!(shows(&t, "Cap:") && shows(&t, "Align Stroke:"), "the Stroke panel's popover: {t:?}");
click(&ctx, &mut app, text_rect(&t, "Stroke").center(), properties::show);
// The weight's dropdown: the 20 pt chevron cell right of the field showing "1 pt".
let field = text_rect(&texts(&ctx, &mut app, properties::show), "1 pt");
let chevron = ctx.viewport(|vp| {
let w = vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).map(|w| w.rect);
w.filter(|r| r.width() == 20.0 && r.left() > field.right() && r.y_range().contains(field.center().y))
.min_by(|a, b| a.left().total_cmp(&b.left()))
});
click(&ctx, &mut app, chevron.expect("the weight's preset chevron").center(), properties::show);
let t = texts(&ctx, &mut app, properties::show);
assert!(!shows(&t, "Cap:") && shows(&t, "0.25 pt") && shows(&t, "100 pt"), "the presets: {t:?}");
click(&ctx, &mut app, text_rect(&t, "3 pt").center(), properties::show);
assert_eq!(app.session.shown_stroke().unwrap().width, 3.0);
}
#[test]
fn mixed_weights_show_blank() {
let ctx = egui::Context::default();
+15 -4
View File
@@ -100,16 +100,27 @@ pub fn default_command_shortcut(id: &str) -> Option<&'static str> {
}
/// Window menu items that show a panel (`window.panel {panel}`) with a default shortcut: (panel
/// id, shortcut). Every icon panel can be given one in the editor (entry key `panel:<id>`).
/// id, shortcut). Every panel can be given one in the editor (entry key `panel:<id>`).
pub const PANEL_SHORTCUTS: &[(&str, &str)] = &[
("brushes", "F5"),
("color", "F6"),
("layers", "F7"),
("colorGuide", "Shift+F3"),
("appearance", "Shift+F6"),
("graphicStyles", "Shift+F5"),
("stroke", "Cmd+F10"),
("appearance", "Shift+F6"),
("align", "Shift+F7"),
("transform", "Shift+F8"),
("info", "Cmd+F8"),
("gradient", "Cmd+F9"),
("pathfinder", "Cmd+Shift+F9"),
("stroke", "Cmd+F10"),
("transparency", "Cmd+Shift+F10"),
("attributes", "Cmd+F11"),
("symbols", "Cmd+Shift+F11"),
("character", "Cmd+T"),
("paragraph", "Cmd+Alt+T"),
("tabs", "Cmd+Shift+T"),
("openType", "Cmd+Alt+Shift+T"),
];
/// Default shortcut of an entry key (`tool:<id>`, `panel:<id>` or a command id).
@@ -252,7 +263,7 @@ pub fn entries() -> &'static [Entry] {
}
v.push(Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false });
}
v.extend(crate::state::ICON_PANELS.iter().map(|(id, label, _)| Entry {
v.extend(crate::state::all_panels().map(|(id, label)| Entry {
key: format!("panel:{id}"),
label: label.to_string(),
group: "Window".into(),
+1 -1
View File
@@ -59,7 +59,7 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json
v.push((sc, c.0, json!({})));
}
}
for (panel, _, _) in crate::state::ICON_PANELS {
for (panel, _) in crate::state::all_panels() {
if let Some(sc) = crate::shortcut_editor::panel_shortcut(panel).and_then(parse) {
v.push((sc, "window.panel", json!({ "panel": panel })));
}
+6
View File
@@ -94,6 +94,12 @@ impl DockTab {
}
}
/// Every panel `window.panel` shows, as (id, English label): the dock tabs, then the icon panels.
pub fn all_panels() -> impl Iterator<Item = (&'static str, &'static str)> {
let tabs = DockTab::ALL.into_iter().map(|t| (t.info().0, t.info().1));
tabs.chain(ICON_PANELS.iter().map(|&(id, label, _)| (id, label)))
}
/// Panels that live as collapsed icons in the dock (Essentials Classic).
pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("color", "Color", "palette"),
+81 -2
View File
@@ -51,12 +51,13 @@ fn paints_rect(shapes: &[Shape], color: Color32) -> bool {
})
}
/// The Control bar's Fill and Stroke chips (22 pt squares with a chevron), left to right.
/// The Fill and Stroke chips (22 pt squares with a chevron): left to right in the Control bar,
/// top to bottom in the Properties panel.
fn chips(ctx: &egui::Context) -> (Rect, Rect) {
let mut r: Vec<Rect> = ctx.viewport(|vp| {
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(38.0, 22.0)).map(|w| w.rect).collect()
});
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r.sort_by(|a, b| (a.left() + a.top()).total_cmp(&(b.left() + b.top())));
(r[0], r[1])
}
@@ -126,6 +127,37 @@ fn a_popover_tile_click_sets_the_fill() {
assert!(text.contains("Recent Colors"), "the Color panel's body: {text}");
}
/// The Properties panel has the Control bar's Fill and Stroke chips and stroke weight, with
/// nothing selected too, where they set up the next object drawn (Discord feedback).
#[test]
fn the_properties_panel_has_the_control_bars_fill_and_stroke() {
let mut app = app();
let props = panels::properties::show;
let text = crate::tests_labels::painted_text(&mut app, props);
for s in ["Document", "Appearance", "Fill", "Stroke", "1 pt"] {
assert!(text.lines().any(|l| l == s), "{s}: {text}");
}
let ctx = context();
frame(&mut app, &ctx, vec![], Modifiers::NONE, props);
let (fill, stroke) = chips(&ctx);
assert!(stroke.top() > fill.bottom(), "Fill above Stroke");
click(&mut app, &ctx, fill.center(), Modifiers::NONE, props);
let tile = ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).expect("the popover shows the swatches").rect;
click(&mut app, &ctx, tile.center(), Modifiers::NONE, props);
assert_eq!(fill_hex(&app), "#ed1c24", "the next object's fill");
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
assert_eq!(fill_hex(&app), "#ed1c24", "the new rectangle (selected) has it");
// With the rectangle selected: the same chips, under the Transform section.
click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, props);
frame(&mut app, &ctx, vec![], Modifiers::NONE, props);
let (fill, _) = chips(&ctx);
click(&mut app, &ctx, fill.center(), Modifiers::NONE, props);
let tile = ctx.read_response(egui::Id::new(("swatch-pop-tile", "Black"))).expect("the popover shows the swatches").rect;
click(&mut app, &ctx, tile.center(), Modifiers::NONE, props);
assert_eq!(fill_hex(&app), "#000000");
assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Fill Color");
}
#[test]
fn recolor_is_a_quick_action_for_multicolour_selections() {
let mut app = app();
@@ -163,3 +195,50 @@ fn the_shortcut_table_maps_f6_to_color() {
let color = crate::menus::menu_entries(&app).into_iter().find(|e| e.path == ["Window"] && e.label == "Color").unwrap();
assert_eq!(color.shortcut, "F6");
}
/// Illustrator's other Window menu panel keys (Discord feedback: Shift+F7 for Align), the dock's
/// Layers tab (F7) among them; the menu shows them and the Keyboard Shortcuts editor lists every
/// panel, so they can be changed.
#[test]
fn the_window_menu_panel_keys() {
let mut app = app();
let ctx = context();
let press = |app: &mut VectorcraftApp, key, modifiers| {
let events = vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }];
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear();
};
let (cmd, shift, alt) = (Modifiers::COMMAND, Modifiers::SHIFT, Modifiers::ALT);
for (key, m, panel) in [
(egui::Key::F5, Modifiers::NONE, "brushes"),
(egui::Key::F7, shift, "align"),
(egui::Key::F8, shift, "transform"),
(egui::Key::F8, cmd, "info"),
(egui::Key::F9, cmd | shift, "pathfinder"),
(egui::Key::F11, cmd | shift, "symbols"),
(egui::Key::T, cmd, "character"),
(egui::Key::T, cmd | alt, "paragraph"),
(egui::Key::T, cmd | shift, "tabs"),
(egui::Key::T, cmd | alt | shift, "openType"),
] {
press(&mut app, key, m);
assert_eq!(app.ui.open_panel.as_deref(), Some(panel), "{m:?}+{key:?}");
press(&mut app, key, m);
assert_eq!(app.ui.open_panel, None, "{m:?}+{key:?} again hides it");
}
press(&mut app, egui::Key::F7, Modifiers::NONE);
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
let entries = crate::menus::menu_entries(&app);
let shortcut =
|label: &str| entries.iter().find(|e| e.path.first().map(String::as_str) == Some("Window") && e.label == label).unwrap().shortcut.clone();
assert_eq!((shortcut("Align"), shortcut("Layers"), shortcut("Paragraph")), ("Shift+F7".into(), "F7".into(), "Cmd+Alt+T".into()));
assert!(shortcut_editor::entry("panel:layers").is_some(), "the dock's tabs can be given keys too");
// Changed in the editor, the old key is free.
app.ui.shortcut_overrides.insert("panel:align".into(), "Cmd+Alt+Shift+F7".into());
shortcut_editor::sync(&app.ui);
press(&mut app, egui::Key::F7, shift);
assert_eq!(app.ui.open_panel, None);
press(&mut app, egui::Key::F7, cmd | alt | shift);
assert_eq!(app.ui.open_panel.as_deref(), Some("align"));
app.ui.shortcut_overrides.clear();
shortcut_editor::sync(&app.ui);
}
+9 -4
View File
@@ -24,7 +24,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel zooms; with the `zoomWithMouseWheel` preference the wheel zooms about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways |
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways |
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
@@ -180,9 +180,14 @@ build are unchanged.
Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy
forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer);
a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Color F6, Color Guide Shift+F3,
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.
a swatch clicked there runs `paint.setFill` / `paint.setStroke`. The Properties panel has them with nothing selected
too (they set up the next object), with the Control bar's Stroke link (the Stroke panel as a popover) and weight
spinner. Panel keys (Brushes F5, Color F6, Layers F7, Color Guide Shift+F3, Graphic Styles Shift+F5, Appearance
Shift+F6, Align Shift+F7, Transform Shift+F8, Info Cmd+F8, Gradient Cmd+F9, Pathfinder Cmd+Shift+F9, Stroke Cmd+F10,
Transparency Cmd+Shift+F10, Attributes Cmd+F11, Symbols Cmd+Shift+F11, Character Cmd+T, Paragraph Cmd+Alt+T, Tabs
Cmd+Shift+T, OpenType Cmd+Alt+Shift+T; Cmd is Ctrl on Windows and Linux) run `window.panel {panel}`, can be changed in
Edit › Keyboard Shortcuts (entry `panel:<id>`, every panel) and pressed with `ui.key`; `ui.menu.list` shows them on
the Window menu's items.
`window.panel` takes a panel id in any case or the panel's display label (`"Layers"`, `"Color Guide"`).
Collapsing the dock: `window.collapseDock {collapsed?}` (the » at the top of the dock; omitted toggles) hides the