Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 b1f7d5ad7d Fix #563: the font menu's live preview no longer flickers
Hovering a family in a font menu previews it on the selected text. The
menu took its caller's font as the current family, and the caller reads
it from the text, which the preview had just changed: the next frame the
hovered family looked like the current one, so the menu ended the
preview, and the frame after it previewed the family again. The text
switched fonts every frame (and the document's revision ran up with it).

The menu now keeps the family the text had as it opened as the current
one: a preview stays on the text until another row is hovered, the
current family is hovered again, or the menu closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:20:21 +07:00
17 changed files with 84 additions and 391 deletions

No files matched your search

+1 -1
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-09 (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), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into flLine truncated
_Last updated: 2026-10-09 (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), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into flLine truncated
## Where we are
+10 -25
View File
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
has_selection,
align
),
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{axis: \"horizontal\"|\"vertical\", spacing?: pt, bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
has_multi,
distribute_spacing
),
@@ -813,15 +813,9 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
}
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
/// selection.
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
str_param(p, "to").or_else(|| s.doc().ok()?.selection.key.map(|_| "key"))
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
let st = s.doc()?;
match align_to(s, p) {
match str_param(p, "to") {
Some("artboard") => {
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
@@ -846,7 +840,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
items_bounds(d, &ids, preview)
.into_iter()
.filter(|(id, _)| Some(*id) != key || align_to(s, p) != Some("key"))
.filter(|(id, _)| Some(*id) != key || str_param(p, "to") != Some("key"))
.map(|(id, b)| {
let dx = match h {
Some("left") => r.x0 - b.x0,
@@ -932,31 +926,22 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let n = items.len();
let &[(_, first), .., (_, last)] = items.as_slice() else {
return Err(EngineError::Other("select two or more objects to distribute".into()));
};
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite());
let gap = match spacing {
let gap = match p.get("spacing").and_then(Value::as_f64) {
Some(g) => g,
None => {
let span = if horiz { last.x1 - first.x0 } else { last.y1 - first.y0 };
let span = if horiz { items[n - 1].1.x1 - items[0].1.x0 } else { items[n - 1].1.y1 - items[0].1.y0 };
(span - items.iter().map(|i| size(&i.1)).sum::<f64>()) / (n - 1) as f64
}
};
let mut pos = start(&first);
let mut deltas = vec![];
let mut pos = start(&items[0].1);
let mut moves = vec![];
for (id, r) in &items {
deltas.push((*id, pos - start(r)));
let delta = pos - start(r);
moves.push((*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }));
pos += size(r) + gap;
}
// With a spacing (aligning to a key object) the key object stays where it is: the others
// are spaced from it.
let key = s.doc()?.selection.key;
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
+1 -9
View File
@@ -36,15 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
toggle
),
cmd!(
"select.key",
"Set Key Object",
[],
None,
"{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it",
has_doc,
key
),
cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key),
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
cmd!(
"select.anchorsMany",
-31
View File
@@ -198,37 +198,6 @@ fn align_left() {
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
}
/// #541: with the Selection tool, a click on one object of the selection makes it the key object:
/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click
/// on the key again lets it go.
#[test]
fn a_click_sets_the_key_object_that_align_and_spacing_keep_still() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0);
let c = rect(&mut s, 300.0, 60.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let click = |s: &mut Session| {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 110.0, 40.0), v).unwrap();
}
};
click(&mut s);
assert_eq!(s.doc().unwrap().selection.key, Some(b));
assert_eq!(s.doc().unwrap().selection.objects.len(), 3, "the selection stays");
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
// With a key object, aligning aligns to it (the Control bar and Properties name no target).
s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b), x0(&s, c)), (100.0, 100.0, 100.0), "the others moved to the key");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 5, "bounds": "geometric"})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b), x0(&s, c)), (85.0, 100.0, 125.0), "5 pt apart, the key still");
click(&mut s);
assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key again lets it go");
}
#[test]
fn selection_tool_drag_is_one_undo_step() {
let mut s = session();
+9 -48
View File
@@ -36,9 +36,6 @@ enum State {
/// Shift-pressed on this selected object: released without a drag, it leaves the
/// selection.
deselect: Option<NodeId>,
/// Pressed (no modifier) on this object of a selection of several: released without a
/// drag, it becomes the key object that Align aligns to (the key again: no key).
key: Option<NodeId>,
},
Scaling {
handle: Handle,
@@ -238,14 +235,14 @@ impl Tool for SelectionTool {
&& cx.select_behind
&& let Some(behind) = object_behind(cx, p)
{
self.state = State::Moving { start: p, began: false, deselect: None, key: None };
self.state = State::Moving { start: p, began: false, deselect: None };
return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))];
}
match hit_test(cx.doc, p, cx.hit_options()) {
Some(h) => {
let top = h.top_object(cx.isolation);
let mut out = vec![];
let (mut deselect, mut key) = (None, None);
let mut deselect = None;
if m.shift {
// A Shift-click takes a selected object out of the selection when it
// is released; a Shift-drag moves the selection, constrained.
@@ -256,10 +253,8 @@ impl Tool for SelectionTool {
}
} else if !cx.selection.contains(top) {
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
} else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt {
key = Some(top);
}
self.state = State::Moving { start: p, began: false, deselect, key };
self.state = State::Moving { start: p, began: false, deselect };
out
}
None => {
@@ -281,7 +276,7 @@ impl Tool for SelectionTool {
if let Some(snap) = &self.moving {
(d, self.guides) = snap.snap(cx, start, d);
}
self.state = State::Moving { start, began: true, deselect: None, key: None };
self.state = State::Moving { start, began: true, deselect: None };
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
out
@@ -328,18 +323,15 @@ impl Tool for SelectionTool {
self.state = State::Idle;
b.finish()
}
(PointerKind::Up, State::Moving { began, deselect, key, .. }) => {
(PointerKind::Up, State::Moving { began, deselect, .. }) => {
self.state = State::Idle;
self.measure = None;
self.guides.clear();
self.moving = None;
match (began, deselect, key) {
(true, ..) => vec![Action::Commit],
(false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
// A click on the key object again: no key.
(false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))],
(false, None, Some(id)) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))],
(false, None, None) => vec![],
match (began, deselect) {
(true, _) => vec![Action::Commit],
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
(false, None) => vec![],
}
}
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
@@ -548,37 +540,6 @@ mod tests {
assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty());
}
/// #541: a click on one object of a selection of several makes it the key object; a click on
/// the key again lets it go. A drag moves them all, a lone object has no key.
#[test]
fn a_click_on_a_selected_object_makes_it_the_key() {
let (d, id) = doc_with_area_type();
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
let p = paint();
let click = |s: &Selection, x, y| {
let c = cx(&d, s, &p);
let mut t = SelectionTool::default();
assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty());
t.pointer(&c, &ev(PointerKind::Up, x, y))
};
let mut s = Selection::default();
s.add(rect);
s.add(id);
assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]);
s.key = Some(rect);
assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key");
// A drag moves the selection and leaves the key alone.
let c = cx(&d, &s, &p);
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0));
t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0));
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 150.0)), vec![Action::Commit]);
// One object selected: a click on it does nothing.
let mut one = Selection::default();
one.add(rect);
assert!(click(&one, 150.0, 150.0).is_empty());
}
#[test]
fn marquee_selects() {
let (d, id) = doc_with_rect();
-32
View File
@@ -1041,9 +1041,6 @@ fn node_outline(n: &Node) -> BezPath {
bp
}
/// The width of the key object's outline (Align to Key Object), thicker than the selection's.
const KEY_OUTLINE: f32 = 2.5;
/// The most anchor points the selection's outlines and anchors, and the hover highlight, are drawn
/// for: an Image Trace of a photo selects hundreds of thousands of paths at once, whose outlines
/// would make a mesh larger than the GPU takes in one buffer (#525).
@@ -1394,18 +1391,6 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) {
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id))));
}
// The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has
// too many paths to outline.
if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) {
let outline = if too_many_anchors([n]) {
n.geometric_bounds().map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath())
} else {
Some(node_outline(n))
};
if let Some(bp) = outline {
stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k))));
}
}
for id in st.selection.objects.iter().filter(|_| !big) {
let Some(n) = st.doc.node(*id) else { continue };
let color = c32(st.doc.layer_color(*id));
@@ -2092,23 +2077,6 @@ mod tests {
assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares");
}
/// #541: the key object's outline is drawn thicker than the rest of the selection's.
#[test]
fn the_key_object_has_a_thicker_outline() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let a = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone();
let b = app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 60, "height": 40})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let thick =
|app: &mut VectorcraftApp| shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == KEY_OUTLINE)).count();
assert_eq!(thick(&mut app), 0, "no key yet");
app.session.execute("select.key", &json!({"id": b})).unwrap();
assert!(thick(&mut app) > 0, "the key's outline");
}
/// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened.
fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
let (shapes, mut delta) = frame_output(app, ctx);
+14 -6
View File
@@ -134,6 +134,10 @@ struct MenuState {
/// The pass the list was last drawn in, and the pass it opened in.
last: u64,
opened: u64,
/// The family the text had as the menu opened. A preview shows another one in the document
/// (and the menu's caller), but this one stays the current family: hovering it ends the
/// preview (#563).
current: String,
/// The highlighted row and the query and filters it belongs to.
highlight: Option<usize>,
query: String,
@@ -194,6 +198,7 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
st.opened = pass;
st.highlight = None;
st.query.clear();
st.current = current.to_string();
}
st.last = pass;
// For [`end_stale_preview`]: the menu is open, in this layer.
@@ -243,8 +248,9 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
});
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let db = vectorcraft_text::FontDb::global();
// The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック).
let current = db.canonical(current, "").0;
// The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック),
// as it was before any preview.
let current = db.canonical(&st.current, "").0;
let current = current.as_str();
let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f));
// The generation is read first: fonts that load meanwhile make the next frame list them.
@@ -307,10 +313,12 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
widgets::dim_label(ui, tl!("No matching fonts"));
return None;
}
// The rows: only those in view are laid out (there are thousands of families). The list is
// the menu's one scrolling part, under the search field and filters (#555): it fills the
// menu's room (its max rect) below them.
let room = (ui.max_rect().bottom() - ui.next_widget_position().y).clamp(120.0, 600.0);
// The rows: only those in view are laid out (there are thousands of families). The list fills
// what the popup shows below the filters (its clip: the popup doesn't grow past its own size,
// and a taller list would scroll inside a scrolling popup), down to the window's bottom.
const BOTTOM_GAP: f32 = 12.0;
let bottom = ui.clip_rect().bottom().min(ui.ctx().content_rect().bottom() - BOTTOM_GAP);
let room = (bottom - ui.next_widget_position().y).max(120.0);
let mut area = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room.min(row_h * rows.len() as f32));
// The highlight in view: centred as the menu opens, scrolled just enough as the keys move it.
if let Some(top) = st.highlight.map(|i| i as f32 * row_h) {
-3
View File
@@ -1492,8 +1492,6 @@
New Art Brush from Selection Nuevo pincel de arte a partir de la selección
New Art Has Basic Appearance La nueva ilustración tiene apariencia básica
New Artboard Nueva mesa de trabajo
Artboard Mesa de trabajo
Exit Salir
New Bristle Brush Nuevo pincel de cerdas
New Brush… Nuevo pincel…
New Calligraphic Brush Nuevo pincel caligráfico
@@ -3119,7 +3117,6 @@ axis Both Ambos
@msg Release Guides: no guides Soltar guías: no hay guías
@msg Release: no layer to put the rectangle in Soltar: no hay ninguna capa donde poner el rectángulo
@msg Replace Spine: select a blend and a path Reemplazar eje: selecciona una fusión y un trazado
@msg select two or more objects to distribute selecciona dos o más objetos para distribuir
@msg select paths or anchor points selecciona trazados o puntos de ancla
@msg SVG parse error: {0} error al analizar el SVG: {0}
@msg Save for Web: the image is empty Guardar para web: la imagen está vacía
Can't render this file because it contains an unexpected character in line 2 and column 73.
-3
View File
@@ -1492,8 +1492,6 @@
New Art Brush from Selection Nuovo pennello artistico dalla selezione
New Art Has Basic Appearance La nuova grafica ha aspetto di base
New Artboard Nuova tavola da disegno
Artboard Tavola da disegno
Exit Esci
New Bristle Brush Nuovo pennello a setole
New Brush… Nuovo pennello…
New Calligraphic Brush Nuovo pennello calligrafico
@@ -3119,7 +3117,6 @@ axis Both Entrambi
@msg Release Guides: no guides Rilascia guide: nessuna guida
@msg Release: no layer to put the rectangle in Rilascia: nessun livello in cui inserire il rettangolo
@msg Replace Spine: select a blend and a path Sostituisci dorsale: seleziona una fusione e un tracciato
@msg select two or more objects to distribute seleziona due o più oggetti da distribuire
@msg select paths or anchor points seleziona tracciati o punti di ancoraggio
@msg SVG parse error: {0} Errore di analisi SVG: {0}
@msg Save for Web: the image is empty Salva per il web: l'immagine è vuota
Can't render this file because it contains an unexpected character in line 2 and column 73.
-2
View File
@@ -1635,8 +1635,6 @@
New Art Brush from Selection 選択範囲から新規アートブラシ
New Art Has Basic Appearance 新規アートに基本アピアランスを使用
New Artboard 新規アートボード
Artboard アートボード
Exit 終了
New Bristle Brush 新規剛毛ブラシ
New Brush… 新規ブラシ…
New Calligraphic Brush 新規絵筆ブラシ
Can't render this file because it contains an unexpected character in line 2 and column 73.
-1
View File
@@ -1469,7 +1469,6 @@
New Art Brush from Selection 从选区新建艺术画笔
New Art Has Basic Appearance 新建图稿使用基本外观
New Artboard 新建画板
Exit 退出
New Bristle Brush 新建鬃毛画笔
New Brush… 新建画笔…
New Calligraphic Brush 新建书法画笔
Can't render this file because it contains an unexpected character in line 2 and column 73.
-2
View File
@@ -1509,8 +1509,6 @@
New Art Brush from Selection 從選取範圍新增藝術筆刷
New Art Has Basic Appearance 新圖稿具有基本外觀
New Artboard 新增工作區域
Artboard 工作區域
Exit 結束
New Bristle Brush 新增鬃毛筆刷
New Brush… 新增筆刷…
New Calligraphic Brush 新增書法筆刷
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -34
View File
@@ -52,20 +52,9 @@ pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [
("dc-dist-right", "Horizontal Distribute Right", "horizontal", "right"),
];
/// Whether the selection has a key object.
fn has_key(app: &VectorcraftApp) -> bool {
app.session.active().is_some_and(|st| st.selection.key.is_some())
}
/// What Align aligns to: the key object while there is one (a click on an object of the
/// selection makes it the key), else the Align To choice.
fn align_to(app: &VectorcraftApp, ctx: &egui::Context) -> AlignTo {
if has_key(app) { AlignTo::Key } else { pstate(ctx, "align-to") }
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let n = selection_len(app);
let to = align_to(app, ui.ctx());
let to: AlignTo = pstate(ui.ctx(), "align-to");
widgets::subheader(ui, tl!("Align Objects:"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
@@ -127,10 +116,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
] {
if widgets::icon_button(ui, icon, tl!(tip), to == v, 28.0).clicked() {
set_pstate(ui.ctx(), "align-to", v);
// Aligning to the selection or the artboard lets go of the key object.
if v != AlignTo::Key && has_key(app) {
app.run("select.key", json!({})).ok();
}
}
}
});
@@ -147,7 +132,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Use Preview Bounds"), true, pb) {
super::transform::set_pref(app, "usePreviewBounds", !pb);
}
if menu_item(ui, tl!("Cancel Key Object"), align_to(app, ui.ctx()) == AlignTo::Key, false) {
let to: AlignTo = pstate(ui.ctx(), "align-to");
if menu_item(ui, tl!("Cancel Key Object"), to == AlignTo::Key, false) {
set_pstate(ui.ctx(), "align-to", AlignTo::Selection);
app.run("select.key", json!({})).ok();
}
@@ -166,21 +152,4 @@ mod tests {
assert_eq!(p["to"], "selection");
assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key");
}
/// #541: while the selection has a key object Align aligns to it, whatever Align To says;
/// choosing Align to Selection lets the key go.
#[test]
fn a_key_object_makes_align_align_to_it() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
assert_eq!(align_to(&app, &ctx), AlignTo::Selection);
app.run("select.key", json!({"id": b})).unwrap();
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
app.run("select.key", json!({})).unwrap();
assert_eq!(align_to(&app, &ctx), AlignTo::Selection, "no key: the choice again");
}
}
+2 -2
View File
@@ -13,13 +13,13 @@ use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
/// The active artboard (of `n`).
pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
fn selected(app: &VectorcraftApp, n: usize) -> usize {
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))
}
/// Make artboard `i` the active one, leaving the view where it is: the navigator's and, while it
/// is the tool, the Artboard tool's.
pub(crate) fn select(app: &mut VectorcraftApp, i: usize) {
fn select(app: &mut VectorcraftApp, i: usize) {
if let Some(v) = app.view_mut() {
v.artboard = i;
}
-104
View File
@@ -18,10 +18,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let n_sel = st.selection.len();
let anchors = crate::chrome::anchor_controls(app);
let first = first_selected(app);
// Edit Artboards (the Artboard tool): the active artboard, whatever is selected.
let editing_artboards = app.session.tool_id() == "artboard";
let label = match (&first, n_sel) {
_ if editing_artboards => tl!("Artboard").to_string(),
(None, _) => tl!("Document").to_string(),
(_, n) if n > 1 => crate::i18n::tn(n as u64, "{n} Object", "{n} Objects"),
(Some(n), _) if crate::panels::image_trace::is_trace(n) => tl!("Image Tracing").to_string(),
@@ -29,10 +26,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
};
ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
ui.add_space(4.0);
if editing_artboards {
artboard_sections(app, ui);
return;
}
if first.is_none() {
document_sections(app, ui);
return;
@@ -195,48 +188,6 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
}
}
/// Edit Artboards (the Artboard tool): the active artboard's name, position and size, New Artboard
/// and Delete Artboard, and Exit back to the Selection tool (#530).
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = app.session.general_unit();
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
let i = super::artboards::selected(app, n);
if let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() {
// Its name as it is (names are never translated).
ui.label(egui::RichText::new(&ab.name).size(13.0).color(Tokens::get(ui.ctx()).text));
let fw = ((ui.available_width() - 50.0) / 2.0).clamp(60.0, 110.0);
egui::Grid::new("ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
let r = ab.rect;
for (row, [(l1, k1, v1), (l2, k2, v2)]) in
[[("X:", "x", r.x0), ("W:", "width", r.width())], [("Y:", "y", r.y0), ("H:", "height", r.height())]].into_iter().enumerate()
{
for (label, key, v) in [(l1, k1, v1), (l2, k2, v2)] {
dim_label(ui, label);
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v), units, fw) {
app.run("artboard.setProps", json!({"index": i, key: v})).ok();
}
}
ui.end_row();
}
});
}
ui.add_space(4.0);
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
if widgets::flat_button(ui, tl!("New Artboard"), w).clicked() && app.run("artboard.new", json!({})).is_ok() {
super::artboards::select(app, n);
}
if ui.add_enabled_ui(n > 1, |ui| widgets::flat_button(ui, tl!("Delete Artboard"), w)).inner.clicked() {
app.run("artboard.delete", json!({"index": i})).ok();
}
});
divider(ui);
section_header(ui, tl!("Quick Actions"));
if widgets::flat_button(ui, tl!("Exit"), w).clicked() {
app.select_tool("selection");
}
}
fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = app.session.general_unit();
section_header(ui, tl!("Document"));
@@ -465,58 +416,3 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
}
#[cfg(test)]
mod tests {
use egui::{Event, PointerButton, Pos2, Rect, vec2};
use vectorcraft_engine::Session;
use super::*;
/// One frame of the panel with `events` → the texts painted, with their rects.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|s| {
if let egui::Shape::Text(t) = &s.shape {
Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size())))
} else {
None
}
})
.collect()
}
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)], label: &str) {
let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"));
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, vec![Event::PointerMoved(at), b(true)]);
frame(app, ctx, vec![b(false)]);
}
/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit.
#[test]
fn edit_artboards_adds_artboards_and_exits() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Edit Artboards");
assert_eq!(app.session.tool_id(), "artboard");
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Artboard 1") && texts.iter().any(|(t, _)| t == "200 pt"), "{texts:?}");
click(&mut app, &ctx, &texts, "New Artboard");
let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len();
assert_eq!(boards(&app), 2);
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Artboard 2"), "the new artboard is the active one: {texts:?}");
click(&mut app, &ctx, &texts, "Delete Artboard");
assert_eq!(boards(&app), 1);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Exit");
assert_eq!(app.session.tool_id(), "selection");
}
}
+42 -32
View File
@@ -271,50 +271,60 @@ fn menus_that_only_pick_a_font_leave_the_document_alone() {
assert!(app.ui.favorite_fonts.is_empty());
}
/// #555: the menu's search field and filters stay at its top while the wheel scrolls the families,
/// past the list's end too: the list is the menu's one scrolling part.
/// #563: a preview stays on the text while the menu stays on its family. The menu's caller passes
/// the text's font, which the preview changed; the menu keeps the font from before the preview as
/// the current one, so it doesn't take the previewed family for it and end the preview, then
/// preview it again, every frame (the text flickered).
#[test]
fn the_search_field_stays_put_while_the_list_scrolls() {
fn a_preview_stays_put_while_the_menu_is_still() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |events: Vec<egui::Event>| {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let font = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.first_style().font_family,
_ => String::new(),
};
let revision = |app: &VectorcraftApp| app.session.active().unwrap().revision;
// A panel's frame: the menu shows the text's font, and what it picks is applied.
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let input =
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 500.0))), ..Default::default() };
let mut at = egui::Pos2::ZERO;
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() };
let current = font(app);
let (mut at, mut picked) = (egui::Pos2::ZERO, None);
let mut out = ctx.run_ui(input, |ui| {
// Low in the window, as in a panel at the bottom of the dock: the menu opens upwards.
ui.add_space(390.0);
crate::font_menu::end_stale_preview(app, ui.ctx());
at = ui.next_widget_position();
font_menu(ui, "test-scroll", "Inter", 260.0, None, MenuLook::default());
picked = font_menu(ui, "test-steady", &current, 260.0, None, MenuLook::default());
if let Some(p) = picked.clone() {
crate::font_menu::apply(app, ui.ctx(), p);
}
});
out.textures_delta.clear();
let search = out.shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == "Search" => Some(t.pos),
_ => None,
});
(at, search)
(at, picked)
};
let (at, _) = frame(vec![]);
let (at, _) = frame(&mut app, vec![]);
let click = at + egui::vec2(40.0, 10.0);
let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(vec![egui::Event::PointerMoved(click), button(true)]);
frame(vec![button(false)]);
let mut field = None;
frame(&mut app, vec![egui::Event::PointerMoved(click), button(true)]);
frame(&mut app, vec![button(false)]);
for _ in 0..5 {
field = frame(vec![]).1;
frame(&mut app, vec![]);
}
let field = field.expect("the search field shows");
let over_list = egui::pos2(field.x + 40.0, field.y + 120.0);
for dy in [-400.0, -4000.0, -4000.0] {
let wheel = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, dy),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
};
frame(vec![egui::Event::PointerMoved(over_list), wheel]);
for _ in 0..10 {
assert_eq!(frame(vec![]).1, Some(field), "the field stays where it was");
}
let (_, picked) = frame(&mut app, key(egui::Key::ArrowDown));
assert!(matches!(picked, Some(FontPick::Preview(..))), "{picked:?}");
let previewed = font(&app);
assert_ne!(previewed, "Inter");
let rev = revision(&app);
for _ in 0..6 {
let (_, picked) = frame(&mut app, vec![]);
assert_eq!(picked, None, "nothing more to apply");
}
assert_eq!((font(&app), revision(&app)), (previewed, rev), "the preview stays, untouched");
// Back on the text's own family, the preview ends.
let (_, picked) = frame(&mut app, key(egui::Key::ArrowUp));
assert_eq!(picked, Some(FontPick::EndPreview));
assert_eq!(font(&app), "Inter");
}
+2 -56
View File
@@ -655,13 +655,11 @@ pub(crate) fn combo<R>(
.stroke(Stroke::new(1.0, t.input_border))
.corner_radius(CornerRadius::same(3))
.show(ui, |ui| {
if searchable {
return searchable_combo(ui, ui.id().with(id), current, width - 4.0, list);
}
egui::ComboBox::from_id_salt(ui.id().with(id))
.selected_text(egui::RichText::new(current).size(12.0))
.width(width - 4.0)
.height(ui.spacing().combo_height)
.close_behavior(if searchable { egui::PopupCloseBehavior::CloseOnClickOutside } else { egui::PopupCloseBehavior::CloseOnClick })
.height(if searchable { f32::INFINITY } else { ui.spacing().combo_height })
.show_ui(ui, list)
.inner
.flatten()
@@ -669,58 +667,6 @@ pub(crate) fn combo<R>(
.inner
}
/// A dropdown whose list keeps its own search field and scrolling rows (the font menu): drawn as
/// egui's combo box, but in a popup without the scroll area egui puts around a combo's list, so
/// the field stays at the top and only the rows scroll (#555). A click outside closes it.
fn searchable_combo<R>(ui: &mut Ui, id: egui::Id, current: &str, width: f32, list: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
let popup = id.with("popup");
let open = egui::Popup::is_id_open(ui.ctx(), popup);
let pad = ui.spacing().button_padding;
let icon = Vec2::splat(ui.spacing().icon_width);
let galley = egui::WidgetText::from(egui::RichText::new(current).size(12.0)).into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
width - 2.0 * pad.x - ui.spacing().icon_spacing - icon.x,
egui::TextStyle::Button,
);
let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y);
let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
if ui.is_rect_visible(rect) {
let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) };
ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside);
let inner = rect.shrink2(pad);
ui.painter().galley(egui::Align2::LEFT_CENTER.align_size_within_rect(galley.size(), inner).min, galley, v.text_color());
// egui's combo arrow: a downward triangle.
let arrow =
Rect::from_center_size(egui::Align2::RIGHT_CENTER.align_size_within_rect(icon, inner).center(), vec2(icon.x * 0.7, icon.y * 0.45));
ui.painter().add(egui::Shape::convex_polygon(
vec![arrow.left_top(), arrow.right_top(), arrow.center_bottom()],
v.fg_stroke.color,
Stroke::NONE,
));
}
// It opens below the button, or above it when there is more room there, and the list gets
// the room left (the popup's frame aside).
const GAP: f32 = 12.0;
let screen = ui.ctx().content_rect();
let frame = ui.spacing().menu_margin.sum().y + GAP;
let (below, above) = (screen.bottom() - rect.bottom() - frame, rect.top() - screen.top() - frame);
let (align, room) = if below >= above { (egui::RectAlign::BOTTOM_START, below) } else { (egui::RectAlign::TOP_START, above) };
egui::Popup::menu(&resp)
.id(popup)
.width(rect.width())
.align(align)
.align_alternatives(&[])
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
ui.set_min_width(ui.available_width());
ui.set_max_height(room);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
list(ui)
})
.and_then(|r| r.inner)
}
/// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and
/// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its
/// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or