Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): toggle button + tooltip

Lewis: May this revision serve well! <lu5a@proton.me>

+724
+364
crates/bone-ui/src/widgets/toggle_button.rs
··· 1 + use crate::frame::{FrameCtx, InteractDeclaration}; 2 + use crate::hit_test::{Interaction, Sense}; 3 + use crate::layout::LayoutRect; 4 + use crate::strings::StringKey; 5 + use crate::widget_id::WidgetId; 6 + 7 + use super::button::ButtonState; 8 + use super::keys::take_activation; 9 + use super::paint::WidgetPaint; 10 + use super::visuals::{Indicator, push_indicator}; 11 + 12 + #[derive(Copy, Clone, Debug, PartialEq)] 13 + pub struct ToggleButton { 14 + pub id: WidgetId, 15 + pub rect: LayoutRect, 16 + pub label: StringKey, 17 + pub state: ButtonState, 18 + pub on: bool, 19 + } 20 + 21 + impl ToggleButton { 22 + #[must_use] 23 + pub const fn new(id: WidgetId, rect: LayoutRect, label: StringKey, on: bool) -> Self { 24 + Self { 25 + id, 26 + rect, 27 + label, 28 + on, 29 + state: ButtonState::Idle, 30 + } 31 + } 32 + 33 + #[must_use] 34 + pub const fn with_state(self, state: ButtonState) -> Self { 35 + Self { state, ..self } 36 + } 37 + } 38 + 39 + #[derive(Clone, Debug, PartialEq)] 40 + pub struct ToggleButtonResponse { 41 + pub interaction: Interaction, 42 + pub on: bool, 43 + pub toggled: bool, 44 + pub paint: Vec<WidgetPaint>, 45 + } 46 + 47 + #[must_use] 48 + pub fn show_toggle_button(ctx: &mut FrameCtx<'_>, toggle: ToggleButton) -> ToggleButtonResponse { 49 + let interactive = toggle.state.is_interactive(); 50 + let interaction = ctx.interact( 51 + InteractDeclaration::new(toggle.id, toggle.rect, Sense::INTERACTIVE) 52 + .focusable(interactive) 53 + .disabled(!interactive) 54 + .active(toggle.on), 55 + ); 56 + let live_focused = ctx.is_focused(toggle.id); 57 + let toggled = interactive 58 + && (interaction.click() || (live_focused && take_activation(ctx.input))); 59 + let next_on = if toggled { !toggle.on } else { toggle.on }; 60 + let disabled = matches!(toggle.state, ButtonState::Disabled); 61 + let mut paint = Vec::new(); 62 + push_indicator( 63 + ctx, 64 + &mut paint, 65 + Indicator { 66 + rect: toggle.rect, 67 + label: toggle.label, 68 + mark: None, 69 + active: next_on, 70 + disabled, 71 + radius: ctx.theme.radius.sm, 72 + }, 73 + interaction, 74 + live_focused, 75 + ); 76 + ToggleButtonResponse { 77 + interaction, 78 + on: next_on, 79 + toggled, 80 + paint, 81 + } 82 + } 83 + 84 + #[cfg(test)] 85 + mod tests { 86 + use std::sync::Arc; 87 + 88 + use super::{ToggleButton, show_toggle_button}; 89 + use crate::focus::FocusManager; 90 + use crate::frame::FrameCtx; 91 + use crate::hit_test::{HitFrame, HitState, resolve}; 92 + use crate::hotkey::HotkeyTable; 93 + use crate::input::{ 94 + FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 95 + PointerButtonMask, PointerSample, 96 + }; 97 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 98 + use crate::strings::StringKey; 99 + use crate::strings::StringTable; 100 + use crate::theme::Theme; 101 + use crate::widget_id::{WidgetId, WidgetKey}; 102 + 103 + const LABEL: StringKey = StringKey::new("toggle.snap_to_grid"); 104 + 105 + fn rect() -> LayoutRect { 106 + LayoutRect::new( 107 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 108 + LayoutSize::new(LayoutPx::new(64.0), LayoutPx::new(28.0)), 109 + ) 110 + } 111 + 112 + fn id_widget() -> WidgetId { 113 + WidgetId::ROOT.child(WidgetKey::new("toggle")) 114 + } 115 + 116 + fn cycle_pointer_click(start_on: bool) -> Vec<bool> { 117 + let theme = Arc::new(Theme::light()); 118 + let mut focus = FocusManager::new(); 119 + let table = HotkeyTable::new(); 120 + let mut hits = HitFrame::new(); 121 + let mut state = HitState::new(); 122 + let mut on = start_on; 123 + let mut history = Vec::new(); 124 + let step = |snap: &mut InputSnapshot, 125 + focus: &mut FocusManager, 126 + hits: &mut HitFrame, 127 + state: &mut HitState, 128 + on: &mut bool, 129 + history: &mut Vec<bool>| { 130 + hits.clear(); 131 + let toggle = ToggleButton::new(id_widget(), rect(), LABEL, *on); 132 + let response = { 133 + let mut ctx = FrameCtx::new( 134 + theme.clone(), 135 + snap, 136 + focus, 137 + &table, 138 + StringTable::empty(), 139 + hits, 140 + state, 141 + ); 142 + show_toggle_button(&mut ctx, toggle) 143 + }; 144 + *on = response.on; 145 + history.push(*on); 146 + *state = resolve(state, hits, snap, focus.focused()); 147 + }; 148 + 149 + let mut press = InputSnapshot::idle(FrameInstant::ZERO); 150 + press.pointer = Some(PointerSample::new(LayoutPos::new( 151 + LayoutPx::new(10.0), 152 + LayoutPx::new(10.0), 153 + ))); 154 + press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 155 + step( 156 + &mut press, 157 + &mut focus, 158 + &mut hits, 159 + &mut state, 160 + &mut on, 161 + &mut history, 162 + ); 163 + 164 + let mut release = InputSnapshot::idle(FrameInstant::ZERO); 165 + release.pointer = Some(PointerSample::new(LayoutPos::new( 166 + LayoutPx::new(15.0), 167 + LayoutPx::new(15.0), 168 + ))); 169 + release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 170 + step( 171 + &mut release, 172 + &mut focus, 173 + &mut hits, 174 + &mut state, 175 + &mut on, 176 + &mut history, 177 + ); 178 + 179 + let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 180 + idle.pointer = Some(PointerSample::new(LayoutPos::new( 181 + LayoutPx::new(15.0), 182 + LayoutPx::new(15.0), 183 + ))); 184 + step( 185 + &mut idle, 186 + &mut focus, 187 + &mut hits, 188 + &mut state, 189 + &mut on, 190 + &mut history, 191 + ); 192 + history 193 + } 194 + 195 + #[test] 196 + fn pointer_click_flips_off_to_on() { 197 + let history = cycle_pointer_click(false); 198 + assert_eq!(history, vec![false, false, true]); 199 + } 200 + 201 + #[test] 202 + fn pointer_click_flips_on_to_off() { 203 + let history = cycle_pointer_click(true); 204 + assert_eq!(history, vec![true, true, false]); 205 + } 206 + 207 + #[test] 208 + fn space_key_toggles_when_focused() { 209 + let theme = Arc::new(Theme::light()); 210 + let table = HotkeyTable::new(); 211 + let mut hits = HitFrame::new(); 212 + let state = HitState::new(); 213 + let mut focus = FocusManager::new(); 214 + focus.register_focusable(id_widget()); 215 + focus.request_focus(id_widget()); 216 + focus.end_frame(); 217 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 218 + input.keys_pressed.push(KeyEvent::new( 219 + KeyCode::Named(NamedKey::Space), 220 + ModifierMask::NONE, 221 + )); 222 + let toggle = ToggleButton::new(id_widget(), rect(), LABEL, false); 223 + let response = { 224 + let mut ctx = FrameCtx::new( 225 + theme, 226 + &mut input, 227 + &mut focus, 228 + &table, 229 + StringTable::empty(), 230 + &mut hits, 231 + &state, 232 + ); 233 + show_toggle_button(&mut ctx, toggle) 234 + }; 235 + assert!(response.toggled); 236 + assert!(response.on); 237 + } 238 + 239 + #[test] 240 + fn disabled_toggle_does_not_flip() { 241 + let theme = Arc::new(Theme::light()); 242 + let mut focus = FocusManager::new(); 243 + let table = HotkeyTable::new(); 244 + let mut hits = HitFrame::new(); 245 + let mut state = HitState::new(); 246 + let toggle = ToggleButton::new(id_widget(), rect(), LABEL, false) 247 + .with_state(super::ButtonState::Disabled); 248 + let history: Vec<bool> = [ 249 + press_snap(), 250 + release_snap(), 251 + InputSnapshot::idle(FrameInstant::ZERO), 252 + ] 253 + .into_iter() 254 + .map(|mut snap| { 255 + hits.clear(); 256 + let response = { 257 + let mut ctx = FrameCtx::new( 258 + theme.clone(), 259 + &mut snap, 260 + &mut focus, 261 + &table, 262 + StringTable::empty(), 263 + &mut hits, 264 + &state, 265 + ); 266 + show_toggle_button(&mut ctx, toggle) 267 + }; 268 + state = resolve(&state, &hits, &snap, focus.focused()); 269 + response.on 270 + }) 271 + .collect(); 272 + assert!(history.iter().all(|on| !on), "disabled never flips"); 273 + } 274 + 275 + fn press_snap() -> InputSnapshot { 276 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 277 + s.pointer = Some(PointerSample::new(LayoutPos::new( 278 + LayoutPx::new(10.0), 279 + LayoutPx::new(10.0), 280 + ))); 281 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 282 + s 283 + } 284 + 285 + fn release_snap() -> InputSnapshot { 286 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 287 + s.pointer = Some(PointerSample::new(LayoutPos::new( 288 + LayoutPx::new(15.0), 289 + LayoutPx::new(15.0), 290 + ))); 291 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 292 + s 293 + } 294 + 295 + fn surface_fill(paint: &[super::WidgetPaint]) -> Option<crate::theme::Color> { 296 + paint.iter().find_map(|p| match p { 297 + super::WidgetPaint::Surface { fill, .. } => Some(*fill), 298 + _ => None, 299 + }) 300 + } 301 + 302 + fn focused_at_widget() -> FocusManager { 303 + let mut focus = FocusManager::new(); 304 + focus.register_focusable(id_widget()); 305 + focus.request_focus(id_widget()); 306 + focus.end_frame(); 307 + focus 308 + } 309 + 310 + #[test] 311 + fn paint_reflects_next_on_not_initial() { 312 + let theme = Arc::new(Theme::light()); 313 + let table = HotkeyTable::new(); 314 + let prelit = { 315 + let mut focus = focused_at_widget(); 316 + let mut hits = HitFrame::new(); 317 + let state = HitState::new(); 318 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 319 + let toggle = ToggleButton::new(id_widget(), rect(), LABEL, true); 320 + let response = { 321 + let mut ctx = FrameCtx::new( 322 + theme.clone(), 323 + &mut input, 324 + &mut focus, 325 + &table, 326 + StringTable::empty(), 327 + &mut hits, 328 + &state, 329 + ); 330 + show_toggle_button(&mut ctx, toggle) 331 + }; 332 + surface_fill(&response.paint) 333 + }; 334 + let toggled = { 335 + let mut focus = focused_at_widget(); 336 + let mut hits = HitFrame::new(); 337 + let state = HitState::new(); 338 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 339 + input.keys_pressed.push(KeyEvent::new( 340 + KeyCode::Named(NamedKey::Space), 341 + ModifierMask::NONE, 342 + )); 343 + let toggle = ToggleButton::new(id_widget(), rect(), LABEL, false); 344 + let response = { 345 + let mut ctx = FrameCtx::new( 346 + theme.clone(), 347 + &mut input, 348 + &mut focus, 349 + &table, 350 + StringTable::empty(), 351 + &mut hits, 352 + &state, 353 + ); 354 + show_toggle_button(&mut ctx, toggle) 355 + }; 356 + assert!(response.on, "Space flipped toggle on"); 357 + surface_fill(&response.paint) 358 + }; 359 + assert_eq!( 360 + toggled, prelit, 361 + "activation frame paints active surface, not stale off-state", 362 + ); 363 + } 364 + }
+360
crates/bone-ui/src/widgets/tooltip.rs
··· 1 + use core::time::Duration; 2 + 3 + use serde::Serialize; 4 + 5 + use crate::frame::FrameCtx; 6 + use crate::hit_test::Interaction; 7 + use crate::input::FrameInstant; 8 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 9 + use crate::strings::StringKey; 10 + use crate::widget_id::WidgetId; 11 + 12 + use super::paint::{LabelText, WidgetPaint}; 13 + 14 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] 15 + pub enum TooltipPlacement { 16 + Below, 17 + Above, 18 + Right, 19 + Left, 20 + } 21 + 22 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 23 + pub struct TooltipState { 24 + pub showing: bool, 25 + pub hover_began: Option<FrameInstant>, 26 + pub focus_began: Option<FrameInstant>, 27 + } 28 + 29 + #[derive(Copy, Clone, Debug, PartialEq)] 30 + pub struct Tooltip { 31 + pub anchor: WidgetId, 32 + pub anchor_rect: LayoutRect, 33 + pub label: StringKey, 34 + pub placement: TooltipPlacement, 35 + pub gap: LayoutPx, 36 + pub size: LayoutSize, 37 + pub delay: Duration, 38 + } 39 + 40 + impl Tooltip { 41 + #[must_use] 42 + pub const fn new( 43 + anchor: WidgetId, 44 + anchor_rect: LayoutRect, 45 + label: StringKey, 46 + placement: TooltipPlacement, 47 + size: LayoutSize, 48 + ) -> Self { 49 + Self { 50 + anchor, 51 + anchor_rect, 52 + label, 53 + placement, 54 + gap: LayoutPx::new(4.0), 55 + size, 56 + delay: Duration::from_millis(500), 57 + } 58 + } 59 + 60 + #[must_use] 61 + pub const fn with_delay(self, delay: Duration) -> Self { 62 + Self { delay, ..self } 63 + } 64 + } 65 + 66 + #[must_use] 67 + pub fn show_tooltip( 68 + ctx: &mut FrameCtx<'_>, 69 + tooltip: Tooltip, 70 + state: &mut TooltipState, 71 + ) -> Vec<WidgetPaint> { 72 + let interaction = ctx.previous.interaction(tooltip.anchor); 73 + let now = ctx.input.frame; 74 + update_state(state, &interaction, now); 75 + state.showing = should_show(state, now, tooltip.delay); 76 + if !state.showing { 77 + return Vec::new(); 78 + } 79 + let rect = popup_rect( 80 + tooltip.anchor_rect, 81 + tooltip.placement, 82 + tooltip.gap, 83 + tooltip.size, 84 + ); 85 + vec![WidgetPaint::Tooltip { 86 + rect, 87 + text: LabelText::Key(tooltip.label), 88 + anchor: tooltip.anchor, 89 + elevation: ctx.theme.elevation.level2, 90 + }] 91 + } 92 + 93 + fn update_state(state: &mut TooltipState, interaction: &Interaction, now: FrameInstant) { 94 + if interaction.hover() { 95 + if state.hover_began.is_none() { 96 + state.hover_began = Some(now); 97 + } 98 + } else { 99 + state.hover_began = None; 100 + } 101 + if interaction.focused() { 102 + if state.focus_began.is_none() { 103 + state.focus_began = Some(now); 104 + } 105 + } else { 106 + state.focus_began = None; 107 + } 108 + } 109 + 110 + fn should_show(state: &TooltipState, now: FrameInstant, delay: Duration) -> bool { 111 + let qualifies = |start: FrameInstant| now.since(start) >= delay; 112 + state.hover_began.is_some_and(qualifies) || state.focus_began.is_some_and(qualifies) 113 + } 114 + 115 + fn popup_rect( 116 + anchor: LayoutRect, 117 + placement: TooltipPlacement, 118 + gap: LayoutPx, 119 + size: LayoutSize, 120 + ) -> LayoutRect { 121 + let centred_x = 122 + anchor.origin.x.value() + anchor.size.width.value() / 2.0 - size.width.value() / 2.0; 123 + let centred_y = 124 + anchor.origin.y.value() + anchor.size.height.value() / 2.0 - size.height.value() / 2.0; 125 + let origin = match placement { 126 + TooltipPlacement::Below => LayoutPos::new( 127 + LayoutPx::new(centred_x), 128 + LayoutPx::new(anchor.origin.y.value() + anchor.size.height.value() + gap.value()), 129 + ), 130 + TooltipPlacement::Above => LayoutPos::new( 131 + LayoutPx::new(centred_x), 132 + LayoutPx::new(anchor.origin.y.value() - size.height.value() - gap.value()), 133 + ), 134 + TooltipPlacement::Right => LayoutPos::new( 135 + LayoutPx::new(anchor.origin.x.value() + anchor.size.width.value() + gap.value()), 136 + LayoutPx::new(centred_y), 137 + ), 138 + TooltipPlacement::Left => LayoutPos::new( 139 + LayoutPx::new(anchor.origin.x.value() - size.width.value() - gap.value()), 140 + LayoutPx::new(centred_y), 141 + ), 142 + }; 143 + LayoutRect::new(origin, size) 144 + } 145 + 146 + #[cfg(test)] 147 + mod tests { 148 + use core::time::Duration; 149 + use std::sync::Arc; 150 + 151 + use super::{Tooltip, TooltipPlacement, TooltipState, show_tooltip}; 152 + use crate::focus::FocusManager; 153 + use crate::frame::FrameCtx; 154 + use crate::hit_test::{HitFrame, HitState, Interaction, InteractionState}; 155 + use crate::hotkey::HotkeyTable; 156 + use crate::input::{FrameInstant, InputSnapshot}; 157 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 158 + use crate::strings::StringKey; 159 + use crate::strings::StringTable; 160 + use crate::theme::Theme; 161 + use crate::widget_id::{WidgetId, WidgetKey}; 162 + use crate::widgets::WidgetPaint; 163 + 164 + const LABEL: StringKey = StringKey::new("tooltip.label"); 165 + 166 + fn anchor_id() -> WidgetId { 167 + WidgetId::ROOT.child(WidgetKey::new("anchor")) 168 + } 169 + 170 + fn anchor_rect() -> LayoutRect { 171 + LayoutRect::new( 172 + LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(40.0)), 173 + LayoutSize::new(LayoutPx::new(60.0), LayoutPx::new(20.0)), 174 + ) 175 + } 176 + 177 + fn tooltip() -> Tooltip { 178 + Tooltip::new( 179 + anchor_id(), 180 + anchor_rect(), 181 + LABEL, 182 + TooltipPlacement::Below, 183 + LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(18.0)), 184 + ) 185 + } 186 + 187 + fn run( 188 + prev: &HitState, 189 + frame: FrameInstant, 190 + state: &mut TooltipState, 191 + tooltip: Tooltip, 192 + ) -> Vec<WidgetPaint> { 193 + let theme = Arc::new(Theme::light()); 194 + let mut focus = FocusManager::new(); 195 + let table = HotkeyTable::new(); 196 + let mut hits = HitFrame::new(); 197 + let mut input = InputSnapshot::idle(frame); 198 + let mut ctx = FrameCtx::new( 199 + theme, 200 + &mut input, 201 + &mut focus, 202 + &table, 203 + StringTable::empty(), 204 + &mut hits, 205 + prev, 206 + ); 207 + show_tooltip(&mut ctx, tooltip, state) 208 + } 209 + 210 + fn prev_with_hover() -> HitState { 211 + let mut state = HitState::new(); 212 + state.interactions.insert( 213 + anchor_id(), 214 + Interaction { 215 + state: InteractionState::HOVER, 216 + ..Interaction::idle() 217 + }, 218 + ); 219 + state 220 + } 221 + 222 + fn prev_with_focus() -> HitState { 223 + let mut state = HitState::new(); 224 + state.interactions.insert( 225 + anchor_id(), 226 + Interaction { 227 + state: InteractionState::FOCUSED, 228 + ..Interaction::idle() 229 + }, 230 + ); 231 + state 232 + } 233 + 234 + fn prev_idle() -> HitState { 235 + HitState::new() 236 + } 237 + 238 + #[test] 239 + fn hover_for_less_than_delay_does_not_show() { 240 + let mut state = TooltipState::default(); 241 + let prev = prev_with_hover(); 242 + let _ = run( 243 + &prev, 244 + FrameInstant::from_duration(Duration::from_millis(100)), 245 + &mut state, 246 + tooltip(), 247 + ); 248 + assert!(!state.showing); 249 + } 250 + 251 + #[test] 252 + fn hover_past_delay_shows_tooltip() { 253 + let mut state = TooltipState::default(); 254 + let prev = prev_with_hover(); 255 + let _ = run( 256 + &prev, 257 + FrameInstant::from_duration(Duration::from_millis(10)), 258 + &mut state, 259 + tooltip(), 260 + ); 261 + let paint = run( 262 + &prev, 263 + FrameInstant::from_duration(Duration::from_millis(800)), 264 + &mut state, 265 + tooltip(), 266 + ); 267 + assert!(state.showing); 268 + assert_eq!(paint.len(), 1); 269 + } 270 + 271 + #[test] 272 + fn losing_hover_resets_timer() { 273 + let mut state = TooltipState::default(); 274 + let _ = run( 275 + &prev_with_hover(), 276 + FrameInstant::from_duration(Duration::from_millis(10)), 277 + &mut state, 278 + tooltip(), 279 + ); 280 + let _ = run( 281 + &prev_idle(), 282 + FrameInstant::from_duration(Duration::from_millis(100)), 283 + &mut state, 284 + tooltip(), 285 + ); 286 + assert!(state.hover_began.is_none()); 287 + assert!(!state.showing); 288 + } 289 + 290 + #[test] 291 + fn focus_satisfies_show_condition() { 292 + let mut state = TooltipState::default(); 293 + let _ = run( 294 + &prev_with_focus(), 295 + FrameInstant::from_duration(Duration::from_millis(10)), 296 + &mut state, 297 + tooltip(), 298 + ); 299 + let paint = run( 300 + &prev_with_focus(), 301 + FrameInstant::from_duration(Duration::from_millis(700)), 302 + &mut state, 303 + tooltip(), 304 + ); 305 + assert!(state.showing); 306 + assert_eq!(paint.len(), 1); 307 + } 308 + 309 + #[test] 310 + fn placement_below_positions_under_anchor() { 311 + let mut state = TooltipState::default(); 312 + let _ = run( 313 + &prev_with_hover(), 314 + FrameInstant::from_duration(Duration::from_millis(10)), 315 + &mut state, 316 + tooltip(), 317 + ); 318 + let paint = run( 319 + &prev_with_hover(), 320 + FrameInstant::from_duration(Duration::from_millis(900)), 321 + &mut state, 322 + tooltip(), 323 + ); 324 + let WidgetPaint::Tooltip { rect, .. } = &paint[0] else { 325 + panic!("expected tooltip paint") 326 + }; 327 + assert!( 328 + rect.origin.y.value() 329 + > anchor_rect().origin.y.value() + anchor_rect().size.height.value() 330 + ); 331 + } 332 + 333 + #[test] 334 + fn placement_above_inverts_y() { 335 + let mut state = TooltipState::default(); 336 + let above = Tooltip::new( 337 + anchor_id(), 338 + anchor_rect(), 339 + LABEL, 340 + TooltipPlacement::Above, 341 + LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(18.0)), 342 + ); 343 + let _ = run( 344 + &prev_with_hover(), 345 + FrameInstant::from_duration(Duration::from_millis(10)), 346 + &mut state, 347 + above, 348 + ); 349 + let paint = run( 350 + &prev_with_hover(), 351 + FrameInstant::from_duration(Duration::from_millis(900)), 352 + &mut state, 353 + above, 354 + ); 355 + let WidgetPaint::Tooltip { rect, .. } = &paint[0] else { 356 + panic!("expected tooltip paint") 357 + }; 358 + assert!(rect.origin.y.value() < anchor_rect().origin.y.value()); 359 + } 360 + }