mnml-rs 0.2.22

A NvChad-style terminal IDE in Rust — vim or standard editing, LSP, git, and an embedded HTTP client. Final Rust release; the project continues in Zig at the same repository.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
//! Shared visual language for the "primary" and "secondary" action
//! chips that sit at the top of every activity-bar panel — the
//! `+ New session`, `+ New note`, `+ from PR`, `+ New Cloud Run`
//! family.
//!
//! Before this module lived here the chips had drifted individually:
//! agents_panel used a solid green + solid cyan (the reference the
//! user liked), but sessions_panel and notes_panel used `bg2` (grey)
//! chips with green text — which visually blended into the filter
//! row's grey chip immediately above. User ask 2026-08-23:
//! "lets set a constant for these buttons and keep them in sync ...
//! primary and secondary buttons ... each of these areas in
//! activity bar should have same look and feel".
//!
//! Two roles, sourced from the active theme so a theme change flows
//! through every panel automatically:
//!
//! - **Primary** — the panel's main call-to-action ("+ New X").
//!   Green fill, dark text.
//! - **Secondary** — a peer action on the same row ("+ from PR",
//!   "Import…"). Purple fill, dark text.
//!
//! Callers wrap a label in [`chip_line`] to render a full padded
//! button, or grab [`primary`]/[`secondary`] directly if they need
//! to compose spans by hand.

use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span},
};

use crate::ui::theme::Theme;

/// Pure black used as the label fg on filled `primary` /
/// `secondary` chips. The theme's `bg_darker` isn't dark enough
/// against mid-brightness fills (soft-green / soft-purple) —
/// user report 2026-08-23: chip label unreadable when routed
/// through `bg_darker`. Rgb(0,0,0) guarantees max contrast
/// across every theme.
pub(crate) const CHIP_LABEL_FG: Color = Color::Rgb(0, 0, 0);

/// Primary action chip style — the panel's main call-to-action.
/// Solid green fill + black text. Use for toolbar-level actions
/// (`+ New session`, `+ New note`).
#[inline]
pub fn primary(t: &Theme) -> Style {
    Style::default()
        .fg(CHIP_LABEL_FG)
        .bg(t.green)
        .add_modifier(Modifier::BOLD)
}

/// Secondary action chip style — a peer action on the same row.
/// Solid purple fill + black text. Use next to a `primary` chip
/// when the panel has two peer create-flows (`+ from PR`).
#[inline]
pub fn secondary(t: &Theme) -> Style {
    Style::default()
        .fg(CHIP_LABEL_FG)
        .bg(t.purple)
        .add_modifier(Modifier::BOLD)
}

/// Text-link "add row" style — green text on the panel's own
/// background, no chip fill. Use for inline "+ New X" prompts
/// that sit at the end of a listed section (HTTP's per-section
/// `+ New request` / `+ New env` / `+ New chain` /
/// `+ New collection`) where a filled chip would read as a
/// heavy button in the middle of a list.
#[inline]
pub fn link(t: &Theme, bg: Color) -> Style {
    Style::default()
        .fg(t.green)
        .bg(bg)
        .add_modifier(Modifier::BOLD)
}

/// Render a full labeled chip: 1-cell pad + label + 1-cell pad, all
/// on the given `role_style`'s background. Returns a `Line` the
/// caller can drop into a `Paragraph` at a `Rect` sized to
/// [`chip_width`]. `role_style` is either [`primary`] or
/// [`secondary`].
pub fn chip_line(label: &str, role_style: Style) -> Line<'_> {
    Line::from(vec![
        Span::styled(" ", role_style),
        Span::styled(label.to_string(), role_style),
        Span::styled(" ", role_style),
    ])
}

/// Cell width of the chip [`chip_line`] renders — label plus the
/// two 1-cell pads.
#[inline]
pub fn chip_width(label: &str) -> u16 {
    (label.chars().count() as u16).saturating_add(2)
}

// ──────────────────────────────────────────────────────────────────
// Button — the general component. Use this for ANY new button.
// ──────────────────────────────────────────────────────────────────

