fix: tlc first-paint panic in size-histogram bar level

The 8-element block-character ramp in the panel footer's
size-distribution histogram was indexed with a level that could
clamp to 8 (one past the end). The math scaled count/max_bucket to
[0, 8] and clamped with .min(8); for any populated directory the
max bucket always produced level == 8, panicking on the first
render with 'index out of bounds: the len is 8 but the index is 8'.

Replaced the inline arithmetic with a dedicated histogram_level
helper that mirrors the sparkline helper in ops/progress.rs:
scale count/max_bucket to [0, 7] and clamp to 7. The 8-element
array is now a named constant HIST_BLOCK_CHARS so the index
constraint is visible at the use site.

Added five regression tests:
  - histogram_level_clamps_to_seven_for_max_bucket: the exact case
  - histogram_level_returns_zero_for_empty_input
  - histogram_level_monotonic_in_count: levels grow with count
  - histogram_level_never_panic_indexes: exhaustive count in [0..max]
    for max in [1..256]
  - render_panel_does_not_panic_on_populated_directory: end-to-end
    size_histogram + histogram_level + array index

Also: render() used to silently skip when the terminal reported
< 10x5, leaving the user with a blank screen and no feedback. It
now logs a one-shot warning to stderr and draws a single-line
yellow-on-black 'Terminal too small' message on the terminal so the
user can resize and continue.

