1use std::rc::Rc;
55
56use iced::futures::channel::mpsc;
57use iced::futures::{Stream, StreamExt, stream};
58use iced::time::Instant;
59use iced::widget::text::LineHeight;
60use iced::widget::{self, column, container, responsive, row, space};
61use iced::{Alignment, Background, Element, Length, Padding, Subscription, Theme, window};
62
63use crate::feedback::badge::{self};
64use crate::feedback::progress::{self, progress as progress_bar};
65use crate::feedback::spinner::{self, advance, spinner as spinning};
66use crate::icon::{Glyph, opacity, themed};
67use crate::overlay::tooltip::{Position, tooltip};
68use crate::primitives::button::{self, Variant};
69use crate::theme::{Tokens, space as gap, text_size};
70
71pub const CHANNEL_CAPACITY: usize = 32;
73pub const BATCH_SIZE: usize = 16;
75pub const SUBSCRIPTION_ID: &str = "iced-cube-status-bar";
77pub const COMPACT_WIDTH: f32 = 480.0;
79pub const LINE: f32 = 16.0;
81pub const PROGRESS_WIDTH: f32 = 80.0;
83
84const ICON_SIZE: f32 = 14.0;
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
88pub enum Section {
89 #[default]
90 Start,
91 Centre,
92 End,
93}
94
95impl Section {
96 pub const ALL: [Section; 3] = [Section::Start, Section::Centre, Section::End];
97}
98
99#[derive(Debug, Clone, Copy, PartialEq)]
101pub enum Content {
102 Text,
104 Action,
106 Spinner,
108 Progress(f32),
110 Badge(badge::Variant),
112}
113
114#[derive(Debug, Clone, PartialEq)]
116pub struct Item<Id> {
117 pub id: Id,
118 pub section: Section,
119 pub label: String,
120 pub icon: Option<Glyph>,
121 pub tooltip: Option<String>,
122 pub content: Content,
123}
124
125fn new_item<Id>(id: Id, label: impl Into<String>, content: Content) -> Item<Id> {
126 Item {
127 id,
128 section: Section::default(),
129 label: label.into(),
130 icon: None,
131 tooltip: None,
132 content,
133 }
134}
135
136pub fn text<Id>(id: Id, label: impl Into<String>) -> Item<Id> {
138 new_item(id, label, Content::Text)
139}
140
141pub fn action<Id>(id: Id, label: impl Into<String>) -> Item<Id> {
143 new_item(id, label, Content::Action)
144}
145
146pub fn spinner<Id>(id: Id, label: impl Into<String>) -> Item<Id> {
148 new_item(id, label, Content::Spinner)
149}
150
151pub fn progress<Id>(id: Id, value: f32) -> Item<Id> {
154 new_item(id, "", Content::Progress(progress::clamp(value)))
155}
156
157pub fn badge<Id>(id: Id, label: impl Into<String>, variant: badge::Variant) -> Item<Id> {
159 new_item(id, label, Content::Badge(variant))
160}
161
162impl<Id> Item<Id> {
163 pub fn section(mut self, section: Section) -> Self {
164 self.section = section;
165 self
166 }
167
168 pub fn label(mut self, label: impl Into<String>) -> Self {
169 self.label = label.into();
170 self
171 }
172
173 pub fn icon(mut self, glyph: Glyph) -> Self {
175 self.icon = Some(glyph);
176 self
177 }
178
179 pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
181 self.tooltip = Some(tooltip.into());
182 self
183 }
184}
185
186#[derive(Debug, Clone, PartialEq)]
188pub enum Update<Id> {
189 Set(Item<Id>),
191 Remove(Id),
192 Clear,
193}
194
195pub type Sender<Id> = mpsc::Sender<Update<Id>>;
197
198#[derive(Debug, Clone)]
200pub enum Event<Id> {
201 Ready(Sender<Id>),
203 Received(Vec<Update<Id>>),
205 Frame(Instant),
207 Press(Id),
209}
210
211#[derive(Debug, Clone)]
213pub enum Output<Id> {
214 Ready(Sender<Id>),
215 Pressed(Id),
217}
218
219#[derive(Debug, Clone, PartialEq)]
221pub struct State<Id> {
222 items: Vec<Item<Id>>,
223 phase: f32,
224 last_frame: Option<Instant>,
225}
226
227impl<Id> Default for State<Id> {
228 fn default() -> Self {
229 Self {
230 items: Vec::new(),
231 phase: 0.0,
232 last_frame: None,
233 }
234 }
235}
236
237impl<Id: PartialEq> State<Id> {
238 pub fn new() -> Self {
240 Self::default()
241 }
242
243 pub fn with_items(mut self, items: impl IntoIterator<Item = Item<Id>>) -> Self {
245 for item in items {
246 self.apply(Update::Set(item));
247 }
248 self
249 }
250
251 pub fn items(&self) -> &[Item<Id>] {
253 &self.items
254 }
255
256 pub fn item(&self, id: &Id) -> Option<&Item<Id>> {
257 self.items.iter().find(|item| item.id == *id)
258 }
259
260 pub fn section(&self, section: Section) -> impl Iterator<Item = &Item<Id>> {
262 self.items
263 .iter()
264 .filter(move |item| item.section == section)
265 }
266
267 pub fn is_empty(&self) -> bool {
268 self.items.is_empty()
269 }
270
271 pub fn phase(&self) -> f32 {
273 self.phase
274 }
275
276 pub fn needs_frames(&self) -> bool {
278 self.items
279 .iter()
280 .any(|item| item.content == Content::Spinner)
281 }
282
283 pub fn apply(&mut self, update: Update<Id>) {
285 match update {
286 Update::Set(item) => match self.items.iter_mut().find(|old| old.id == item.id) {
287 Some(old) => *old = item,
288 None => self.items.push(item),
289 },
290 Update::Remove(id) => self.items.retain(|item| item.id != id),
291 Update::Clear => self.items.clear(),
292 }
293 if !self.needs_frames() {
294 self.last_frame = None;
295 }
296 }
297
298 pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
300 match event {
301 Event::Ready(sender) => return Some(Output::Ready(sender)),
302 Event::Received(batch) => {
303 for update in batch {
304 self.apply(update);
305 }
306 }
307 Event::Frame(now) => {
308 if let Some(last) = self.last_frame {
309 self.phase = advance(self.phase, now.saturating_duration_since(last));
310 }
311 self.last_frame = Some(now);
312 }
313 Event::Press(id) => {
314 let pressable = self
315 .item(&id)
316 .is_some_and(|item| item.content == Content::Action);
317 return pressable.then_some(Output::Pressed(id));
318 }
319 }
320 None
321 }
322}
323
324pub fn subscription<Id: Send + 'static>() -> Subscription<Event<Id>> {
328 Subscription::run_with(SUBSCRIPTION_ID, |_| stream::<Id>())
329}
330
331pub fn stream<Id: Send + 'static>() -> impl Stream<Item = Event<Id>> {
333 let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
334 stream::once(async move { Event::Ready(sender) }).chain(batches(receiver).map(Event::Received))
335}
336
337pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
339 receiver.ready_chunks(BATCH_SIZE)
340}
341
342pub fn animation<Id: PartialEq + 'static>(state: &State<Id>) -> Subscription<Event<Id>> {
344 if !state.needs_frames() {
345 return Subscription::none();
346 }
347 window::frames().map(Event::Frame)
348}
349
350pub fn bar_style(tokens: &Tokens) -> container::Style {
353 container::Style {
354 background: Some(Background::Color(tokens.background)),
355 text_color: Some(tokens.muted_foreground),
356 ..container::Style::default()
357 }
358}
359
360pub fn text_colour(tokens: &Tokens) -> iced::Color {
362 tokens.muted_foreground
363}
364
365type OnEvent<'a, Id, Message> = Rc<dyn Fn(Event<Id>) -> Message + 'a>;
366
367pub struct StatusBar<'a, Id, Message> {
369 state: &'a State<Id>,
370 compact_below: f32,
371 on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
372}
373
374impl<Id: std::fmt::Debug, Message> std::fmt::Debug for StatusBar<'_, Id, Message> {
375 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376 f.debug_struct("StatusBar")
377 .field("state", self.state)
378 .field("compact_below", &self.compact_below)
379 .finish_non_exhaustive()
380 }
381}
382
383pub fn status_bar<'a, Id, Message>(state: &'a State<Id>) -> StatusBar<'a, Id, Message> {
386 StatusBar {
387 state,
388 compact_below: COMPACT_WIDTH,
389 on_event: None,
390 }
391}
392
393impl<'a, Id, Message> StatusBar<'a, Id, Message> {
394 pub fn compact_below(mut self, width: f32) -> Self {
398 self.compact_below = width;
399 self
400 }
401
402 pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
403 self.on_event = Some(Box::new(on_event));
404 self
405 }
406}
407
408impl<'a, Id, Message> From<StatusBar<'a, Id, Message>> for Element<'a, Message>
409where
410 Id: Clone + PartialEq + 'a,
411 Message: Clone + 'a,
412{
413 fn from(bar: StatusBar<'a, Id, Message>) -> Self {
414 let StatusBar {
415 state,
416 compact_below,
417 on_event,
418 } = bar;
419 let on_event: Option<OnEvent<'a, Id, Message>> = on_event.map(Rc::from);
420
421 let content = responsive(move |size| {
422 let items = |section: Section| -> Vec<Element<'a, Message>> {
423 state
424 .section(section)
425 .map(|item| view_item(item, state.phase, on_event.as_ref()))
426 .collect()
427 };
428 if size.width < compact_below {
429 let all = Section::ALL.into_iter().flat_map(items);
430 return row(all)
431 .spacing(gap::MD)
432 .align_y(Alignment::Center)
433 .wrap()
434 .vertical_spacing(gap::XS)
435 .into();
436 }
437 let section = |section| {
438 row(items(section))
439 .spacing(gap::MD)
440 .align_y(Alignment::Center)
441 };
442 row![
443 section(Section::Start),
444 space::horizontal(),
445 section(Section::Centre),
446 space::horizontal(),
447 section(Section::End),
448 ]
449 .spacing(gap::MD)
450 .align_y(Alignment::Center)
451 .into()
452 })
453 .height(Length::Shrink);
454
455 column![
456 crate::application::edge(),
457 container(content)
458 .padding(Padding::from([gap::XS, gap::SM]))
459 .width(Length::Fill)
460 .style(|theme| bar_style(&Tokens::of(theme))),
461 ]
462 .width(Length::Fill)
463 .into()
464 }
465}
466
467fn muted_text<'a>(label: String) -> widget::Text<'a, Theme> {
468 widget::text(label)
469 .size(text_size::XS)
470 .line_height(LineHeight::Absolute(LINE.into()))
471 .wrapping(widget::text::Wrapping::None)
472}
473
474fn glyph<'a, Message: 'a>(glyph: Glyph, enabled: bool) -> Element<'a, Message> {
475 themed(glyph, ICON_SIZE, opacity(enabled), |theme| {
476 text_colour(&Tokens::of(theme))
477 })
478 .into()
479}
480
481fn view_item<'a, Id, Message>(
482 item: &Item<Id>,
483 phase: f32,
484 on_event: Option<&OnEvent<'a, Id, Message>>,
485) -> Element<'a, Message>
486where
487 Id: Clone + 'a,
488 Message: Clone + 'a,
489{
490 let label = || -> Option<Element<'a, Message>> {
491 (!item.label.is_empty()).then(|| {
492 muted_text(item.label.clone())
493 .style(|theme| widget::text::Style {
494 color: Some(text_colour(&Tokens::of(theme))),
495 })
496 .into()
497 })
498 };
499 let icon = item.icon.map(|icon| glyph(icon, true));
500
501 let element: Element<'a, Message> = match item.content {
502 Content::Text => labelled(icon.into_iter().chain(label())),
503 Content::Spinner => {
504 labelled(std::iter::once(spinning(phase).size(spinner::Size::Sm).into()).chain(label()))
505 }
506 Content::Progress(value) => labelled(
507 icon.into_iter().chain(label()).chain(std::iter::once(
508 progress_bar(value)
509 .size(progress::Size::Sm)
510 .width(PROGRESS_WIDTH)
511 .into(),
512 )),
513 ),
514 Content::Badge(variant) => {
515 let badge = badge::badge(item.label.clone()).variant(variant);
516 match item.icon {
517 Some(icon) => badge.icon(icon).into(),
518 None => badge.into(),
519 }
520 }
521 Content::Action => action_button(item, on_event),
522 };
523 match &item.tooltip {
524 Some(label) => tooltip(element, label.clone())
525 .position(Position::Top)
526 .into(),
527 None => element,
528 }
529}
530
531fn labelled<'a, Message: 'a>(
532 parts: impl Iterator<Item = Element<'a, Message>>,
533) -> Element<'a, Message> {
534 row(parts)
535 .spacing(gap::XS + 2.0)
536 .align_y(Alignment::Center)
537 .into()
538}
539
540fn action_button<'a, Id, Message>(
541 item: &Item<Id>,
542 on_event: Option<&OnEvent<'a, Id, Message>>,
543) -> Element<'a, Message>
544where
545 Id: Clone + 'a,
546 Message: Clone + 'a,
547{
548 let enabled = on_event.is_some();
549 let mut content = row![].spacing(gap::XS + 2.0).align_y(Alignment::Center);
550 if let Some(icon) = item.icon {
551 content = content.push(themed(icon, ICON_SIZE, opacity(enabled), |theme| {
552 Tokens::of(theme).foreground
553 }));
554 }
555 content = content.push(muted_text(item.label.clone()));
556 widget::button(content)
557 .padding(Padding::from([2.0, 6.0]))
558 .style(|theme, status| button::style(&Tokens::of(theme), Variant::Ghost, status))
559 .on_press_maybe(on_event.map(|on_event| on_event(Event::Press(item.id.clone()))))
560 .into()
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use crate::theme::{dark, light};
567 use iced::futures::SinkExt;
568 use iced::futures::executor::block_on;
569
570 fn sample() -> State<&'static str> {
571 State::new().with_items([
572 text("branch", "main").icon(crate::lucide!(GitBranch)),
573 spinner("sync", "Syncing"),
574 progress("build", 0.4)
575 .label("Building")
576 .section(Section::Centre),
577 action("problems", "2 problems").section(Section::End),
578 badge("mode", "Insert", badge::Variant::Secondary).section(Section::End),
579 ])
580 }
581
582 #[test]
583 fn builders_set_every_field() {
584 let item = text("cursor", "Ln 1, Col 1")
585 .icon(crate::lucide!(MousePointer))
586 .section(Section::End)
587 .tooltip("Go to line");
588 assert_eq!(item.id, "cursor");
589 assert_eq!(item.label, "Ln 1, Col 1");
590 assert_eq!(item.icon, Some(crate::lucide!(MousePointer)));
591 assert_eq!(item.section, Section::End);
592 assert_eq!(item.tooltip.as_deref(), Some("Go to line"));
593 assert_eq!(item.content, Content::Text);
594
595 assert_eq!(action(1, "Run").content, Content::Action);
596 assert_eq!(spinner(1, "Busy").content, Content::Spinner);
597 assert_eq!(progress(1, 1.5).content, Content::Progress(1.0));
598 assert_eq!(progress(1, f32::NAN).content, Content::Progress(0.0));
599 assert!(progress(1, 0.5).label.is_empty());
600 assert_eq!(
601 badge(1, "3", badge::Variant::Destructive).content,
602 Content::Badge(badge::Variant::Destructive)
603 );
604 assert_eq!(Section::default(), Section::Start);
605 }
606
607 #[test]
608 fn items_keep_their_order_within_sections() {
609 let state = sample();
610 let start: Vec<_> = state.section(Section::Start).map(|item| item.id).collect();
611 let end: Vec<_> = state.section(Section::End).map(|item| item.id).collect();
612 assert_eq!(start, ["branch", "sync"]);
613 assert_eq!(end, ["problems", "mode"]);
614 assert_eq!(state.items().len(), 5);
615 assert!(!state.is_empty());
616 }
617
618 #[test]
619 fn set_replaces_in_place_and_remove_and_clear_delete() {
620 let mut state = sample();
621 state.apply(Update::Set(text("sync", "Synced")));
622 assert_eq!(state.items()[1].label, "Synced");
623 assert_eq!(state.items()[1].content, Content::Text);
624 assert_eq!(state.items().len(), 5);
625
626 state.apply(Update::Remove("build"));
627 assert!(state.item(&"build").is_none());
628 state.apply(Update::Remove("missing"));
629 assert_eq!(state.items().len(), 4);
630
631 state.apply(Update::Clear);
632 assert!(state.is_empty());
633 }
634
635 #[test]
636 fn a_batch_applies_in_order() {
637 let mut state: State<&str> = State::new();
638 let output = state.update(Event::Received(vec![
639 Update::Set(text("a", "one")),
640 Update::Set(text("b", "two")),
641 Update::Set(text("a", "three")),
642 Update::Remove("b"),
643 ]));
644 assert!(output.is_none());
645 assert_eq!(state.items().len(), 1);
646 assert_eq!(state.items()[0].label, "three");
647 assert!(state.update(Event::Received(Vec::new())).is_none());
648 }
649
650 #[test]
651 fn frames_move_the_spinners_only_while_one_shows() {
652 let mut state = sample();
653 assert!(state.needs_frames());
654 let start = Instant::now();
655 let _ = state.update(Event::Frame(start));
656 assert_eq!(state.phase(), 0.0, "the first frame only starts the clock");
657 let _ = state.update(Event::Frame(start + spinner::PERIOD / 4));
658 assert!((state.phase() - 0.25).abs() < 1e-3);
659
660 state.apply(Update::Remove("sync"));
661 assert!(!state.needs_frames());
662 assert!(
663 state.last_frame.is_none(),
664 "the clock stops with the spinner"
665 );
666 }
667
668 #[test]
669 fn only_clickable_items_report_presses() {
670 let mut state = sample();
671 assert!(matches!(
672 state.update(Event::Press("problems")),
673 Some(Output::Pressed("problems"))
674 ));
675 assert!(state.update(Event::Press("branch")).is_none());
676 assert!(state.update(Event::Press("missing")).is_none());
677 }
678
679 #[test]
680 fn ready_hands_the_sender_back() {
681 let (sender, _receiver) = mpsc::channel(1);
682 let output = State::<u8>::new().update(Event::Ready(sender));
683 assert!(matches!(output, Some(Output::Ready(_))));
684 }
685
686 #[test]
687 fn stream_emits_ready_first_then_batches() {
688 let mut events = Box::pin(stream::<u8>());
689 let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
690 panic!("first event must be Ready");
691 };
692 block_on(async {
693 for n in 0..3 {
694 sender.send(Update::Set(text(n, format!("{n}")))).await.ok();
695 }
696 });
697 let Some(Event::Received(batch)) = block_on(events.next()) else {
698 panic!("expected a batch");
699 };
700 assert_eq!(batch.len(), 3);
701 }
702
703 #[test]
704 fn batches_split_bursts_at_batch_size() {
705 let items = stream::iter(0..BATCH_SIZE + 3);
706 let sizes: Vec<_> = block_on(batches(items).map(|batch| batch.len()).collect());
707 assert_eq!(sizes, [BATCH_SIZE, 3]);
708 }
709
710 #[test]
711 fn builder_defaults_and_overrides() {
712 let state = sample();
713 let bar: StatusBar<'_, &str, ()> = status_bar(&state);
714 assert_eq!(bar.compact_below, COMPACT_WIDTH);
715 assert!(bar.on_event.is_none());
716 let bar: StatusBar<'_, &str, ()> = status_bar(&state).compact_below(0.0).on_event(|_| ());
717 assert_eq!(bar.compact_below, 0.0);
718 assert!(bar.on_event.is_some());
719 }
720
721 #[test]
722 fn the_bar_has_muted_text_in_both_themes() {
723 for theme in [light(), dark()] {
724 let tokens = Tokens::of(&theme);
725 let style = bar_style(&tokens);
726 assert_eq!(style.background, Some(Background::Color(tokens.background)));
727 assert_eq!(style.text_color, Some(tokens.muted_foreground));
728 assert_eq!(text_colour(&tokens), tokens.muted_foreground);
729 }
730 }
731}