libs/progress-display.lua
1-- {{{ progress-display.lua
2-- Pure-Lua mirror of the C progress renderer (libs/vulkan-compute/src/vk_compute.c
3-- vkc_progress_*). Lua stages that have no reason to load the Vulkan shared
4-- library -- HTML generation, word pages -- use this so their progress bar
5-- looks identical to the GPU stages and obeys the same rules:
6--
7-- * VKC_DEBUG set (run.sh --debug) -> verbose: one plain, newline-terminated
8-- line per update, so a redirected log keeps the full history of a run.
9-- * else stdout is a TTY -> animated: updates overwrite one line with a "\r"
10-- Unicode bar (█ done, ░ pending).
11-- * else (piped to a file / cron, no debug) -> quiet: nothing is drawn.
12--
13-- The C version is the source of truth for the look; this is kept byte-for-byte
14-- compatible (same bar width, same glyphs, same "label [bar] cur/total (pct%)
15-- suffix" layout) so a reader cannot tell which stage drew a given bar.
16local ffi = require("ffi")
17-- isatty lives in libc; pcall guards the (impossible-in-practice) case where
18-- the symbol is unavailable, so a missing isatty degrades to "not a TTY"
19-- (quiet) rather than erroring a whole HTML run over a progress bar.
20pcall(ffi.cdef, "int isatty(int fd);")
21-- ioctl + winsize, for asking the terminal how wide it is. Same pcall guard:
22-- if the declaration or the call fails we fall back to a conservative width
23-- rather than taking a build down over a cosmetic detail.
24pcall(ffi.cdef, [[
25struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; };
26int ioctl(int fd, unsigned long request, ...);
27]])
28
29local M = {}
30
31-- Issue 10-065: the bar is sized to the TERMINAL, not fixed at 40 columns.
32--
33-- Why it had to change. At a fixed 40 the full line came to 84 display columns
34-- (" <emoji> Semantic colors [40 cells] 8510/8510 (100%)" plus the trailing
35-- pad) -- and 84 does not fit an 80-column terminal. It wrapped, and a wrapped
36-- line defeats the whole mechanism: "\r" returns to the start of the LAST
37-- screen row, not the start of the logical line, so each update redrew below
38-- the previous one instead of over it. The console filled with hundreds of
39-- half-erased bars. With a suffix -- the semantic-colour stage appends
40-- "poem_index 1234 = orange" -- the line reached ~108 columns and every single
41-- update survived on screen.
42--
43-- It looked exactly like the bar was ignoring the --debug setting and printing
44-- a line per update. It was not: the mode was correct, the line was too long.
45--
46-- MIN_BAR keeps the bar meaningful on a narrow terminal; below that there is
47-- not enough resolution for movement to be visible and the counts carry the
48-- information instead. MAX_BAR preserves the old look where there is room.
49local MIN_BAR, MAX_BAR = 10, 40
50local FALLBACK_COLS = 80
51local MODE_QUIET, MODE_BAR, MODE_VERBOSE = 0, 1, 2
52
53-- {{{ local function resolve_mode()
54-- Resolved once and memoised: neither stdout's TTY-ness nor VKC_DEBUG changes
55-- during a run. Mirrors vkc_progress_mode()'s ordering -- debug is checked
56-- BEFORE isatty, so --debug through a pipe still yields verbose lines (the
57-- whole point of --debug) instead of falling through to quiet.
58local cached_mode = nil
59local function resolve_mode()
60 if cached_mode ~= nil then return cached_mode end
61 local debug_flag = os.getenv("VKC_DEBUG")
62 if debug_flag and debug_flag ~= "" then
63 cached_mode = MODE_VERBOSE
64 else
65 local is_tty = false
66 local ok, result = pcall(function() return ffi.C.isatty(1) end)
67 if ok and result ~= 0 then is_tty = true end
68 cached_mode = is_tty and MODE_BAR or MODE_QUIET
69 end
70 return cached_mode
71end
72-- }}}
73
74-- {{{ local function display_width(s)
75-- Columns a string occupies on screen, which is not its byte length and not its
76-- character count. UTF-8 encodes the box-drawing glyphs used by the bar in three
77-- bytes for one column each, and the emoji in the stage labels in four bytes for
78-- TWO columns each. Counting bytes would make every label look far wider than it
79-- is; counting characters would under-count every emoji by one. Both errors put
80-- the line over the edge, which is the failure this whole exercise is about.
81local function display_width(s)
82 local cols, i = 0, 1
83 while i <= #s do
84 local b = s:byte(i)
85 local seq = (b < 0x80) and 1 or (b < 0xE0) and 2 or (b < 0xF0) and 3 or 4
86 -- Four-byte sequences are the astral plane, which is where the emoji
87 -- live; those render double-width in every terminal font this project
88 -- targets. Everything else is treated as one column.
89 cols = cols + ((seq == 4) and 2 or 1)
90 i = i + seq
91 end
92 return cols
93end
94-- }}}
95
96-- {{{ local function terminal_cols()
97-- How wide the terminal is, asked once and remembered. A resize mid-run would go
98-- unnoticed; that is a deliberate trade, since asking on every frame means an
99-- ioctl per redraw and a stale width merely makes the bar narrower than it could
100-- be, never wider than it may be.
101--
102-- Order: ask the kernel what the terminal actually is; failing that trust the
103-- COLUMNS the shell exported; failing that assume 80, which is the width that
104-- has been safe since the punch card.
105local cached_cols = nil
106local function terminal_cols()
107 if cached_cols then return cached_cols end
108
109 local ok, cols = pcall(function()
110 local ws = ffi.new("struct winsize")
111 -- TIOCGWINSZ on Linux. This project is Linux-only throughout (it assumes
112 -- /dev/shm, elogind, Vulkan), so the constant is written directly rather
113 -- than discovered.
114 if ffi.C.ioctl(1, 0x5413, ws) == 0 and ws.ws_col > 0 then
115 return tonumber(ws.ws_col)
116 end
117 return nil
118 end)
119 if ok and cols then
120 cached_cols = cols
121 return cached_cols
122 end
123
124 local env_cols = tonumber(os.getenv("COLUMNS") or "")
125 cached_cols = (env_cols and env_cols > 0) and env_cols or FALLBACK_COLS
126 return cached_cols
127end
128-- }}}
129
130-- {{{ function M.mode()
131-- Exposes the resolved mode (0 quiet / 1 bar / 2 verbose) so callers can
132-- throttle: animate every step in bar mode, but emit sparse lines when verbose.
133function M.mode()
134 return resolve_mode()
135end
136-- }}}
137
138-- {{{ function M.update(label, current, total, suffix)
139-- Draw one progress frame. suffix is optional extra text (e.g. rate / ETA)
140-- appended after the percentage. Cheap to call; in bar mode call as often as
141-- you like, in verbose mode throttle to keep the log readable.
142function M.update(label, current, total, suffix)
143 local mode = resolve_mode()
144 if mode == MODE_QUIET then return end
145
146 local frac = (total > 0) and (current / total) or 1.0
147 if frac > 1.0 then frac = 1.0 end -- callers may overshoot
148 local pct = frac * 100
149 local tail = suffix and (" " .. suffix) or ""
150
151 if mode == MODE_VERBOSE then
152 io.write(string.format("%s %d/%d (%.0f%%)%s\n", label, current, total, pct, tail))
153 io.flush()
154 return
155 end
156
157 -- Animated bar, sized so the WHOLE line fits the terminal. Everything except
158 -- the bar is measured first; the bar gets what is left.
159 local counts = string.format(" %d/%d (%3.0f%%)", current, total, pct)
160 local fixed = display_width(label) + display_width(counts)
161 + 3 -- the " [" and "] " framing
162 + 3 -- trailing pad, which erases a shrinking suffix
163 local budget = terminal_cols() - 1 - fixed -- -1: never write the last cell
164
165 -- A suffix is a luxury: it is dropped entirely before the bar is allowed to
166 -- shrink below MIN_BAR, and truncated to whatever room is left after the bar
167 -- has taken its minimum. An ETA is worth less than a bar that stays put.
168 local tail_room = budget - MIN_BAR
169 if tail_room < 1 then
170 tail = ""
171 elseif display_width(tail) > tail_room then
172 tail = tail:sub(1, tail_room)
173 end
174 budget = budget - display_width(tail)
175
176 local bar_width = budget
177 if bar_width > MAX_BAR then bar_width = MAX_BAR end
178 if bar_width < MIN_BAR then bar_width = MIN_BAR end
179
180 local filled = math.floor(frac * bar_width)
181 local bar = string.rep("█", filled) .. string.rep("░", bar_width - filled)
182 -- Writing the last cell of a line makes some terminals wrap immediately,
183 -- which would reintroduce the very problem this sizing prevents -- hence the
184 -- -1 above, and hence the trailing pad being counted as part of the budget
185 -- rather than tacked on afterwards.
186 io.write(string.format("\r%s [%s]%s%s ", label, bar, counts, tail))
187 io.flush()
188end
189-- }}}
190
191-- {{{ function M.finish()
192-- Close an animated line with a newline. No-op in verbose/quiet modes (they
193-- never left the cursor mid-line).
194function M.finish()
195 if resolve_mode() == MODE_BAR then
196 io.write("\n")
197 io.flush()
198 end
199end
200-- }}}
201
202return M
203-- }}}
204