/// Visual state of a [`Button`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonState {
    /// Normal, clickable.
    #[default]
    Normal,
    /// Inert — painted but not actionable. Dimmed, and callers should
    /// skip registering a click rect so a visible button is never a
    /// dead click.
    Disabled,
    /// Currently-active / toggled-on (a pressed tab, an enabled mode).
    Active,
    /// The button's action is in flight — its ICON is replaced by a
    /// spinner frame while the work runs.
    ///
    /// 2026-09-04, user ask: "when i click refresh can i see the arrow
    /// spin or something to indicate its doing what i asked for?" A
    /// refresh that returns in 300ms and one that has silently failed
    /// look identical without this, so the user clicks again.
    ///
    /// It replaces the icon rather than appending a spinner, so the
    /// button does not change width mid-flight and shove its
    /// neighbours sideways — and every spinner frame is one cell for
    /// the same reason (see `ui::spinner`).
    Busy,
}

/// One button, described declaratively.
///
/// Added 2026-08-29 on a user ask — "we should probably have a design
/// component for this stuff ... anywhere we want buttons we use that
/// component ... should support icon, label, bg, fg, font color, other
/// things too perhaps".
///
/// Before this, every surface hand-rolled its own chip and they drifted:
/// the git toolbar painted transparent labels separated by `│` dividers
/// (so they did not read as buttons at all), while activity panels used
/// the filled [`primary`] / [`secondary`] chips. This type is the single
/// description; the role constructors below are the house styles.
///
/// Build with a role constructor and override what you need:
///
/// ```ignore
/// let b = Button::toolbar(&t, "Pull").icon("\u{F0450}").accent(t.green);
/// let spans = b.spans();          // drop into a Line
/// let w = b.width();              // size the Rect / click target
/// ```
#[derive(Debug, Clone)]
pub struct Button<'a> {
    /// Optional leading glyph, rendered in [`Self::accent`].
    pub icon: Option<&'a str>,
    pub label: &'a str,
    /// Chip background.
    pub fill: Color,
    /// Label foreground.
    pub text: Color,
    /// Icon foreground. Falls back to [`Self::text`] when unset — an
    /// icon-less or single-colour button needs no accent.
    pub accent: Option<Color>,
    pub bold: bool,
    pub state: ButtonState,
}

impl<'a> Button<'a> {
    /// Neutral **toolbar** button — a raised chip on a darker strip.
    ///
    /// Distinct from [`primary`] / [`secondary`] because a toolbar is a
    /// ROW OF PEERS, not one call-to-action. The git toolbar carries a
    /// dozen buttons, each with its own accent on the icon (green Pull,
    /// blue Push, yellow Stash…), and those accents are what make the
    /// row scannable. Filling every chip green would shout AND destroy
    /// that signal — so the fill is neutral `bg2` and the accent stays
    /// on the icon.
    ///
    /// `bg2` is the same fill the tab chips and palette-bar chips use,
    /// so a toolbar button reads as the same physical object as the rest
    /// of mnml's chrome.
    pub fn toolbar(t: &Theme, label: &'a str) -> Self {
        Self {
            icon: None,
            label,
            fill: t.bg2,
            text: t.fg,
            accent: None,
            bold: true,
            state: ButtonState::Normal,
        }
    }

    /// This button with its label dropped — the COMPACT form.
    ///
    /// Icon only, so it survives a narrow row. A button with no icon
    /// keeps its label, because a compact button with neither would be
    /// an unlabelled box.
    pub fn compact(self) -> Self {
        if self.icon.is_none() {
            return self;
        }
        Self { label: "", ..self }
    }

