Skip to main content

max / audiofiles

22.7 KB · 630 lines History Blame Raw
1 //! Reusable UI widgets: tag chips, classification badges, modals, and progress indicators.
2 //!
3 //! See `docs/design-system.md` for the canonical primitive set. Every shared
4 //! visual recipe lives here; panel files compose these helpers and never
5 //! reinvent rows, headers, modals, or buttons inline.
6 //!
7 //! ## Brand-rule glyph exceptions
8 //!
9 //! Per `docs/design-system.md`, user-facing strings should not contain emoji or
10 //! checkmark glyphs. The following are *documented* exceptions:
11 //!
12 //! * Sort-direction arrows (`U+25B2`, `U+25BC`) in `file_list.rs::draw_sort_header`
13 //! — functional column-header affordance, no word equivalent fits the layout.
14 //! * File-tree node-type prefixes (`U+1F4C1` folder, `U+2601` cloud, `U+1F50A`
15 //! speaker) in `file_list.rs::draw_name_column` — visual hierarchy for the
16 //! table primary column; revisit during the Phase 3 surface audit.
17 //! * Typography (em-dash `U+2014`, right-arrow `U+2192`, middle-dot `U+00B7`,
18 //! bullet `U+2022`) in prose — these are punctuation, not emoji.
19 //!
20 //! Everything else (✓ ✖ ▶ ⏹ 🎵 🔍 🎹 🔁 ⚙ ↩ 💾 ⚠ ☰) was migrated to words
21 //! during Batch 5 of the consolidation plan.
22
23 use egui;
24
25 use super::theme;
26
27 // --- Modal scaffolds ---------------------------------------------------------
28
29 /// Outcome of a modal that ends in a Confirm/Cancel action row.
30 pub enum ConfirmOutcome {
31 /// User hasn't acted yet this frame.
32 None,
33 /// User pressed the confirm button (or Enter, where applicable).
34 Confirmed,
35 /// User pressed Cancel.
36 Cancelled,
37 }
38
39 /// Outcome of a single-field name modal (used by create/rename modals).
40 pub enum NameModalOutcome {
41 /// User hasn't acted yet this frame.
42 None,
43 /// User submitted the (trimmed) name.
44 Submitted(String),
45 /// User cancelled.
46 Cancelled,
47 }
48
49 /// Canonical center-anchored, non-resizable modal scaffold.
50 ///
51 /// Replaces the inline
52 /// `Window::new(title).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])`
53 /// recipe repeated across `overlays.rs`. Pass `resizable: true` only for the
54 /// bulk-rename modal — every other modal uses the default.
55 pub fn modal_window<R>(
56 ctx: &egui::Context,
57 title: &str,
58 resizable: bool,
59 default_width: Option<f32>,
60 add_contents: impl FnOnce(&mut egui::Ui) -> R,
61 ) -> Option<R> {
62 modal_window_with_open(ctx, title, None, resizable, default_width, add_contents)
63 }
64
65 /// Floating tool window scaffold.
66 ///
67 /// Distinct from [`modal_window`]: not anchored, resizable, collapsible, and
68 /// user-dismissible via an `open` bool. Use for tool surfaces that the user
69 /// keeps open alongside the main UI (the sample editor, the MIDI/instrument
70 /// panel). Anything that demands focus and blocks the rest of the UI is a
71 /// modal — use [`modal_window`] or [`confirm_modal`] instead.
72 pub fn tool_window<R>(
73 ctx: &egui::Context,
74 title: &str,
75 open: &mut bool,
76 default_width: f32,
77 min_width: f32,
78 add_contents: impl FnOnce(&mut egui::Ui) -> R,
79 ) -> Option<R> {
80 egui::Window::new(title)
81 .open(open)
82 .resizable(true)
83 .collapsible(true)
84 .default_width(default_width)
85 .min_width(min_width)
86 .show(ctx, |ui| add_contents(ui))
87 .and_then(|r| r.inner)
88 }
89
90 /// Like `modal_window`, but with an optional `open` bool the user can toggle by
91 /// clicking the close (×) chrome. Used by the help overlay.
92 pub fn modal_window_with_open<R>(
93 ctx: &egui::Context,
94 title: &str,
95 open: Option<&mut bool>,
96 resizable: bool,
97 default_width: Option<f32>,
98 add_contents: impl FnOnce(&mut egui::Ui) -> R,
99 ) -> Option<R> {
100 let mut window = egui::Window::new(title)
101 .collapsible(false)
102 .resizable(resizable)
103 .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]);
104 if let Some(o) = open {
105 window = window.open(o);
106 }
107 if let Some(w) = default_width {
108 window = window.default_width(w);
109 }
110 window.show(ctx, |ui| add_contents(ui)).map(|r| r.inner.unwrap())
111 }
112
113 /// Render a `[Cancel] [primary]` action row at the bottom of a modal.
114 ///
115 /// Action ordering follows platform convention: Cancel on the left, primary on
116 /// the right. The user's muscle memory from macOS/Windows native dialogs is
117 /// "the affirmative button is on the right" — matching that avoids surprise
118 /// clicks, especially in destructive modals where Delete-on-the-right-cursor
119 /// would be catastrophic. The primary button is enabled when `can_confirm` is
120 /// true.
121 pub fn confirm_action_row(
122 ui: &mut egui::Ui,
123 confirm_label: &str,
124 can_confirm: bool,
125 danger: bool,
126 ) -> ConfirmOutcome {
127 let mut outcome = ConfirmOutcome::None;
128 ui.horizontal(|ui| {
129 if ui.button("Cancel").clicked() {
130 outcome = ConfirmOutcome::Cancelled;
131 }
132 let label = if danger {
133 egui::RichText::new(confirm_label).color(theme::accent_red())
134 } else {
135 egui::RichText::new(confirm_label)
136 };
137 if ui.add_enabled(can_confirm, egui::Button::new(label)).clicked() {
138 outcome = ConfirmOutcome::Confirmed;
139 }
140 });
141 outcome
142 }
143
144 /// Spec for a destructive-confirm modal.
145 pub struct ConfirmSpec<'a> {
146 pub title: &'a str,
147 pub prompt: &'a str,
148 pub detail: Option<&'a str>,
149 pub confirm_label: &'a str,
150 pub danger: bool,
151 }
152
153 /// Render a confirm modal with a prompt, optional detail body, and a
154 /// `[confirm] [Cancel]` action row. Returns the outcome for the caller to
155 /// act on after the closure exits (egui borrow constraints).
156 pub fn confirm_modal(ctx: &egui::Context, spec: &ConfirmSpec) -> ConfirmOutcome {
157 let mut outcome = ConfirmOutcome::None;
158 modal_window(ctx, spec.title, false, None, |ui| {
159 ui.label(spec.prompt);
160 if let Some(detail) = spec.detail {
161 ui.add_space(theme::space::SM);
162 ui.label(
163 egui::RichText::new(detail)
164 .small()
165 .color(theme::text_secondary()),
166 );
167 }
168 ui.add_space(theme::space::LG);
169 outcome = confirm_action_row(ui, spec.confirm_label, true, spec.danger);
170 });
171 outcome
172 }
173
174 /// Single-field name modal: title, optional hint, label, text input,
175 /// submit/cancel. Enter in the field submits.
176 ///
177 /// Autofocus: the text field grabs focus on first open (detected as "input is
178 /// empty and nothing in the app currently has focus"). After the user clicks
179 /// any widget the autofocus stops firing, so Cancel/Submit clicks aren't
180 /// stolen back by the input.
181 pub fn name_modal(
182 ctx: &egui::Context,
183 title: &str,
184 hint: Option<&str>,
185 label: &str,
186 input: &mut String,
187 submit_label: &str,
188 error: Option<&str>,
189 ) -> NameModalOutcome {
190 let mut outcome = NameModalOutcome::None;
191 modal_window(ctx, title, false, None, |ui| {
192 if let Some(h) = hint {
193 ui.label(egui::RichText::new(h).small().color(theme::text_muted()));
194 ui.add_space(theme::space::SM);
195 }
196 ui.label(label);
197 let resp = ui.text_edit_singleline(input);
198 if input.is_empty() && ui.memory(|m| m.focused().is_none()) {
199 resp.request_focus();
200 }
201 // C-3: inline error below the input. Re-focus the input when an error
202 // is surfaced so the user can edit and retry without re-clicking.
203 if let Some(err) = error {
204 ui.add_space(theme::space::XS);
205 ui.label(
206 egui::RichText::new(err)
207 .small()
208 .color(theme::accent_red()),
209 );
210 if !resp.has_focus() {
211 resp.request_focus();
212 }
213 }
214 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
215 outcome = NameModalOutcome::Submitted(input.trim().to_string());
216 }
217 ui.add_space(theme::space::MD);
218 ui.horizontal(|ui| {
219 if ui.button("Cancel").clicked() {
220 outcome = NameModalOutcome::Cancelled;
221 }
222 if ui.button(submit_label).clicked() {
223 outcome = NameModalOutcome::Submitted(input.trim().to_string());
224 }
225 });
226 });
227 outcome
228 }
229
230 // --- Empty state and banner --------------------------------------------------
231
232 /// CTA slot for an empty-state panel.
233 pub struct EmptyStateCta<'a> {
234 pub label: &'a str,
235 pub tooltip: Option<&'a str>,
236 }
237
238 /// Centered empty-state panel.
239 ///
240 /// Renders a centred column with a heading (`text_secondary`, 20 px), an
241 /// optional body (`text_muted`), and an optional CTA button. The vertical
242 /// offset is 15% of the available height to keep the column visually anchored.
243 /// Returns `true` if the CTA was clicked (always `false` when no CTA).
244 pub fn empty_state(
245 ui: &mut egui::Ui,
246 heading: &str,
247 body: Option<&str>,
248 cta: Option<EmptyStateCta>,
249 ) -> bool {
250 let mut clicked = false;
251 ui.vertical_centered(|ui| {
252 ui.add_space(ui.available_height() * 0.15);
253 ui.label(
254 egui::RichText::new(heading)
255 .size(20.0)
256 .color(theme::text_secondary()),
257 );
258 if let Some(body_text) = body {
259 ui.add_space(theme::space::MD);
260 ui.label(egui::RichText::new(body_text).color(theme::text_muted()));
261 }
262 if let Some(cta) = cta {
263 ui.add_space(theme::space::LG);
264 let btn = secondary_button(ui, cta.label);
265 let btn = if let Some(t) = cta.tooltip { btn.on_hover_text(t) } else { btn };
266 if btn.clicked() {
267 clicked = true;
268 }
269 }
270 });
271 clicked
272 }
273
274 /// Format a byte count as B / KB / MB / GB. Three other call sites in this
275 /// crate define their own private copies — new callers should reach for this
276 /// one; the legacy copies can be migrated opportunistically.
277 pub fn format_bytes(bytes: u64) -> String {
278 if bytes < 1024 {
279 format!("{bytes} B")
280 } else if bytes < 1024 * 1024 {
281 format!("{:.1} KB", bytes as f64 / 1024.0)
282 } else if bytes < 1024 * 1024 * 1024 {
283 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
284 } else {
285 format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
286 }
287 }
288
289 /// Inline informational banner: rounded frame, `bg_tertiary` fill, body text
290 /// in `text_secondary`. Used for one-time tips and unobtrusive panel notices.
291 pub fn info_banner(ui: &mut egui::Ui, body: &str) {
292 egui::Frame::new()
293 .fill(theme::bg_tertiary())
294 .corner_radius(egui::CornerRadius::same(4))
295 .inner_margin(egui::Margin::same(8))
296 .show(ui, |ui| {
297 ui.label(
298 egui::RichText::new(body)
299 .small()
300 .color(theme::text_secondary()),
301 );
302 });
303 }
304
305 /// Inline warning banner: same shape as `info_banner` but body text in
306 /// `accent_yellow` at body weight (not `.small()`). Used for actions whose
307 /// consequences are important enough that the weak/small footnote style would
308 /// under-sell them — currently the irrecoverable encryption-password setup.
309 pub fn warning_banner(ui: &mut egui::Ui, body: &str) {
310 egui::Frame::new()
311 .fill(theme::bg_tertiary())
312 .corner_radius(egui::CornerRadius::same(4))
313 .inner_margin(egui::Margin::same(8))
314 .show(ui, |ui| {
315 ui.label(egui::RichText::new(body).color(theme::accent_yellow()));
316 });
317 }
318
319 // --- Toolbar toggle and segmented pills --------------------------------------
320
321 /// Toolbar toggle button.
322 ///
323 /// Active state colours the label `accent_blue`; inactive state colours it
324 /// `text_muted`. Returns true on click. Optional `count` renders a parenthesised
325 /// suffix (e.g. "Filters (3)") for active-with-count toolbar buttons.
326 pub fn toolbar_toggle(
327 ui: &mut egui::Ui,
328 label: &str,
329 active: bool,
330 tooltip: &str,
331 count: Option<usize>,
332 ) -> bool {
333 let text = match count {
334 Some(n) if n > 0 => format!("{label} ({n})"),
335 _ => label.to_string(),
336 };
337 let colour = if active { theme::accent_blue() } else { theme::text_muted() };
338 ui.button(egui::RichText::new(text).color(colour))
339 .on_hover_text(tooltip)
340 .clicked()
341 }
342
343 /// Mutually-exclusive segmented pill control.
344 ///
345 /// `options` is a list of `(value, label, tooltip)` triples. Returns
346 /// `Some(value)` if a non-current option was clicked, `None` otherwise.
347 /// Caller assigns the returned value to its state.
348 pub fn toggle_pills<T: Clone + PartialEq>(
349 ui: &mut egui::Ui,
350 current: &T,
351 options: &[(T, &str, &str)],
352 ) -> Option<T> {
353 let mut chosen = None;
354 ui.horizontal(|ui| {
355 for (value, label, tooltip) in options {
356 let is_active = value == current;
357 if ui
358 .selectable_label(is_active, *label)
359 .on_hover_text(*tooltip)
360 .clicked()
361 && !is_active
362 {
363 chosen = Some(value.clone());
364 }
365 }
366 });
367 chosen
368 }
369
370 // --- Button hierarchy --------------------------------------------------------
371 //
372 // Three button weights:
373 // - `primary_button` — strong label; the single primary action in a row.
374 // - `secondary_button` — default weight; cancel and peer actions.
375 // - `danger_button` — `accent_red` label; destructive primary actions.
376 //
377 // `confirm_action_row` (above) composes these for the standard modal pattern;
378 // reach for these directly only when building a non-modal action row.
379
380 /// Primary action button. Strong label weight.
381 pub fn primary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
382 ui.add(egui::Button::new(egui::RichText::new(label).strong()))
383 }
384
385 /// Secondary / peer action button. Default weight. Use for Cancel and for any
386 /// action that isn't the primary focus of the row.
387 pub fn secondary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
388 ui.button(label)
389 }
390
391 /// Destructive primary action. Label rendered in `accent_red` so the user
392 /// reads the consequence before clicking. Used for Delete, Purge, Discard.
393 pub fn danger_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
394 ui.add(egui::Button::new(egui::RichText::new(label).color(theme::accent_red())))
395 }
396
397 /// Small destructive action (per-row Remove/Delete affordances, context-menu
398 /// items inside a tighter layout). Same colouring as [`danger_button`].
399 pub fn danger_small_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
400 ui.add(egui::Button::new(egui::RichText::new(label).color(theme::accent_red())).small())
401 }
402
403 // --- Section headers ---------------------------------------------------------
404
405 /// Panel section heading: strong, `text_secondary` label, separator, small gap.
406 pub fn section_header(ui: &mut egui::Ui, label: &str) {
407 ui.label(egui::RichText::new(label).strong().color(theme::text_secondary()));
408 ui.separator();
409 ui.add_space(theme::space::SM);
410 }
411
412 /// Sub-block label inside an already-headed section. No separator, no gap.
413 pub fn subsection_label(ui: &mut egui::Ui, label: &str) {
414 ui.label(egui::RichText::new(label).strong().color(theme::text_secondary()));
415 }
416
417 /// Filter-panel collapsing section.
418 ///
419 /// Header is suffixed with `" *"` when `active` is true (visual indicator of an
420 /// in-effect filter); the section is `default_open` when active so the user
421 /// sees what's filtering them.
422 pub fn filter_section<R>(
423 ui: &mut egui::Ui,
424 label: &str,
425 active: bool,
426 add_contents: impl FnOnce(&mut egui::Ui) -> R,
427 ) {
428 let header = if active { format!("{label} *") } else { label.to_string() };
429 egui::CollapsingHeader::new(header)
430 .default_open(active)
431 .show(ui, |ui| {
432 add_contents(ui);
433 });
434 }
435
436 // --- Selectable rows ---------------------------------------------------------
437
438 /// Primary selectable list row.
439 ///
440 /// Active state renders the label as `strong()` + `accent_blue`. Inactive state
441 /// renders as `text_primary`. Used for top-level list items where the inactive
442 /// state is meant to read as "default text" (e.g. VFS rows, breadcrumb).
443 pub fn selectable_row(ui: &mut egui::Ui, active: bool, label: impl Into<String>) -> egui::Response {
444 let text = label.into();
445 let rich = if active {
446 egui::RichText::new(text).strong().color(theme::accent_blue())
447 } else {
448 egui::RichText::new(text).color(theme::text_primary())
449 };
450 ui.selectable_label(active, rich)
451 }
452
453 /// Secondary selectable list row.
454 ///
455 /// Same active state as [`selectable_row`], but inactive state uses
456 /// `text_secondary`. Used for nested or de-emphasised lists (collections,
457 /// sort headers, secondary navigation).
458 pub fn selectable_row_secondary(
459 ui: &mut egui::Ui,
460 active: bool,
461 label: impl Into<String>,
462 ) -> egui::Response {
463 let text = label.into();
464 let rich = if active {
465 egui::RichText::new(text).strong().color(theme::accent_blue())
466 } else {
467 egui::RichText::new(text).color(theme::text_secondary())
468 };
469 ui.selectable_label(active, rich)
470 }
471
472 /// Tag-tree row.
473 ///
474 /// Active state uses `accent_blue` *without* `strong()` weight — tag leaves
475 /// are dense and the bold weight reads too heavy. Inactive state is
476 /// `text_secondary`. Use [`selectable_row`] family for non-tag rows.
477 pub fn selectable_tag(ui: &mut egui::Ui, active: bool, label: impl Into<String>) -> egui::Response {
478 let text = label.into();
479 let rich = if active {
480 egui::RichText::new(text).color(theme::accent_blue())
481 } else {
482 egui::RichText::new(text).color(theme::text_secondary())
483 };
484 ui.selectable_label(active, rich)
485 }
486
487 /// Render a wizard step indicator: a horizontal row of step labels with the
488 /// current step in `accent_blue` strong, completed steps in `text_secondary`,
489 /// and upcoming steps in `text_muted`. Use at the top of any multi-screen flow
490 /// (import wizard, export wizard, future onboarding tour). Steps are separated
491 /// by a middle-dot.
492 pub fn wizard_steps(ui: &mut egui::Ui, steps: &[&str], current: usize) {
493 ui.horizontal_wrapped(|ui| {
494 ui.spacing_mut().item_spacing.x = theme::space::SM;
495 for (i, step) in steps.iter().enumerate() {
496 let label = format!("{}. {}", i + 1, step);
497 let colored = if i == current {
498 egui::RichText::new(label).strong().color(theme::accent_blue())
499 } else if i < current {
500 egui::RichText::new(label).color(theme::text_secondary())
501 } else {
502 egui::RichText::new(label).color(theme::text_muted())
503 };
504 ui.label(colored);
505 if i + 1 < steps.len() {
506 ui.label(egui::RichText::new("\u{00B7}").color(theme::text_muted()));
507 }
508 }
509 });
510 ui.add_space(theme::space::MD);
511 ui.separator();
512 ui.add_space(theme::space::MD);
513 }
514
515 /// Render a numbered step label: `strong()` `accent_blue` "N." used to head
516 /// each line of a numbered onboarding list. Distinct from `selectable_row` —
517 /// these aren't clickable, they're just emphasised list markers.
518 pub fn step_number(ui: &mut egui::Ui, n: u32) {
519 ui.label(accent_strong(format!("{n}.")));
520 }
521
522 /// `RichText` builder for the canonical "this is the active thing" label:
523 /// `strong()` weight, `accent_blue` colour. Use for non-selectable labels that
524 /// signal the current context (e.g. the active collection name in the
525 /// breadcrumb). For selectable list rows, prefer [`selectable_row`].
526 pub fn accent_strong(label: impl Into<String>) -> egui::RichText {
527 egui::RichText::new(label.into()).strong().color(theme::accent_blue())
528 }
529
530 // --- Tag and classification widgets ------------------------------------------
531
532 /// Draw a colored classification badge.
533 pub fn classification_badge(ui: &mut egui::Ui, class: &str) {
534 let color = theme::classification_color(class);
535 let label = egui::RichText::new(class)
536 .small()
537 .color(color);
538 ui.label(label);
539 }
540
541 /// Draw a tag as a small colored chip.
542 ///
543 /// Uses custom rendering (`allocate_exact_size` + `painter()`) instead of a standard
544 /// egui widget because tag chips need a specific rounded-rect background, precise
545 /// font size (11pt), and hover highlighting that standard `Label` doesn't provide.
546 pub fn tag_chip(ui: &mut egui::Ui, tag: &str) -> egui::Response {
547 // Estimate width from character count * average glyph width + padding.
548 let (rect, response) = ui.allocate_exact_size(
549 egui::vec2(ui.fonts(|f| f.glyph_width(&egui::TextStyle::Small.resolve(ui.style()), ' ')) * tag.len() as f32 + 16.0, 20.0),
550 egui::Sense::click(),
551 );
552
553 if ui.is_rect_visible(rect) {
554 let bg = if response.hovered() {
555 theme::bg_hover()
556 } else {
557 theme::bg_surface()
558 };
559 ui.painter().rect_filled(rect, 4.0, bg);
560 ui.painter().text(
561 rect.center(),
562 egui::Align2::CENTER_CENTER,
563 tag,
564 egui::FontId::proportional(11.0),
565 theme::accent_blue(),
566 );
567 }
568
569 response
570 }
571
572 /// Draw a tag chip with an X remove button. Returns true if X was clicked.
573 ///
574 /// When `hover_only_remove` is true, the X is rendered dimmed until the chip
575 /// (or the X itself) is hovered — reduces the accidental-click surface in
576 /// browse-heavy surfaces like the detail panel.
577 pub fn tag_chip_removable(ui: &mut egui::Ui, tag: &str, hover_only_remove: bool) -> bool {
578 // Pre-flight: compute the row's expected rect from the pending cursor so we
579 // can check hover before drawing — egui style is sticky once a widget is
580 // added, so we need to know the hover state up front.
581 let mut removed = false;
582 ui.horizontal(|ui| {
583 ui.spacing_mut().item_spacing.x = 2.0;
584 let label_resp = ui.label(
585 egui::RichText::new(tag)
586 .small()
587 .color(theme::accent_blue()),
588 );
589 let row_hovered = label_resp.hovered()
590 || ui.rect_contains_pointer(label_resp.rect.expand2(egui::vec2(20.0, 0.0)));
591 let x_color = if hover_only_remove && !row_hovered {
592 theme::text_muted()
593 } else {
594 theme::accent_red()
595 };
596 let btn = ui
597 .add(
598 egui::Button::new(egui::RichText::new("x").small().color(x_color))
599 .small(),
600 )
601 .on_hover_text("Remove tag");
602 if btn.clicked() {
603 removed = true;
604 }
605 });
606 removed
607 }
608
609 /// Format duration as mm:ss or just seconds for short durations.
610 pub fn format_duration(seconds: f64) -> String {
611 if seconds < 60.0 {
612 format!("{:.1}s", seconds)
613 } else {
614 let mins = (seconds / 60.0).floor() as u32;
615 let secs = seconds % 60.0;
616 format!("{}:{:04.1}", mins, secs)
617 }
618 }
619
620 /// Format BPM for display: show as integer when close to a whole number,
621 /// otherwise one decimal place. The 0.05 threshold avoids displaying "120.0"
622 /// for values like 119.97 that are effectively integer BPMs.
623 pub fn format_bpm(bpm: f64) -> String {
624 if (bpm - bpm.round()).abs() < 0.05 {
625 format!("{:.0}", bpm)
626 } else {
627 format!("{:.1}", bpm)
628 }
629 }
630