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)