Toasts: a close (×) with a countdown ring instead of a Close button; a shorter 3D mouse driver label (#7971) (#8763)

* claim: #7971

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Toasts: a close (×) with a countdown ring instead of a Close button (#7971)

Every toast the user may close (the status, the 3D mouse offer and the
stand-up note) has a close (×) at its top right, handle toast:<name>:close,
in place of a Close button; it does what the button did, is a kit-drawn
button with an accessible name and hover text, reachable by Tab, and dead
behind an issue report. Toasts that go by themselves draw a thin ring
around it that shrinks over their time; pointing at the offer or the note
starts their time again, as before, so their ring is full while pointed at.

The 3D mouse offer keeps three buttons, Calibrate…, Use Direct Driver (the
detail in its tooltip) and Never show again, on one row: the corner
toasts are 400 points wide, as the row's estimated widths need 356.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Toasts: lay buttons out at their drawn widths; corner toasts 360 wide (#7971)

The toast's button rows used an estimate of each button's width that ran
about 40 points over the kit button as drawn, which wrapped the 3D mouse
offer's three buttons and left the main action short of the right edge.
Each button's drawn width is now remembered and the rows laid out with it
(the estimate stands in for the one frame before a button was first
drawn), so the main action is flush right; the corner width is the
smallest round one that fits the offer's three buttons in a row: 360.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Toasts: keep the analysis status test whole after rebasing onto main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
PaddleStroke
2026-10-30 13:48:29 +00:00
committed by GitHub
parent 014ccf5ff4
commit 56e752c182
7 changed files with 550 additions and 64 deletions
@@ -0,0 +1,122 @@
{
"en": {
"ui.3d_mouse_use_direct": "Use Direct Driver",
"ui.3d_mouse_use_direct_tip": "Read the 3D mouse directly (hidraw) instead of through spacenavd."
},
"ar": {
"ui.3d_mouse_use_direct": "المشغل المباشر",
"ui.3d_mouse_use_direct_tip": "قراءة الفأرة ثلاثية الأبعاد مباشرة (hidraw) بدلًا من المرور عبر spacenavd."
},
"bs": {
"ui.3d_mouse_use_direct": "Koristi direktni drajver",
"ui.3d_mouse_use_direct_tip": "Čitaj 3D miš direktno (hidraw) umjesto preko spacenavd."
},
"cs": {
"ui.3d_mouse_use_direct": "Použít přímý ovladač",
"ui.3d_mouse_use_direct_tip": "Číst 3D myš přímo (hidraw) místo přes spacenavd."
},
"da": {
"ui.3d_mouse_use_direct": "Brug direkte driver",
"ui.3d_mouse_use_direct_tip": "Læs 3D-musen direkte (hidraw) i stedet for gennem spacenavd."
},
"de": {
"ui.3d_mouse_use_direct": "Direkten Treiber verwenden",
"ui.3d_mouse_use_direct_tip": "Die 3D-Maus direkt lesen (hidraw) statt über spacenavd."
},
"es": {
"ui.3d_mouse_use_direct": "Usar controlador directo",
"ui.3d_mouse_use_direct_tip": "Leer el ratón 3D directamente (hidraw) en lugar de a través de spacenavd."
},
"fr": {
"ui.3d_mouse_use_direct": "Utiliser le pilote direct",
"ui.3d_mouse_use_direct_tip": "Lire la souris 3D directement (hidraw) au lieu de passer par spacenavd."
},
"gl": {
"ui.3d_mouse_use_direct": "Usar controlador directo",
"ui.3d_mouse_use_direct_tip": "Ler o rato 3D directamente (hidraw) en vez de a través de spacenavd."
},
"he": {
"ui.3d_mouse_use_direct": "שימוש במנהל ישיר",
"ui.3d_mouse_use_direct_tip": "קריאת העכבר התלת־ממדי ישירות (hidraw) במקום דרך spacenavd."
},
"hr": {
"ui.3d_mouse_use_direct": "Koristi izravni upravljački program",
"ui.3d_mouse_use_direct_tip": "Čitaj 3D miš izravno (hidraw) umjesto preko spacenavd."
},
"hu": {
"ui.3d_mouse_use_direct": "Közvetlen illesztőprogram",
"ui.3d_mouse_use_direct_tip": "A 3D-egér közvetlen olvasása (hidraw) a spacenavd helyett."
},
"id": {
"ui.3d_mouse_use_direct": "Gunakan driver langsung",
"ui.3d_mouse_use_direct_tip": "Baca mouse 3D secara langsung (hidraw), bukan melalui spacenavd."
},
"it": {
"ui.3d_mouse_use_direct": "Usa driver diretto",
"ui.3d_mouse_use_direct_tip": "Leggi il mouse 3D direttamente (hidraw) invece che tramite spacenavd."
},
"ja": {
"ui.3d_mouse_use_direct": "直接ドライバーを使用",
"ui.3d_mouse_use_direct_tip": "spacenavd を経由せず、3Dマウスを直接 (hidraw) 読み取ります。"
},
"ko": {
"ui.3d_mouse_use_direct": "직접 드라이버 사용",
"ui.3d_mouse_use_direct_tip": "spacenavd를 거치지 않고 3D 마우스를 직접(hidraw) 읽습니다."
},
"ms": {
"ui.3d_mouse_use_direct": "Guna pemacu terus",
"ui.3d_mouse_use_direct_tip": "Baca tetikus 3D secara terus (hidraw) dan bukan melalui spacenavd."
},
"nb": {
"ui.3d_mouse_use_direct": "Bruk direkte driver",
"ui.3d_mouse_use_direct_tip": "Les 3D-musen direkte (hidraw) i stedet for gjennom spacenavd."
},
"nl": {
"ui.3d_mouse_use_direct": "Direct stuurprogramma gebruiken",
"ui.3d_mouse_use_direct_tip": "De 3D-muis direct uitlezen (hidraw) in plaats van via spacenavd."
},
"pl": {
"ui.3d_mouse_use_direct": "Użyj sterownika bezpośredniego",
"ui.3d_mouse_use_direct_tip": "Odczytuj mysz 3D bezpośrednio (hidraw) zamiast przez spacenavd."
},
"pt-BR": {
"ui.3d_mouse_use_direct": "Usar driver direto",
"ui.3d_mouse_use_direct_tip": "Ler o mouse 3D diretamente (hidraw) em vez de pelo spacenavd."
},
"ro": {
"ui.3d_mouse_use_direct": "Folosește driverul direct",
"ui.3d_mouse_use_direct_tip": "Citește mouse-ul 3D direct (hidraw) în loc de prin spacenavd."
},
"ru": {
"ui.3d_mouse_use_direct": "Использовать прямой драйвер",
"ui.3d_mouse_use_direct_tip": "Читать 3D-мышь напрямую (hidraw), а не через spacenavd."
},
"sr": {
"ui.3d_mouse_use_direct": "Користи директни драјвер",
"ui.3d_mouse_use_direct_tip": "Читај 3D миш директно (hidraw) уместо преко spacenavd."
},
"sv": {
"ui.3d_mouse_use_direct": "Använd direkt drivrutin",
"ui.3d_mouse_use_direct_tip": "Läs 3D-musen direkt (hidraw) i stället för via spacenavd."
},
"tr": {
"ui.3d_mouse_use_direct": "Doğrudan sürücüyü kullan",
"ui.3d_mouse_use_direct_tip": "3D fareyi spacenavd yerine doğrudan (hidraw) oku."
},
"uk": {
"ui.3d_mouse_use_direct": "Використати прямий драйвер",
"ui.3d_mouse_use_direct_tip": "Читати 3D-мишу напряму (hidraw), а не через spacenavd."
},
"vi": {
"ui.3d_mouse_use_direct": "Dùng trình điều khiển trực tiếp",
"ui.3d_mouse_use_direct_tip": "Đọc chuột 3D trực tiếp (hidraw) thay vì qua spacenavd."
},
"zh": {
"ui.3d_mouse_use_direct": "使用直接驱动",
"ui.3d_mouse_use_direct_tip": "直接读取 3D 鼠标(hidraw),不经过 spacenavd。"
},
"zh-TW": {
"ui.3d_mouse_use_direct": "使用直接驅動",
"ui.3d_mouse_use_direct_tip": "直接讀取 3D 滑鼠(hidraw),不經過 spacenavd。"
}
}
+78 -17
View File
@@ -1,6 +1,7 @@
//! The offer to calibrate a 3D mouse that has no calibration yet: people don't know calibration
//! exists, and it is what fixes swapped or reversed axes. It shows once per device, as one of the
//! shared toasts (`viewport::transient_messages`), until the user calibrates or closes it.
//! shared toasts (`viewport::transient_messages`), until the user calibrates or closes it with the
//! toast's close (×), whose countdown ring shows the time it has left (#7971).
use std::collections::BTreeSet;
@@ -26,7 +27,7 @@ pub fn swap_offered(device: &str, drivers: Option<crate::space_mouse::DriversPre
/// How long the notice stays when nobody is pointing at it: long enough to read it, find the
/// mouse and decide, which 15 seconds was too short for.
const SHOW_SECONDS: f64 = 30.0;
pub(crate) const SHOW_SECONDS: f64 = 30.0;
/// A longer gap than this between two looks at the offer means it was not on screen meanwhile.
const AWAY_SECONDS: f64 = 2.0;
@@ -36,12 +37,12 @@ pub fn expired(since: f64, now: f64) -> bool {
now - since >= SHOW_SECONDS
}
/// What the offer's toast can do when clicked.
/// What the offer's toast can do when clicked: its buttons, and `Close`, its close (×).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// Opens Preferences at Navigation and starts the calibration window.
Calibrate,
/// Records this device as offered.
/// Records this device as offered: the toast's close (×), never a button (#7971).
Close,
/// Records that no offer is wanted any more.
Never,
@@ -66,7 +67,15 @@ impl Action {
Self::Calibrate => "ui.3d_mouse_calibrate",
Self::Close => "ui.close",
Self::Never => "ui.3d_mouse_offer_never",
Self::Swap => "ui.3d_mouse_offer_swap",
Self::Swap => "ui.3d_mouse_use_direct",
}
}
/// The button's hover text, as a message id: what a short label leaves out.
pub fn tip(self) -> Option<&'static str> {
match self {
Self::Swap => Some("ui.3d_mouse_use_direct_tip"),
Self::Calibrate | Self::Close | Self::Never => None,
}
}
}
@@ -94,8 +103,8 @@ pub struct Offer {
/// `file_picker_busy`, `file_load.busy`, `prefs_window.open`, `space_mouse.calibrating`,
/// `issue_report::cad_input_blocked`.
/// - Writes, only its own: `space_mouse.offer_since`, `space_mouse.offer_hidden`;
/// `space_mouse_offered` and `space_mouse_offer_never` (preferences) when the user clicks Close,
/// Calibrate or Never.
/// `space_mouse_offered` and `space_mouse_offer_never` (preferences) when the user closes it (×),
/// or clicks Calibrate or Never.
/// - Calibrate opens Preferences at Navigation and starts the calibration window
/// (`prefs::open_at`, `space_mouse_calibration::start`), exactly as Preferences does. Swap (offered only
/// when the user chose spacenavd and a device is there) sets `space_mouse_driver` to Direct, the same
@@ -132,10 +141,18 @@ pub fn due(app: &mut SolveApp, ctx: &egui::Context) -> Option<Offer> {
if swap_offered(&device, app.space_mouse.drivers, app.preferences.space_mouse_driver) {
actions.push(Action::Swap);
}
actions.extend([Action::Close, Action::Never]);
// Close is the toast's close (×), not a button, so the buttons fit one row (#7971).
actions.push(Action::Never);
Some(Offer { device, actions })
}
/// The share of the offer's time left now, 1 to 0, for the countdown ring around its close (×).
/// Pointing at it (`keep`) starts its time again, so the ring is full while it is pointed at.
pub(crate) fn left(app: &SolveApp, ctx: &egui::Context) -> f32 {
let now = ctx.input(|i| i.time);
app.space_mouse.offer_since.as_ref().map_or(1.0, |(_, since)| crate::viewport::countdown_left(*since, now, SHOW_SECONDS))
}
/// The toast was drawn at `rect`: a pointer on it keeps it from expiring.
pub fn keep(app: &mut SolveApp, ctx: &egui::Context, rect: egui::Rect) {
if ctx.input(|i| i.pointer.hover_pos()).is_some_and(|pos| rect.contains(pos)) {
@@ -297,7 +314,10 @@ mod tests {
fn closing_the_offer_records_the_device_so_it_does_not_come_back() {
let mut h = offered();
assert!(crate::scenario::rect_of("toast:space_mouse_offer").is_some(), "the notice is drawn");
press(&mut h, "Close");
// Its close (×), as the Close button it replaces (#7971).
h.frames(3);
h.step(&serde_json::json!({"click": {"handle": "toast:space_mouse_offer:close"}})).unwrap();
h.frames(2);
assert!(h.app.preferences.space_mouse_offered.contains("hid 046d:c62b"));
assert!(h.app.space_mouse.calibrating.is_none());
h.frames(3);
@@ -476,7 +496,7 @@ mod preferences_tests {
assert!(h.app.space_mouse.offer_since.is_some(), "{how}: the offer is up");
assert_eq!(state(&h), before, "{how}: showing it changes nothing else");
if how == "close" {
h.step(&serde_json::json!({"widget": "Close"})).unwrap();
h.step(&serde_json::json!({"click": {"handle": "toast:space_mouse_offer:close"}})).unwrap();
} else if let Some((device, _)) = h.app.space_mouse.offer_since.take() {
h.app.space_mouse.offer_since = Some((device, -1000.0));
}
@@ -501,7 +521,7 @@ mod preferences_tests {
fn swapping_to_the_direct_driver_sets_it_and_leaves_the_offered_set_alone() {
let mut h = spacenavd_harness();
assert!(h.app.space_mouse.offer_since.is_some(), "the card is up");
h.step(&serde_json::json!({"widget": "Swap to Hidraw (Direct Driver)"})).unwrap();
h.step(&serde_json::json!({"widget": "Use Direct Driver"})).unwrap();
h.frames(3);
assert_eq!(h.app.preferences.space_mouse_driver, crate::prefs::SpaceMouseDriver::Direct);
assert!(h.app.preferences.space_mouse_offered.is_empty(), "the new source may still be offered");
@@ -510,7 +530,47 @@ mod preferences_tests {
let mut h = spacenavd_harness();
h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false });
h.frames(3);
assert!(h.step(&serde_json::json!({"widget": "Swap to Hidraw (Direct Driver)"})).is_err());
assert!(h.step(&serde_json::json!({"widget": "Use Direct Driver"})).is_err());
}
/// The card has no Close button (#7971): its × closes it as the button did, its three buttons
/// fit one row, and the ring around the × follows the share of its 30 seconds left.
#[test]
fn the_offer_closes_with_its_x_its_three_buttons_fit_one_row_and_its_ring_follows_its_time() {
let mut h = spacenavd_harness();
run(&mut h, 1.0);
assert!(card_up(&h));
let spots: Vec<[f64; 2]> = ["Calibrate…", "Use Direct Driver", "Never show again"]
.iter()
.map(|label| h.widget(label).unwrap_or_else(|| panic!("{label} is on the card")))
.collect();
let card = crate::scenario::rect_of("toast:space_mouse_offer").unwrap();
assert!(spots.iter().all(|p| (p[1] - spots[0][1]).abs() < 1.0), "one row: {spots:?} in {card:?}");
// Laid out with the buttons' drawn widths: the main action is flush with the row's right
// edge (the card's, less its 12-point margin), and the buttons sit a gap apart.
let button = |i: usize| crate::scenario::rect_of(&format!("toast:space_mouse_offer:button:{i}")).unwrap();
let (calibrate, direct, never) = (button(0), button(1), button(2));
assert!((calibrate.right() - (card.right() - 12.0)).abs() < 0.5, "flush right: {calibrate:?} in {card:?}");
let gap = crate::theme::Tokens::get().space_8;
assert!((calibrate.left() - direct.right() - gap).abs() < 0.5, "{direct:?} then {calibrate:?}");
assert!((direct.left() - never.right() - gap).abs() < 0.5, "{never:?} then {direct:?}");
assert!(never.left() >= card.left() + 28.0 - 0.5, "inside the row: {never:?} in {card:?}");
// The only control named Close is the ×, in the card's top-right corner.
let close = h.at(&serde_json::json!({"handle": "toast:space_mouse_offer:close"})).unwrap();
let near = |p: [f64; 2]| (p[0] - close[0]).abs() < 1.0 && (p[1] - close[1]).abs() < 1.0;
assert!(h.widget("Close").is_some_and(near), "the × is the Close: {:?} at {close:?}", h.widget("Close"));
assert!(close[0] as f32 > card.center().x && (close[1] as f32) < spots[0][1] as f32, "top right: {close:?} in {card:?}");
// The ring: about 1 of 30 seconds gone, then 11.
let left = |h: &crate::scenario::Harness| super::left(&h.app, &h.ctx);
assert!((left(&h) - (1.0 - 1.0 / 30.0)).abs() < 0.03, "{}", left(&h));
run(&mut h, 10.0);
assert!((left(&h) - (1.0 - 11.0 / 30.0)).abs() < 0.03, "{}", left(&h));
// Closing records the device, as Close did.
h.step(&serde_json::json!({"click": {"handle": "toast:space_mouse_offer:close"}})).unwrap();
h.frames(2);
assert!(h.app.preferences.space_mouse_offered.contains("spacenavd"));
assert!(!card_up(&h));
assert_eq!(h.app.preferences.space_mouse_driver, crate::prefs::SpaceMouseDriver::Spacenavd, "nothing else");
}
#[test]
@@ -652,7 +712,7 @@ mod preferences_tests {
/// card's four buttons does anything.
#[test]
fn a_click_after_a_guard_became_active_does_nothing_behind_it() {
for widget in ["Calibrate…", "Close", "Never show again", "Swap to Hidraw (Direct Driver)"] {
for widget in ["Calibrate…", "Close", "Never show again", "Use Direct Driver"] {
let mut h = spacenavd_harness();
run(&mut h, 2.0);
assert!(card_up(&h), "{widget}");
@@ -681,7 +741,8 @@ mod preferences_tests {
let mut h = spacenavd_harness();
run(&mut h, 2.0);
let offer = due(&mut h.app, &h.ctx).expect("due");
assert!(offer.actions.contains(&action), "{action:?} is on the card");
// Close is the card's × rather than one of its buttons (#7971).
assert!(action == Action::Close || offer.actions.contains(&action), "{action:?} is on the card");
(h, offer)
};
// Control.
@@ -777,7 +838,7 @@ mod preferences_tests {
h.app.space_mouse.device = Some("spacenavd".into());
run(&mut h, 2.0);
assert!(card_up(&h), "the first record shows the offer");
assert!(h.widget("Swap to Hidraw (Direct Driver)").is_some(), "with a direct device there, Swap shows");
assert!(h.widget("Use Direct Driver").is_some(), "with a direct device there, Swap shows");
// Hidden for the run (a status toast), it stays hidden when the device is seen again.
h.app.status = Some(("Done".into(), crate::now_ms(), false));
run(&mut h, 1.0);
@@ -794,14 +855,14 @@ mod preferences_tests {
h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: true, denied: false });
h.app.space_mouse.device = Some("spacenavd".into());
run(&mut h, 2.0);
assert!(card_up(&h) && h.widget("Swap to Hidraw (Direct Driver)").is_none(), "no Swap under Automatic");
assert!(card_up(&h) && h.widget("Use Direct Driver").is_none(), "no Swap under Automatic");
// Without a direct device there is no Swap.
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false });
h.app.space_mouse.device = Some("spacenavd".into());
run(&mut h, 2.0);
assert!(card_up(&h) && h.widget("Swap to Hidraw (Direct Driver)").is_none());
assert!(card_up(&h) && h.widget("Use Direct Driver").is_none());
}
/// The same device, plugged back in, gets what it had: its saved calibration applies, Preferences
+17 -6
View File
@@ -243,14 +243,25 @@ pub(crate) fn note_due(app: &mut SolveApp, ctx: &egui::Context) -> Option<String
Some(crate::i18n::tr_label(text).to_string())
}
/// The handles of the note's buttons, in order: the quarter turn, turning the offer off, then the
/// dismissal. They are published for scripts; `stand_up:stand` and `stand_up:never` are the
/// dialog's handles too.
pub const NOTE_HANDLES: [&str; 3] = ["stand_up:stand", "stand_up:never", "stand_up:dismiss"];
/// The handles of the note's buttons, in order: the quarter turn, then turning the offer off. They
/// are published for scripts and are the dialog's handles too.
pub const NOTE_HANDLES: [&str; 2] = ["stand_up:stand", "stand_up:never"];
/// The handles of the note's close (×), which leaves the file as it opened (#7971):
/// `toast:stand_up:close`, as every toast's, and `stand_up:dismiss`, the handle of the Close
/// button it replaces, kept for scripts.
pub const NOTE_CLOSE_HANDLES: [&str; 2] = ["toast:stand_up:close", "stand_up:dismiss"];
/// The note's button labels, in the order of `NOTE_HANDLES`.
pub(crate) fn note_labels() -> [String; 3] {
[crate::i18n::tr_label("Stand it up").to_string(), crate::i18n::tr_label("Don't ask again").to_string(), crate::i18n::tr("ui.close").to_string()]
pub(crate) fn note_labels() -> [String; 2] {
[crate::i18n::tr_label("Stand it up").to_string(), crate::i18n::tr_label("Don't ask again").to_string()]
}
/// The share of the note's time left now, 1 to 0, for the countdown ring around its close (×).
/// Pointing at it (`note_keep`) starts its time again, so the ring is full while it is pointed at.
pub(crate) fn note_left(app: &SolveApp, ctx: &egui::Context) -> f32 {
let now = ctx.input(|i| i.time);
app.stand_up_note_since.map_or(1.0, |since| crate::viewport::countdown_left(since, now, NOTE_SECONDS))
}
/// The note was drawn at `rect`: a pointer on it keeps it from going.
+236 -29
View File
@@ -5073,19 +5073,30 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re
// (#7717): the shared toast with its buttons, which go by themselves and never block input.
if let Some(text) = crate::stand_up::note_due(app, ui.ctx()) {
let labels = crate::stand_up::note_labels();
let actions =
[(labels[0].as_str(), ToastKind::Primary), (labels[1].as_str(), ToastKind::Secondary), (labels[2].as_str(), ToastKind::Dismiss)];
if let Some((r, clicked, buttons)) = paint_toast_with(ui, free, text, ToastLead::Dot(false), &actions) {
let actions = [(labels[0].as_str(), ToastKind::Primary), (labels[1].as_str(), ToastKind::Secondary)];
let [close, dismiss] = crate::stand_up::NOTE_CLOSE_HANDLES;
let extras = ToastExtras {
close: Some(close),
countdown: Some((crate::stand_up::note_left(app, ui.ctx()), crate::stand_up::NOTE_SECONDS)),
..Default::default()
};
if let Some(toast) = paint_toast_ext(ui, free, text, ToastLead::Dot(false), &actions, extras) {
let r = toast.rect;
crate::scenario::publish_rect("toast:stand_up", r);
for (handle, button) in crate::stand_up::NOTE_HANDLES.iter().zip(&buttons) {
for (handle, button) in crate::stand_up::NOTE_HANDLES.iter().zip(&toast.buttons) {
crate::scenario::publish_handle(handle, button.center());
}
if let Some(at) = toast.close {
crate::scenario::publish_handle(dismiss, at.center());
}
obstacles.push(r);
free.max.y = r.top() - t.space_8;
shown.push(r);
crate::stand_up::note_keep(app, ui.ctx(), r);
if let Some(index) = clicked {
if let Some(index) = toast.clicked {
crate::stand_up::note_act(app, index);
} else if toast.closed {
crate::stand_up::leave(app);
}
}
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
@@ -5094,14 +5105,28 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re
if let Some(offer) = crate::space_mouse_offer::due(app, ui.ctx()) {
let labels: Vec<String> = offer.actions.iter().map(|action| crate::i18n::tr(action.label()).to_string()).collect();
let labels: Vec<(&str, ToastKind)> = labels.iter().map(String::as_str).zip(offer.actions.iter().map(|action| action.kind())).collect();
if let Some((r, clicked, _)) = paint_toast_with(ui, free, crate::i18n::tr("ui.3d_mouse_offer").to_string(), ToastLead::Dot(false), &labels) {
let tips: Vec<&str> = offer.actions.iter().map(|action| action.tip().map_or("", crate::i18n::tr)).collect();
let extras = ToastExtras {
close: Some("toast:space_mouse_offer:close"),
countdown: Some((crate::space_mouse_offer::left(app, ui.ctx()), crate::space_mouse_offer::SHOW_SECONDS)),
tips: &tips,
..Default::default()
};
if let Some(toast) = paint_toast_ext(ui, free, crate::i18n::tr("ui.3d_mouse_offer").to_string(), ToastLead::Dot(false), &labels, extras) {
let r = toast.rect;
crate::scenario::publish_rect("toast:space_mouse_offer", r);
for (index, button) in toast.buttons.iter().enumerate() {
crate::scenario::publish_rect(&format!("toast:space_mouse_offer:button:{index}"), *button);
}
obstacles.push(r);
free.max.y = r.top() - t.space_8;
shown.push(r);
crate::space_mouse_offer::keep(app, ui.ctx(), r);
if let Some(action) = clicked.and_then(|i| offer.actions.get(i)) {
crate::space_mouse_offer::act(app, ui.ctx(), &offer, *action);
// The close (×) does what the Close button did (#7971).
let action =
if toast.closed { Some(crate::space_mouse_offer::Action::Close) } else { toast.clicked.and_then(|i| offer.actions.get(i).copied()) };
if let Some(action) = action {
crate::space_mouse_offer::act(app, ui.ctx(), &offer, action);
}
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
}
@@ -5118,8 +5143,16 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re
if shown_ms < STATUS_TOAST_MS {
let undo = app.status_undo;
let actions = if undo { vec![(crate::i18n::tr_label("Undo"), ToastKind::Secondary)] } else { Vec::new() };
if let Some((r, clicked, _)) = paint_toast_with(ui, free, msg, ToastLead::Dot(error), &actions) {
if undo && clicked == Some(0) {
let extras = ToastExtras {
close: Some("toast:status:close"),
countdown: Some((countdown_left(0.0, shown_ms / 1000.0, STATUS_TOAST_MS / 1000.0), STATUS_TOAST_MS / 1000.0)),
// Without Undo, a click anywhere else on it opens the run's messages.
card_hint: (!undo).then(|| crate::i18n::tr("messages.chip_hint")),
..Default::default()
};
if let Some(toast) = paint_toast_ext(ui, free, msg, ToastLead::Dot(error), &actions, extras) {
let r = toast.rect;
if undo && toast.clicked == Some(0) {
app.status = None;
app.status_undo = false;
let _ = app.run("edit.undo", json!({}));
@@ -5130,11 +5163,13 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re
shown.push(r);
// Open the run's messages from the tokenised status toast.
crate::scenario::publish_handle("status:chip", r.center());
if !undo
&& ui.interact(r, egui::Id::new("sc_status_chip"), Sense::click()).on_hover_text(crate::i18n::tr("messages.chip_hint")).clicked()
{
if toast.card_clicked {
app.messages.open = true;
}
if toast.closed {
app.status = None;
app.status_undo = false;
}
}
// One frame when it is due to go, not a frame every half second while it shows.
let left_ms = STATUS_TOAST_MS - shown_ms;
@@ -5187,8 +5222,9 @@ impl ToastPlace {
}
}
/// Every toast's width in the corner stack: room for three buttons on one row; longer text wraps.
pub(crate) const TOAST_WIDTH: f32 = 340.0;
/// Every toast's width in the corner stack: room for three buttons on one row (the 3D mouse offer's
/// Calibrate…, Use Direct Driver and Never show again, #7971); longer text wraps.
pub(crate) const TOAST_WIDTH: f32 = 360.0;
/// Where the corner stack may go: the largest free rect in a `TOAST_WIDTH` column at the right of
/// `view`, clear of `obstacles` (the axis view and cube at the top, the view toolbar when a narrow
@@ -5231,7 +5267,7 @@ pub(crate) fn corner_free(view: Rect, obstacles: &[Rect]) -> Rect {
/// What a toast's button is for, which sets its look and where it goes: the main action first
/// (the accent button), then an alternative, then the quiet dismissals.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum ToastKind {
Primary,
Secondary,
@@ -5312,6 +5348,16 @@ fn toast_action_spots_trailing(buttons: &[(f32, ToastKind)], row_width: f32, gap
spots
}
/// Where a toast button's drawn width is remembered: by its text and kind, which set its look.
fn toast_button_key(label: &str, kind: ToastKind) -> egui::Id {
egui::Id::new(("sc_toast_button_width", label, kind))
}
/// The width a toast button with this text and kind was last drawn at, if it has been drawn.
fn toast_button_width(ctx: &egui::Context, label: &str, kind: ToastKind) -> Option<f32> {
ctx.data(|data| data.get_temp::<f32>(toast_button_key(label, kind))).filter(|width| width.is_finite() && *width > 0.0)
}
/// What leads a toast's text: a dot (red for an error) or a spinner.
#[derive(Clone, Copy)]
enum ToastLead {
@@ -5324,11 +5370,8 @@ fn paint_toast(ui: &egui::Ui, free: Rect, text: String, error: bool, computing:
paint_toast_with(ui, free, text, lead, &[]).map(|(rect, _, _)| rect)
}
/// The one toast painter: its text in a card at the bottom centre of `free`, led by a dot or a
/// spinner, and, below the text, a wrapping row of buttons (`actions`, kit buttons). Returns the
/// card's rect, which button was clicked, if any, and where each button is (for handles). With no actions the card is exactly the status
/// and computing toast it always was. The buttons are not live while an issue report holds CAD
/// input; none is laid outside the card, whose width is bounded by `free`.
/// [`paint_toast_ext`] with no close (×), countdown, hover texts or card click: the card, which
/// button was clicked, if any, and where each button is.
fn paint_toast_with(
ui: &egui::Ui,
free: Rect,
@@ -5336,6 +5379,111 @@ fn paint_toast_with(
lead: ToastLead,
actions: &[(&str, ToastKind)],
) -> Option<(Rect, Option<usize>, Vec<Rect>)> {
paint_toast_ext(ui, free, text, lead, actions, ToastExtras::default()).map(|toast| (toast.rect, toast.clicked, toast.buttons))
}
/// What a toast has besides its text and buttons (#7971).
#[derive(Clone, Copy, Default)]
pub(crate) struct ToastExtras<'a> {
/// A close (×) in its top-right corner, published as this control-channel handle
/// (`toast:<name>:close`). Every toast the user may close has one, in place of a Close button.
pub close: Option<&'a str>,
/// For a toast that goes by itself: the share of its time left (1 to 0) and its whole time in
/// seconds, drawn as a thin ring around the × that shrinks over its time.
pub countdown: Option<(f32, f64)>,
/// Hover texts of the buttons, by index; an empty one has none.
pub tips: &'a [&'a str],
/// The whole card answers a click, under its buttons and ×, with this hover text.
pub card_hint: Option<&'a str>,
}
/// A toast as drawn: its card, which button was clicked, if any, where each button and the × are
/// (for handles), and whether the × or the card itself was clicked.
pub(crate) struct ToastShown {
pub rect: Rect,
pub clicked: Option<usize>,
pub buttons: Vec<Rect>,
pub close: Option<Rect>,
pub closed: bool,
pub card_clicked: bool,
}
/// The share of a countdown of `lifetime` seconds left at `now` when it started at `since`: 1 when
/// it starts, 0 when it is over, and 1 for times no clock gives (non-finite, or a start in the future).
pub(crate) fn countdown_left(since: f64, now: f64, lifetime: f64) -> f32 {
let left = 1.0 - (now - since) / lifetime;
if left.is_finite() { left.clamp(0.0, 1.0) as f32 } else { 1.0 }
}
/// The countdown ring's radius around a toast's ×; the × is drawn inside it and its hit target is
/// the ring's square.
fn toast_close_radius() -> f32 {
Tokens::get().space_10
}
/// The points of the countdown ring: an arc of `left` of a turn round `centre`, from twelve o'clock
/// clockwise, so it shrinks back towards twelve o'clock as the time goes. Empty when nothing is left.
pub(crate) fn countdown_arc(centre: Pos2, radius: f32, left: f32) -> Vec<Pos2> {
let left = if left.is_finite() { left.clamp(0.0, 1.0) } else { 0.0 };
if left <= 0.0 || !radius.is_finite() || radius <= 0.0 {
return Vec::new();
}
// One segment per 7.5 degrees of the arc, at least one.
let segments = ((48.0 * left).ceil() as usize).max(1);
(0..=segments)
.map(|i| {
let angle = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * left * i as f32 / segments as f32;
centre + radius * vec2(angle.cos(), angle.sin())
})
.collect()
}
/// A toast's close (×) at `rect`, with its countdown ring when `left` is given: a kit-drawn button
/// with an accessible name and hover text (`ui.close`), reachable by Tab and taken with Enter or Space.
fn toast_close(ui: &egui::Ui, rect: Rect, id: egui::Id, left: Option<f32>) -> egui::Response {
let t = Tokens::get();
let response = ui.interact(rect, id, Sense::click());
let painter = ui.painter();
let centre = rect.center();
let radius = rect.width().min(rect.height()) * 0.5;
let lit = response.hovered() || response.has_focus();
if lit && ui.is_enabled() {
painter.circle_filled(centre, radius, t.hover);
}
if response.has_focus() {
painter.circle_stroke(centre, radius + t.space_2, Stroke::new(1.0, t.form_primary));
}
if let Some(left) = left {
let arc = countdown_arc(centre, radius - 0.75, left);
if arc.len() >= 2 {
painter.add(egui::Shape::line(arc, Stroke::new(1.5, t.text_dim)));
}
}
let ink = if lit && ui.is_enabled() { t.text } else { t.text_dim };
crate::icons::paint_ink(painter, Rect::from_center_size(centre, vec2(t.space_10, t.space_10)), "close", ink);
let label = crate::i18n::tr("ui.close");
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
if response.hovered() && ui.is_enabled() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response.on_hover_text(label)
}
/// The one toast painter: its text in a card at the bottom centre of `free`, led by a dot or a
/// spinner, and, below the text, a wrapping row of buttons (`actions`, kit buttons), with what
/// `extras` adds (a close (×) at the top right with its countdown ring, hover texts, a click on the
/// card). Returns the card, which button was clicked, if any, and where each button is (for
/// handles). With no actions and no extras the card is exactly the status and computing toast it
/// always was. The buttons and the × are not live while an issue report holds CAD input; none is
/// laid outside the card, whose width is bounded by `free`.
fn paint_toast_ext(
ui: &egui::Ui,
free: Rect,
text: String,
lead: ToastLead,
actions: &[(&str, ToastKind)],
extras: ToastExtras<'_>,
) -> Option<ToastShown> {
// Rects no screen has are rejected too: the corner layout anchors to `free`'s right edge.
if !free.is_finite() || !free.is_positive() || free.width() <= 40.0 || free.max.x.abs().max(free.max.y.abs()) > 1.0e7 {
return None;
@@ -5346,16 +5494,23 @@ fn paint_toast_with(
// In the corner every toast is `TOAST_WIDTH` wide (or the room there is); at the centre each
// is as wide as its text, up to 440 points.
let wrap = if corner { (TOAST_WIDTH.min(free.width()) - 40.0).max(0.0) } else { (free.width() - 40.0).min(440.0) };
let galley = painter.layout(text, crate::fonts::medium(t.font_label), t.text, wrap);
// The × keeps the top-right corner: the text stops short of its ring.
let radius = toast_close_radius();
let close_room = if extras.close.is_some() { 2.0 * radius + t.space_4 + t.space_8 - 12.0 } else { 0.0 };
let galley = painter.layout(text, crate::fonts::medium(t.font_label), t.text, (wrap - close_room).max(0.0));
// The buttons: each as wide as its text and a margin, in rows no wider than the text may be.
let gap = t.space_8;
let button_height = crate::ui_kit::row_height();
let row_width = if corner { wrap } else { (free.width() - 40.0).clamp(0.0, 440.0) };
// Each button's width as the kit drew it (#7971): the same text, font and padding, measured
// from the button itself. Before a button was ever drawn, an estimate stands in for one frame.
let sizes: Vec<(f32, ToastKind)> = actions
.iter()
.map(|(label, kind)| {
let padding = if *kind == ToastKind::Primary { t.space_16 } else { t.space_12 };
let width = painter.layout_no_wrap((*label).to_string(), crate::fonts::medium(t.font_label), t.text).size().x + 2.0 * padding;
let width = toast_button_width(ui.ctx(), label, *kind).unwrap_or_else(|| {
let padding = if *kind == ToastKind::Primary { t.space_16 } else { t.space_12 };
painter.layout_no_wrap((*label).to_string(), crate::fonts::medium(t.font_label), t.text).size().x + 2.0 * padding
});
(width.min(row_width), *kind)
})
.collect();
@@ -5367,11 +5522,14 @@ fn paint_toast_with(
let widest = spots.iter().map(|spot| spot.right()).fold(0.0, f32::max);
let y = spots.iter().map(|spot| spot.top()).fold(0.0, f32::max);
let actions_height = if spots.is_empty() { 0.0 } else { y + button_height + gap };
let content = vec2(galley.size().x.max(widest), galley.size().y + actions_height);
let content = vec2((galley.size().x + close_room).max(widest), galley.size().y + actions_height);
let mut size = content + vec2(40.0, 16.0);
if corner {
size.x = row_width + 40.0;
}
if extras.close.is_some() {
size.y = size.y.max(2.0 * radius + 12.0);
}
if !size.is_finite() || size.x > free.width() || size.y > free.height() {
return None;
}
@@ -5389,31 +5547,80 @@ fn paint_toast_with(
}
let text_top = r.min + vec2(28.0, 8.0);
painter.galley(text_top, galley.clone(), t.text);
// The card's own click goes first, so its buttons and × (added after it) take theirs. Behind an
// issue report it is dead too, so a click on the dead × doesn't fall through to it.
let blocked = crate::issue_report::cad_input_blocked();
let card_clicked = extras.card_hint.is_some_and(|hint| {
let sense = if blocked { Sense::hover() } else { Sense::click() };
ui.interact(r, egui::Id::new(("sc_toast_card", extras.close.unwrap_or_default())), sense).on_hover_text(hint).clicked()
});
let mut clicked = None;
let mut buttons = Vec::with_capacity(actions.len());
let origin = text_top + vec2(0.0, galley.size().y + gap);
for (index, ((label, kind), spot)) in actions.iter().zip(&spots).enumerate() {
let at = spot.translate(origin.to_vec2());
// A UI of its own on the viewport's layer, so the toast needs no `&mut Ui`.
// A UI of its own on the viewport's layer, so the toast needs no `&mut Ui`. It has the
// row's width, so a button is never squeezed into a spot laid out for a smaller one and
// its measured width is its own.
let room = Rect::from_min_size(at.min, vec2(row_width.max(at.width()), at.height()));
let mut button_ui = egui::Ui::new(
ui.ctx().clone(),
egui::Id::new(("sc_toast_action", index)),
egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(at).layout(egui::Layout::left_to_right(egui::Align::Center)),
egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(room).layout(egui::Layout::left_to_right(egui::Align::Center)),
);
button_ui.set_clip_rect(button_ui.clip_rect().intersect(r));
let response = button_ui
.add_enabled_ui(!crate::issue_report::cad_input_blocked(), |ui| match kind {
.add_enabled_ui(!blocked, |ui| match kind {
ToastKind::Primary => crate::ui_kit::primary_button(ui, label),
ToastKind::Secondary => crate::ui_kit::button(ui, *label, crate::ui_kit::Button::Normal),
ToastKind::Dismiss => crate::ui_kit::button(ui, *label, crate::ui_kit::Button::Small),
})
.inner;
let response = match extras.tips.get(index).filter(|tip| !tip.is_empty()) {
Some(tip) => response.on_hover_text(*tip),
None => response,
};
if response.clicked() {
clicked = Some(index);
}
// Remember the width it was drawn at; laid out at another, the toast is laid out again next frame.
let drawn = response.rect.width();
if drawn.is_finite() && drawn > 0.0 {
if sizes.get(index).is_some_and(|(used, _)| (used - drawn).abs() > 0.5 && drawn < row_width) {
ui.ctx().request_repaint();
}
ui.ctx().data_mut(|data| data.insert_temp(toast_button_key(label, *kind), drawn));
}
buttons.push(response.rect);
}
Some((r, clicked, buttons))
// The close (×), top right, level with the text's first line.
let mut close = None;
let mut closed = false;
if let Some(handle) = extras.close {
let line = painter.layout_no_wrap("A".into(), crate::fonts::medium(t.font_label), t.text).size().y;
let centre = pos2(r.right() - t.space_8 - radius, (text_top.y + line * 0.5).max(r.top() + 6.0 + radius));
let at = Rect::from_center_size(centre, vec2(2.0 * radius, 2.0 * radius));
let mut close_ui = egui::Ui::new(
ui.ctx().clone(),
egui::Id::new(("sc_toast_close_ui", handle)),
egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(at),
);
close_ui.set_clip_rect(close_ui.clip_rect().intersect(r));
if blocked {
close_ui.disable();
}
let left = extras.countdown.map(|(left, _)| left);
closed = toast_close(&close_ui, at, egui::Id::new(("sc_toast_close", handle)), left).clicked();
crate::scenario::publish_handle(handle, centre);
close = Some(at);
// A frame each time the ring's end moves about a point, not one per display refresh.
if let Some((_, lifetime)) = extras.countdown {
let every = lifetime / (std::f64::consts::TAU * f64::from(radius));
let every = if every.is_finite() { every.clamp(0.05, 1.0) } else { 1.0 };
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(every));
}
}
Some(ToastShown { rect: r, clicked, buttons, close, closed, card_clicked })
}
/// A click without a command: select the candidate (replacing the selection, or adding /
+81 -7
View File
@@ -582,12 +582,7 @@ fn the_shared_toast_draws_old_callers_exactly_as_before() {
/// holds CAD input.
#[test]
fn toast_buttons_wrap_inside_the_card_and_are_dead_behind_an_issue_report() {
let labels = [
("Calibrate…", ToastKind::Primary),
("Swap to Hidraw (Direct Driver)", ToastKind::Secondary),
("Close", ToastKind::Dismiss),
("Never show again", ToastKind::Dismiss),
];
let labels = [("Calibrate…", ToastKind::Primary), ("Use Direct Driver", ToastKind::Secondary), ("Never show again", ToastKind::Dismiss)];
for width in [260.0_f32, 360.0, 600.0, 1000.0] {
for blocked in [false, true] {
crate::issue_report::set_cad_input_blocked_for_test(blocked);
@@ -632,7 +627,7 @@ fn toast_buttons_wrap_inside_the_card_and_are_dead_behind_an_issue_report() {
} else {
hits.sort_unstable();
hits.dedup();
assert_eq!(hits, [0, 1, 2, 3], "width {width}: every button can be clicked inside the card");
assert_eq!(hits, [0, 1, 2], "width {width}: every button can be clicked inside the card");
}
}
}
@@ -1535,3 +1530,82 @@ fn analysis_status_preferences_restore_false_keeps_underlying_dialog_through_the
h.step(&json!({"key":"Escape"})).unwrap();
assert!(h.app.session.analysis.is_some(), "the restored legacy policy keeps shading on idle Escape");
}
/// The countdown ring around a toast's × (#7971) is the share of its time left: whole when it
/// starts, half at half time, gone when it is over, and whole for times no clock gives.
#[test]
fn the_countdown_ring_follows_the_time_left() {
assert_eq!(countdown_left(10.0, 10.0, 6.0), 1.0);
assert!((countdown_left(10.0, 13.0, 6.0) - 0.5).abs() < 1e-6);
assert!((countdown_left(0.0, 1.5, 6.0) - 0.75).abs() < 1e-6);
assert_eq!(countdown_left(10.0, 16.0, 6.0), 0.0);
assert_eq!(countdown_left(10.0, 99.0, 6.0), 0.0);
assert_eq!(countdown_left(10.0, 5.0, 6.0), 1.0, "a start in the future");
for (since, now, lifetime) in [(f64::NAN, 1.0, 6.0), (0.0, f64::INFINITY, f64::INFINITY), (0.0, 1.0, 0.0), (0.0, 0.0, 0.0)] {
let left = countdown_left(since, now, lifetime);
assert!((0.0..=1.0).contains(&left), "{since} {now} {lifetime}: {left}");
}
// The arc: from twelve o'clock, clockwise (y down), over that share of a turn, on the ring.
let centre = pos2(100.0, 50.0);
for left in [1.0_f32, 0.75, 0.5, 0.25, 0.01] {
let arc = countdown_arc(centre, 10.0, left);
assert!(arc.len() >= 2, "{left}");
assert!(arc.iter().all(|p| ((*p - centre).length() - 10.0).abs() < 1e-3), "{left}: on the ring");
assert!((arc[0] - pos2(100.0, 40.0)).length() < 1e-3, "starts at twelve o'clock");
assert!((swept(&arc, centre) - left).abs() < 1e-3, "{left}: sweeps {}", swept(&arc, centre));
}
assert!((countdown_arc(centre, 10.0, 0.25).last().copied().unwrap() - pos2(110.0, 50.0)).length() < 1e-3, "a quarter ends at three o'clock");
for bad in [0.0_f32, -1.0, f32::NAN] {
assert!(countdown_arc(centre, 10.0, bad).is_empty(), "{bad}");
}
assert!(countdown_arc(centre, f32::NAN, 0.5).is_empty() && countdown_arc(centre, 0.0, 0.5).is_empty());
}
/// The share of a turn an arc of `points` sweeps clockwise (y down) round `centre`.
fn swept(points: &[Pos2], centre: Pos2) -> f32 {
let turn = std::f32::consts::TAU;
points.windows(2).map(|w| ((w[1] - centre).angle() - (w[0] - centre).angle()).rem_euclid(turn)).sum::<f32>() / turn
}
/// The status toast has a close (×) at its top right and no Close button (#7971): the × closes it
/// without opening the run's messages (a click elsewhere on it still does), its ring shows the
/// share of its six seconds left, and behind an issue report the × does nothing.
#[test]
fn the_status_toast_closes_with_its_x_and_shows_its_time_left() {
let t = Tokens::get();
let mut h = Harness::new();
h.app.home.open = false;
// Half its time gone.
h.app.status = Some(("Opened".into(), crate::now_ms() - STATUS_TOAST_MS / 2.0, false));
h.frames(2);
let toast = crate::scenario::rect_of("toast").expect("the status toast");
let close = crate::scenario::handle_at("toast:status:close").expect("its ×");
assert!(toast.contains(close) && close.x > toast.center().x && close.y <= toast.center().y + 1.0, "top right: {close:?} in {toast:?}");
fn ring(shape: &Shape, ink: Color32) -> Option<Vec<Pos2>> {
match shape {
Shape::Path(p) if !p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(ink) && p.points.len() >= 2 => Some(p.points.clone()),
Shape::Vec(shapes) => shapes.iter().find_map(|s| ring(s, ink)),
_ => None,
}
}
let arc = h.shapes.iter().find_map(|s| ring(&s.shape, t.text_dim)).expect("the ring is drawn");
assert!((swept(&arc, close) - 0.5).abs() < 0.05, "about half a turn left: {}", swept(&arc, close));
assert!(h.widget("Close").is_some(), "the × has an accessible name");
// Behind an issue report it does nothing.
crate::issue_report::set_cad_input_blocked_for_test(true);
h.step(&json!({"click": {"handle": "toast:status:close"}})).unwrap();
h.frames(2);
crate::issue_report::set_cad_input_blocked_for_test(false);
assert!(h.app.status.is_some(), "not closed behind a report");
h.step(&json!({"click": {"handle": "toast:status:close"}})).unwrap();
h.frames(2);
assert!(h.app.status.is_none(), "closed");
assert!(!h.app.messages.open, "the × doesn't open the messages");
assert!(crate::scenario::rect_of("toast").is_none());
// A click elsewhere on it still opens them.
h.app.status = Some(("Opened".into(), crate::now_ms(), false));
h.frames(2);
h.step(&json!({"click": {"handle": "status:chip"}})).unwrap();
h.frames(2);
assert!(h.app.messages.open, "the card opens the messages");
}
+9 -3
View File
@@ -2,7 +2,8 @@
//! as `stand_up:stand`, `stand_up:leave` and `stand_up:never`, it stays up through a camera move
//! and a command until answered, and `ui.prefs {"stand_up": false}` leaves the file as it is.
//! A file from a program not known to save Y up gets a small note instead by default (#7717),
//! with `stand_up:stand`, `stand_up:never` and `stand_up:dismiss`; Preferences picks the dialog or nothing.
//! with `stand_up:stand`, `stand_up:never` and its close (×), `toast:stand_up:close` (also published
//! as `stand_up:dismiss`, the Close button's handle, #7971); Preferences picks the dialog or nothing.
use serde_json::json;
use solidcraft_ui_egui::prefs::StandUpUnknown;
use solidcraft_ui_egui::scenario::Harness;
@@ -256,9 +257,14 @@ fn an_unknown_exporter_lying_on_its_back_gets_the_note_not_the_dialog() {
assert!(h.app.stand_up_note, "as the note");
assert!(painted(&h, "Did this part open the wrong way up?"));
assert!(h.widget("Stand this part up?").is_none(), "no dialog");
for name in ["stand_up:stand", "stand_up:never", "stand_up:dismiss"] {
for name in ["stand_up:stand", "stand_up:never", "toast:stand_up:close", "stand_up:dismiss"] {
assert!(h.at(&json!({"handle": name})).is_ok(), "{name} is published");
}
// No Close button (#7971): the only control named Close is the note's ×, which the old handle finds.
let close = h.at(&json!({"handle": "toast:stand_up:close"})).unwrap();
let near = |p: [f64; 2]| (p[0] - close[0]).abs() < 1.0 && (p[1] - close[1]).abs() < 1.0;
assert!(h.widget("Close").is_some_and(near), "the × is the Close: {:?} at {close:?}", h.widget("Close"));
assert!(near(h.at(&json!({"handle": "stand_up:dismiss"})).unwrap()));
assert!(h.at(&json!({"handle": "stand_up:leave"})).is_err(), "the dialog's buttons are not");
// It doesn't hold anything up: the view and commands work while it shows.
h.run(&json!([{"camera": {"yaw": 0.6, "pitch": 0.4}}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1, "corner": [100, 0, 0]}}}]))
@@ -328,7 +334,7 @@ fn the_notes_dont_ask_again_turns_the_offer_off() {
#[test]
fn the_note_closes_on_dismiss_and_by_itself() {
let mut h = opened_box(ON_ITS_BACK, UNKNOWN, |_| {});
h.run(&json!([{"click": {"handle": "stand_up:dismiss"}}])).unwrap();
h.run(&json!([{"click": {"handle": "toast:stand_up:close"}}])).unwrap();
h.frames(3);
assert!(h.app.stand_up.is_none(), "dismissed");
assert!(h.app.preferences.offer_stand_up, "the preference stays");
+7 -2
View File
@@ -7,6 +7,11 @@ scenario fixture does not change engine state or open a dialog. `off` clears it.
The published `viewport_status:<id>:item`, `:setting`, `:key` and `:close` handles
support the ordinary `ui.at`, `ui.move`, `ui.click` and keyboard controls.
Toasts the user may close have a close (×) at their top right instead of a Close button, published
as `toast:<name>:close`: `toast:status:close`, `toast:space_mouse_offer:close` and
`toast:stand_up:close` (#7971). Those that go by themselves draw a thin ring around it that
shrinks over their time; the ring is full again while the pointer is on a toast that waits for it.
The control channel is **on by default**. SolidCraft listens on `127.0.0.1` only, for JSON lines,
so agents can drive the app without a launch flag. Every connection still needs this launch's
token (below); nothing else on the machine, and nothing off it, can send a command.
@@ -451,7 +456,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own
- `sketch_tab_always` (default false: the SKETCH tab stays in the ribbon right after MODEL while no sketch is open; a tool in its CREATE or REFERENCE section then opens Create Sketch, on the selected plane or planar face or else on the plane picked next, and starts in the new sketch, #7904)
- `smooth_wheel_zoom` (default true)
- `space_mouse_model_pivot` (default false: Classic orbit around the view's centre; true opts into Traced orbit around the model for the 3D mouse and navigation bridge)
- `stand_up_unknown` (`note` (default: a file whose header names no program known to save Y up and whose bodies seem to lie on their back (their flat area across Y outweighs their flat area facing down Z) gets a small note over the view, "Did this part open the wrong way up?", with Stand it up, Don't ask again and Close as the handles `stand_up:stand`, `stand_up:never` and `stand_up:dismiss`; it goes by itself after 10 seconds and never blocks input), `ask` (the "Stand this part up?" dialog for every file from another program, SolidWorks too, without looking at how it rests, as before #7717), `off` (no offer); otherwise a SolidWorks file gets the dialog, or the note when its bodies clearly stand on Z already)
- `stand_up_unknown` (`note` (default: a file whose header names no program known to save Y up and whose bodies seem to lie on their back (their flat area across Y outweighs their flat area facing down Z) gets a small note over the view, "Did this part open the wrong way up?", with Stand it up and Don't ask again as the handles `stand_up:stand` and `stand_up:never`, and its close (×) as `toast:stand_up:close` (also `stand_up:dismiss`); it goes by itself after 10 seconds and never blocks input), `ask` (the "Stand this part up?" dialog for every file from another program, SolidWorks too, without looking at how it rests, as before #7717), `off` (no offer); otherwise a SolidWorks file gets the dialog, or the note when its bodies clearly stand on Z already)
- `status_bar` (default false: a thin bar along the window's bottom edge, below History, handle `status:bar`: what is being edited (`status:editing`), the open sketch's state in its colour (`status:sketch`) and the design's units (`status:units`, a click opens the Units menu, whose rows run `document.units`); the sketch's state then leaves the view's top-left corner, #7243)
- `step_values` (default false: Up/Down and the wheel step a plain number in a focused value field)
- `theme` (`dark`, `light`, `system` or a theme file's id: a built-in one such as `gruvbox-dark`, or an imported one; the reply's `theme_ids` lists them)
@@ -460,7 +465,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own
- `theme_import` (a `solidcraft-theme/1` file's JSON or text: adds the theme and turns it on; the reply's `theme_imported` is its id)
- `theme_remove` (an imported theme's id)
- `timeline_origin_bodies` (default false: the History on the left or under the browser starts with the active component's Bodies folder and the Origin, the browser's rows with their eyes, folds and menus; the bottom History is unchanged)
- `toast_place` (`corner` (default: notices and offers stack in one column in the view's bottom-right corner, each 340 points wide, buttons right-aligned with the main action right-most, #7893), `centre` (at the bottom centre above the view toolbar, each as wide as its text, as before))
- `toast_place` (`corner` (default: notices and offers stack in one column in the view's bottom-right corner, each 360 points wide, buttons right-aligned with the main action right-most, #7893), `centre` (at the bottom centre above the view toolbar, each as wide as its text, as before))
- `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms)
- `trackpad_zoom_modifier` (`off` default, `cmd`, `alt`, or `ctrl`: hold this key alone for vertical scroll to zoom)
- `ui_font` (an installed font family or a font file for the interface; empty for the built-in font)