Skip to content

Commit eba27b2

Browse files
authored
fix: proportional column scaling across all tabs when the detail pane opens (#37)
1 parent b027e10 commit eba27b2

1 file changed

Lines changed: 152 additions & 50 deletions

File tree

src/tui/ui.rs

Lines changed: 152 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ use ratatui::{
77
};
88

99
use super::app::{
10-
all_columns, App, CounterRate, DeviceClass, PortThroughput, TableColumn, BAR_WIDTH,
11-
EXTRA_COUNTERS,
10+
all_columns, App, CounterRate, DeviceClass, PortThroughput, TableColumn, EXTRA_COUNTERS,
1211
};
1312
use super::theme::ThemeColors;
1413
use crate::gpu::GpuMetrics;
@@ -257,11 +256,11 @@ fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect, tc: &ThemeColors) {
257256
frame.render_widget(Paragraph::new(tab_bar_line(app, tc)), area);
258257
}
259258

260-
fn gbps_bar(gbps: f64, link_gbps: Option<f64>) -> String {
259+
fn gbps_bar(gbps: f64, link_gbps: Option<f64>, width: usize) -> String {
261260
// Scale to the port's line rate; fall back to a default when unknown.
262261
let max = link_gbps.filter(|&r| r > 0.0).unwrap_or(RDMA_LINK_GBPS);
263262
let ratio = (gbps / max).clamp(0.0, 1.0);
264-
ratio_bar(ratio, BAR_WIDTH)
263+
ratio_bar(ratio, width)
265264
}
266265

267266
/// Meter string for a 0..=1 ratio, `width` chars, glyph-mode aware.
@@ -275,25 +274,98 @@ fn ratio_bar(ratio: f64, width: usize) -> String {
275274
}
276275

277276
/// "██████░░░░ 87%" gauge text for a 0-100 percent, or "-" when None.
278-
fn pct_gauge_text(pct: Option<u32>) -> String {
277+
/// `bar_w` sizes the meter; the trailing " {:>3}%" text is fixed.
278+
fn pct_gauge_text(pct: Option<u32>, bar_w: usize) -> String {
279279
match pct {
280-
Some(p) => format!("{} {:>3}%", ratio_bar(p as f64 / 100.0, 10), p),
280+
Some(p) => format!("{} {:>3}%", ratio_bar(p as f64 / 100.0, bar_w), p),
281281
None => "-".to_string(),
282282
}
283283
}
284284

285-
fn column_cell(col: &TableColumn, t: &PortThroughput, tc: &ThemeColors) -> Cell<'static> {
285+
/// Scale column widths by a common fraction of their naturals to fit
286+
/// `avail` (incl. one-cell gaps), flooring each at `mins`. Fits whenever
287+
/// `avail >= sum(mins) + gaps`; below that all columns sit at their min
288+
/// and ratatui clips. See the shave pass for how floor overshoot is paid.
289+
fn scaled_col_widths(natural: &[u16], mins: &[u16], avail: u16) -> Vec<u16> {
290+
debug_assert_eq!(natural.len(), mins.len());
291+
if natural.is_empty() {
292+
return Vec::new();
293+
}
294+
let gaps = natural.len().saturating_sub(1) as u16;
295+
let total: u16 = natural.iter().sum::<u16>() + gaps;
296+
if avail >= total {
297+
return natural.to_vec();
298+
}
299+
// Rounded division avoids a systematic downward bias; overshoot from
300+
// rounding or min-flooring is paid back by the shave pass below.
301+
let mut out: Vec<u16> = natural
302+
.iter()
303+
.zip(mins)
304+
.map(|(&nat, &min)| {
305+
let scaled = (nat as u32 * avail as u32 + total as u32 / 2) / total as u32;
306+
(scaled as u16).max(min)
307+
})
308+
.collect();
309+
// Reclaim overshoot from columns still above their floor, largest
310+
// excess first, so the result fits whenever sum(mins) does.
311+
let mut sum: u16 = out.iter().sum();
312+
while sum + gaps > avail {
313+
let Some(i) = (0..out.len())
314+
.filter(|&i| out[i] > mins[i])
315+
.max_by_key(|&i| out[i] - mins[i])
316+
else {
317+
break; // all at min: avail < sum(mins)+gaps, clipping unavoidable
318+
};
319+
out[i] -= 1;
320+
sum -= 1;
321+
}
322+
out
323+
}
324+
325+
/// Natural per-column widths for the GPU table, scaled to fit `avail`
326+
/// (inner table width) by a common fraction, each floored at a minimum
327+
/// so labels stay legible before ratatui clips whole columns.
328+
fn gpu_col_widths(avail: u16) -> [u16; 12] {
329+
const BAR: u16 = super::app::BAR_WIDTH as u16;
330+
const NATURAL: [u16; 12] = [9, 16, 16, 16, 5, 6, BAR, 7, BAR, 7, 6, 5];
331+
const MIN: [u16; 12] = [7, 8, 9, 9, 4, 4, 6, 6, 6, 6, 5, 4];
332+
scaled_col_widths(&NATURAL, &MIN, avail)
333+
.try_into()
334+
.expect("12 naturals in, 12 widths out")
335+
}
336+
337+
/// Minimum width a scaled RDMA column may shrink to: bars stay 6 cells
338+
/// wide so the meter reads; everything else keeps 3/5 of its natural
339+
/// width (at least 4) so values stay legible.
340+
fn rdma_col_min(col: &TableColumn) -> u16 {
341+
match col {
342+
TableColumn::TxBar | TableColumn::RxBar => 6,
343+
_ => {
344+
// 3/5 of natural, floored at 4 cells; capped at natural so a
345+
// future column narrower than 4 can't get a min wider than itself.
346+
let n = col.width();
347+
(n * 3 / 5).max(4).min(n)
348+
}
349+
}
350+
}
351+
352+
fn column_cell(
353+
col: &TableColumn,
354+
t: &PortThroughput,
355+
tc: &ThemeColors,
356+
col_w: u16,
357+
) -> Cell<'static> {
286358
match col {
287359
TableColumn::Device => Cell::from(t.dev_name.clone()).style(Style::default().fg(tc.fg)),
288360
TableColumn::Port => {
289361
let port_text = t.port_label.clone().unwrap_or_else(|| t.port.to_string());
290362
Cell::from(port_text).style(Style::default().fg(tc.muted))
291363
}
292-
TableColumn::TxBar => Cell::from(gbps_bar(t.tx_gbps, t.link_gbps))
364+
TableColumn::TxBar => Cell::from(gbps_bar(t.tx_gbps, t.link_gbps, col_w as usize))
293365
.style(Style::default().fg(throughput_color(t.tx_gbps, t.link_gbps, tc))),
294366
TableColumn::TxGbps => Cell::from(format!("{:.2}", t.tx_gbps))
295367
.style(Style::default().fg(throughput_color(t.tx_gbps, t.link_gbps, tc))),
296-
TableColumn::RxBar => Cell::from(gbps_bar(t.rx_gbps, t.link_gbps))
368+
TableColumn::RxBar => Cell::from(gbps_bar(t.rx_gbps, t.link_gbps, col_w as usize))
297369
.style(Style::default().fg(throughput_color(t.rx_gbps, t.link_gbps, tc))),
298370
TableColumn::RxGbps => Cell::from(format!("{:.2}", t.rx_gbps))
299371
.style(Style::default().fg(throughput_color(t.rx_gbps, t.link_gbps, tc))),
@@ -373,7 +445,7 @@ fn gpu_metrics(t: &PortThroughput) -> Option<&GpuMetrics> {
373445
nv.or_else(|| t.xgmi.as_ref().and_then(|m| m.metrics.as_ref()))
374446
}
375447

376-
fn gpu_row_cells(t: &PortThroughput, tc: &ThemeColors) -> Vec<Cell<'static>> {
448+
fn gpu_row_cells(t: &PortThroughput, tc: &ThemeColors, widths: &[u16; 12]) -> Vec<Cell<'static>> {
377449
let m = gpu_metrics(t);
378450
let util = m.and_then(|m| m.util_pct);
379451
let mem = m.and_then(|m| match (m.vram_used_mb, m.vram_total_mb) {
@@ -382,12 +454,14 @@ fn gpu_row_cells(t: &PortThroughput, tc: &ThemeColors) -> Vec<Cell<'static>> {
382454
}
383455
_ => None,
384456
});
385-
let pct_cell = |p: Option<u32>| {
457+
// Gauge bar fills its column minus the fixed " 100%" suffix (5 chars).
458+
let pct_cell = |p: Option<u32>, col_w: u16| {
386459
let color = match p {
387460
Some(v) => capacity_color(v as f64 / 100.0, tc),
388461
None => tc.muted,
389462
};
390-
Cell::from(pct_gauge_text(p)).style(Style::default().fg(color))
463+
let bar_w = (col_w as usize).saturating_sub(5).max(3);
464+
Cell::from(pct_gauge_text(p, bar_w)).style(Style::default().fg(color))
391465
};
392466
let temp_cell = match m.and_then(|m| m.temp_c) {
393467
Some(t) => Cell::from(format!("{}C", t)).style(Style::default().fg(temp_color(t, tc))),
@@ -402,25 +476,19 @@ fn gpu_row_cells(t: &PortThroughput, tc: &ThemeColors) -> Vec<Cell<'static>> {
402476
vec![
403477
Cell::from(t.dev_name.clone()).style(Style::default().fg(tc.fg)),
404478
Cell::from(gpu_meta_name(t)).style(Style::default().fg(tc.muted)),
405-
pct_cell(util),
406-
pct_cell(mem),
479+
pct_cell(util, widths[2]),
480+
pct_cell(mem, widths[3]),
407481
temp_cell,
408482
pwr_cell,
409-
Cell::from(gbps_bar(t.tx_gbps, t.link_gbps)).style(Style::default().fg(throughput_color(
410-
t.tx_gbps,
411-
t.link_gbps,
412-
tc,
413-
))),
483+
Cell::from(gbps_bar(t.tx_gbps, t.link_gbps, widths[6] as usize))
484+
.style(Style::default().fg(throughput_color(t.tx_gbps, t.link_gbps, tc))),
414485
Cell::from(format!("{:.2}", t.tx_gbps)).style(Style::default().fg(throughput_color(
415486
t.tx_gbps,
416487
t.link_gbps,
417488
tc,
418489
))),
419-
Cell::from(gbps_bar(t.rx_gbps, t.link_gbps)).style(Style::default().fg(throughput_color(
420-
t.rx_gbps,
421-
t.link_gbps,
422-
tc,
423-
))),
490+
Cell::from(gbps_bar(t.rx_gbps, t.link_gbps, widths[8] as usize))
491+
.style(Style::default().fg(throughput_color(t.rx_gbps, t.link_gbps, tc))),
424492
Cell::from(format!("{:.2}", t.rx_gbps)).style(Style::default().fg(throughput_color(
425493
t.rx_gbps,
426494
t.link_gbps,
@@ -487,29 +555,17 @@ fn draw_gpu_table(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors
487555
)
488556
.height(1);
489557

558+
// Block borders (2) and the "▶ " highlight column (2) shrink what the
559+
// Table layout actually distributes among columns; scale to fit that.
560+
let avail = area.width.saturating_sub(2 + 2);
561+
let widths = gpu_col_widths(avail);
562+
490563
let rows: Vec<Row> = display
491564
.iter()
492-
.map(|t| Row::new(gpu_row_cells(t, tc)))
565+
.map(|t| Row::new(gpu_row_cells(t, tc, &widths)))
493566
.collect();
494567

495-
// Util/Mem hold a 10-char gauge + " 100%" (15 chars); bars reuse
496-
// BAR_WIDTH so the TX/RX meters match the RDMA tab visually.
497-
let widths = [
498-
Constraint::Length(9),
499-
Constraint::Length(16),
500-
Constraint::Length(16),
501-
Constraint::Length(16),
502-
Constraint::Length(5),
503-
Constraint::Length(6),
504-
Constraint::Length(BAR_WIDTH as u16),
505-
Constraint::Length(7),
506-
Constraint::Length(BAR_WIDTH as u16),
507-
Constraint::Length(7),
508-
Constraint::Length(6),
509-
Constraint::Length(5),
510-
];
511-
512-
let table = Table::new(rows, widths)
568+
let table = Table::new(rows, widths.map(Constraint::Length))
513569
.header(header)
514570
.block(
515571
Block::default()
@@ -633,22 +689,28 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors) {
633689
)
634690
.height(1);
635691

692+
// Scale the rendered set to fit, like the GPU table. Normal-mode
693+
// windowing already fits naturals, so scaling only bites when it
694+
// doesn't (detail mode's fixed default set, or a too-narrow pane).
695+
let natural: Vec<u16> = cols_to_render.iter().map(|c| c.width()).collect();
696+
let mins: Vec<u16> = cols_to_render.iter().map(|c| rdma_col_min(c)).collect();
697+
// Block borders (2) and the highlight column (2), as in draw_gpu_table.
698+
let col_widths = scaled_col_widths(&natural, &mins, area.width.saturating_sub(2 + 2));
699+
636700
let rows: Vec<Row> = display
637701
.iter()
638702
.map(|t| {
639703
Row::new(
640704
cols_to_render
641705
.iter()
642-
.map(|c| column_cell(c, t, tc))
706+
.zip(&col_widths)
707+
.map(|(c, &w)| column_cell(c, t, tc, w))
643708
.collect::<Vec<_>>(),
644709
)
645710
})
646711
.collect();
647712

648-
let widths: Vec<Constraint> = cols_to_render
649-
.iter()
650-
.map(|c| Constraint::Length(c.width()))
651-
.collect();
713+
let widths: Vec<Constraint> = col_widths.iter().map(|&w| Constraint::Length(w)).collect();
652714

653715
let table = Table::new(rows, widths)
654716
.header(header)
@@ -2436,14 +2498,54 @@ mod gauge_tests {
24362498

24372499
#[test]
24382500
fn pct_gauge_text_formats_percent_with_bar() {
2439-
let text = pct_gauge_text(Some(87));
2501+
let text = pct_gauge_text(Some(87), 10);
24402502
assert!(text.contains("87%"), "{text:?}");
24412503
let (fill, _) = crate::tui::glyphs::meter();
24422504
assert!(text.contains(fill), "expected bar glyph in {text:?}");
24432505
}
24442506

24452507
#[test]
24462508
fn pct_gauge_text_none_is_dash() {
2447-
assert_eq!(pct_gauge_text(None), "-");
2509+
assert_eq!(pct_gauge_text(None, 10), "-");
2510+
}
2511+
2512+
#[test]
2513+
fn gpu_col_widths_wide_returns_natural() {
2514+
const BAR: u16 = crate::tui::app::BAR_WIDTH as u16;
2515+
assert_eq!(
2516+
gpu_col_widths(200),
2517+
[9, 16, 16, 16, 5, 6, BAR, 7, BAR, 7, 6, 5]
2518+
);
2519+
}
2520+
2521+
#[test]
2522+
fn gpu_col_widths_narrow_fits_avail_and_respects_minimums() {
2523+
const MIN: [u16; 12] = [7, 8, 9, 9, 4, 4, 6, 6, 6, 6, 5, 4];
2524+
let widths = gpu_col_widths(100);
2525+
let total: u16 = widths.iter().sum::<u16>() + 11; // 11 column gaps
2526+
assert!(total <= 100, "scaled widths too wide: {total}");
2527+
for (i, (&w, &min)) in widths.iter().zip(MIN.iter()).enumerate() {
2528+
assert!(w >= min, "col {i}: width {w} below min {min}");
2529+
}
2530+
}
2531+
2532+
#[test]
2533+
fn gpu_col_widths_below_min_floor_pins_every_column_at_min() {
2534+
const MIN: [u16; 12] = [7, 8, 9, 9, 4, 4, 6, 6, 6, 6, 5, 4];
2535+
// sum(MIN) + 11 gaps = 85 > 80: nothing left to shave, all at min.
2536+
assert_eq!(gpu_col_widths(80), MIN);
2537+
}
2538+
2539+
#[test]
2540+
fn scaled_col_widths_naturals_when_wide_fits_when_narrow() {
2541+
let natural = [10u16, 20, 6];
2542+
let mins = [4u16, 8, 3];
2543+
assert_eq!(scaled_col_widths(&natural, &mins, 100), natural);
2544+
let scaled = scaled_col_widths(&natural, &mins, 24);
2545+
let total: u16 = scaled.iter().sum::<u16>() + 2; // 2 column gaps
2546+
assert!(total <= 24, "scaled widths too wide: {total}");
2547+
for (i, (&w, &min)) in scaled.iter().zip(mins.iter()).enumerate() {
2548+
assert!(w >= min, "col {i}: width {w} below min {min}");
2549+
}
24482550
}
24492551
}

0 commit comments

Comments
 (0)