diff --git a/CHANGELOG.md b/CHANGELOG.md index 6418c4ed4d..f89e59d4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index e1caa0e1bf..224fba42b5 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index f2b734058b..5e021103ee 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -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), diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs index 05710da6cf..c26b04f4bd 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/render.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs @@ -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); + } }