Last active
July 26, 2025 16:28
-
-
Save pmarreck/80ad97decadf42bfda26707b92d34c24 to your computer and use it in GitHub Desktop.
Visual Terminal Timer, a timer written in LuaJIT that slowly fills your terminal with blocks (and responds to terminal resize events)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
local ffi = require("ffi") | |
local bit = require("bit") | |
ffi.cdef([[ struct winsize { | |
unsigned short ws_row; | |
unsigned short ws_col; | |
unsigned short ws_xpixel; | |
unsigned short ws_ypixel; | |
}; | |
int ioctl(int fd, unsigned long request, ...); | |
typedef void (*sighandler_t)(int); | |
int signal(int signum, sighandler_t handler); | |
int usleep(unsigned int usec); | |
unsigned int sleep(unsigned int seconds); | |
]]) | |
local TIOCGWINSZ = 0x5413 | |
local TIOCGWINSZ_BSD = 0x40087468 | |
local SIGWINCH = 28 | |
local SIGINT = 2 | |
local STDOUT_FILENO = 1 | |
local STDERR_FILENO = 2 | |
local terminal_resized = false | |
local interrupted = false | |
local Timer | |
do | |
local _class_0 | |
local _base_0 = { | |
start = function(self) | |
self.is_running = true | |
self.start_time = os.time() | |
end, | |
stop = function(self) | |
self.is_running = false | |
end, | |
advance_to = function(self, elapsed_seconds) | |
self.elapsed_seconds = math.min(elapsed_seconds, self.total_seconds) | |
self.remaining_seconds = math.max(0, self.total_seconds - self.elapsed_seconds) | |
self.start_time = os.time() - self.elapsed_seconds | |
end, | |
update = function(self) | |
if self.is_running then | |
local current_time = os.time() | |
self.elapsed_seconds = current_time - self.start_time | |
self.remaining_seconds = math.max(0, self.total_seconds - self.elapsed_seconds) | |
if self.remaining_seconds == 0 then | |
self.is_running = false | |
return true | |
end | |
end | |
return false | |
end, | |
get_progress = function(self) | |
if self.total_seconds == 0 then | |
return 1.0 | |
end | |
return self.elapsed_seconds / self.total_seconds | |
end, | |
is_finished = function(self) | |
return self.remaining_seconds <= 0 | |
end | |
} | |
if _base_0.__index == nil then | |
_base_0.__index = _base_0 | |
end | |
_class_0 = setmetatable({ | |
__init = function(self, seconds) | |
self.total_seconds = seconds | |
self.remaining_seconds = seconds | |
self.elapsed_seconds = 0 | |
self.start_time = os.time() | |
self.is_running = false | |
end, | |
__base = _base_0, | |
__name = "Timer" | |
}, { | |
__index = _base_0, | |
__call = function(cls, ...) | |
local _self_0 = setmetatable({ }, _base_0) | |
cls.__init(_self_0, ...) | |
return _self_0 | |
end | |
}) | |
_base_0.__class = _class_0 | |
Timer = _class_0 | |
end | |
local Terminal | |
do | |
local _class_0 | |
local _base_0 = { | |
try_ioctl_detection = function(self) | |
local winsize = ffi.new("struct winsize") | |
local ioctls = { | |
TIOCGWINSZ, | |
TIOCGWINSZ_BSD | |
} | |
local fds = { | |
STDOUT_FILENO, | |
STDERR_FILENO, | |
0 | |
} | |
for _index_0 = 1, #ioctls do | |
local ioctl_val = ioctls[_index_0] | |
for _index_1 = 1, #fds do | |
local fd = fds[_index_1] | |
local result = ffi.C.ioctl(fd, ioctl_val, winsize) | |
if result == 0 and winsize.ws_row > 0 and winsize.ws_col > 0 then | |
return winsize.ws_row, winsize.ws_col, "ioctl(" .. tostring(fd) .. ", " .. tostring(string.format('0x%x', ioctl_val)) .. ")" | |
end | |
end | |
end | |
return nil, nil, "ioctl failed" | |
end, | |
try_tput_detection = function(self) | |
local success, result = pcall(function() | |
local rows_cmd = io.popen("tput lines 2>/dev/null") | |
if rows_cmd then | |
local rows_str = rows_cmd:read("*l") | |
rows_cmd:close() | |
local cols_cmd = io.popen("tput cols 2>/dev/null") | |
if cols_cmd then | |
local cols_str = cols_cmd:read("*l") | |
cols_cmd:close() | |
local rows = tonumber(rows_str) | |
local cols = tonumber(cols_str) | |
if rows and cols and rows > 0 and cols > 0 then | |
return rows, cols, "tput command" | |
end | |
end | |
end | |
end) | |
if success and result then | |
return result | |
end | |
return nil, nil, "tput command failed" | |
end, | |
get_dimensions = function(self) | |
if self.override_rows and self.override_cols then | |
return self.override_rows, self.override_cols | |
end | |
local rows, cols, method = self:try_ioctl_detection() | |
if rows and cols then | |
self.last_rows = rows | |
self.last_cols = cols | |
self.detection_method = method | |
return rows, cols | |
end | |
rows, cols, method = self:try_tput_detection() | |
if rows and cols then | |
self.last_rows = rows | |
self.last_cols = cols | |
self.detection_method = method | |
return rows, cols | |
end | |
rows = tonumber(os.getenv("LINES")) | |
cols = tonumber(os.getenv("COLUMNS")) | |
if rows and cols and rows > 0 and cols > 0 then | |
self.last_rows = rows | |
self.last_cols = cols | |
self.detection_method = "environment variables" | |
return rows, cols | |
end | |
rows = self.last_rows or 24 | |
cols = self.last_cols or 80 | |
if rows <= 0 then | |
rows = 24 | |
end | |
if cols <= 0 then | |
cols = 80 | |
end | |
self.last_rows = rows | |
self.last_cols = cols | |
self.detection_method = "defaults" | |
return rows, cols | |
end, | |
update_dimensions = function(self) | |
self.rows, self.cols = self:get_dimensions() | |
end, | |
clear_screen = function(self) | |
return io.write("\027[2J\027[H") | |
end, | |
move_cursor = function(self, row, col) | |
return io.write(string.format("\027[%d;%dH", row, col)) | |
end, | |
hide_cursor = function(self) | |
return io.write("\027[?25l") | |
end, | |
show_cursor = function(self) | |
return io.write("\027[?25h") | |
end | |
} | |
if _base_0.__index == nil then | |
_base_0.__index = _base_0 | |
end | |
_class_0 = setmetatable({ | |
__init = function(self, override_rows, override_cols) | |
if override_rows == nil then | |
override_rows = nil | |
end | |
if override_cols == nil then | |
override_cols = nil | |
end | |
self.last_rows = 0 | |
self.last_cols = 0 | |
self.override_rows = override_rows | |
self.override_cols = override_cols | |
return self:update_dimensions() | |
end, | |
__base = _base_0, | |
__name = "Terminal" | |
}, { | |
__index = _base_0, | |
__call = function(cls, ...) | |
local _self_0 = setmetatable({ }, _base_0) | |
cls.__init(_self_0, ...) | |
return _self_0 | |
end | |
}) | |
_base_0.__class = _class_0 | |
Terminal = _class_0 | |
end | |
local VisualDisplay | |
do | |
local _class_0 | |
local _base_0 = { | |
update_dimensions = function(self) | |
self.terminal:update_dimensions() | |
self.display_rows = self.terminal.rows - 2 | |
self.rows = self.display_rows | |
self.cols = self.terminal.cols | |
self.total_blocks = self.display_rows * self.cols | |
end, | |
blocks_for_progress = function(self, progress) | |
return math.floor(self.total_blocks * math.min(1.0, progress)) | |
end, | |
render = function(self, progress, remaining_time, total_time) | |
local blocks_filled = self:blocks_for_progress(progress) | |
self.terminal:clear_screen() | |
self.terminal:hide_cursor() | |
local block_count = 0 | |
for row = 1, self.display_rows do | |
self.terminal:move_cursor(row, 1) | |
for col = 1, self.cols do | |
block_count = block_count + 1 | |
if block_count <= blocks_filled then | |
io.write(self.fill_char) | |
else | |
io.write(self.empty_char) | |
end | |
end | |
end | |
self.terminal:move_cursor(self.display_rows + 1, 1) | |
local progress_percent = math.floor(progress * 100) | |
local time_str = self:format_time(remaining_time) | |
local total_str = self:format_time(total_time) | |
local info_line = string.format("Progress: %d%% | Time: %s / %s | Press Ctrl+C to exit", progress_percent, time_str, total_str) | |
io.write(info_line) | |
return io.flush() | |
end, | |
format_time = function(self, seconds) | |
local hours = math.floor(seconds / 3600) | |
local minutes = math.floor((seconds % 3600) / 60) | |
local secs = seconds % 60 | |
if hours > 0 then | |
return string.format("%d:%02d:%02d", hours, minutes, secs) | |
else | |
return string.format("%d:%02d", minutes, secs) | |
end | |
end | |
} | |
if _base_0.__index == nil then | |
_base_0.__index = _base_0 | |
end | |
_class_0 = setmetatable({ | |
__init = function(self, terminal) | |
self.terminal = terminal | |
self.fill_char = "█" | |
self.empty_char = "░" | |
return self:update_dimensions() | |
end, | |
__base = _base_0, | |
__name = "VisualDisplay" | |
}, { | |
__index = _base_0, | |
__call = function(cls, ...) | |
local _self_0 = setmetatable({ }, _base_0) | |
cls.__init(_self_0, ...) | |
return _self_0 | |
end | |
}) | |
_base_0.__class = _class_0 | |
VisualDisplay = _class_0 | |
end | |
local SignalHandler | |
do | |
local _class_0 | |
local _base_0 = { | |
setup_signal_handlers = function(self) | |
local winch_handler = ffi.cast("sighandler_t", function(signum) | |
terminal_resized = true | |
end) | |
local int_handler = ffi.cast("sighandler_t", function(signum) | |
interrupted = true | |
end) | |
local winch_result = ffi.C.signal(SIGWINCH, winch_handler) | |
local int_result = ffi.C.signal(SIGINT, int_handler) | |
return (winch_result ~= ffi.cast("sighandler_t", -1)) and (int_result ~= ffi.cast("sighandler_t", -1)) | |
end | |
} | |
if _base_0.__index == nil then | |
_base_0.__index = _base_0 | |
end | |
_class_0 = setmetatable({ | |
__init = function(self) | |
return self:setup_signal_handlers() | |
end, | |
__base = _base_0, | |
__name = "SignalHandler" | |
}, { | |
__index = _base_0, | |
__call = function(cls, ...) | |
local _self_0 = setmetatable({ }, _base_0) | |
cls.__init(_self_0, ...) | |
return _self_0 | |
end | |
}) | |
_base_0.__class = _class_0 | |
SignalHandler = _class_0 | |
end | |
local parse_time_preset | |
parse_time_preset = function(preset) | |
preset = preset:lower():gsub("%s+", "") | |
if preset:match("^%d+s$") then | |
local seconds = tonumber(preset:match("^(%d+)s$")) | |
return seconds | |
elseif preset:match("^%d+m$") then | |
local minutes = tonumber(preset:match("^(%d+)m$")) | |
return minutes * 60 | |
elseif preset:match("^%d+h$") then | |
local hours = tonumber(preset:match("^(%d+)h$")) | |
return hours * 3600 | |
elseif preset:match("^%d+$") then | |
return tonumber(preset) * 60 | |
else | |
return error("Invalid time format: " .. tostring(preset) .. ". Use formats like '30s', '5m', '1h', etc.") | |
end | |
end | |
local VisualTimer | |
do | |
local _class_0 | |
local _base_0 = { | |
play_completion_beeps = function(self) | |
if not self.options.silence then | |
for i = 1, 3 do | |
os.execute("tput bel 2>/dev/null") | |
if i < 3 then | |
ffi.C.usleep(1000000) | |
end | |
end | |
end | |
end, | |
setup_signal_handling = function(self) end, | |
run = function(self, time_preset) | |
local seconds = parse_time_preset(time_preset) | |
self.timer = Timer(seconds) | |
if not (self.options.single_frame or self.options.advance_time) then | |
print("Visual Timer starting for " .. tostring(self.display:format_time(seconds))) | |
print("Terminal: " .. tostring(self.terminal.rows) .. "x" .. tostring(self.terminal.cols) .. " (" .. tostring(self.display.display_rows) .. "x" .. tostring(self.display.cols) .. " display area)") | |
print("Detection method: " .. tostring(self.terminal.detection_method or 'unknown')) | |
print("Total blocks: " .. tostring(self.display.total_blocks)) | |
print("Starting timer... (Press Ctrl+C to exit)") | |
end | |
self.timer:start() | |
if self.options.advance_time then | |
self.timer:advance_to(self.options.advance_time) | |
end | |
if not self.options.single_frame then | |
for i = 1, self.terminal.rows do | |
print("") | |
end | |
end | |
if self.options.single_frame then | |
local progress = self.timer:get_progress() | |
self.display:render(progress, self.timer.remaining_seconds, self.timer.total_seconds) | |
self.terminal:show_cursor() | |
return | |
end | |
while not self.timer:is_finished() do | |
if interrupted then | |
self.terminal:show_cursor() | |
self.terminal:move_cursor(self.terminal.rows, 1) | |
print("\n\nTimer interrupted. Goodbye!") | |
return | |
end | |
local completed = self.timer:update() | |
if terminal_resized then | |
terminal_resized = false | |
self.display:update_dimensions() | |
end | |
local progress = self.timer:get_progress() | |
self.display:render(progress, self.timer.remaining_seconds, self.timer.total_seconds) | |
if completed then | |
break | |
end | |
ffi.C.usleep(100000) | |
end | |
self.display:render(1.0, 0, self.timer.total_seconds) | |
self:play_completion_beeps() | |
self.terminal:move_cursor(self.terminal.rows, 1) | |
print("\n\n🎉 Timer completed! Press Enter to exit.") | |
self.terminal:show_cursor() | |
return io.read() | |
end | |
} | |
if _base_0.__index == nil then | |
_base_0.__index = _base_0 | |
end | |
_class_0 = setmetatable({ | |
__init = function(self, options) | |
if options == nil then | |
options = { } | |
end | |
self.options = options or { } | |
self.terminal = Terminal(self.options.rows, self.options.cols) | |
self.display = VisualDisplay(self.terminal) | |
self.signal_handler = SignalHandler() | |
self.timer = nil | |
return self:setup_signal_handling() | |
end, | |
__base = _base_0, | |
__name = "VisualTimer" | |
}, { | |
__index = _base_0, | |
__call = function(cls, ...) | |
local _self_0 = setmetatable({ }, _base_0) | |
cls.__init(_self_0, ...) | |
return _self_0 | |
end | |
}) | |
_base_0.__class = _class_0 | |
VisualTimer = _class_0 | |
end | |
local parse_args | |
parse_args = function(args) | |
local options = { } | |
local time_preset = nil | |
local i = 1 | |
while i <= #args do | |
local arg_val = args[i] | |
if arg_val == "--dimensions" or arg_val == "-d" then | |
if i + 1 <= #args then | |
local dims = args[i + 1]:match("^(%d+)x(%d+)$") | |
if dims then | |
local rows, cols = args[i + 1]:match("^(%d+)x(%d+)$") | |
options.rows = tonumber(rows) | |
options.cols = tonumber(cols) | |
i = i + 2 | |
else | |
error("Invalid dimensions format. Use: 24x80") | |
end | |
else | |
error("--dimensions requires a value (e.g., 24x80)") | |
end | |
elseif arg_val == "--advance-time" or arg_val == "-a" then | |
if i + 1 <= #args then | |
options.advance_time = tonumber(args[i + 1]) | |
i = i + 2 | |
else | |
error("--advance-time requires a value (seconds)") | |
end | |
elseif arg_val == "--single-frame" then | |
options.single_frame = true | |
i = i + 1 | |
elseif arg_val == "--silence" or arg_val == "-s" then | |
options.silence = true | |
i = i + 1 | |
elseif arg_val == "--help" or arg_val == "-h" then | |
print("Visual Terminal Timer") | |
print("") | |
print("A visual countdown timer that fills your terminal with blocks as time progresses.") | |
print("Perfect for pomodoro sessions, cooking timers, or any timed activity.") | |
print("The display automatically adapts to your terminal size and redraws properly") | |
print("when the window is resized. Plays 3 beeps when the timer completes.") | |
print("") | |
print("Usage: vtt <time> [options]") | |
print("") | |
print("Time formats:") | |
print(" 30s # 30 seconds") | |
print(" 5m # 5 minutes") | |
print(" 55m # 55 minutes") | |
print(" 1h # 1 hour") | |
print(" 2h # 2 hours") | |
print("") | |
print("Options:") | |
print(" --silence, -s Disable completion beeps") | |
print("") | |
print("Testing options:") | |
print(" --dimensions, -d ROWSxCOLS Override terminal dimensions (e.g., 24x80)") | |
print(" --advance-time, -a SECONDS Advance timer to specific time position") | |
print(" --single-frame Output single frame and exit (no loop)") | |
print(" --help, -h Show this help message") | |
print("") | |
print("Examples:") | |
print(" vtt 5m # 5-minute timer with beeps") | |
print(" vtt 30m --silence # 30-minute silent timer") | |
print(" vtt 25m # Pomodoro timer") | |
print("") | |
print("The timer shows:") | |
print(" • Filled blocks (█) for elapsed time") | |
print(" • Empty blocks (░) for remaining time") | |
print(" • Progress percentage and remaining time") | |
print(" • Terminal dimensions detected automatically") | |
os.exit(0) | |
elseif not time_preset then | |
if arg_val:match("^%-") then | |
error("Unknown option: " .. tostring(arg_val) .. ". Use --help for available options.") | |
else | |
time_preset = arg_val | |
i = i + 1 | |
end | |
else | |
error("Unknown argument: " .. tostring(arg_val)) | |
end | |
end | |
return time_preset, options | |
end | |
local main | |
main = function() | |
if #arg == 0 then | |
print("Visual Terminal Timer") | |
print("Usage: vtt <time> [options]") | |
print("Use --help for more information") | |
os.exit(2) | |
end | |
local success, err = pcall(function() | |
local time_preset, options = parse_args(arg) | |
if not time_preset then | |
error("Time preset is required") | |
end | |
local app = VisualTimer(options) | |
return app:run(time_preset) | |
end) | |
if not success then | |
print("Error: " .. tostring(err)) | |
if err:match("Unknown option:") or err:match("Invalid time format:") or err:match("Time preset is required") or err:match("requires a value") then | |
return os.exit(2) | |
else | |
return os.exit(1) | |
end | |
end | |
end | |
if arg and arg[0] and arg[0]:match("vtt") then | |
return main() | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
If the syntax seems a little off, it's because it was generated to Lua via YueScript. I should have a separate repo up for that soon.