    /// The refresh affordance, in one of its two sizes.
    ///
    /// `label: None` is the COMPACT form — icon only, for a tight panel
    /// header. `Some(word)` is the EXPANDED form for a toolbar with
    /// room for it. Both are the same button with the same glyph, which
    /// is the point: the family had drifted to three different refresh
    /// icons — core's codicon, Jira's `⟳`, Bitbucket's `\u{f0450}` —
    /// each with its own spacing.
    ///
    /// Deliberately a constructor on THIS component rather than a
    /// `RefreshChip` of its own: `Button` already carries icon, label,
    /// fill, accent and state, so a second chip type would be a
    /// parallel system to keep in sync (user: "i have asked for
    /// components before ... we may have some already").
    ///
    /// The glyph still comes from [`crate::ui::refresh_glyph`], which
    /// stays the single source of truth for WHICH glyph; this decides
    /// how it is dressed.
    pub fn refresh(t: &Theme, ascii: bool, label: Option<&'a str>) -> Self {
        Self {
            icon: Some(crate::ui::refresh_glyph::for_ascii(ascii)),
            label: label.unwrap_or(""),
            fill: t.bg2,
            text: t.fg,
            accent: Some(t.blue),
            bold: false,
            state: ButtonState::Normal,
        }
    }

    /// The panel's main call-to-action — green fill, black text. Same
    /// visual as the free function [`primary`].
    pub fn primary(t: &Theme, label: &'a str) -> Self {
        Self {
            icon: None,
            label,
            fill: t.green,
            text: CHIP_LABEL_FG,
            accent: None,
            bold: true,
            state: ButtonState::Normal,
        }
    }

    /// A peer create-flow beside a [`Self::primary`] — purple fill.
    pub fn secondary(t: &Theme, label: &'a str) -> Self {
        Self {
            icon: None,
            label,
            fill: t.purple,
            text: CHIP_LABEL_FG,
            accent: None,
            bold: true,
            state: ButtonState::Normal,
        }
    }

    pub fn icon(mut self, icon: &'a str) -> Self {
        self.icon = Some(icon);
        self
    }
    pub fn accent(mut self, c: Color) -> Self {
        self.accent = Some(c);
        self
    }
    pub fn fill(mut self, c: Color) -> Self {
        self.fill = c;
        self
    }
    pub fn text(mut self, c: Color) -> Self {
        self.text = c;
        self
    }
    pub fn bold(mut self, on: bool) -> Self {
        self.bold = on;
        self
    }
    pub fn state(mut self, s: ButtonState) -> Self {
        self.state = s;
        self
    }

    /// Cell width: 1 pad + (icon + 1 space, when present) + label + 1
    /// pad. Size the click rect to this — the pads are part of the
    /// target, which matters for a 1-cell glyph.
    pub fn width(&self) -> u16 {
        let icon_w = self.icon.map(|i| i.chars().count() as u16 + 1).unwrap_or(0);
        icon_w
            .saturating_add(self.label.chars().count() as u16)
            .saturating_add(2)
    }

    /// Fit a row of buttons into `avail` cells.
    ///
    /// Returns the buttons — expanded if they fit, otherwise all
    /// compact — and the width they will occupy including `sep` cells
    /// between each.
    ///
    /// 2026-09-04, user report: buttons ran off the right edge of the
    /// Jira work rows, "how will i click the button?". The first fix
    /// attempt simply skipped the click rect for an off-screen button,
    /// which is worse: the button is still PAINTED, so it looks
    /// clickable and is not. The answer is to never let it leave the
    /// row — the surrounding TEXT yields, because the buttons are the
    /// only interactive thing in it and the text is readable elsewhere.
    ///
    /// Never drops a button. Losing one silently removes an action the
    /// user can perform, which is a worse failure than a cramped row —
    /// if even the compact set overflows, the caller is told the true
    /// width and can decide.
    pub fn fit_row(buttons: Vec<Button<'a>>, avail: u16, sep: u16) -> (Vec<Button<'a>>, u16) {
        let width_of = |bs: &[Button<'a>]| -> u16 {
            let inner: u16 = bs.iter().map(|b| b.width()).sum();
            inner.saturating_add(sep.saturating_mul(bs.len().saturating_sub(1) as u16))
        };
        let full = width_of(&buttons);
        if full <= avail {
            return (buttons, full);
        }
        let compact: Vec<Button<'a>> = buttons.into_iter().map(|b| b.compact()).collect();
        let w = width_of(&compact);
        (compact, w)
    }

