Another project
1use crate::a11y::{AccessNode, Role};
2use crate::frame::{FrameCtx, InteractDeclaration};
3use crate::hit_test::Sense;
4use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
5use crate::strings::StringKey;
6use crate::theme::{Border, Color, Step12, StrokeWidth};
7use crate::widget_id::WidgetId;
8
9use super::keys::take_activation;
10use super::paint::{HorizontalAlign, LabelText, WidgetPaint};
11use super::visuals::push_focus_ring;
12
13#[derive(Copy, Clone, Debug, PartialEq, Eq)]
14pub enum StatusAlign {
15 Start,
16 Center,
17 End,
18}
19
20#[derive(Clone, Debug, PartialEq)]
21pub struct StatusItem {
22 pub id: WidgetId,
23 pub label: LabelText,
24 pub align: StatusAlign,
25 pub width: LayoutPx,
26 pub interactive: bool,
27 pub badge: Option<Color>,
28}
29
30impl StatusItem {
31 #[must_use]
32 pub fn new(id: WidgetId, label: StringKey, align: StatusAlign, width: LayoutPx) -> Self {
33 Self::with_text(id, LabelText::Key(label), align, width)
34 }
35
36 #[must_use]
37 pub fn with_text(id: WidgetId, label: LabelText, align: StatusAlign, width: LayoutPx) -> Self {
38 Self {
39 id,
40 label,
41 align,
42 width,
43 interactive: false,
44 badge: None,
45 }
46 }
47
48 #[must_use]
49 pub fn interactive(self, interactive: bool) -> Self {
50 Self {
51 interactive,
52 ..self
53 }
54 }
55
56 #[must_use]
57 pub fn badge(self, color: Color) -> Self {
58 Self {
59 badge: Some(color),
60 ..self
61 }
62 }
63}
64
65#[derive(Copy, Clone, Debug, PartialEq)]
66pub struct StatusBar<'a> {
67 pub id: WidgetId,
68 pub rect: LayoutRect,
69 pub label: StringKey,
70 pub items: &'a [StatusItem],
71}
72
73impl<'a> StatusBar<'a> {
74 #[must_use]
75 pub const fn new(
76 id: WidgetId,
77 rect: LayoutRect,
78 label: StringKey,
79 items: &'a [StatusItem],
80 ) -> Self {
81 Self {
82 id,
83 rect,
84 label,
85 items,
86 }
87 }
88}
89
90#[derive(Clone, Debug, PartialEq)]
91pub struct StatusBarResponse {
92 pub activated: Option<WidgetId>,
93 pub paint: Vec<WidgetPaint>,
94}
95
96#[must_use]
97pub fn show_status_bar(ctx: &mut FrameCtx<'_>, bar: StatusBar<'_>) -> StatusBarResponse {
98 ctx.a11y.push(
99 bar.id,
100 bar.rect,
101 AccessNode::new(Role::Status).with_label(bar.label),
102 );
103 let mut paint = vec![WidgetPaint::Surface {
104 rect: bar.rect,
105 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BG),
106 border: Some(Border {
107 width: StrokeWidth::HAIRLINE,
108 color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
109 }),
110 radius: ctx.theme().radius.none,
111 elevation: None,
112 }];
113 let direction = ctx.direction();
114 let layouts: Vec<LayoutRect> = lay_out_items(bar.rect, bar.items)
115 .into_iter()
116 .map(|r| r.mirror_horizontally_within(bar.rect, direction))
117 .collect();
118 paint.extend(cell_dividers(ctx, bar.rect, bar.items, &layouts));
119 let mut activated: Option<WidgetId> = None;
120 bar.items
121 .iter()
122 .zip(layouts.iter())
123 .for_each(|(item, item_rect)| {
124 let item_paint = draw_item(ctx, item, *item_rect, &mut activated);
125 paint.extend(item_paint);
126 });
127 StatusBarResponse { activated, paint }
128}
129
130fn draw_item(
131 ctx: &mut FrameCtx<'_>,
132 item: &StatusItem,
133 rect: LayoutRect,
134 activated: &mut Option<WidgetId>,
135) -> Vec<WidgetPaint> {
136 let mut paint = Vec::new();
137 let live_focused = if item.interactive {
138 let interaction = ctx.interact(
139 InteractDeclaration::new(item.id, rect, Sense::INTERACTIVE)
140 .focusable(true)
141 .a11y(AccessNode::new(Role::Button).with_label_text(item.label.clone())),
142 );
143 let focused = ctx.is_focused(item.id);
144 let activated_via_pointer = interaction.click();
145 let activated_via_key = focused && take_activation(ctx.input);
146 if (activated_via_pointer || activated_via_key) && activated.is_none() {
147 *activated = Some(item.id);
148 }
149 if interaction.hover() || interaction.pressed() {
150 paint.push(WidgetPaint::Surface {
151 rect,
152 fill: ctx.theme().colors.neutral.step(if interaction.pressed() {
153 Step12::SELECTED_BG
154 } else {
155 Step12::HOVER_BG
156 }),
157 border: None,
158 radius: ctx.theme().radius.none,
159 elevation: None,
160 });
161 }
162 focused
163 } else {
164 false
165 };
166 if let Some(badge) = item.badge {
167 let dot_rect = badge_rect(rect);
168 paint.push(WidgetPaint::Surface {
169 rect: dot_rect,
170 fill: badge,
171 border: None,
172 radius: ctx.theme().radius.pill,
173 elevation: None,
174 });
175 }
176 let text_align = if item.badge.is_some() {
177 HorizontalAlign::Start
178 } else {
179 match item.align {
180 StatusAlign::Start => HorizontalAlign::Start,
181 StatusAlign::Center => HorizontalAlign::Center,
182 StatusAlign::End => HorizontalAlign::End,
183 }
184 };
185 paint.push(WidgetPaint::AlignedLabel {
186 rect: label_rect(rect, item.badge.is_some()),
187 text: item.label.clone(),
188 color: ctx.theme().colors.text_primary(),
189 role: ctx.theme().typography.label,
190 align: text_align,
191 });
192 push_focus_ring(ctx, &mut paint, rect, ctx.theme().radius.none, live_focused);
193 paint
194}
195
196const BADGE_PX: f32 = 8.0;
197const BADGE_GAP: f32 = 6.0;
198const ITEM_PAD_X: f32 = 6.0;
199
200fn badge_rect(item: LayoutRect) -> LayoutRect {
201 let pad = (item.size.height.value() - BADGE_PX).max(0.0) / 2.0;
202 LayoutRect::new(
203 LayoutPos::new(
204 LayoutPx::new(item.origin.x.value() + ITEM_PAD_X),
205 LayoutPx::new(item.origin.y.value() + pad),
206 ),
207 LayoutSize::new(LayoutPx::new(BADGE_PX), LayoutPx::new(BADGE_PX)),
208 )
209}
210
211fn label_rect(item: LayoutRect, has_badge: bool) -> LayoutRect {
212 let lead = ITEM_PAD_X + if has_badge { BADGE_PX + BADGE_GAP } else { 0.0 };
213 LayoutRect::new(
214 LayoutPos::new(LayoutPx::new(item.origin.x.value() + lead), item.origin.y),
215 LayoutSize::new(
216 LayoutPx::saturating_nonneg(item.size.width.value() - lead - ITEM_PAD_X),
217 item.size.height,
218 ),
219 )
220}
221
222fn cell_dividers(
223 ctx: &FrameCtx<'_>,
224 bar: LayoutRect,
225 items: &[StatusItem],
226 layouts: &[LayoutRect],
227) -> Vec<WidgetPaint> {
228 items
229 .windows(2)
230 .zip(layouts.windows(2))
231 .filter(|(pair, _)| pair[0].align == pair[1].align)
232 .filter_map(|(_, rects)| divider_at_seam(ctx, bar, rects[0], rects[1]))
233 .collect()
234}
235
236fn divider_at_seam(
237 ctx: &FrameCtx<'_>,
238 bar: LayoutRect,
239 a: LayoutRect,
240 b: LayoutRect,
241) -> Option<WidgetPaint> {
242 let (left, right) = if a.origin.x.value() <= b.origin.x.value() {
243 (a, b)
244 } else {
245 (b, a)
246 };
247 let seam = left.max_x().value();
248 if (right.origin.x.value() - seam).abs() > 0.5 {
249 return None;
250 }
251 Some(WidgetPaint::Surface {
252 rect: LayoutRect::new(
253 LayoutPos::new(LayoutPx::new(seam), bar.origin.y),
254 LayoutSize::new(
255 LayoutPx::new(StrokeWidth::HAIRLINE.value_px()),
256 bar.size.height,
257 ),
258 ),
259 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
260 border: None,
261 radius: ctx.theme().radius.none,
262 elevation: None,
263 })
264}
265
266#[derive(Copy, Clone)]
267struct AlignTotals {
268 start: f32,
269 center: f32,
270 end: f32,
271}
272
273fn lay_out_items(bar: LayoutRect, items: &[StatusItem]) -> Vec<LayoutRect> {
274 let totals = items.iter().fold(
275 AlignTotals {
276 start: 0.0,
277 center: 0.0,
278 end: 0.0,
279 },
280 |t, item| match item.align {
281 StatusAlign::Start => AlignTotals {
282 start: t.start + item.width.value(),
283 ..t
284 },
285 StatusAlign::Center => AlignTotals {
286 center: t.center + item.width.value(),
287 ..t
288 },
289 StatusAlign::End => AlignTotals {
290 end: t.end + item.width.value(),
291 ..t
292 },
293 },
294 );
295 let bar_x = bar.origin.x.value();
296 let bar_w = bar.size.width.value();
297 let start_end = bar_x + totals.start;
298 let ideal_center = bar_x + (bar_w - totals.center) / 2.0;
299 let center_origin = ideal_center.max(start_end);
300 let center_end = center_origin + totals.center;
301 let ideal_end = bar_x + bar_w - totals.end;
302 let end_origin = ideal_end.max(center_end);
303 items
304 .iter()
305 .scan(
306 (bar_x, center_origin, end_origin),
307 |(start_cursor, center_cursor, end_cursor), item| {
308 let origin_x = match item.align {
309 StatusAlign::Start => {
310 let r = *start_cursor;
311 *start_cursor += item.width.value();
312 r
313 }
314 StatusAlign::Center => {
315 let r = *center_cursor;
316 *center_cursor += item.width.value();
317 r
318 }
319 StatusAlign::End => {
320 let r = *end_cursor;
321 *end_cursor += item.width.value();
322 r
323 }
324 };
325 Some(LayoutRect::new(
326 LayoutPos::new(LayoutPx::new(origin_x), bar.origin.y),
327 LayoutSize::new(item.width, bar.size.height),
328 ))
329 },
330 )
331 .collect()
332}
333
334#[cfg(test)]
335mod tests {
336 use std::sync::Arc;
337
338 use super::{StatusAlign, StatusBar, StatusItem, show_status_bar};
339 use crate::focus::FocusManager;
340 use crate::frame::FrameCtx;
341 use crate::hit_test::{HitFrame, HitState, resolve};
342 use crate::hotkey::HotkeyTable;
343 use crate::input::{
344 FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample,
345 };
346 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
347 use crate::strings::{StringKey, StringTable};
348 use crate::theme::Theme;
349 use crate::widget_id::{WidgetId, WidgetKey};
350
351 fn item_id(name: &'static str) -> WidgetId {
352 WidgetId::ROOT.child(WidgetKey::new(name))
353 }
354
355 fn bar_id() -> WidgetId {
356 WidgetId::ROOT.child(WidgetKey::new("status_bar"))
357 }
358
359 fn items() -> Vec<StatusItem> {
360 vec![
361 StatusItem::new(
362 item_id("status"),
363 StringKey::new("status.fully"),
364 StatusAlign::Start,
365 LayoutPx::new(140.0),
366 ),
367 StatusItem::new(
368 item_id("zoom"),
369 StringKey::new("status.zoom"),
370 StatusAlign::End,
371 LayoutPx::new(80.0),
372 )
373 .interactive(true),
374 ]
375 }
376
377 fn bar_rect() -> LayoutRect {
378 LayoutRect::new(
379 LayoutPos::new(LayoutPx::ZERO, LayoutPx::new(700.0)),
380 LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(24.0)),
381 )
382 }
383
384 fn render(
385 items: &[StatusItem],
386 focus: &mut FocusManager,
387 snap: &mut InputSnapshot,
388 prev: &HitState,
389 ) -> (super::StatusBarResponse, HitState) {
390 let theme = Arc::new(Theme::light());
391 let table = HotkeyTable::new();
392 let mut hits = HitFrame::new();
393 let response = {
394 let mut shaper = bone_text::Shaper::new();
395 let mut a11y = crate::a11y::AccessTreeBuilder::new();
396 let mut ctx = FrameCtx::new(
397 theme,
398 snap,
399 focus,
400 &table,
401 StringTable::empty(),
402 &mut hits,
403 prev,
404 &mut a11y,
405 &mut shaper,
406 );
407 show_status_bar(
408 &mut ctx,
409 StatusBar::new(bar_id(), bar_rect(), StringKey::new("test.status"), items),
410 )
411 };
412 let next = resolve(prev, &hits, snap, focus.focused());
413 (response, next)
414 }
415
416 #[test]
417 fn renders_one_paint_per_item_plus_surface() {
418 let items = items();
419 let mut focus = FocusManager::new();
420 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
421 let prev = HitState::new();
422 let (response, _) = render(&items, &mut focus, &mut snap, &prev);
423 let label_count = response
424 .paint
425 .iter()
426 .filter(|p| matches!(p, super::WidgetPaint::AlignedLabel { .. }))
427 .count();
428 assert_eq!(label_count, 2);
429 }
430
431 #[test]
432 fn click_interactive_item_activates_it() {
433 let items = items();
434 let mut focus = FocusManager::new();
435 let mut prev = HitState::new();
436 let click_pos = LayoutPos::new(LayoutPx::new(760.0), LayoutPx::new(712.0));
437 let mut last: Option<super::StatusBarResponse> = None;
438 [press(click_pos), release(click_pos), idle(click_pos)]
439 .into_iter()
440 .for_each(|mut snap| {
441 let (response, next) = render(&items, &mut focus, &mut snap, &prev);
442 last = Some(response);
443 prev = next;
444 });
445 let Some(response) = last else {
446 panic!("response missing")
447 };
448 assert_eq!(response.activated, Some(items[1].id));
449 }
450
451 #[test]
452 fn enter_on_focused_interactive_item_activates_it() {
453 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey};
454
455 let items = items();
456 let interactive_id = items[1].id;
457 let mut focus = FocusManager::new();
458 let prev = HitState::new();
459 focus.request_focus(interactive_id);
460 let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
461 let _ = render(&items, &mut focus, &mut warm, &prev);
462 assert_eq!(focus.focused(), Some(interactive_id));
463
464 let mut enter = InputSnapshot::idle(FrameInstant::ZERO);
465 enter.keys_pressed.push(KeyEvent::new(
466 KeyCode::Named(NamedKey::Enter),
467 ModifierMask::NONE,
468 ));
469 let (response, _) = render(&items, &mut focus, &mut enter, &prev);
470 assert_eq!(response.activated, Some(interactive_id));
471 }
472
473 #[test]
474 fn overflow_packs_center_and_end_after_start_without_underflow() {
475 let crowded = vec![
476 StatusItem::new(
477 item_id("a"),
478 StringKey::new("status.a"),
479 StatusAlign::Start,
480 LayoutPx::new(400.0),
481 ),
482 StatusItem::new(
483 item_id("b"),
484 StringKey::new("status.b"),
485 StatusAlign::Center,
486 LayoutPx::new(400.0),
487 ),
488 StatusItem::new(
489 item_id("c"),
490 StringKey::new("status.c"),
491 StatusAlign::End,
492 LayoutPx::new(400.0),
493 ),
494 ];
495 let rects = super::lay_out_items(bar_rect(), &crowded);
496 assert_eq!(rects.len(), 3);
497 let bar_x = bar_rect().origin.x.value();
498 rects.iter().for_each(|r| {
499 assert!(
500 r.origin.x.value() >= bar_x,
501 "item origin must not underflow bar start",
502 );
503 });
504 assert!(rects[1].origin.x.value() >= rects[0].origin.x.value() + 400.0);
505 assert!(rects[2].origin.x.value() >= rects[1].origin.x.value() + 400.0);
506 }
507
508 #[test]
509 fn non_interactive_item_does_not_activate() {
510 let items = items();
511 let mut focus = FocusManager::new();
512 let mut prev = HitState::new();
513 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(712.0));
514 [press(click_pos), release(click_pos), idle(click_pos)]
515 .into_iter()
516 .for_each(|mut snap| {
517 let (response, next) = render(&items, &mut focus, &mut snap, &prev);
518 assert!(response.activated.is_none());
519 prev = next;
520 });
521 }
522
523 fn press(pos: LayoutPos) -> InputSnapshot {
524 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
525 s.pointer = Some(PointerSample::new(pos));
526 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
527 s
528 }
529
530 fn release(pos: LayoutPos) -> InputSnapshot {
531 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
532 s.pointer = Some(PointerSample::new(pos));
533 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
534 s
535 }
536
537 fn idle(pos: LayoutPos) -> InputSnapshot {
538 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
539 s.pointer = Some(PointerSample::new(pos));
540 s
541 }
542}