Compare commits

..
3 Commits
Author SHA1 Message Date
BFlatandClaude Opus 5.5 ee7a7ebfac Fix #290: hovering another menu title switches the open menu
On the in-window menu bar (Windows and Linux) each top-level menu needed
its own click: with File open, moving the pointer onto Edit or Object did
nothing. Now, as on a native menu bar, while one menu is open a moving
pointer that reaches another title opens that menu instead (ported from
PhotoCraft). A pointer resting on a title doesn't switch, and a tall menu
that covers the bar doesn't switch to the title underneath it. The native
macOS menu bar is unchanged.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:32:03 +07:00
BFlat 0d4d125f9b Merge pull request #323 from storytold/fix/issue-291
Fix #291: live corners stay circular when a rectangle is scaled unevenly
2026-10-07 20:28:06 +07:00
BFlatandClaude Opus 5.5 deaec7fc71 Fix #291: live corners stay circular when a rectangle is scaled unevenly
A live rectangle kept every transform in its `xf`, so a non-uniform scale
(a bounding-box drag, Object > Transform > Scale) stretched the rounded
corners built in untransformed space into elliptical arcs. Scale Corners
off divided the radii by the mean scale, which only compensated uniform
scales.

LiveShape::transform now folds a scale along the rectangle's own sides
(any rotation or reflection, no shear) into w/h and keeps only rotation,
reflection and position in `xf`, so the radii are document lengths and the
path is regenerated with circular corners. Radii scale by the mean scale
(sqrt|det|), which keep_corners undoes for Scale Corners off. Shears and
plain moves keep composing into `xf` as before, so files saved with a
scale in `xf` keep their exact geometry until next scaled or rotated.
A corner-widget drag starts from the radius as drawn (clamped to fit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:25:41 +07:00
6 changed files with 357 additions and 16 deletions

No files matched your search

+2 -2
View File
@@ -100,14 +100,14 @@ 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, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
+152 -10
View File
@@ -75,12 +75,16 @@ impl LayerColor {
#[serde(tag = "shape", rename_all = "lowercase")]
pub enum LiveShape {
Rectangle {
/// Untransformed width/height.
/// Untransformed width/height. Transforms fold their scale into these (see
/// [`LiveShape::transform`]), so they are document lengths unless `xf` shears.
w: f64,
h: f64,
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
radii: [f64; 4],
/// Maps the untransformed shape (origin at its top-left) into the document.
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
/// their scale into `w`/`h`) put more in it.
xf: Affine,
},
Ellipse {
@@ -129,12 +133,44 @@ impl LiveShape {
LiveShape::Line { a, b } => shapes::line(*a, *b),
}
}
pub fn transform(&mut self, a: Affine) {
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
/// the caller must regenerate the path with [`LiveShape::to_path`].
///
/// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own
/// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
/// scale in `xf` keep their exact geometry until they're next transformed).
pub fn transform(&mut self, a: Affine) -> bool {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
LiveShape::Rectangle { w, h, radii, xf } => {
let m = a * *xf;
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
if moves_only || !orthogonal || !finite {
*xf = m;
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
true
}
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
*xf = a * *xf;
false
}
LiveShape::Line { a: p, b } => {
*p = a * *p;
*b = a * *b;
false
}
}
}
@@ -613,11 +649,10 @@ impl Node {
NodeKind::Path { path, live, .. } => {
path.transform(a);
if let Some(l) = live {
l.transform(a);
if sc.keep_corners
&& let Some(k) = k
&& l.keep_corners(k)
{
// A rectangle scaled unevenly keeps circular corners: its path is regenerated.
let reshaped = l.transform(a);
let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k));
if reshaped || kept {
*path = l.to_path();
}
}
@@ -1135,6 +1170,113 @@ mod tests {
assert_eq!(l.label(), "Rectangle");
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
}
n
}
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
(*w, *h, *radii, *xf)
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn near_xf(a: Affine, b: Affine) -> bool {
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
}
/// Size and radius of a live rectangle, within rounding.
fn assert_live(n: &Node, w: f64, h: f64, r: f64) {
let (lw, lh, radii, _) = live_of(n);
assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}");
}
/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation),
/// spans `r` along x and y: the corner is a circular arc of radius `r`.
fn assert_round_corners(n: &Node, back: Affine, r: f64) {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
let a: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect();
assert_eq!(a.len(), 8, "a rounded rectangle");
// Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs.
for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] {
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}");
}
}
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
#[test]
fn uneven_scales_keep_live_corners_circular() {
// A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291).
let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0)));
let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0));
n.transform(stretch, KEEP_CORNERS);
assert_live(&n, 300.0, 100.0, 20.0);
assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size");
let b = n.geometric_bounds().unwrap();
assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}");
assert_round_corners(&n, Affine::IDENTITY, 20.0);
// Scale Corners on: the radius scales by the mean scale, still circular.
let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default());
assert_live(&n, 400.0, 100.0, 40.0);
assert_round_corners(&n, Affine::IDENTITY, 40.0);
// Squashed below the radius, the short sides are fully round; stretched back, it returns.
n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS);
assert_round_corners(&n, Affine::IDENTITY, 25.0);
n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS);
assert_round_corners(&n, Affine::IDENTITY, 40.0);
}
#[test]
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
let mut n = live_rect(100.0, 50.0, 10.0, place);
// Stretched along its own width, as a bounding-box drag of a rotated shape does.
n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS);
assert_live(&n, 200.0, 50.0, 10.0);
assert!(near_xf(live_of(&n).3, place));
assert_round_corners(&n, place.inverse(), 10.0);
// A reflection stays in `xf`; the corners stay as they were.
n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS);
assert_live(&n, 200.0, 50.0, 10.0);
let xf = live_of(&n).3;
assert!(near(xf.determinant(), -1.0));
assert_round_corners(&n, xf.inverse(), 10.0);
}
#[test]
fn shears_and_moves_keep_the_old_transform() {
// Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before.
let rot = Affine::rotate(30f64.to_radians());
let mut n = live_rect(100.0, 50.0, 10.0, rot);
let before = n.clone();
let a = Affine::scale_non_uniform(2.0, 1.0);
n.transform(a, Scaling::default());
assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot));
let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() };
assert_eq!(*path, old.transformed(a));
// A file saved with an uneven scale in `xf` keeps its exact geometry when moved...
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0);
let mut n = live_rect(100.0, 100.0, 20.0, legacy);
n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS);
assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy));
// ...and once scaled, has document units and circular corners of its mean radius.
n.transform(Affine::scale(2.0), Scaling::default());
assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0);
assert!(near(live_of(&n).3.determinant(), 1.0));
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
}
#[test]
fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
+55
View File
@@ -153,6 +153,61 @@ fn scale_corners_keeps_or_scales_live_corner_radii() {
assert!((n.geometric_bounds().unwrap().width() - 400.0).abs() < 1e-6);
}
/// The extent along x and y of each corner arc of an upright live rounded rectangle's path.
fn corner_spans(n: &Node) -> Vec<(f64, f64)> {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
let a = &path.subpaths[0].anchors;
assert_eq!(a.len(), 8, "a rounded rectangle");
[(1, 2), (3, 4), (5, 6), (7, 0)].iter().map(|(i, j)| ((a[*j].p.x - a[*i].p.x).abs(), (a[*j].p.y - a[*i].p.y).abs())).collect()
}
/// Every corner is a circular arc of radius `r`.
fn round_corners(n: &Node, r: f64) -> bool {
corner_spans(n).iter().all(|(x, y)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9)
}
#[test]
fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() {
// #291: a rounded square widened into a long rectangle keeps round corners.
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10})));
run(&mut s, "object.scale", json!({"sx": 300, "sy": 100, "corners": false}));
let n = node(&s, id);
assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9);
assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n));
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") };
assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units");
// Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below).
run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true}));
assert!(round_corners(&node(&s, id), 10.0), "{:?}", corner_spans(&node(&s, id)));
run(&mut s, "object.scale", json!({"sx": 100, "sy": 400, "corners": true}));
assert!(round_corners(&node(&s, id), 20.0), "{:?}", corner_spans(&node(&s, id)));
// Undo brings each step back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(round_corners(&node(&s, id), 10.0));
run(&mut s, "edit.undo", json!({}));
assert!(round_corners(&node(&s, id), 10.0));
assert!((node(&s, id).geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
}
#[test]
fn a_bounding_box_drag_keeps_live_corners_circular() {
// The Selection tool's handle drag previews `object.transform` from the shape as it began.
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "radius": 25})));
s.begin_interaction("Scale").unwrap();
for sx in [1.5, 2.5, 4.0] {
// Dragging the right-middle handle: scaled about the left edge.
s.preview("object.transform", &json!({"matrix": [sx, 0, 0, 1, 50.0 - 50.0 * sx, 0]})).unwrap();
}
s.commit_interaction().unwrap();
let n = node(&s, id);
let b = n.geometric_bounds().unwrap();
assert!((b.x0 - 50.0).abs() < 1e-9 && (b.width() - 400.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9, "{b:?}");
assert!(round_corners(&n, 25.0), "{:?}", corner_spans(&n));
}
#[test]
fn the_journal_records_the_scale_options_used() {
let mut s = session();
+4 -3
View File
@@ -93,14 +93,15 @@ impl CornerDrag {
Some(Self { widgets, corner, start: p, began: false, radius: widgets.radii[corner], at: p })
}
/// The start radius plus the pointer's travel along the corner's inward diagonal (in the
/// shape's own units), from square to fully round.
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
/// corner's inward diagonal (in the shape's own units), from square to fully round.
fn radius_at(&self, p: Point) -> f64 {
let w = &self.widgets;
let inv = w.xf.inverse();
let d = inv * p - inv * self.start;
let (_, (sx, sy)) = CORNERS[self.corner];
(w.radii[self.corner] + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, w.w.min(w.h) / 2.0)
let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0);
(w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max)
}
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
+15
View File
@@ -274,6 +274,21 @@ fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() {
assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width());
}
#[test]
fn menu_parameter_dialogs_are_compact() {
// #292: Object › Path › Simplify (and its neighbours) spanned the whole window.
for id in ["object.path.simplify", "object.path.offsetPath", "object.move", "object.rotate", "object.path.splitIntoGrid", "path.average"] {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
assert!(app.ui.dialog.is_some(), "{id} opened no dialog");
let wide = dialog_rect(&mut app, 1600.0);
eprintln!("{id}: {:.0} × {:.0} in a 1600-point window", wide.width(), wide.height());
assert!(wide.width() < 420.0, "{id} is {:.0} wide in a 1600-point window", wide.width());
}
}
/// A list mixing built-in labels with names translates only the built-in entries: names that
/// happen to be catalog keys ("Black", "Regular") are shown as they are.
#[test]
+129 -1
View File
@@ -2343,14 +2343,16 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
let tree = menu_tree();
let end = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 {
egui::RichText::new(tl!(title)).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(tl!(title)).size(13.0).color(t.text)
};
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
}
switch_on_hover(ui.ctx(), &titles);
ui.cursor().min.x
})
.inner;
@@ -2360,6 +2362,39 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
end
}
/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no
/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must
/// not switch to the title under it).
fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool {
title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id)
}
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
/// title opens that menu instead (egui alone needs a click on each title).
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) {
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
return;
};
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
let Some(p) = ctx.pointer_hover_pos() else {
return;
};
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) else {
return;
};
if i != open
&& let Some(id) = ids.get(i)
{
egui::Popup::open_id(ctx, *id);
ctx.request_repaint();
}
}
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
/// it scrolls when it is taller than the window.
fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
@@ -2954,6 +2989,99 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
mod tests {
use super::*;
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
/// (rect, id of the title's popup).
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(egui::Rect, egui::Id)> {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
let mut layer = None;
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
layer = Some(ui.layer_id());
menu_bar(app, ui);
});
out.textures_delta.clear();
let layer = layer.unwrap();
let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(layer)
.filter(|w| w.sense.senses_click() && w.rect.top() < 30.0)
// `egui::Popup::default_response_id` of the title's response.
.map(|w| (w.rect, w.id.with("popup")))
.collect()
});
titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left()));
titles
}
fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec<usize> {
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
}
#[test]
fn hovering_another_title_switches_the_open_menu() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
bar_frame(&mut app, &ctx, vec![]);
let titles = bar_frame(&mut app, &ctx, vec![]);
assert_eq!(titles.len(), menu_tree().len());
// 0 is the app menu, then File, Edit, Object.
let (file, edit, object) = (titles[1].0.center(), titles[2].0.center(), titles[3].0.center());
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut t = bar_frame(app, &ctx, events);
for _ in 0..2 {
t = bar_frame(app, &ctx, vec![]);
}
t
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
// Nothing open: hovering a title opens nothing.
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert!(open_titles(&ctx, &t).is_empty());
// Click File, then move onto Edit and on to Object: each opens in turn, alone.
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
frames(&mut app, vec![button(file, true)]);
let t = frames(&mut app, vec![button(file, false)]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a click opens File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Edit opens it and closes File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
assert_eq!(open_titles(&ctx, &t), vec![3], "hovering Object opens it and closes Edit");
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
// stays open while the pointer stays still over Object.
egui::Popup::open_id(&ctx, t[1].1);
let t = frames(&mut app, vec![]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a still pointer leaves the open menu alone");
}
#[test]
fn a_popup_covering_a_title_does_not_switch_menus() {
let ctx = egui::Context::default();
let p = egui::pos2(90.0, 12.0);
let mut reaches = (true, false);
// The popup becomes hit-testable once egui has laid it out: draw a few frames.
for _ in 0..3 {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
let ctx = ui.ctx().clone();
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::new("covered"), egui::Sense::click());
let q = p + egui::vec2(0.0, 100.0);
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::new("free"), egui::Sense::click());
egui::Area::new(egui::Id::new("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
ui.allocate_space(egui::vec2(20.0, 20.0));
});
reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q));
});
out.textures_delta.clear();
}
assert_eq!(reaches, (false, true));
}
#[test]
fn names_in_menus_are_not_interface_labels() {
for (id, label) in [