Skip to main content

bee_tui/components/
stamps.rs

1//! S2 — Stamps screen (`docs/PLAN.md` § 8.S2).
2//!
3//! Renders one row per known postage batch with the volume +
4//! duration framing operators actually reason about (bee#4992 is
5//! retiring depth+amount). The "worst bucket" column tells the
6//! truth that the API's `utilization` field is `MaxBucketCount`
7//! — operators see exactly which batch is about to fail uploads
8//! even though average usage is far from 100%.
9//!
10//! `Enter` on a selected row drills into the per-bucket histogram
11//! (`/stamps/{id}/buckets`): the batch ID alone tells you the
12//! worst bucket, but the drill answers the next operator question
13//! — *how concentrated* is the load? A batch where 98 buckets are
14//! at 90% behaves very differently from one where two are at 100%
15//! and the rest are near-empty.
16//!
17//! Behaviour is data-driven via [`Stamps::rows_for`] and
18//! [`Stamps::compute_drill_view`] so insta snapshot tests can stub
19//! the input and verify status / value / `why` strings without
20//! launching a TUI.
21
22use std::sync::Arc;
23
24use color_eyre::Result;
25use crossterm::event::{KeyCode, KeyEvent};
26use ratatui::{
27    Frame,
28    layout::{Constraint, Layout, Rect},
29    style::{Color, Modifier, Style},
30    text::{Line, Span},
31    widgets::{Block, Borders, Paragraph},
32};
33use tokio::sync::{mpsc, watch};
34
35use super::Component;
36use crate::action::Action;
37use crate::api::ApiClient;
38use crate::theme;
39use crate::watch::StampsSnapshot;
40
41use bee::postage::{BatchBucket, PostageBatch, PostageBatchBuckets};
42use bee::swarm::BatchId;
43
44/// Tri-state row outcome with `Pending` for chain-confirmation gating.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum StampStatus {
47    /// `usable=false` — chain hasn't confirmed the batch yet.
48    Pending,
49    /// `batch_ttl ≤ 0` — paid balance is exhausted, nothing to stamp.
50    Expired,
51    /// Worst bucket ≥ 95 % — the very next upload may fail
52    /// (immutable batches return ErrBucketFull at the upper bound).
53    Critical,
54    /// Worst bucket ≥ 80 %. Above the safe-headroom line; warn early.
55    Skewed,
56    /// Everything green: usable, in budget, headroom present.
57    Healthy,
58}
59
60impl StampStatus {
61    fn color(self) -> Color {
62        let t = theme::active();
63        match self {
64            Self::Pending => t.info,
65            Self::Expired => t.fail,
66            Self::Critical => t.fail,
67            Self::Skewed => t.warn,
68            Self::Healthy => t.pass,
69        }
70    }
71    fn label(self) -> &'static str {
72        match self {
73            Self::Pending => "⏳ pending",
74            Self::Expired => "✗ expired",
75            Self::Critical => "✗ critical",
76            Self::Skewed => "⚠ skewed",
77            Self::Healthy => "✓",
78        }
79    }
80}
81
82/// One row of the stamps table.
83#[derive(Debug, Clone)]
84pub struct StampRow {
85    pub label: String,
86    pub batch_id_short: String,
87    /// Theoretical volume = `2^depth × 4 KiB`, formatted to a
88    /// human-readable string. Effective volume is bounded by the
89    /// worst bucket — see `worst_bucket_pct`.
90    pub volume: String,
91    /// Worst-bucket fill percentage in `0..=100`. This *is* what the
92    /// API calls `utilization`; operators don't always realise.
93    pub worst_bucket_pct: u32,
94    /// Raw `utilization` count plus `BucketUpperBound`.
95    pub worst_bucket_raw: String,
96    /// Pre-formatted `Xd Yh` countdown string. `"-"` if expired.
97    pub ttl: String,
98    /// `true` if `immutable` — flagged in the `value` line because
99    /// mutable + full silently overwrites prior chunks (bee#5334).
100    pub immutable: bool,
101    pub status: StampStatus,
102    /// Inline tooltip rendered on the continuation line.
103    pub why: Option<String>,
104}
105
106/// Bucket fill distribution for [`StampDrillView`]. Six buckets keep
107/// the display compact while still distinguishing "nearly full" from
108/// "actually full". Ordered low → high.
109pub const FILL_BIN_LABELS: &[&str] = &[
110    "0 %",
111    "1 – 19 %",
112    "20 – 49 %",
113    "50 – 79 %",
114    "80 – 99 %",
115    "100 %",
116];
117
118/// Aggregated drill view for the bucket histogram screen. Pure —
119/// computed from [`PostageBatchBuckets`] without any I/O.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct StampDrillView {
122    pub depth: u8,
123    pub bucket_depth: u8,
124    pub upper_bound: u32,
125    /// Sum of every bucket's collisions count (the chunks-stamped
126    /// total Bee tracks for the batch).
127    pub total_chunks: u64,
128    /// `2^bucket_depth × upper_bound` — the headline "what the batch
129    /// could hold if perfectly distributed" number. Computed in
130    /// `u128` to dodge overflow on max-depth batches.
131    pub theoretical_capacity: u128,
132    /// Count of buckets whose fill percentage falls in each
133    /// [`FILL_BIN_LABELS`] bin. `[u32; 6]` matches the bin labels
134    /// 1-for-1.
135    pub fill_distribution: [u32; 6],
136    /// Up to 10 worst buckets sorted by collisions descending.
137    /// Stable ordering: ties broken by bucket-id ascending.
138    pub worst_buckets: Vec<WorstBucket>,
139    /// Worst single bucket fill percentage (matches the row's
140    /// `worst_bucket_pct`).
141    pub worst_pct: u32,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct WorstBucket {
146    pub bucket_id: u32,
147    pub collisions: u32,
148    pub pct: u32,
149}
150
151/// Drill-pane state machine. `Idle` keeps the regular table
152/// rendered; the other variants replace it with the drill view.
153#[derive(Debug, Clone)]
154pub enum DrillState {
155    Idle,
156    Loading {
157        batch_id: BatchId,
158    },
159    Loaded {
160        batch_id: BatchId,
161        view: StampDrillView,
162    },
163    Failed {
164        batch_id: BatchId,
165        error: String,
166    },
167}
168
169type DrillFetchResult = (BatchId, std::result::Result<PostageBatchBuckets, String>);
170
171pub struct Stamps {
172    client: Arc<ApiClient>,
173    rx: watch::Receiver<StampsSnapshot>,
174    snapshot: StampsSnapshot,
175    selected: usize,
176    /// Visual-line scroll offset for the table body. Updated lazily
177    /// inside `draw_table`. Continuations (the `why` tooltip lines)
178    /// count as additional visual lines so the offset is in lines,
179    /// not rows.
180    scroll_offset: usize,
181    drill: DrillState,
182    fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
183    fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
184}
185
186impl Stamps {
187    pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<StampsSnapshot>) -> Self {
188        let snapshot = rx.borrow().clone();
189        let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
190        Self {
191            client,
192            rx,
193            snapshot,
194            selected: 0,
195            scroll_offset: 0,
196            drill: DrillState::Idle,
197            fetch_tx,
198            fetch_rx,
199        }
200    }
201
202    fn pull_latest(&mut self) {
203        self.snapshot = self.rx.borrow().clone();
204        // If batches disappear/shrink, clamp the selection so we don't
205        // dangle an out-of-bounds index.
206        let n = self.snapshot.batches.len();
207        if n == 0 {
208            self.selected = 0;
209        } else if self.selected >= n {
210            self.selected = n - 1;
211        }
212    }
213
214    /// Drain any drill fetches that completed since the last tick.
215    /// Late results from a since-cancelled drill (operator hit Esc
216    /// then Enter on a different row before the network came back)
217    /// are dropped silently — `drill` already moved on.
218    fn drain_fetches(&mut self) {
219        while let Ok((batch_id, result)) = self.fetch_rx.try_recv() {
220            match &self.drill {
221                DrillState::Loading { batch_id: pending } if *pending == batch_id => {}
222                _ => continue, // user moved on; ignore
223            }
224            self.drill = match result {
225                Ok(buckets) => DrillState::Loaded {
226                    batch_id,
227                    view: Self::compute_drill_view(&buckets),
228                },
229                Err(error) => DrillState::Failed { batch_id, error },
230            };
231        }
232    }
233
234    /// Pure, snapshot-driven row computation. Exposed for snapshot
235    /// tests.
236    pub fn rows_for(snap: &StampsSnapshot) -> Vec<StampRow> {
237        snap.batches.iter().map(row_from_batch).collect()
238    }
239
240    /// Pure compute path for the drill pane. Buckets the per-bucket
241    /// collisions into [`FILL_BIN_LABELS`] bins, picks the top-10
242    /// worst, totals the chunk count.
243    pub fn compute_drill_view(buckets: &PostageBatchBuckets) -> StampDrillView {
244        let upper_bound = buckets.bucket_upper_bound.max(1);
245        let mut fill_distribution = [0u32; 6];
246        let mut total_chunks: u64 = 0;
247        for b in &buckets.buckets {
248            total_chunks += u64::from(b.collisions);
249            let bin = bucket_fill_bin(b.collisions, upper_bound);
250            fill_distribution[bin] += 1;
251        }
252        let mut sorted: Vec<&BatchBucket> = buckets.buckets.iter().collect();
253        // Worst first; ties broken by bucket-id ascending so the list
254        // is deterministic regardless of how Bee returns the array.
255        sorted.sort_by(|a, b| {
256            b.collisions
257                .cmp(&a.collisions)
258                .then_with(|| a.bucket_id.cmp(&b.bucket_id))
259        });
260        let worst_buckets: Vec<WorstBucket> = sorted
261            .iter()
262            .take(10)
263            .map(|b| WorstBucket {
264                bucket_id: b.bucket_id,
265                collisions: b.collisions,
266                pct: pct_of(b.collisions, upper_bound),
267            })
268            .collect();
269        let worst_pct = worst_buckets.first().map(|w| w.pct).unwrap_or(0);
270        let theoretical_capacity = (1u128 << buckets.bucket_depth) * u128::from(upper_bound);
271        StampDrillView {
272            depth: buckets.depth,
273            bucket_depth: buckets.bucket_depth,
274            upper_bound,
275            total_chunks,
276            theoretical_capacity,
277            fill_distribution,
278            worst_buckets,
279            worst_pct,
280        }
281    }
282
283    /// Spawn a background fetch for the batch under the cursor.
284    /// No-op if there are no batches or a fetch is already in
285    /// flight for the same batch.
286    fn maybe_start_drill(&mut self) {
287        if self.snapshot.batches.is_empty() {
288            return;
289        }
290        let i = self.selected.min(self.snapshot.batches.len() - 1);
291        let batch_id = self.snapshot.batches[i].batch_id;
292        if let DrillState::Loading { batch_id: pending } = &self.drill {
293            if *pending == batch_id {
294                return; // already in flight
295            }
296        }
297        let client = self.client.clone();
298        let tx = self.fetch_tx.clone();
299        tokio::spawn(async move {
300            let res = client
301                .bee()
302                .postage()
303                .get_postage_batch_buckets(&batch_id)
304                .await
305                .map_err(|e| e.to_string());
306            let _ = tx.send((batch_id, res));
307        });
308        self.drill = DrillState::Loading { batch_id };
309    }
310}
311
312fn bucket_fill_bin(collisions: u32, upper_bound: u32) -> usize {
313    if collisions == 0 {
314        return 0;
315    }
316    if collisions >= upper_bound {
317        return 5; // 100 % (and over-saturated edge — Bee can over-report)
318    }
319    let pct = pct_of(collisions, upper_bound);
320    match pct {
321        0 => 0, // belt-and-braces; the early return handles 0
322        1..=19 => 1,
323        20..=49 => 2,
324        50..=79 => 3,
325        80..=99 => 4,
326        _ => 5,
327    }
328}
329
330fn pct_of(collisions: u32, upper_bound: u32) -> u32 {
331    if upper_bound == 0 {
332        return 0;
333    }
334    let pct = (u64::from(collisions) * 100) / u64::from(upper_bound);
335    pct.min(100) as u32
336}
337
338fn row_from_batch(b: &PostageBatch) -> StampRow {
339    let label = if b.label.is_empty() {
340        "(unlabeled)".to_string()
341    } else {
342        b.label.clone()
343    };
344    let batch_hex = b.batch_id.to_hex();
345    let batch_id_short = if batch_hex.len() > 8 {
346        format!("{}…", &batch_hex[..8])
347    } else {
348        batch_hex
349    };
350    let theoretical_bytes: u128 = (1u128 << b.depth) * 4096;
351    let volume = format_bytes(theoretical_bytes);
352    let worst_bucket_pct = worst_bucket_pct(b);
353    let upper_bound = 1u32 << b.depth.saturating_sub(b.bucket_depth);
354    let worst_bucket_raw = format!("{}/{}", b.utilization, upper_bound);
355    let ttl = format_ttl_seconds(b.batch_ttl);
356
357    let (status, why) = if !b.usable {
358        (
359            StampStatus::Pending,
360            Some("waiting on chain confirmation (~10 blocks).".into()),
361        )
362    } else if b.batch_ttl <= 0 {
363        (
364            StampStatus::Expired,
365            Some("paid balance exhausted; topup or stop using.".into()),
366        )
367    } else if worst_bucket_pct >= 95 {
368        (
369            StampStatus::Critical,
370            Some(if b.immutable {
371                "immutable batch will REJECT next upload at this bucket.".into()
372            } else {
373                "mutable batch will silently overwrite oldest chunks.".into()
374            }),
375        )
376    } else if worst_bucket_pct >= 80 {
377        (
378            StampStatus::Skewed,
379            Some(format!(
380                "worst bucket {worst_bucket_pct}% > safe headroom — dilute or stop using."
381            )),
382        )
383    } else {
384        (StampStatus::Healthy, None)
385    };
386
387    StampRow {
388        label,
389        batch_id_short,
390        volume,
391        worst_bucket_pct,
392        worst_bucket_raw,
393        ttl,
394        immutable: b.immutable,
395        status,
396        why,
397    }
398}
399
400/// `MaxBucketCount` (Bee's `utilization`) as a 0..=100 percentage of
401/// the per-bucket upper bound `2^(depth - bucket_depth)`.
402fn worst_bucket_pct(b: &PostageBatch) -> u32 {
403    let upper_bound: u32 = 1u32 << b.depth.saturating_sub(b.bucket_depth);
404    if upper_bound == 0 {
405        0
406    } else {
407        let pct = (u64::from(b.utilization) * 100) / u64::from(upper_bound);
408        pct.min(100) as u32
409    }
410}
411
412/// Bytes → IEC binary (KiB / MiB / GiB / TiB).
413fn format_bytes(bytes: u128) -> String {
414    const K: u128 = 1024;
415    const M: u128 = K * 1024;
416    const G: u128 = M * 1024;
417    const T: u128 = G * 1024;
418    if bytes >= T {
419        format!("{:.1} TiB", bytes as f64 / T as f64)
420    } else if bytes >= G {
421        format!("{:.1} GiB", bytes as f64 / G as f64)
422    } else if bytes >= M {
423        format!("{:.1} MiB", bytes as f64 / M as f64)
424    } else if bytes >= K {
425        format!("{:.1} KiB", bytes as f64 / K as f64)
426    } else {
427        format!("{bytes} B")
428    }
429}
430
431fn format_ttl_seconds(secs: i64) -> String {
432    if secs <= 0 {
433        return "expired".into();
434    }
435    let days = secs / 86_400;
436    let hours = (secs % 86_400) / 3_600;
437    if days >= 1 {
438        format!("{days}d {hours:>2}h")
439    } else {
440        let minutes = (secs % 3_600) / 60;
441        format!("{hours}h {minutes:>2}m")
442    }
443}
444
445/// 8-character ASCII fill bar.
446fn fill_bar(pct: u32, width: usize) -> String {
447    let filled = ((pct as usize) * width) / 100;
448    let mut bar = String::with_capacity(width);
449    for _ in 0..filled.min(width) {
450        bar.push('▇');
451    }
452    for _ in filled.min(width)..width {
453        bar.push('░');
454    }
455    bar
456}
457
458impl Component for Stamps {
459    fn update(&mut self, action: Action) -> Result<Option<Action>> {
460        if matches!(action, Action::Tick) {
461            self.pull_latest();
462            self.drain_fetches();
463        }
464        Ok(None)
465    }
466
467    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
468        // Drill mode swallows Esc to dismiss; otherwise keys behave
469        // the same as in list mode (so `j`/`k` etc. don't surprise
470        // the operator after pressing Enter).
471        if matches!(
472            self.drill,
473            DrillState::Loaded { .. } | DrillState::Loading { .. } | DrillState::Failed { .. }
474        ) && matches!(key.code, KeyCode::Esc)
475        {
476            self.drill = DrillState::Idle;
477            return Ok(None);
478        }
479        match key.code {
480            KeyCode::Char('j') | KeyCode::Down => {
481                let n = self.snapshot.batches.len();
482                if n > 0 && self.selected + 1 < n {
483                    self.selected += 1;
484                }
485            }
486            KeyCode::Char('k') | KeyCode::Up => {
487                self.selected = self.selected.saturating_sub(1);
488            }
489            KeyCode::Enter => {
490                self.maybe_start_drill();
491            }
492            _ => {}
493        }
494        Ok(None)
495    }
496
497    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
498        let chunks = Layout::vertical([
499            Constraint::Length(3), // header
500            Constraint::Min(0),    // body (table or drill)
501            Constraint::Length(1), // footer
502        ])
503        .split(area);
504
505        // Header
506        let count = self.snapshot.batches.len();
507        let mut header_l1 = vec![
508            Span::styled("STAMPS", Style::default().add_modifier(Modifier::BOLD)),
509            Span::raw(format!("  {count} batch(es)")),
510        ];
511        if let DrillState::Loaded { batch_id, .. }
512        | DrillState::Loading { batch_id }
513        | DrillState::Failed { batch_id, .. } = &self.drill
514        {
515            let hex = batch_id.to_hex();
516            let short = if hex.len() > 8 { &hex[..8] } else { &hex };
517            header_l1.push(Span::raw(format!("   · drill {short}…")));
518        }
519        let header_l1 = Line::from(header_l1);
520        let mut header_l2 = Vec::new();
521        let t = theme::active();
522        if let Some(err) = &self.snapshot.last_error {
523            let (color, msg) = theme::classify_header_error(err);
524            header_l2.push(Span::styled(msg, Style::default().fg(color)));
525        } else if !self.snapshot.is_loaded() {
526            header_l2.push(Span::styled(
527                format!("{} loading…", theme::spinner_glyph()),
528                Style::default().fg(t.dim),
529            ));
530        }
531        frame.render_widget(
532            Paragraph::new(vec![header_l1, Line::from(header_l2)])
533                .block(Block::default().borders(Borders::BOTTOM)),
534            chunks[0],
535        );
536
537        // Body
538        match &self.drill {
539            DrillState::Idle => self.draw_table(frame, chunks[1]),
540            DrillState::Loading { .. } => {
541                let msg = Line::from(Span::styled(
542                    "  fetching /stamps/<id>/buckets…  (Esc cancel)",
543                    Style::default().fg(t.dim),
544                ));
545                frame.render_widget(Paragraph::new(msg), chunks[1]);
546            }
547            DrillState::Failed { error, .. } => {
548                let msg = Line::from(vec![
549                    Span::raw("  drill failed: "),
550                    Span::styled(error.clone(), Style::default().fg(t.fail)),
551                    Span::raw("    (Esc to dismiss)"),
552                ]);
553                frame.render_widget(Paragraph::new(msg), chunks[1]);
554            }
555            DrillState::Loaded { view, .. } => self.draw_drill(frame, chunks[1], view),
556        }
557
558        // Footer — keymap shifts in drill mode.
559        let footer = match &self.drill {
560            DrillState::Idle => Line::from(vec![
561                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
562                Span::raw(" switch screen  "),
563                Span::styled(
564                    " ↑↓/jk ",
565                    Style::default().fg(Color::Black).bg(Color::White),
566                ),
567                Span::raw(" select  "),
568                Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
569                Span::raw(" drill  "),
570                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
571                Span::raw(" help  "),
572                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
573                Span::raw(" quit  "),
574                Span::styled(" I/M ", Style::default().fg(t.dim)),
575                Span::raw(" immutable / mutable "),
576            ]),
577            _ => Line::from(vec![
578                Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
579                Span::raw(" close drill  "),
580                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
581                Span::raw(" switch screen  "),
582                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
583                Span::raw(" help  "),
584                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
585                Span::raw(" quit "),
586            ]),
587        };
588        frame.render_widget(Paragraph::new(footer), chunks[2]);
589
590        Ok(())
591    }
592}
593
594impl Stamps {
595    fn draw_table(&mut self, frame: &mut Frame, area: Rect) {
596        use ratatui::layout::{Constraint, Layout};
597
598        let t = theme::active();
599
600        // Pinned column header + scrollable body, same pattern as
601        // S6. Header doesn't scroll out from under the cursor.
602        let table_chunks =
603            Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
604        frame.render_widget(
605            Paragraph::new(Line::from(Span::styled(
606                "   LABEL                BATCH        VOLUME      WORST BUCKET                TTL         STATUS",
607                Style::default()
608                    .fg(t.dim)
609                    .add_modifier(Modifier::BOLD),
610            ))),
611            table_chunks[0],
612        );
613
614        if self.snapshot.batches.is_empty() {
615            frame.render_widget(
616                Paragraph::new(Line::from(Span::styled(
617                    "   (no batches yet — buy one with swarm-cli or `bee stamps buy`)",
618                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
619                ))),
620                table_chunks[1],
621            );
622            return;
623        }
624
625        let mut lines: Vec<Line> = Vec::new();
626        // Map batch index → visual line index where that row's main
627        // line starts. Used by `clamp_scroll` to translate a row
628        // selection into a visual-line offset, since rows may emit
629        // 1 or 2 lines depending on whether they have a `why`
630        // continuation.
631        let mut row_starts: Vec<usize> = Vec::new();
632        for (i, r) in Self::rows_for(&self.snapshot).into_iter().enumerate() {
633            row_starts.push(lines.len());
634            let bar = fill_bar(r.worst_bucket_pct, 8);
635            let immut_glyph = if r.immutable { "I" } else { "M" };
636            let cursor = if i == self.selected {
637                format!("{} ", t.glyphs.cursor)
638            } else {
639                "  ".to_string()
640            };
641            lines.push(Line::from(vec![
642                Span::styled(
643                    cursor,
644                    Style::default()
645                        .fg(if i == self.selected { t.accent } else { t.dim })
646                        .add_modifier(Modifier::BOLD),
647                ),
648                Span::styled(
649                    format!("{:<20}", truncate(&r.label, 20)),
650                    Style::default().add_modifier(Modifier::BOLD),
651                ),
652                Span::raw(format!("{:<13}", r.batch_id_short)),
653                Span::raw(format!("{:<12}", r.volume)),
654                Span::styled(
655                    format!("{bar} {:>3}% ({})", r.worst_bucket_pct, r.worst_bucket_raw),
656                    Style::default().fg(bucket_color(r.worst_bucket_pct)),
657                ),
658                Span::raw("    "),
659                Span::raw(format!("{:<10} ", r.ttl)),
660                Span::styled(immut_glyph, Style::default().fg(t.dim)),
661                Span::raw(" "),
662                Span::styled(
663                    r.status.label(),
664                    Style::default()
665                        .fg(r.status.color())
666                        .add_modifier(Modifier::BOLD),
667                ),
668            ]));
669            if let Some(why) = r.why {
670                lines.push(Line::from(vec![
671                    Span::raw(format!("        {} ", t.glyphs.continuation)),
672                    Span::styled(
673                        why,
674                        Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
675                    ),
676                ]));
677            }
678        }
679
680        // Translate the row cursor → visual-line cursor. Use the
681        // first line of the selected row as the "selected visual
682        // line" so clamp_scroll keeps that row's main line on
683        // screen (the continuation tooltip will follow if it fits).
684        let visual_cursor = row_starts.get(self.selected).copied().unwrap_or(0);
685        let body = table_chunks[1];
686        let visible_rows = body.height as usize;
687        self.scroll_offset = super::scroll::clamp_scroll(
688            visual_cursor,
689            self.scroll_offset,
690            visible_rows,
691            lines.len(),
692        );
693        frame.render_widget(
694            Paragraph::new(lines.clone()).scroll((self.scroll_offset as u16, 0)),
695            body,
696        );
697        super::scroll::render_scrollbar(frame, body, self.scroll_offset, visible_rows, lines.len());
698    }
699
700    fn draw_drill(&self, frame: &mut Frame, area: Rect, view: &StampDrillView) {
701        let t = theme::active();
702        let mut lines: Vec<Line> = Vec::new();
703        // Headline summary line.
704        let total_buckets: u32 = view.fill_distribution.iter().sum();
705        lines.push(Line::from(vec![
706            Span::raw("  depth "),
707            Span::styled(
708                format!("{}", view.depth),
709                Style::default().add_modifier(Modifier::BOLD),
710            ),
711            Span::raw("   bucket-depth "),
712            Span::styled(
713                format!("{}", view.bucket_depth),
714                Style::default().add_modifier(Modifier::BOLD),
715            ),
716            Span::raw("   per-bucket cap "),
717            Span::styled(
718                format!("{}", view.upper_bound),
719                Style::default().add_modifier(Modifier::BOLD),
720            ),
721            Span::raw("   "),
722            Span::styled(
723                format!("{} buckets", total_buckets),
724                Style::default().fg(t.dim),
725            ),
726        ]));
727        lines.push(Line::from(vec![
728            Span::raw("  total chunks "),
729            Span::styled(
730                format!("{}", view.total_chunks),
731                Style::default().add_modifier(Modifier::BOLD),
732            ),
733            Span::raw(" / "),
734            Span::styled(
735                format!("{}", view.theoretical_capacity),
736                Style::default().fg(t.dim),
737            ),
738            Span::raw("   worst bucket "),
739            Span::styled(
740                format!("{}%", view.worst_pct),
741                Style::default()
742                    .fg(bucket_color(view.worst_pct))
743                    .add_modifier(Modifier::BOLD),
744            ),
745        ]));
746        lines.push(Line::from(""));
747
748        // Fill-distribution histogram.
749        lines.push(Line::from(Span::styled(
750            "  FILL %       COUNT   DISTRIBUTION",
751            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
752        )));
753        let max_bin = view
754            .fill_distribution
755            .iter()
756            .copied()
757            .max()
758            .unwrap_or(1)
759            .max(1);
760        for (idx, count) in view.fill_distribution.iter().enumerate() {
761            let label = FILL_BIN_LABELS[idx];
762            let bar_width = ((u64::from(*count) * 30) / u64::from(max_bin)) as usize;
763            let bar: String = std::iter::repeat_n('▇', bar_width).collect();
764            // Bin colour follows the fill range so the operator's eye
765            // jumps to the rows that matter (red 100 %, yellow 80–99,
766            // pass otherwise).
767            let bin_color = match idx {
768                5 => t.fail,
769                4 => t.warn,
770                _ => t.pass,
771            };
772            lines.push(Line::from(vec![
773                Span::raw("  "),
774                Span::raw(format!("{label:<10}  ")),
775                Span::styled(
776                    format!("{count:>5}   "),
777                    Style::default().add_modifier(Modifier::BOLD),
778                ),
779                Span::styled(bar, Style::default().fg(bin_color)),
780            ]));
781        }
782        lines.push(Line::from(""));
783
784        // Top-N worst buckets.
785        if !view.worst_buckets.is_empty() {
786            lines.push(Line::from(Span::styled(
787                "  WORST BUCKETS",
788                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
789            )));
790            for w in &view.worst_buckets {
791                if w.collisions == 0 {
792                    // Once we hit zero-collision buckets the rest are
793                    // also zero — don't pad the worst-N with junk.
794                    break;
795                }
796                lines.push(Line::from(vec![
797                    Span::raw("  "),
798                    Span::raw(format!("#{:<8}", w.bucket_id)),
799                    Span::raw(format!("{:>4} / {}    ", w.collisions, view.upper_bound)),
800                    Span::styled(
801                        format!("{}%", w.pct),
802                        Style::default()
803                            .fg(bucket_color(w.pct))
804                            .add_modifier(Modifier::BOLD),
805                    ),
806                ]));
807            }
808        }
809
810        frame.render_widget(Paragraph::new(lines), area);
811    }
812}
813
814fn truncate(s: &str, max: usize) -> String {
815    if s.chars().count() <= max {
816        s.to_string()
817    } else {
818        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
819        out.push('…');
820        out
821    }
822}
823
824fn bucket_color(pct: u32) -> Color {
825    let t = theme::active();
826    if pct >= 95 {
827        t.fail
828    } else if pct >= 80 {
829        t.warn
830    } else {
831        t.pass
832    }
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    fn buckets_with(counts: &[(u32, u32)], depth: u8, bucket_depth: u8) -> PostageBatchBuckets {
840        let upper_bound = 1u32 << (depth - bucket_depth);
841        let buckets = counts
842            .iter()
843            .map(|(id, c)| BatchBucket {
844                bucket_id: *id,
845                collisions: *c,
846            })
847            .collect();
848        PostageBatchBuckets {
849            depth,
850            bucket_depth,
851            bucket_upper_bound: upper_bound,
852            buckets,
853        }
854    }
855
856    #[test]
857    fn fill_bar_clamps_to_width() {
858        assert_eq!(fill_bar(0, 8), "░░░░░░░░");
859        assert_eq!(fill_bar(50, 8), "▇▇▇▇░░░░");
860        assert_eq!(fill_bar(100, 8), "▇▇▇▇▇▇▇▇");
861        assert_eq!(fill_bar(150, 8), "▇▇▇▇▇▇▇▇"); // saturating
862    }
863
864    #[test]
865    fn format_bytes_iec() {
866        assert_eq!(format_bytes(0), "0 B");
867        assert_eq!(format_bytes(1024), "1.0 KiB");
868        assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
869        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GiB");
870        assert_eq!(format_bytes(16 * 1024 * 1024 * 1024), "16.0 GiB");
871    }
872
873    #[test]
874    fn format_ttl_zero_is_expired() {
875        assert_eq!(format_ttl_seconds(0), "expired");
876        assert_eq!(format_ttl_seconds(-5), "expired");
877    }
878
879    #[test]
880    fn format_ttl_days_and_hours() {
881        // 47d 12h
882        assert_eq!(format_ttl_seconds(47 * 86_400 + 12 * 3_600), "47d 12h");
883    }
884
885    #[test]
886    fn format_ttl_under_a_day_uses_hours_minutes() {
887        assert_eq!(format_ttl_seconds(2 * 3_600 + 30 * 60), "2h 30m");
888    }
889
890    #[test]
891    fn drill_view_bins_and_worst_n() {
892        // depth=22, bucket_depth=16 → upper_bound=64. Construct a
893        // small sample with one full bucket, one near-full, two
894        // mid, rest empty.
895        let buckets = buckets_with(
896            &[
897                (0, 64), // 100 %  → bin 5
898                (1, 60), // 93 %   → bin 4
899                (2, 40), // 62 %   → bin 3
900                (3, 20), // 31 %   → bin 2
901                (4, 1),  // 1 %    → bin 1
902                (5, 0),  // 0 %    → bin 0
903            ],
904            22,
905            16,
906        );
907        let view = Stamps::compute_drill_view(&buckets);
908        assert_eq!(view.depth, 22);
909        assert_eq!(view.bucket_depth, 16);
910        assert_eq!(view.upper_bound, 64);
911        assert_eq!(view.total_chunks, 64 + 60 + 40 + 20 + 1);
912        assert_eq!(view.fill_distribution, [1, 1, 1, 1, 1, 1]);
913        assert_eq!(view.worst_pct, 100);
914        // Worst-N is the worst-3 here (rest are zero — truncated).
915        // The order is: bucket 0 (64), bucket 1 (60), bucket 2 (40),
916        // then 20, 1; the trailing zero-bucket is included only up
917        // to 10 entries — we filter zero-collisions in the renderer
918        // but compute_drill_view includes them up to the cap.
919        assert_eq!(view.worst_buckets.len(), 6);
920        assert_eq!(view.worst_buckets[0].bucket_id, 0);
921        assert_eq!(view.worst_buckets[0].pct, 100);
922        assert_eq!(view.worst_buckets[1].bucket_id, 1);
923        assert_eq!(view.worst_buckets[1].pct, 93);
924    }
925
926    #[test]
927    fn drill_view_handles_empty_buckets() {
928        let buckets = buckets_with(&[], 22, 16);
929        let view = Stamps::compute_drill_view(&buckets);
930        assert_eq!(view.total_chunks, 0);
931        assert_eq!(view.fill_distribution, [0; 6]);
932        assert_eq!(view.worst_pct, 0);
933        assert!(view.worst_buckets.is_empty());
934    }
935
936    #[test]
937    fn drill_view_caps_worst_at_ten() {
938        // 12 distinct buckets, all collisions=1 — top-10 should
939        // truncate at 10 entries.
940        let entries: Vec<(u32, u32)> = (0..12).map(|i| (i, 1)).collect();
941        let buckets = buckets_with(&entries, 22, 16);
942        let view = Stamps::compute_drill_view(&buckets);
943        assert_eq!(view.worst_buckets.len(), 10);
944    }
945
946    #[test]
947    fn drill_view_breaks_ties_by_bucket_id() {
948        let buckets = buckets_with(&[(7, 5), (3, 5), (10, 5)], 22, 16);
949        let view = Stamps::compute_drill_view(&buckets);
950        // All three tie on collisions=5 → ascending by bucket_id.
951        assert_eq!(
952            view.worst_buckets
953                .iter()
954                .map(|w| w.bucket_id)
955                .collect::<Vec<_>>(),
956            vec![3, 7, 10],
957        );
958    }
959
960    #[test]
961    fn fill_bin_handles_overflow_collisions() {
962        // Bee occasionally over-reports collisions vs upper_bound.
963        // Treat it as the saturated 100 % bin rather than panicking.
964        assert_eq!(bucket_fill_bin(70, 64), 5);
965    }
966}