authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 11:50:52-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 11:56:16-07:00
logd596a5bc8ce82fbf0d90660c5cc24b5b48b16922
tree1116387b6d74fb3ef015ec2b1385da0fc20c4371
parent93933f26a4f155a3654291ce96aa67bfcf3c69f3
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: tooltips centre under their control, shifting only to stay inside the window

A tooltip opened from the leading edge of its control, so a wide one hung off to the right of a toolbar button. It now centres on the control, and near an edge of the window moves only as far as keeps it inside the window's margin. Assisted-by: claude-opus-5.5

4 files changed, 52 insertions(+), 3 deletions(-)

crates/ui/src/layout.rs+1-1
...@@ -62,7 +62,7 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap<...@@ -62,7 +62,7 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap<
62 let around = match anchor {62 let around = match anchor {
63 Anchor::Point([x, y]) => [x, y, x, y],63 Anchor::Point([x, y]) => [x, y, x, y],
64 Anchor::Dialog | Anchor::Top => [0.0; 4],64 Anchor::Dialog | Anchor::Top => [0.0; 4],
65 Anchor::Below(id) | Anchor::Right(id) | Anchor::Over(id) => {65 Anchor::Below(id) | Anchor::Tip(id) | Anchor::Right(id) | Anchor::Over(id) => {
66 let (rect, holder) = laid_out(id).unwrap_or_default();66 let (rect, holder) = laid_out(id).unwrap_or_default();
67 let [left, top, right, bottom] = rect;67 let [left, top, right, bottom] = rect;
68 match anchor {68 match anchor {
crates/ui/src/lib.rs+7
...@@ -230,6 +230,8 @@ pub enum Anchor {...@@ -230,6 +230,8 @@ pub enum Anchor {
230 /// Over the box from its corner, the popup's contents covering it, as a combo box opens230 /// Over the box from its corner, the popup's contents covering it, as a combo box opens
231 /// into its own list; at least as wide as it.231 /// into its own list; at least as wide as it.
232 Over(Id),232 Over(Id),
233 /// Under the box, centred on it, as a tooltip shows.
234 Tip(Id),
233 /// At a point, as a context menu opens.235 /// At a point, as a context menu opens.
234 Point([f32; 2]),236 Point([f32; 2]),
235 /// Centred across the window near its top, over the interface, which dims, as a dialog237 /// Centred across the window near its top, over the interface, which dims, as a dialog
...@@ -252,6 +254,10 @@ impl Anchor {...@@ -252,6 +254,10 @@ impl Anchor {
252 Anchor::Below(_) | Anchor::Point(_) => Some(1),254 Anchor::Below(_) | Anchor::Point(_) => Some(1),
253 Anchor::Right(_) => Some(0),255 Anchor::Right(_) => Some(0),
254 Anchor::Over(_) => None,256 Anchor::Over(_) => None,
257 Anchor::Tip(_) if axis == 0 => {
258 return ((around[0] + around[2] - size) / 2.0).clamp(POPUP_MARGIN, most);
259 }
260 Anchor::Tip(_) => Some(1),
255 Anchor::Dialog | Anchor::Top if axis == 0 => {261 Anchor::Dialog | Anchor::Top if axis == 0 => {
256 return ((room - size) / 2.0).max(POPUP_MARGIN);262 return ((room - size) / 2.0).max(POPUP_MARGIN);
257 }263 }
...@@ -593,6 +599,7 @@ impl Placed {...@@ -593,6 +599,7 @@ impl Placed {
593 } = self;599 } = self;
594 let pivot = match anchor {600 let pivot = match anchor {
595 Anchor::Below(_) | Anchor::Point(_) => [around[0], around[3]],601 Anchor::Below(_) | Anchor::Point(_) => [around[0], around[3]],
602 Anchor::Tip(_) => [(around[0] + around[2]) / 2.0, around[3]],
596 Anchor::Right(_) => [around[2], around[1]],603 Anchor::Right(_) => [around[2], around[1]],
597 Anchor::Over(_) => [around[0], around[1]],604 Anchor::Over(_) => [around[0], around[1]],
598 Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]],605 Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]],
crates/ui/src/popup.rs+2-2
...@@ -220,7 +220,7 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) ->...@@ -220,7 +220,7 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) ->
220}220}
221221
222/// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip222/// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip
223/// below the box built last while the pointer rests on it or on a box inside it:223/// centred below the box built last while the pointer rests on it or on a box inside it:
224/// after a delay, or at once while another has just shown. A press or the wheel hides it224/// after a delay, or at once while another has just shown. A press or the wheel hides it
225/// until the pointer leaves. It names the box to assistive technology, or where the box has225/// until the pointer leaves. It names the box to assistive technology, or where the box has
226/// no role, the unnamed controls inside it.226/// no role, the unnamed controls inside it.
...@@ -316,7 +316,7 @@ fn tooltip_below(...@@ -316,7 +316,7 @@ fn tooltip_below(
316 radius: 4.0,316 radius: 4.0,
317 pad: [8.0, 5.0],317 pad: [8.0, 5.0],
318 gap: 3.0,318 gap: 3.0,
319 anchor: Some(Anchor::Below(part.unwrap_or(id))),319 anchor: Some(Anchor::Tip(part.unwrap_or(id))),
320 ..Spec::default()320 ..Spec::default()
321 };321 };
322 ui.open_as(id.child("tooltip"), spec);322 ui.open_as(id.child("tooltip"), spec);
crates/ui/src/tests.rs+42
...@@ -1266,6 +1266,48 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() {...@@ -1266,6 +1266,48 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() {
1266 assert!(rest(&mut ui, 500)[0].is_some());1266 assert!(rest(&mut ui, 500)[0].is_some());
1267}1267}
12681268
1269#[test]
1270fn a_tooltip_centres_under_its_control_and_shifts_only_to_stay_in_the_window() {
1271 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1272 let mut shown_at = |x: f32| {
1273 ui.event(Event::PointerMoved([x + 10.0, 110.0]));
1274 let mut tip = None;
1275 for _ in 0..40 {
1276 frame(&mut ui, |ui| {
1277 let id = ui.open(
1278 "tool",
1279 Spec {
1280 flags: Flags::CLICKABLE | Flags::FLOAT,
1281 size: [px(20.0), px(20.0)],
1282 position: [x, 100.0],
1283 ..Spec::default()
1284 },
1285 );
1286 ui.close();
1287 popup::tooltip(ui, "Bold", "Ctrl+B", Some("Makes the selected text bold."));
1288 tip = Some(id.child("tooltip"));
1289 });
1290 }
1291 ui.laid_out(tip.unwrap()).unwrap()
1292 };
1293 let middle = shown_at(190.0);
1294 assert_eq!(middle[1], 120.0 + popup::PAD, "under the control");
1295 assert!(
1296 ((middle[0] + middle[2]) / 2.0 - 200.0).abs() <= 0.5,
1297 "centred on it: {middle:?}"
1298 );
1299 let width = middle[2] - middle[0];
1300 assert_eq!(
1301 shown_at(2.0)[..3],
1302 [POPUP_MARGIN, 120.0 + popup::PAD, POPUP_MARGIN + width]
1303 );
1304 assert_eq!(
1305 shown_at(378.0)[0],
1306 400.0 - POPUP_MARGIN - width,
1307 "shifted only to fit"
1308 );
1309}
1310
1269#[test]1311#[test]
1270fn a_popup_opens_beside_a_box_first_laid_out_in_the_same_frame() {1312fn a_popup_opens_beside_a_box_first_laid_out_in_the_same_frame() {
1271 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1313 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);