From 2ab66bea59a5b2113737d1e88d870d8d7f282a0d Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 24 May 2026 00:41:34 +0200 Subject: [PATCH] feat(tui): contract long paths to fit the terminal width Long file paths used to spill out of the centered box because shore's ui.text does not clip or wrap. The TUI now queries the terminal width on every render (via a new io:columns FFI), computes a per-row path budget, and contracts paths with ".../" elision or middle-truncation when even the basename overflows. Also widens the box from 80% to 95% of the terminal so there is more room before contraction kicks in. --- src/tinyimg/format.gleam | 85 ++++++++++++++++++++++++++++++++++++++++ src/tinyimg/tui.gleam | 43 +++++++++++++++----- src/tinyimg_ffi.erl | 8 +++- test/format_test.gleam | 52 ++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 10 deletions(-) diff --git a/src/tinyimg/format.gleam b/src/tinyimg/format.gleam index e3b16c2..5343fa3 100644 --- a/src/tinyimg/format.gleam +++ b/src/tinyimg/format.gleam @@ -1,5 +1,6 @@ import gleam/float import gleam/int +import gleam/list import gleam/string pub fn bytes(n: Int) -> String { @@ -54,3 +55,87 @@ pub fn relative(root: String, path: String) -> String { False -> path } } + +/// Contract `path` so its rendered width is at most `max` characters. +/// +/// Strategy, in order of preference: +/// 1. If the path already fits, return it unchanged. +/// 2. Drop leading path segments and prepend ".../", keeping as many +/// trailing segments as fit. The trailing filename is never dropped. +/// 3. If even ".../" exceeds `max`, middle-truncate the +/// filename with "..." in the middle. +/// 4. If `max` is very small (<= 3), return the first `max` chars. +pub fn contract_path(path: String, max: Int) -> String { + case max <= 0 { + True -> "" + False -> case string.length(path) <= max { + True -> path + False -> shrink(path, max) + } + } +} + +fn shrink(path: String, max: Int) -> String { + let segments = string.split(path, "/") + case segments { + [] -> path + _ -> { + let base = case list.last(segments) { + Ok(s) -> s + Error(_) -> path + } + let base_len = string.length(base) + let ellipsis = ".../" + let ellipsis_len = 4 + + case base_len + ellipsis_len > max { + // Even ".../" doesn't fit — middle-truncate the filename. + True -> middle_truncate(base, max) + False -> { + // Try to keep as many trailing segments as fit, with ".../" prefix. + let budget = max - ellipsis_len + let tail = build_tail(segments |> list.reverse, budget, "", True) + ellipsis <> tail + } + } + } + } +} + +fn build_tail( + rev_segments: List(String), + budget: Int, + acc: String, + is_first: Bool, +) -> String { + case rev_segments { + [] -> acc + [s, ..rest] -> { + let candidate = case is_first { + True -> s + False -> s <> "/" <> acc + } + case string.length(candidate) <= budget { + True -> build_tail(rest, budget, candidate, False) + False -> acc + } + } + } +} + +fn middle_truncate(s: String, max: Int) -> String { + let len = string.length(s) + case len <= max { + True -> s + False -> case max <= 3 { + True -> string.slice(s, 0, max) + False -> { + let ellipsis = "..." + let keep = max - 3 + let left = keep / 2 + keep % 2 + let right = keep - left + string.slice(s, 0, left) <> ellipsis <> string.slice(s, len - right, right) + } + } + } +} diff --git a/src/tinyimg/tui.gleam b/src/tinyimg/tui.gleam index e03c750..09643d5 100644 --- a/src/tinyimg/tui.gleam +++ b/src/tinyimg/tui.gleam @@ -23,6 +23,24 @@ fn system_cpus() -> Int @external(erlang, "tinyimg_ffi", "monotonic_ms") fn monotonic_ms() -> Int +@external(erlang, "tinyimg_ffi", "terminal_columns") +fn terminal_columns() -> Int + +/// Width budget for path text inside the box. Recomputed on every render so +/// the layout adapts when the terminal is resized. +/// - box outer width: 95% of terminal columns +/// - 2 cols for the box border +/// - 2 cols for the inner indent +/// - 22 cols reserved for " -> " (e.g. "1023 KB -> 999 KB -2%") +fn path_budget() -> Int { + let cols = terminal_columns() + let inner = cols * 95 / 100 - 4 - 22 + case inner < 12 { + True -> 12 + False -> inner + } +} + pub type Msg { Boot(Subject(shore.Event(Msg))) WorkerFinished(FileResult) @@ -196,9 +214,13 @@ fn take_first(list: List(a), n: Int) -> List(a) { // --------------------------------------------------------------------------- fn view(model: Model) -> shore.Node(Msg) { + let budget = path_budget() + // Header gets a slightly bigger budget because it has no per-row sizes. + let header_budget = budget + 18 + ui.box( [ - ui.text("tinyimg " <> model.root), + ui.text("tinyimg " <> format.contract_path(model.root, header_budget)), case model.notice { "" -> ui.br() text -> ui.text_styled(text, Some(style.Cyan), None) @@ -207,14 +229,14 @@ fn view(model: Model) -> shore.Node(Msg) { progress_row(model), ui.text(stats_line(model)), ui.br(), - recent_section(model), + recent_section(model, budget), ui.br(), footer_row(model), ui.keybind(key.Char("q"), QuitPressed), ], None, ) - |> layout.center(style.Pct(80), style.Pct(80)) + |> layout.center(style.Pct(95), style.Pct(90)) } fn progress_row(model: Model) -> shore.Node(Msg) { @@ -238,22 +260,25 @@ fn stats_line(model: Model) -> String { <> format.bytes(model.summary.saved) } -fn recent_section(model: Model) -> shore.Node(Msg) { +fn recent_section(model: Model, budget: Int) -> shore.Node(Msg) { case model.recent { [] -> ui.text("(waiting for first result...)") items -> ui.col([ ui.text("recent"), - ..list.map(items, fn(r) { recent_line(model.root, r) }) + ..list.map(items, fn(r) { recent_line(model.root, r, budget) }) ]) } } -fn recent_line(root: String, r: FileResult) -> shore.Node(Msg) { +fn recent_line(root: String, r: FileResult, budget: Int) -> shore.Node(Msg) { + let shown = fn(p) { + format.contract_path(format.relative(root, p), budget) + } case r { Optimized(path, before, after) -> ui.text( " " - <> format.relative(root, path) + <> shown(path) <> " " <> format.bytes(before) <> " -> " @@ -263,13 +288,13 @@ fn recent_line(root: String, r: FileResult) -> shore.Node(Msg) { ) Skipped(path, size) -> ui.text_styled( - " " <> format.relative(root, path) <> " " <> format.bytes(size) <> " skipped", + " " <> shown(path) <> " " <> format.bytes(size) <> " skipped", Some(style.Yellow), None, ) Failed(path, reason) -> ui.text_styled( - " " <> format.relative(root, path) <> " FAIL " <> reason, + " " <> shown(path) <> " FAIL " <> reason, Some(style.Red), None, ) diff --git a/src/tinyimg_ffi.erl b/src/tinyimg_ffi.erl index df7bd55..969b269 100644 --- a/src/tinyimg_ffi.erl +++ b/src/tinyimg_ffi.erl @@ -1,5 +1,5 @@ -module(tinyimg_ffi). --export([is_tty/0, system_cpus/0, monotonic_ms/0, unique_id/0]). +-export([is_tty/0, system_cpus/0, monotonic_ms/0, unique_id/0, terminal_columns/0]). is_tty() -> case io:columns() of @@ -18,3 +18,9 @@ monotonic_ms() -> unique_id() -> erlang:unique_integer([positive]). + +terminal_columns() -> + case io:columns() of + {ok, N} when is_integer(N), N > 0 -> N; + _ -> 80 + end. diff --git a/test/format_test.gleam b/test/format_test.gleam index 8f849ad..1ad2fdf 100644 --- a/test/format_test.gleam +++ b/test/format_test.gleam @@ -72,3 +72,55 @@ pub fn relative_root_only_test() { // The root path itself returned as-is (no trailing slash to strip). assert format.relative("/foo", "/foo") == "/foo" } + +pub fn contract_path_fits_test() { + // Short paths pass through. + assert format.contract_path("a/b.png", 20) == "a/b.png" +} + +pub fn contract_path_drops_leading_segments_test() { + // Long path with a short basename: keep the trailing segments that fit + // behind a ".../" prefix. + let path = "assets/heroes/illustrations/big/banner.png" + let out = format.contract_path(path, 28) + assert out == ".../big/banner.png" +} + +pub fn contract_path_keeps_filename_test() { + // Even when only the basename fits, the filename is preserved. + let path = "assets/heroes/big/banner.png" + let out = format.contract_path(path, 14) + assert out == ".../banner.png" +} + +pub fn contract_path_middle_truncates_filename_test() { + // If the filename alone exceeds the budget, middle-truncate it. + let path = "very-long-image-name-that-is-too-wide.png" + let out = format.contract_path(path, 20) + // Length must be at most 20 and contain "..." + assert string_length(out) == 20 + assert contains(out, "...") +} + +pub fn contract_path_zero_budget_test() { + assert format.contract_path("a/b.png", 0) == "" +} + +pub fn contract_path_tiny_budget_test() { + // Budget of 3 chars: just the first 3 of the basename. + let out = format.contract_path("very-long-name.png", 3) + assert string_length(out) == 3 +} + +@external(erlang, "string", "length") +fn string_length(s: String) -> Int + +fn contains(haystack: String, needle: String) -> Bool { + case erl_split(haystack, needle) { + [_] -> False + _ -> True + } +} + +@external(erlang, "string", "split") +fn erl_split(s: String, sep: String) -> List(String) -- 2.51.2