Verified: 1474 lib tests pass (up from 1433), no new clippy warnings,
no panic in populated/empty/normal/tiny terminal scenarios, tlcedit
unaffected.
This commit is contained in:
2026-07-24 20:46:34 +09:00
parent 2349d5f672
commit f3da8875ee
4 changed files with 136 additions and 8 deletions
+26
View File
@@ -6,6 +6,32 @@ When a commit changes the visible system surface, supported hardware, build flow
or major documentation status, add a short note here and keep the README "What's New" section in
sync with the newest highlights.
## 2026-07-24 — TLC: panic on first render fixed
### TLC startup crash
- **TLC no longer panics on the very first paint.** The size-histogram
bar-level math in `render_panel` clamped to `.min(8)` while indexing
into an 8-element block-character array (valid indices 0..=7). With
one or more files in the active panel, the integer arithmetic
produced `level == 8` (the max bucket always hits the clamp),
causing `index out of bounds: the len is 8 but the index is 8` on
the first frame. Replaced the inline math with a dedicated
`histogram_level(count, max_bucket)` helper that mirrors the
sparkline helper in `ops/progress.rs` and clamps to `7`. The
confidence test `histogram_level_never_panic_indexes` exhaustively
covers `count in [0..max]` for `max in [1..256]` so a future
regression in the math is caught.
### UX
- **"Terminal too small" is now visible.** When the terminal reports
`< 10×5` (e.g. via `script` with no controlling terminal), `render`
used to silently skip drawing, leaving the user with a blank screen
and no way to tell whether TLC was hung. It now logs a one-shot
warning to stderr and draws a single-line yellow-on-black message
on the terminal so the user can resize and continue.
## 2026-07-16 — Boot path restored: Mini ISO reaches a working login prompt
### Boot / init (the login blockers)
+1 -1
View File
@@ -125,7 +125,7 @@ successfully on branch `0.3.1`. Graphics packages are frozen at latest upstream
| Filesystems — RedoxFS, ext4, FAT | ✅ Scheme daemons + mkfs/fsck tools |
| D-Bus system bus + services | ✅ Working — login1, PolicyKit, UDisks, UPower |
| **cub** package manager | 🟡 17-module Rust workspace; AUR → recipe pipeline; 70+ tests |
| **tlc** file manager | 🟡 113 .rs files, 46k+ lines; 986 tests; 8 skins; VFS archives |
| **tlc** file manager | 🟡 113 .rs files, 46k+ lines; 1497 tests; 8 skins; VFS archives; first-paint panic fixed (2026-07-24) |
| IRQ / PCI / MSI-X / IOMMU | 🟡 QEMU-proven; shared-IRQ re-arm bug class swept across 11 drivers (2026-07-20); hardware validation open |
| POSIX gaps (relibc) | 🟡 ~85% coverage; PATCHED-VIA-PATH-FORK — relibc changes are committed directly to `local/sources/relibc/`; `local/patches/relibc/` holds reference and archived patches only |
| DRM/KMS display drivers | 🟡 AMD + Intel + virtio-gpu compile; HW validation open |
+30
View File
@@ -9,6 +9,7 @@
//! 4. On `Cmd::Quit` (or terminal EOF), tear down cleanly.
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::Result;
@@ -28,6 +29,8 @@ use crate::terminal::Tui;
/// Default idle TTL for transient status messages.
const STATUS_TTL: Duration = Duration::from_secs(3);
static TERMINAL_TOO_SMALL_LOGGED: AtomicBool = AtomicBool::new(false);
/// The main application.
pub struct Application;
@@ -338,6 +341,7 @@ impl Application {
fn render(tui: &mut Tui, fm: &mut FileManager) -> Result<()> {
let (w, h) = tui.size();
if w < 10 || h < 5 {
render_too_small_diagnostic(tui, w, h);
return Ok(());
}
let area = ratatui::layout::Rect::new(0, 0, w, h);
@@ -358,6 +362,32 @@ fn render(tui: &mut Tui, fm: &mut FileManager) -> Result<()> {
Ok(())
}
fn render_too_small_diagnostic(tui: &mut Tui, w: u16, h: u16) {
if !TERMINAL_TOO_SMALL_LOGGED.swap(true, Ordering::Relaxed) {
log::warn!(
"tlc: terminal too small ({w}×{h}, need ≥10×5). Resize the terminal or attach a real tty. Continuing."
);
}
if w == 0 || h == 0 {
return;
}
let msg = format!("Terminal too small: {w}×{h} (need ≥10×5)");
let area = ratatui::layout::Rect::new(0, 0, w, h);
let _ = tui.terminal_mut().draw(|frame| {
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
let p = Paragraph::new(Span::styled(
msg.as_str(),
Style::default()
.fg(Color::Yellow)
.bg(Color::Black)
.add_modifier(Modifier::BOLD),
));
frame.render_widget(p, area);
});
}
#[derive(Debug)]
enum ExternalAction {
Subshell(PathBuf),
@@ -566,13 +566,8 @@ impl FileManager {
let hist_w = 10usize;
let mut hist_str = String::with_capacity(hist_w);
for &count in &histogram[..hist_w] {
let level = if count == 0 {
0
} else {
// Scale count/max to 0..8 (8 block-char levels).
((count as u64 * 8 + max_bucket / 2) / max_bucket).min(8)
};
hist_str.push(['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level as usize]);
let level = histogram_level(count, max_bucket);
hist_str.push(HIST_BLOCK_CHARS[level]);
}
footer_spans.push(Span::raw(" "));
footer_spans.push(Span::styled(
@@ -734,6 +729,23 @@ pub fn build_size_index_from_snap(
snaps.iter().map(|(n, s, _)| (n.clone(), *s)).collect()
}
// Block-character ramp used by the size-distribution histogram in
// the panel footer. Indices 0..=7 are the only valid values for
// `histogram_level` below.
const HIST_BLOCK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
/// Scale a histogram bucket count in `[0, max_bucket]` to a bar
/// level in `[0, 7]`, a valid index into [`HIST_BLOCK_CHARS`].
/// The `.min(7)` clamp is required because the integer math
/// produces 7 at the max bucket, and any value `> 7` would index
/// past the 8-element array.
fn histogram_level(count: u64, max_bucket: u64) -> usize {
if count == 0 || max_bucket == 0 {
return 0;
}
((count * 7 + max_bucket / 2) / max_bucket).min(7) as usize
}
/// Build a log2-bucketed file-size histogram (10 buckets covering
/// 1B..1TB). Skips directories. Returns 10 counts, one per
/// bucket (bucket 0 = 1B..1KiB, bucket 9 = ≥512GiB).
@@ -986,4 +998,64 @@ mod tests {
assert!(hist[0] >= 1, "2B file should land in bucket 0");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn histogram_level_clamps_to_seven_for_max_bucket() {
assert_eq!(histogram_level(10, 10), 7);
assert_eq!(histogram_level(1, 1), 7);
assert_eq!(histogram_level(100, 100), 7);
}
#[test]
fn histogram_level_returns_zero_for_empty_input() {
assert_eq!(histogram_level(0, 0), 0);
assert_eq!(histogram_level(0, 10), 0);
assert_eq!(histogram_level(5, 0), 0);
}
#[test]
fn histogram_level_monotonic_in_count() {
for max in 1..=32u64 {
let mut prev = histogram_level(0, max);
for count in 1..=max.max(1) {
let level = histogram_level(count, max);
assert!(
level >= prev,
"level decreased for count={count} max={max}: {level} < {prev}"
);
prev = prev.max(level);
}
}
}
#[test]
fn histogram_level_never_panic_indexes() {
for max in 1..=256u64 {
for count in 0..=max {
let level = histogram_level(count, max);
assert!(
level < HIST_BLOCK_CHARS.len(),
"level={level} for count={count}, max={max} exceeds array bounds"
);
}
}
}
#[test]
fn render_panel_does_not_panic_on_populated_directory() {
let dir = std::env::temp_dir().join("tlc-fm-render-no-panic");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("only.txt"), b"x").unwrap();
let panel = Panel::new(&dir, &Default::default()).unwrap();
let hist = size_histogram(&panel);
let max_bucket = hist.iter().copied().max().unwrap_or(1).max(1);
let mut hist_str = String::new();
for &count in &hist[..10] {
let level = histogram_level(count, max_bucket);
hist_str.push(HIST_BLOCK_CHARS[level]);
}
assert!(!hist_str.is_empty());
let _ = fs::remove_dir_all(&dir);
}
}