From faa244e381601d89fa7f842f0c94b5d78195e128 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Sun, 26 Oct 2025 13:46:39 +0100 Subject: [PATCH 1/2] chore(example): add luarocket fullscreen example app --- experimental/luarocket/bin/luarocket.lua | 17 + .../luarocket/luarocket-scm-1.rockspec | 51 +++ experimental/luarocket/src/json-encode.lua | 55 +++ experimental/luarocket/src/luarocks.lua | 172 +++++++++ experimental/luarocket/src/main.lua | 354 ++++++++++++++++++ 5 files changed, 649 insertions(+) create mode 100644 experimental/luarocket/bin/luarocket.lua create mode 100644 experimental/luarocket/luarocket-scm-1.rockspec create mode 100644 experimental/luarocket/src/json-encode.lua create mode 100644 experimental/luarocket/src/luarocks.lua create mode 100644 experimental/luarocket/src/main.lua diff --git a/experimental/luarocket/bin/luarocket.lua b/experimental/luarocket/bin/luarocket.lua new file mode 100644 index 00000000..a6f4262c --- /dev/null +++ b/experimental/luarocket/bin/luarocket.lua @@ -0,0 +1,17 @@ +#!/usr/bin/env lua + +local terminal = require("terminal") +local copas = require("copas") + +-- Run the main application inside the initialized terminal +local main = terminal.initwrap(require("luarocket.main"), { + displaybackup = true, + filehandle = io.stdout, + sleep = copas.pause, -- required for coroutine based multithreading +}) + +-- run the Copas scheduler +copas(function() + main() + copas.exit() -- signal to other coroutines we're done +end) diff --git a/experimental/luarocket/luarocket-scm-1.rockspec b/experimental/luarocket/luarocket-scm-1.rockspec new file mode 100644 index 00000000..f79b2c58 --- /dev/null +++ b/experimental/luarocket/luarocket-scm-1.rockspec @@ -0,0 +1,51 @@ +local package_name = "luarocket" +local package_version = "scm" +local rockspec_revision = "1" +local github_account_name = "Tieske" +local github_repo_name = "luarocket" + + +package = package_name +version = package_version.."-"..rockspec_revision + +source = { + url = "git+https://github.com/"..github_account_name.."/"..github_repo_name..".git", + branch = (package_version == "scm") and "main" or nil, + tag = (package_version ~= "scm") and package_version or nil, +} + +description = { + summary = "Terminal UI for LuaRocks", + detailed = [[ + Cross platform Terminal UI for LuaRocks. + ]], + license = "MIT", + homepage = "https://github.com/"..github_account_name.."/"..github_repo_name, +} + +dependencies = { + "terminal", + "copas-async", + "lua-cjson", +} + +build = { + type = "builtin", + + install = { + bin = { + luarocket = "bin/luarocket.lua", + } + }, + + modules = { + ["luarocket.main"] = "src/main.lua", + ["luarocket.luarocks"] = "src/luarocks.lua", + ["luarocket.json-encode"] = "src/json-encode.lua", + }, + + copy_directories = { + -- can be accessed by `luarocks terminal doc` from the commandline + -- "docs", + }, +} diff --git a/experimental/luarocket/src/json-encode.lua b/experimental/luarocket/src/json-encode.lua new file mode 100644 index 00000000..929d2dfc --- /dev/null +++ b/experimental/luarocket/src/json-encode.lua @@ -0,0 +1,55 @@ +-- Code by Aapo Talvensari +-- https://github.com/bungle/lua-resty-prettycjson +local ok, cjson = pcall(require, "cjson.safe") +local enc = ok and cjson.encode or function() return nil, "Lua cJSON encoder not found" end +local cat = table.concat +local sub = string.sub +local rep = string.rep +-- @tparam table dt the data to encode +-- @tparam[opt="\n"] string lf the line feed separator +-- @tparam[opt="\t"] string id the indent characters +-- @tparam[opt=" "] string ac the array continuation characters +-- @tparam function ec the encoder function, defaults to CJSON encoder +-- @return string or nil+error +return function(dt, lf, id, ac, ec) + local s, e = (ec or enc)(dt) + if not s then return s, e end + lf, id, ac = lf or "\n", id or "\t", ac or " " + local i, j, k, n, r, p, q = 1, 0, 0, #s, {}, nil, nil + local al = sub(ac, -1) == "\n" + for x = 1, n do + local c = sub(s, x, x) + if not q and (c == "{" or c == "[") then + r[i] = p == ":" and cat{ c, lf } or cat{ rep(id, j), c, lf } + j = j + 1 + elseif not q and (c == "}" or c == "]") then + j = j - 1 + if p == "{" or p == "[" then + i = i - 1 + r[i] = cat{ rep(id, j), p, c } + else + r[i] = cat{ lf, rep(id, j), c } + end + elseif not q and c == "," then + r[i] = cat{ c, lf } + k = -1 + elseif not q and c == ":" then + r[i] = cat{ c, ac } + if al then + i = i + 1 + r[i] = rep(id, j) + end + else + if c == '"' and p ~= "\\" then + q = not q and true or nil + end + if j ~= k then + r[i] = rep(id, j) + i, k = i + 1, j + end + r[i] = c + end + p, i = c, i + 1 + end + return cat(r) +end diff --git a/experimental/luarocket/src/luarocks.lua b/experimental/luarocket/src/luarocks.lua new file mode 100644 index 00000000..5fd7563a --- /dev/null +++ b/experimental/luarocket/src/luarocks.lua @@ -0,0 +1,172 @@ +local json_encode = require("luarocket.json-encode") +local json_decode = require("cjson.safe").decode +local strwidth = require("terminal.text.width").utf8swidth +local split = require("pl.utils").split +local async = require("copas.async") +local M = {} + +local logpanel = nil +local configpanel = nil +local listpanel = nil +local lr_config = nil + + + +function M.set_logpanel(panel) + logpanel = panel +end + + +function M.set_configpanel(panel) + configpanel = panel +end + + +function M.set_listpanel(panel) + listpanel = panel +end + + +--- cache table that returns the display width of common strings. +-- This caches the string, but is more performant than looping over them everytime again +local common_string_width = setmetatable({}, { + __index = function(t, k) + local w = strwidth(k) + t[k] = w + return w + end +}) + + +--- Takes a list of lists, and padds each element to the max width of that column. +-- Returns a list of strings, each string being a row with '|' separated columns. +local function tableize(list) + local col_widths = {} + for _, row in ipairs(list) do + for col_idx, value in ipairs(row) do + local col_len = common_string_width[value] + if not col_widths[col_idx] or col_len > col_widths[col_idx] then + col_widths[col_idx] = col_len + end + end + end + + local result = {} + for _, row in ipairs(list) do + local padded_cols = {} + for col_idx, value in ipairs(row) do + padded_cols[col_idx] = value .. string.rep(" ", col_widths[col_idx] - common_string_width[value]) + end + result[#result + 1] = table.concat(padded_cols, " │ ") + end + + return result +end + + +-- run a LuaRocks command asynchronously and return the result as a table of lines. +-- Arguments will be tostringed and quoted for the command line. +local function run_luarocks_command(...) + local args = {...} + local qargs = {} + for i, arg in ipairs(args) do + qargs[i] = '"' .. tostring(arg) .. '"' + end + local cmd = "luarocks " .. table.concat(qargs, " ") + + -- redirect stderr to stdout + cmd = cmd .. " 2>&1" + + logpanel:add_line("> " .. cmd, true) + + local f, err = async.io_popen(cmd) + if not f then + logpanel:add_line("Lua error: " .. err, true) + return nil, err + end + + local result = {} + while true do + local line = f:read("*l") + if not line then + break + end + logpanel:add_line(line:gsub("\t", " "), true) + result[#result + 1] = line + end + + local s, et, ec = f:close() + if not s then + logpanel:add_line("# error: " .. tostring(et) .. " (" .. tostring(ec) .. ")", true) + return nil, et, ec + end + + return result +end + + +-- tests luarocks availability +function M.test_luarocks() + return run_luarocks_command("--version") +end + + + +--- Retrieves the config from LR. +-- Result is stored in lr_config and returned. +function M.refresh_config() + local result = run_luarocks_command("config", "--json") + if not result then + return { + error = "failed to collect LuaRocks config (check logs)" + } + end + + local config, err = json_decode(result[1]) + if not config then + return { + error = "failed to parse LuaRocks config: " .. tostring(err) + } + end + + lr_config = config + + local newline = string.char(0) + local indent = " " + local array_continuation = " " + local lines = split(json_encode(config, newline, indent, array_continuation), newline) + + configpanel:set_lines(lines) + return config +end + + + +--- returns the LR config from cache, or retrieves it if not cached yet. +function M.get_config() + if lr_config == nil then + M.refresh_config() + end + return lr_config +end + + +--- lists installed rocks +function M.list_rocks(tree) + if tree then + assert(type(tree) == "string", "tree must be a string") + tree = "--tree=" .. tostring(tree) + end + local list = run_luarocks_command("list", "--porcelain", tree) + if not list then + return {} + end + for i, line in ipairs(list) do + list[i] = split(line, "\t") + end + listpanel:set_lines(tableize(list)) +end + + +return M + diff --git a/experimental/luarocket/src/main.lua b/experimental/luarocket/src/main.lua new file mode 100644 index 00000000..f62a8605 --- /dev/null +++ b/experimental/luarocket/src/main.lua @@ -0,0 +1,354 @@ +local TextPanel = require("terminal.ui.panel.text") +local luarocks = require("luarocket.luarocks") +local PanelSet = require("terminal.ui.panel.set") +local terminal = require("terminal") +local keymap = require("terminal.input.keymap").default_key_map +local KeyBar = require("terminal.ui.panel.key_bar") +local Screen = require("terminal.ui.panel.screen") +local Panel = require("terminal.ui.panel") +local copas = require("copas") +local keys = require("terminal.input.keymap").default_keys +local Bar = require("terminal.ui.panel.bar") + +local active_panel = nil + + + +local screen = Screen { + header = Bar { + name = "header", + -- left = { + -- text = "TextPanel Example", + -- attr = { fg = "cyan", brightness = "bright" } + -- }, + center = { + text = "LuaRocket 🚀", + attr = { fg = "yellow", brightness = "bright" } + }, + -- right = { + -- text = "Press 'q' to quit", + -- attr = { fg = "green", brightness = "bright" } + -- }, + attr = { bg = "blue" }, + auto_render = true, + }, + + body = Panel { + name = "screen_body", + orientation = Panel.orientations.vertical, + split_ratio = 0.7, + children = { + -- top panel is where we have the interactive panels + Panel { + name = "content_body", + orientation = Panel.orientations.horizontal, + split_ratio = 0.5, + children = { + TextPanel { + name = "rockstree", + lines = {"contents of rockstree"}, + -- line_formatter = TextPanel.format_line_wordwrap, + scroll_step = 1, + text_attr = { fg = "cyan", brightness = "bright" }, + border = { + title = "Rockstree", + format = terminal.draw.box_fmt.single, + }, + auto_render = true, + }, + TextPanel { + name = "config", + lines = {"luarocks config"}, + border = { + title = "LR Config", + format = terminal.draw.box_fmt.single, + }, + auto_render = true, + }, + }, + }, + -- bottom panel is for luarocks log output + TextPanel { + name = "command_log", + lines = {"waiting for LuaRocks command..."}, + line_formatter = TextPanel.format_line_wrap, + scroll_step = 1, + text_attr = { fg = "cyan", brightness = "bright" }, + border = { + title = "Luarocks logs", + format = terminal.draw.box_fmt.single_top, + }, + auto_render = true, + max_lines = 300, -- keep last 300 lines of log output + }, + }, + }, + + footer = PanelSet { + name = "keybar_set", + children = { + KeyBar { + name = "keybar1", + rows = 1, + margin = 1, + padding = 2, + separator = ":", + items = { + { key = "TAB", desc = "Next Panel" }, + { key = "Shift+TAB", desc = "Prev Panel" }, + { key = "c", desc = "Toggle Config" }, + { key = "l", desc = "Toggle Log" }, + { key = "q", desc = "Quit" }, + }, + key_attr = { fg = "yellow", bg = "black" }, + desc_attr = { brightness = "bright" }, + attr = { bg = "blue", fg = "white" }, + }, + }, + } +} + + +-- Table providing key-handler function on a per-panel basis. +-- looking up the current selected panel, to call the corresponding handler function. +-- Each handler takes the key+keytype from input.readansi() results as arguments. +-- They should return truthy if the key needs further handling, falsey otherwise. +local keyhandlers = setmetatable({ + + [screen.panels.rockstree] = function(rawkey, keyname, ktype) + local panel = screen.panels.rockstree + + if keyname == keys.up then + panel:set_highlight((panel:get_highlight() or 0) - 1, true) + + elseif keyname == keys.down then + panel:set_highlight((panel:get_highlight() or 0) + 1, true) + + elseif keyname == keys.pageup then + panel:page_up() + + elseif keyname == keys.pagedown then + panel:page_down() + + else + return true -- report unhandled key + end + end, + + + + [screen.panels.command_log] = function(rawkey, keyname, ktype) + local panel = screen.panels.command_log + + if keyname == keys.up then + panel:scroll_up() + + elseif keyname == keys.down then + panel:scroll_down() + + elseif keyname == keys.pageup then + panel:page_up() + + elseif keyname == keys.pagedown then + panel:page_down() + + else + return true -- report unhandled key + end + end, + + + + [screen.panels.config] = function(rawkey, keyname, ktype) + local panel = screen.panels.config + + if keyname == keys.up then + panel:scroll_up() + + elseif keyname == keys.down then + panel:scroll_down() + + elseif keyname == keys.pageup then + panel:page_up() + + elseif keyname == keys.pagedown then + panel:page_down() + + else + return true -- report unhandled key + end + end, + + + +}, { + __index = function(self, key) + error("No key-handler found for panel: " .. tostring(key), 2) + end +}) + + + +local tab_select do + + -- The TAB order to switch between panels + local tab_order = { + [screen.panels.rockstree] = 1, + [screen.panels.config] = 2, + [screen.panels.command_log] = 3, + } + local tab_count = 0 + for _, _ in pairs(tab_order) do + tab_count = tab_count + 1 + end + + -- select another tab in the tab order. + -- `delta` can be 0 to reconfirm the current panel. + -- @param delta the number of tabs to switch, positive to switch forward, negative to switch backward + function tab_select(delta) + local target = active_panel and (tab_order[active_panel] + delta) or 1 + while target < 1 do + target = tab_count + target + end + while target > tab_count do + target = target - tab_count + end + + local new_active_panel + for panel, order in pairs(tab_order) do + if order == target then + new_active_panel = panel + break + end + end + + if not new_active_panel:visible() then + -- selected panel is not visible, so move one more in the same direction, + -- and recurse to try again. + + -- if delta is too high/low, we're in a loop, so bail out. + if delta > tab_count or delta < -tab_count then + error("there are no more visible panels to select", 2) + end + + if delta >= 0 then -- MUST cater for the 0 case ! + return tab_select(delta + 1) + else + return tab_select(delta - 1) + end + end + + if new_active_panel == active_panel then + return -- no change, so don't do anything + end + + -- unreverse the current tab title. + if active_panel then + local attr = active_panel.border.title_attr + if not attr then + attr = { reverse = false } + active_panel.border.title_attr = attr + else + attr.reverse = false + end + active_panel:draw_border() + end + + -- reverse the newly selected tab title. + local attr = new_active_panel.border.title_attr + if not attr then + attr = { reverse = true } + new_active_panel.border.title_attr = attr + else + attr.reverse = true + end + new_active_panel:draw_border() + + active_panel = new_active_panel + end +end + + + +local core_keyhandler do + + -- toggle the visibility of the log panel + local function toggle_log_panel() + local log_panel = screen.panels.command_log + log_panel:hide(log_panel:visible()) + tab_select(0) -- reselect current panel to ensure valid selection + screen:calculate_layout() + screen:render() + end + + + -- toggle the visibility of the config panel + local function toggle_config_panel() + local config_panel = screen.panels.config + config_panel:hide(config_panel:visible()) + tab_select(0) -- reselect current panel to ensure valid selection + screen:calculate_layout() + screen:render() + end + + + + -- will be called if the key received wasn't handled by the panel specific keyhandler. + core_keyhandler = function(rawkey, keyname, ktype) + if rawkey == "l" then + toggle_log_panel() + + elseif rawkey == "c" then + toggle_config_panel() + + elseif rawkey == "q" then + copas.exit() -- exit the application + + elseif keyname == keys.tab then + tab_select(1) -- select next panel + + elseif keyname == keys.shift_tab then + tab_select(-1) -- select previous panel + + else + return true + end + end +end + + + +local function main() + + luarocks.set_logpanel(screen.panels.command_log) + luarocks.set_configpanel(screen.panels.config) + luarocks.set_listpanel(screen.panels.rockstree) + + screen:calculate_layout() + screen:render() + terminal.cursor.visible.stack.push(false) + + tab_select(0) -- select the first panel by default + luarocks.test_luarocks() + luarocks.get_config() + luarocks.list_rocks() + + + while not copas.exiting() do + local rawkey, ktype = terminal.input.readansi(0.1) + local keyname = keymap[rawkey] + + -- first handle key by the panel-specific key-handler + if keyhandlers[active_panel](rawkey, keyname, ktype) then + -- Key remained unhandled, call generic key-handler +-- if key then print("key:", rawkey, "ktype:", ktype) end + core_keyhandler(rawkey, keyname, ktype) + end + + screen:check_resize(true) + end +end + + + +return main From ac4f1a194f39884fbf0920167318f9e61ebc608d Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Mon, 23 Feb 2026 08:57:12 +0100 Subject: [PATCH 2/2] chore(examples): rearrange experimental folder --- experimental/README.md | 8 ++++ experimental/{ => luarox/bin}/luarox.lua | 0 experimental/luarox/luarox-scm-1.rockspec | 49 +++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 experimental/README.md rename experimental/{ => luarox/bin}/luarox.lua (100%) create mode 100644 experimental/luarox/luarox-scm-1.rockspec diff --git a/experimental/README.md b/experimental/README.md new file mode 100644 index 00000000..039fe560 --- /dev/null +++ b/experimental/README.md @@ -0,0 +1,8 @@ +# Experiments + +This folder contains experiments of using `terminal.lua` to power the real-world LuaRocks CLI application. + +- `luarox` a interactive CLI wrapper for LuaRocks +- `luarocket` a full screen application wrapper for LuaRocks + +Both applications are just visual experiments for testing `terminal.lua`, they are not functional. diff --git a/experimental/luarox.lua b/experimental/luarox/bin/luarox.lua similarity index 100% rename from experimental/luarox.lua rename to experimental/luarox/bin/luarox.lua diff --git a/experimental/luarox/luarox-scm-1.rockspec b/experimental/luarox/luarox-scm-1.rockspec new file mode 100644 index 00000000..7005014a --- /dev/null +++ b/experimental/luarox/luarox-scm-1.rockspec @@ -0,0 +1,49 @@ +local package_name = "luarox" +local package_version = "scm" +local rockspec_revision = "1" +local github_account_name = "Tieske" +local github_repo_name = "luarox" + + +package = package_name +version = package_version.."-"..rockspec_revision + +source = { + url = "git+https://github.com/"..github_account_name.."/"..github_repo_name..".git", + branch = (package_version == "scm") and "main" or nil, + tag = (package_version ~= "scm") and package_version or nil, +} + +description = { + summary = "Terminal UI for LuaRocks", + detailed = [[ + Cross platform Terminal UI for LuaRocks. + ]], + license = "MIT", + homepage = "https://github.com/"..github_account_name.."/"..github_repo_name, +} + +dependencies = { + "terminal", +} + +build = { + type = "builtin", + + install = { + bin = { + luarox = "bin/luarox.lua", + } + }, + + modules = { + -- ["luarocket.main"] = "src/main.lua", + -- ["luarocket.luarocks"] = "src/luarocks.lua", + -- ["luarocket.json-encode"] = "src/json-encode.lua", + }, + + copy_directories = { + -- can be accessed by `luarocks terminal doc` from the commandline + -- "docs", + }, +}