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!(self.drill, DrillState::Loaded { .. } | DrillState::Loading { .. } | DrillState::Failed { .. })
472            && matches!(key.code, KeyCode::Esc)
473        {
474            self.drill = DrillState::Idle;
475            return Ok(None);
476        }
477        match key.code {
478            KeyCode::Char('j') | KeyCode::Down => {
479                let n = self.snapshot.batches.len();
480                if n > 0 && self.selected + 1 < n {
481                    self.selected += 1;
482                }
483            }
484            KeyCode::Char('k') | KeyCode::Up => {
485                self.selected = self.selected.saturating_sub(1);
486            }
487            KeyCode::Enter => {
488                self.maybe_start_drill();
489            }
490            _ => {}
491        }
492        Ok(None)
493    }
494
495    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
496        let chunks = Layout::vertical([
497            Constraint::Length(3), // header
498            Constraint::Min(0),    // body (table or drill)
499            Constraint::Length(1), // footer
500        ])
501        .split(area);
502
503        // Header
504        let count = self.snapshot.batches.len();
505        let mut header_l1 = vec![
506            Span::styled("STAMPS", Style::default().add_modifier(Modifier::BOLD)),
507            Span::raw(format!("  {count} batch(es)")),
508        ];
509        if let DrillState::Loaded { batch_id, .. }
510        | DrillState::Loading { batch_id }
511        | DrillState::Failed { batch_id, .. } = &self.drill
512        {
513            let hex = batch_id.to_hex();
514            let short = if hex.len() > 8 { &hex[..8] } else { &hex };
515            header_l1.push(Span::raw(format!("   · drill {short}…")));
516        }
517        let header_l1 = Line::from(header_l1);
518        let mut header_l2 = Vec::new();
519        let t = theme::active();
520        if let Some(err) = &self.snapshot.last_error {
521            let (color, msg) = theme::classify_header_error(err);
522            header_l2.push(Span::styled(msg, Style::default().fg(color)));
523        } else if !self.snapshot.is_loaded() {
524            header_l2.push(Span::styled(
525                "loading…",
526                Style::default().fg(t.dim),
527            ));
528        }
529        frame.render_widget(
530            Paragraph::new(vec![header_l1, Line::from(header_l2)])
531                .block(Block::default().borders(Borders::BOTTOM)),
532            chunks[0],
533        );
534
535        // Body
536        match &self.drill {
537            DrillState::Idle => self.draw_table(frame, chunks[1]),
538            DrillState::Loading { .. } => {
539                let msg = Line::from(Span::styled(
540                    "  fetching /stamps/<id>/buckets…  (Esc cancel)",
541                    Style::default().fg(t.dim),
542                ));
543                frame.render_widget(Paragraph::new(msg), chunks[1]);
544            }
545            DrillState::Failed { error, .. } => {
546                let msg = Line::from(vec![
547                    Span::raw("  drill failed: "),
548                    Span::styled(error.clone(), Style::default().fg(t.fail)),
549                    Span::raw("    (Esc to dismiss)"),
550                ]);
551                frame.render_widget(Paragraph::new(msg), chunks[1]);
552            }
553            DrillState::Loaded { view, .. } => self.draw_drill(frame, chunks[1], view),
554        }
555
556        // Footer — keymap shifts in drill mode.
557        let footer = match &self.drill {
558            DrillState::Idle => Line::from(vec![
559                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
560                Span::raw(" switch screen  "),
561                Span::styled(" ↑↓/jk ", Style::default().fg(Color::Black).bg(Color::White)),
562                Span::raw(" select  "),
563                Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
564                Span::raw(" drill  "),
565                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
566                Span::raw(" quit  "),
567                Span::styled(" I/M ", Style::default().fg(t.dim)),
568                Span::raw(" immutable / mutable "),
569            ]),
570            _ => Line::from(vec![
571                Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
572                Span::raw(" close drill  "),
573                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
574                Span::raw(" switch screen  "),
575                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
576                Span::raw(" quit "),
577            ]),
578        };
579        frame.render_widget(Paragraph::new(footer), chunks[2]);
580
581        Ok(())
582    }
583}
584
585impl Stamps {
586    fn draw_table(&mut self, frame: &mut Frame, area: Rect) {
587        use ratatui::layout::{Constraint, Layout};
588
589        let t = theme::active();
590
591        // Pinned column header + scrollable body, same pattern as
592        // S6. Header doesn't scroll out from under the cursor.
593        let table_chunks = Layout::vertical([
594            Constraint::Length(1),
595            Constraint::Min(0),
596        ])
597        .split(area);
598        frame.render_widget(
599            Paragraph::new(Line::from(Span::styled(
600                "   LABEL                BATCH        VOLUME      WORST BUCKET                TTL         STATUS",
601                Style::default()
602                    .fg(t.dim)
603                    .add_modifier(Modifier::BOLD),
604            ))),
605            table_chunks[0],
606        );
607
608        if self.snapshot.batches.is_empty() {
609            frame.render_widget(
610                Paragraph::new(Line::from(Span::styled(
611                    "   (no batches yet — buy one with swarm-cli or `bee stamps buy`)",
612                    Style::default()
613                        .fg(t.dim)
614                        .add_modifier(Modifier::ITALIC),
615                ))),
616                table_chunks[1],
617            );
618            return;
619        }
620
621        let mut lines: Vec<Line> = Vec::new();
622        // Map batch index → visual line index where that row's main
623        // line starts. Used by `clamp_scroll` to translate a row
624        // selection into a visual-line offset, since rows may emit
625        // 1 or 2 lines depending on whether they have a `why`
626        // continuation.
627        let mut row_starts: Vec<usize> = Vec::new();
628        for (i, r) in Self::rows_for(&self.snapshot).into_iter().enumerate() {
629            row_starts.push(lines.len());
630            let bar = fill_bar(r.worst_bucket_pct, 8);
631            let immut_glyph = if r.immutable { "I" } else { "M" };
632            let cursor = if i == self.selected {
633                format!("{} ", t.glyphs.cursor)
634            } else {
635                "  ".to_string()
636            };
637            lines.push(Line::from(vec![
638                Span::styled(
639                    cursor,
640                    Style::default()
641                        .fg(if i == self.selected { t.accent } else { t.dim })
642                        .add_modifier(Modifier::BOLD),
643                ),
644                Span::styled(
645                    format!("{:<20}", truncate(&r.label, 20)),
646                    Style::default().add_modifier(Modifier::BOLD),
647                ),
648                Span::raw(format!("{:<13}", r.batch_id_short)),
649                Span::raw(format!("{:<12}", r.volume)),
650                Span::styled(
651                    format!("{bar} {:>3}% ({})", r.worst_bucket_pct, r.worst_bucket_raw),
652                    Style::default().fg(bucket_color(r.worst_bucket_pct)),
653                ),
654                Span::raw("    "),
655                Span::raw(format!("{:<10} ", r.ttl)),
656                Span::styled(immut_glyph, Style::default().fg(t.dim)),
657                Span::raw(" "),
658                Span::styled(
659                    r.status.label(),
660                    Style::default()
661                        .fg(r.status.color())
662                        .add_modifier(Modifier::BOLD),
663                ),
664            ]));
665            if let Some(why) = r.why {
666                lines.push(Line::from(vec![
667                    Span::raw(format!("        {} ", t.glyphs.continuation)),
668                    Span::styled(
669                        why,
670                        Style::default()
671                            .fg(t.dim)
672                            .add_modifier(Modifier::ITALIC),
673                    ),
674                ]));
675            }
676        }
677
678        // Translate the row cursor → visual-line cursor. Use the
679        // first line of the selected row as the "selected visual
680        // line" so clamp_scroll keeps that row's main line on
681        // screen (the continuation tooltip will follow if it fits).
682        let visual_cursor = row_starts.get(self.selected).copied().unwrap_or(0);
683        let body = table_chunks[1];
684        let visible_rows = body.height as usize;
685        self.scroll_offset = super::scroll::clamp_scroll(
686            visual_cursor,
687            self.scroll_offset,
688            visible_rows,
689            lines.len(),
690        );
691        frame.render_widget(
692            Paragraph::new(lines.clone()).scroll((self.scroll_offset as u16, 0)),
693            body,
694        );
695        super::scroll::render_scrollbar(
696            frame,
697            body,
698            self.scroll_offset,
699            visible_rows,
700            lines.len(),
701        );
702    }
703
704    fn draw_drill(&self, frame: &mut Frame, area: Rect, view: &StampDrillView) {
705        let t = theme::active();
706        let mut lines: Vec<Line> = Vec::new();
707        // Headline summary line.
708        let total_buckets: u32 = view.fill_distribution.iter().sum();
709        lines.push(Line::from(vec![
710            Span::raw("  depth "),
711            Span::styled(
712                format!("{}", view.depth),
713                Style::default().add_modifier(Modifier::BOLD),
714            ),
715            Span::raw("   bucket-depth "),
716            Span::styled(
717                format!("{}", view.bucket_depth),
718                Style::default().add_modifier(Modifier::BOLD),
719            ),
720            Span::raw("   per-bucket cap "),
721            Span::styled(
722                format!("{}", view.upper_bound),
723                Style::default().add_modifier(Modifier::BOLD),
724            ),
725            Span::raw("   "),
726            Span::styled(
727                format!("{} buckets", total_buckets),
728                Style::default().fg(t.dim),
729            ),
730        ]));
731        lines.push(Line::from(vec![
732            Span::raw("  total chunks "),
733            Span::styled(
734                format!("{}", view.total_chunks),
735                Style::default().add_modifier(Modifier::BOLD),
736            ),
737            Span::raw(" / "),
738            Span::styled(
739                format!("{}", view.theoretical_capacity),
740                Style::default().fg(t.dim),
741            ),
742            Span::raw("   worst bucket "),
743            Span::styled(
744                format!("{}%", view.worst_pct),
745                Style::default()
746                    .fg(bucket_color(view.worst_pct))
747                    .add_modifier(Modifier::BOLD),
748            ),
749        ]));
750        lines.push(Line::from(""));
751
752        // Fill-distribution histogram.
753        lines.push(Line::from(Span::styled(
754            "  FILL %       COUNT   DISTRIBUTION",
755            Style::default()
756                .fg(t.dim)
757                .add_modifier(Modifier::BOLD),
758        )));
759        let max_bin = view.fill_distribution.iter().copied().max().unwrap_or(1).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()
789                    .fg(t.dim)
790                    .add_modifier(Modifier::BOLD),
791            )));
792            for w in &view.worst_buckets {
793                if w.collisions == 0 {
794                    // Once we hit zero-collision buckets the rest are
795                    // also zero — don't pad the worst-N with junk.
796                    break;
797                }
798                lines.push(Line::from(vec![
799                    Span::raw("  "),
800                    Span::raw(format!("#{:<8}", w.bucket_id)),
801                    Span::raw(format!(
802                        "{:>4} / {}    ",
803                        w.collisions, view.upper_bound
804                    )),
805                    Span::styled(
806                        format!("{}%", w.pct),
807                        Style::default()
808                            .fg(bucket_color(w.pct))
809                            .add_modifier(Modifier::BOLD),
810                    ),
811                ]));
812            }
813        }
814
815        frame.render_widget(Paragraph::new(lines), area);
816    }
817}
818
819fn truncate(s: &str, max: usize) -> String {
820    if s.chars().count() <= max {
821        s.to_string()
822    } else {
823        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
824        out.push('…');
825        out
826    }
827}
828
829fn bucket_color(pct: u32) -> Color {
830    let t = theme::active();
831    if pct >= 95 {
832        t.fail
833    } else if pct >= 80 {
834        t.warn
835    } else {
836        t.pass
837    }
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    fn buckets_with(counts: &[(u32, u32)], depth: u8, bucket_depth: u8) -> PostageBatchBuckets {
845        let upper_bound = 1u32 << (depth - bucket_depth);
846        let buckets = counts
847            .iter()
848            .map(|(id, c)| BatchBucket {
849                bucket_id: *id,
850                collisions: *c,
851            })
852            .collect();
853        PostageBatchBuckets {
854            depth,
855            bucket_depth,
856            bucket_upper_bound: upper_bound,
857            buckets,
858        }
859    }
860
861    #[test]
862    fn fill_bar_clamps_to_width() {
863        assert_eq!(fill_bar(0, 8), "░░░░░░░░");
864        assert_eq!(fill_bar(50, 8), "▇▇▇▇░░░░");
865        assert_eq!(fill_bar(100, 8), "▇▇▇▇▇▇▇▇");
866        assert_eq!(fill_bar(150, 8), "▇▇▇▇▇▇▇▇"); // saturating
867    }
868
869    #[test]
870    fn format_bytes_iec() {
871        assert_eq!(format_bytes(0), "0 B");
872        assert_eq!(format_bytes(1024), "1.0 KiB");
873        assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
874        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GiB");
875        assert_eq!(format_bytes(16 * 1024 * 1024 * 1024), "16.0 GiB");
876    }
877
878    #[test]
879    fn format_ttl_zero_is_expired() {
880        assert_eq!(format_ttl_seconds(0), "expired");
881        assert_eq!(format_ttl_seconds(-5), "expired");
882    }
883
884    #[test]
885    fn format_ttl_days_and_hours() {
886        // 47d 12h
887        assert_eq!(format_ttl_seconds(47 * 86_400 + 12 * 3_600), "47d 12h");
888    }
889
890    #[test]
891    fn format_ttl_under_a_day_uses_hours_minutes() {
892        assert_eq!(format_ttl_seconds(2 * 3_600 + 30 * 60), "2h 30m");
893    }
894
895    #[test]
896    fn drill_view_bins_and_worst_n() {
897        // depth=22, bucket_depth=16 → upper_bound=64. Construct a
898        // small sample with one full bucket, one near-full, two
899        // mid, rest empty.
900        let buckets = buckets_with(
901            &[
902                (0, 64), // 100 %  → bin 5
903                (1, 60), // 93 %   → bin 4
904                (2, 40), // 62 %   → bin 3
905                (3, 20), // 31 %   → bin 2
906                (4, 1),  // 1 %    → bin 1
907                (5, 0),  // 0 %    → bin 0
908            ],
909            22,
910            16,
911        );
912        let view = Stamps::compute_drill_view(&buckets);
913        assert_eq!(view.depth, 22);
914        assert_eq!(view.bucket_depth, 16);
915        assert_eq!(view.upper_bound, 64);
916        assert_eq!(view.total_chunks, 64 + 60 + 40 + 20 + 1);
917        assert_eq!(view.fill_distribution, [1, 1, 1, 1, 1, 1]);
918        assert_eq!(view.worst_pct, 100);
919        // Worst-N is the worst-3 here (rest are zero — truncated).
920        // The order is: bucket 0 (64), bucket 1 (60), bucket 2 (40),
921        // then 20, 1; the trailing zero-bucket is included only up
922        // to 10 entries — we filter zero-collisions in the renderer
923        // but compute_drill_view includes them up to the cap.
924        assert_eq!(view.worst_buckets.len(), 6);
925        assert_eq!(view.worst_buckets[0].bucket_id, 0);
926        assert_eq!(view.worst_buckets[0].pct, 100);
927        assert_eq!(view.worst_buckets[1].bucket_id, 1);
928        assert_eq!(view.worst_buckets[1].pct, 93);
929    }
930
931    #[test]
932    fn drill_view_handles_empty_buckets() {
933        let buckets = buckets_with(&[], 22, 16);
934        let view = Stamps::compute_drill_view(&buckets);
935        assert_eq!(view.total_chunks, 0);
936        assert_eq!(view.fill_distribution, [0; 6]);
937        assert_eq!(view.worst_pct, 0);
938        assert!(view.worst_buckets.is_empty());
939    }
940
941    #[test]
942    fn drill_view_caps_worst_at_ten() {
943        // 12 distinct buckets, all collisions=1 — top-10 should
944        // truncate at 10 entries.
945        let entries: Vec<(u32, u32)> = (0..12).map(|i| (i, 1)).collect();
946        let buckets = buckets_with(&entries, 22, 16);
947        let view = Stamps::compute_drill_view(&buckets);
948        assert_eq!(view.worst_buckets.len(), 10);
949    }
950
951    #[test]
952    fn drill_view_breaks_ties_by_bucket_id() {
953        let buckets = buckets_with(&[(7, 5), (3, 5), (10, 5)], 22, 16);
954        let view = Stamps::compute_drill_view(&buckets);
955        // All three tie on collisions=5 → ascending by bucket_id.
956        assert_eq!(
957            view.worst_buckets
958                .iter()
959                .map(|w| w.bucket_id)
960                .collect::<Vec<_>>(),
961            vec![3, 7, 10],
962        );
963    }
964
965    #[test]
966    fn fill_bin_handles_overflow_collisions() {
967        // Bee occasionally over-reports collisions vs upper_bound.
968        // Treat it as the saturated 100 % bin rather than panicking.
969        assert_eq!(bucket_fill_bin(70, 64), 5);
970    }
971}