Another project
0

Configure Feed

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

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