    /// Render to spans. Owned (`'static`) so callers can build a row of
    /// buttons without fighting the borrow checker over theme lifetimes.
    pub fn spans(&self, t: &Theme) -> Vec<Span<'static>> {
        let (fill, text, dim) = match self.state {
            ButtonState::Normal => (self.fill, self.text, false),
            // Inert: keep the chip shape so the row does not reflow, but
            // drop it back so it never reads as actionable.
            ButtonState::Disabled => (self.fill, t.comment, true),
            ButtonState::Active => (t.blue, CHIP_LABEL_FG, false),
            // Keep the resting colours: a busy button is still the
            // same button, and recolouring it would read as a state
            // change rather than as work in progress.
            ButtonState::Busy => (self.fill, self.text, false),
        };
        let mut base = Style::default().fg(text).bg(fill);
        if self.bold {
            base = base.add_modifier(Modifier::BOLD);
        }
        if dim {
            base = base.add_modifier(Modifier::DIM);
        }
        let mut out = vec![Span::styled(" ".to_string(), base)];
        if let Some(icon) = self.icon {
            let icon_style = match self.state {
                // A disabled button's accent would still draw the eye.
                ButtonState::Disabled => base,
                _ => base.fg(self.accent.unwrap_or(text)),
            };
            // Busy swaps the icon for a spinner frame — same cell
            // count, so the button cannot resize as it turns.
            let glyph = if self.state == ButtonState::Busy {
                crate::ui::spinner::frame().to_string()
            } else {
                icon.to_string()
            };
            out.push(Span::styled(glyph, icon_style));
            out.push(Span::styled(" ".to_string(), base));
        }
        out.push(Span::styled(self.label.to_string(), base));
        out.push(Span::styled(" ".to_string(), base));
        out
    }
}

/// Lay a row of buttons out with 1-cell gaps, centred in `width`.
///
/// Returns the leading pad and the per-button x offsets (relative to the
/// row's own x), so the caller can register click rects that line up
/// with the paint. Centring is the point: a left-aligned toolbar on a
/// wide window leaves the whole right half empty, which the user
/// reported as "when zoomed out it looks pretty bad".
pub fn centred_row(buttons: &[Button<'_>], width: u16, gap: u16) -> (u16, Vec<u16>) {
    let total: u16 = buttons
        .iter()
        .map(|b| b.width())
        .sum::<u16>()
        .saturating_add(gap.saturating_mul(buttons.len().saturating_sub(1) as u16));
    let lead = width.saturating_sub(total) / 2;
    let mut xs = Vec::with_capacity(buttons.len());
    let mut x = lead;
    for b in buttons {
        xs.push(x);
        x += b.width() + gap;
    }
    (lead, xs)
}

#[cfg(test)]
mod button_tests {
    use super::*;
    use crate::ui::theme;

    /// The refresh button's two modes must carry the SAME glyph. That
    /// is the whole reason it is a constructor here rather than each
    /// caller assembling its own chip: the family had drifted to three
    /// different refresh icons.

    #[test]
    fn both_refresh_modes_use_one_glyph() {
        let t = crate::ui::theme::cur();
        let compact = Button::refresh(&t, false, None);
        let expanded = Button::refresh(&t, false, Some("Refresh"));
        assert_eq!(
            compact.icon, expanded.icon,
            "the compact and expanded refresh buttons disagree on the glyph"
        );
        assert_eq!(
            compact.icon,
            Some(crate::ui::refresh_glyph::NERD),
            "the refresh button stopped using the canonical glyph"
        );
    }

    /// ASCII mode must reach the icon, or a non-Nerd-Font terminal gets
    /// a replacement box in both modes.
    #[test]
    fn refresh_honours_ascii_mode() {
        let t = crate::ui::theme::cur();
        assert_eq!(
            Button::refresh(&t, true, Some("Refresh")).icon,
            Some(crate::ui::refresh_glyph::ASCII)
        );
    }

    /// Compact is icon-only; expanded adds the word. Asserted through
    /// `width()` because that is what callers size their click rect
    /// with — a chip wider than its rect clips itself.
    #[test]
    fn expanded_is_wider_than_compact_by_its_label() {
        let t = crate::ui::theme::cur();
        let compact = Button::refresh(&t, false, None);
        let expanded = Button::refresh(&t, false, Some("Refresh"));
        assert_eq!(
            expanded.width() - compact.width(),
            "Refresh".chars().count() as u16,
            "the expanded button is not exactly its label wider"
        );
    }

    #[test]
    fn width_counts_the_pads_and_the_icon_space() {
        let t = theme::cur();
        assert_eq!(
            Button::toolbar(&t, "Pull").width(),
            6,
            "` Pull ` = 4 + 2 pads"
        );
        assert_eq!(
            Button::toolbar(&t, "Pull").icon("x").width(),
            8,
            "` x Pull ` = icon + space + label + 2 pads"
        );
    }

    /// The rendered span run must be exactly as wide as `width()` claims,
    /// or every click rect in every toolbar is off by a cell.
    #[test]
    fn rendered_width_matches_the_declared_width() {
        let t = theme::cur();
        for b in [
            Button::toolbar(&t, "Pull"),
            Button::toolbar(&t, "Pull").icon("\u{F0450}"),
            Button::primary(&t, "+ New session"),
            Button::secondary(&t, "+ from PR").icon("*"),
        ] {
            let painted: usize = b.spans(&t).iter().map(|s| s.content.chars().count()).sum();
            assert_eq!(
                painted as u16,
                b.width(),
                "label {:?}: painted {painted} cells but width() said {}",
                b.label,
                b.width()
            );
        }
    }

    /// #1229 — the whole point of the row helper. A left-aligned toolbar
    /// on a wide window leaves the right half empty ("when zoomed out it
    /// looks pretty bad").
    #[test]
    fn centred_row_leaves_equal_margins() {
        let t = theme::cur();
        let bs = [Button::toolbar(&t, "A"), Button::toolbar(&t, "B")];
        // Each ` A ` = 3 cells, gap 1 => total 7. In 21 cells, lead = 7.
        let (lead, xs) = centred_row(&bs, 21, 1);
        assert_eq!(lead, 7);
        assert_eq!(xs, vec![7, 11]);
        let right_margin = 21 - (xs[1] + bs[1].width());
        assert_eq!(
            lead, right_margin,
            "margins are not equal: {lead} vs {right_margin}"
        );
    }

    /// Must not underflow or misplace when the row is wider than the
    /// space — the caller drops buttons, but this must stay sane meanwhile.
    #[test]
    fn centred_row_degrades_to_zero_lead_when_too_narrow() {
        let t = theme::cur();
        let bs = [Button::toolbar(&t, "Wide label here")];
        let (lead, xs) = centred_row(&bs, 4, 1);
        assert_eq!(lead, 0);
        assert_eq!(xs, vec![0]);
    }

    /// A disabled button keeps its footprint so the row does not reflow,
    /// but must not carry its accent colour — that would still draw the
    /// eye to something inert.
    #[test]
    fn disabled_keeps_its_width_but_drops_the_accent() {
        let t = theme::cur();
        let normal = Button::toolbar(&t, "Pop").icon("x").accent(t.yellow);
        let off = normal.clone().state(ButtonState::Disabled);
        assert_eq!(normal.width(), off.width());
        let accent_used = off.spans(&t).iter().any(|s| s.style.fg == Some(t.yellow));
        assert!(!accent_used, "disabled button still painted its accent");
    }

    /// A busy button must not change width as it spins, or the row
    /// reflows under the pointer every 150ms and neighbours jump.
    #[test]
    fn a_busy_button_keeps_its_width() {
        let th = crate::ui::theme::cur();
        let idle = Button::refresh(&th, false, Some("Refresh"));
        let busy = Button {
            state: ButtonState::Busy,
            ..Button::refresh(&th, false, Some("Refresh"))
        };
        assert_eq!(idle.width(), busy.width(), "the button resized while busy");
        let cells =
            |b: &Button| -> usize { b.spans(&th).iter().map(|s| s.content.chars().count()).sum() };
        assert_eq!(
            cells(&idle),
            cells(&busy),
            "painted cell count changed while busy"
        );
    }

    /// Busy must REPLACE the icon, not sit beside it — otherwise the
    /// refresh arrow and a spinner both show and the button widens.
    #[test]
    fn busy_replaces_the_icon_rather_than_adding_one() {
        let th = crate::ui::theme::cur();
        let busy = Button {
            state: ButtonState::Busy,
            ..Button::refresh(&th, false, Some("Refresh"))
        };
        let painted: String = busy
            .spans(&th)
            .iter()
            .map(|s| s.content.to_string())
            .collect();
        assert!(
            !painted.contains(crate::ui::refresh_glyph::NERD),
            "the refresh glyph is still painted alongside the spinner: {painted:?}"
        );
        assert!(
            crate::ui::spinner::FRAMES
                .iter()
                .any(|f| painted.contains(f)),
            "no spinner frame painted: {painted:?}"
        );
    }

    /// The icon and the label must not touch — the user reported
    /// exactly this on a hand-rolled button (`glyph` + `Refresh` with
    /// nothing between them).
    #[test]
    fn the_icon_is_separated_from_the_label() {
        let th = crate::ui::theme::cur();
        let b = Button::refresh(&th, false, Some("Refresh"));
        let painted: String = b.spans(&th).iter().map(|s| s.content.to_string()).collect();
        let after: String = painted
            .split(crate::ui::refresh_glyph::NERD)
            .nth(1)
            .unwrap_or_default()
            .chars()
            .take(1)
            .collect();
        assert_eq!(after, " ", "icon runs straight into the label: {painted:?}");
    }

    /// A row that fits stays expanded — compacting when there is room
    /// would throw away labels for nothing.
    #[test]
    fn a_row_that_fits_keeps_its_labels() {
        let th = crate::ui::theme::cur();
        let btns = vec![
            Button::refresh(&th, false, Some("Refresh")),
            Button::refresh(&th, false, Some("Reload")),
        ];
        let want: u16 = btns.iter().map(|b| b.width()).sum::<u16>() + 1;
        let (out, w) = Button::fit_row(btns, 200, 1);
        assert_eq!(w, want);
        assert!(
            out.iter().all(|b| !b.label.is_empty()),
            "labels were dropped with room to spare"
        );
    }

    /// Too narrow → compact. The buttons must SHRINK rather than run
    /// off the row: an off-screen button is painted and unclickable,
    /// which is what the user hit.
    #[test]
    fn a_row_that_does_not_fit_goes_compact() {
        let th = crate::ui::theme::cur();
        let btns = vec![
            Button::refresh(&th, false, Some("Refresh")),
            Button::refresh(&th, false, Some("Reload")),
        ];
        let (out, w) = Button::fit_row(btns, 12, 1);
        assert!(
            out.iter().all(|b| b.label.is_empty()),
            "labels survived a too-narrow row"
        );
        assert!(w <= 12, "compact row still overflows: {w} > 12");
        assert!(
            out.iter().all(|b| b.icon.is_some()),
            "a compact button lost its icon too"
        );
    }

    /// Never drop a button. Losing one silently removes an action the
    /// user can perform — a worse failure than a cramped row.
    #[test]
    fn fitting_never_drops_a_button() {
        let th = crate::ui::theme::cur();
        for avail in [0u16, 1, 3, 7, 40] {
            let btns = vec![
                Button::refresh(&th, false, Some("Refresh")),
                Button::refresh(&th, false, Some("Reload")),
                Button::refresh(&th, false, Some("Retry")),
            ];
            let (out, _) = Button::fit_row(btns, avail, 1);
            assert_eq!(out.len(), 3, "a button vanished at avail={avail}");
        }
    }

    /// A button with no icon keeps its label — compacting it to
    /// nothing would leave an unlabelled box.
    #[test]
    fn an_iconless_button_keeps_its_label_when_compacted() {
        let th = crate::ui::theme::cur();
        let b = Button::primary(&th, "Install").compact();
        assert_eq!(b.label, "Install");
    }
}