diff --git a/README.md b/README.md index 390aad4..484d613 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![LuaRocks](https://img.shields.io/luarocks/v/tkolleh/roda)](https://luarocks.org/modules/tkolleh/roda) [![License: EUPL 1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://opensource.org/licenses/EUPL-1.2) -**Roda** (Portuguese for "wheel") is a pure Lua terminal spinner library +**Roda** (Portuguese for "wheel") is a pure Lua terminal spinner library. ## Features @@ -17,8 +17,9 @@ - **Terminal states** - succeed, fail, warn, info with symbols - **Dynamic text updates** - change text while spinning - **Highly configurable** - intervals, colors, prefixes, suffixes -- **Minimal dependencies** - only requires `luasystem` +- **Minimal dependencies** - only requires `luasystem` and `luv` - **Lua 5.1+ compatible** - works with Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT +- **Asynchronous** - non-blocking execution using `luv` ## Installation @@ -34,14 +35,6 @@ lx add roda luarocks install roda ``` -### Manual Installation - -Clone the repository and add to your `package.path`: - -```lua -package.path = "/path/to/roda.lua/lua/?.lua;/path/to/roda.lua/lua/?/init.lua;" .. package.path -``` - ## Quick Start ```lua @@ -63,6 +56,26 @@ spinner:start() spinner:succeed() ``` +## Async Command Execution + +Roda supports running child processes asynchronously without blocking the Lua runtime. + +```lua +local roda = require("roda") + +local spinner = roda("Installing dependencies...") + +-- Execute a command asynchronously +spinner:execute("npm", {"install"})(function(exit_code, output) + if exit_code == 0 then + print("\nOutput:\n" .. output) + end +end) + +-- Run the event loop to wait for async tasks +roda.run() +``` + ## API Reference ### `roda(opts)` / `roda.new(opts)` @@ -87,229 +100,22 @@ Create a new spinner instance. ### Instance Methods -#### `:start(text?)` - -Start the spinner. Optionally set new text. - -```lua -spinner:start() -spinner:start("New loading text") -``` - -#### `:stop()` - -Stop and clear the spinner from the terminal. - -#### `:succeed(text?)` - -Stop with green checkmark. - -```lua -spinner:succeed("Completed!") -``` - -#### `:fail(text?)` - -Stop with red X. - -```lua -spinner:fail("Failed to connect") -``` - -#### `:warn(text?)` - -Stop with yellow warning. - -```lua -spinner:warn("Deprecated API used") -``` - -#### `:info(text?)` - -Stop with blue info. - -```lua -spinner:info("Using cached data") -``` - -#### `:spin()` - -Render next frame. Call this in a loop for manual animation control. - -```lua -while working do - spinner:spin() - system.sleep(0.08) -end -``` - -#### `:setText(text)` - -Update spinner text while spinning. - -```lua -spinner:setText("Processing item 5/10") -``` - -#### `:setColor(color)` - -Change spinner color. - -```lua -spinner:setColor("yellow") -``` - -#### `:isSpinning()` - -Check if spinner is currently active. - -```lua -if spinner:isSpinning() then - -- still working -end -``` - -#### `:stopAndPersist(opts)` - -Stop with custom symbol and text. - -```lua -spinner:stopAndPersist({ - symbol = "->", - text = "Skipped", -}) -``` - -### Available Spinners - -| Name | Interval | -| ------ | ---------- | -| `dots` | 80ms | -| `dots2` | 80ms | -| `dots3` | 80ms | -| `line` | 130ms | -| `line2` | 100ms | -| `pipe` | 100ms | -| `simpleDots` | 400ms | -| `star` | 70ms | -| `arc` | 100ms | -| `circle` | 120ms | -| `bounce` | 120ms | -| `bouncingBar` | 80ms | -| `arrow` | 100ms | -| `growVertical` | 120ms | -| `growHorizontal` | 120ms | -| `aesthetic` | 80ms | - - -### Available Colors - -`black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray` - -Set to `false` to disable coloring. - -## Advanced Usage - -### Async Command Execution - -```lua -local roda = require("roda") -local system = require("system") - -local function exec_with_spinner(cmd, text) - local spinner = roda(text):start() - - local tmpfile = os.tmpname() - local exitfile = os.tmpname() - os.execute(string.format("(%s) > %s 2>&1; echo $? > %s &", cmd, tmpfile, exitfile)) - - local exit_code = nil - while exit_code == nil do - spinner:spin() - local ef = io.open(exitfile, "r") - if ef then - local content = ef:read("*a") - ef:close() - if content:match("%d+") then - exit_code = tonumber(content:match("%d+")) - end - end - system.sleep(0.08) - end - - local f = io.open(tmpfile, "r") - local output = f and f:read("*a") or "" - if f then f:close() end - os.remove(tmpfile) - os.remove(exitfile) - - if exit_code == 0 then - spinner:succeed(text) - else - spinner:fail(text) - end - - return output, exit_code == 0 -end - --- Usage -local output, success = exec_with_spinner("npm install", "Installing dependencies") -``` - -### Custom Spinners - -```lua -local spinner = roda({ - text = "Moon phases", - spinner = { - interval = 100, - frames = { "moon1", "moon2", "moon3", "moon4", "moon5", "moon6", "moon7", "moon8" }, - }, -}) -spinner:start() -``` - -### Promise-style Wrapping - -```lua -local result = roda.promise({ - text = "Fetching data...", - successText = "Data fetched!", - failText = "Failed to fetch data", - fn = function() - -- your work here - return fetch_data() - end, -}) -``` - -### Progress Updates - -```lua -local spinner = roda("Processing..."):start() -for i = 1, total do - spinner:setText(string.format("Processing [%d/%d]", i, total)) - spinner:spin() - process_item(i) - system.sleep(0.02) -end -spinner:succeed(string.format("Processed %d items", total)) -``` - -## Compatibility - -- Lua 5.1, 5.2, 5.3, 5.4 -- LuaJIT 2.0, 2.1 -- Requires a terminal that supports ANSI escape codes - -## Related Projects - -- [sindresorhus/ora](https://github.com/sindresorhus/ora) - An inspirational Node.js implementation -- [cli-spinners](https://github.com/sindresorhus/cli-spinners) - Spinner frame definitions - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines, including how to debug with Neovim DAP. +- `:start(text?)` - Start the spinner. Optionally set new text. +- `:stop()` - Stop and clear the spinner from the terminal. +- `:succeed(text?)` - Stop with green checkmark. +- `:fail(text?)` - Stop with red X. +- `:warn(text?)` - Stop with yellow warning. +- `:info(text?)` - Stop with blue info. +- `:spin()` - Render next frame. Call this in a loop for manual animation control. +- `:setText(text)` - Update spinner text while spinning. +- `:setColor(color)` - Change spinner color. +- `:isSpinning()` - Check if spinner is currently active. +- `:stopAndPersist(opts)` - Stop with custom symbol and text. +- `:execute(command, args)` - Execute a child process asynchronously while spinning. Returns a Thunk that expects an `on_complete` callback. + +### Module Methods + +- `roda.run()` - Run the libuv event loop. Blocks until all async tasks finish. ## License diff --git a/demo/demo.lua b/demo/demo.lua old mode 100644 new mode 100755 index c626260..565bbe4 --- a/demo/demo.lua +++ b/demo/demo.lua @@ -1,4 +1,4 @@ -#!/usr/bin/env lua +#!/usr/bin/env -S lx lua --- Demo script for Roda terminal spinner library --- Run this script to see all features in action diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..446408c --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,14 @@ +pre-commit: + commands: + format: + glob: "*.lua" + run: lx fmt {staged_files} + stage_fixed: true + lint: + glob: "*.lua" + run: lx lint {staged_files} + +pre-push: + commands: + test: + run: lx test diff --git a/lua/roda/ansi.lua b/lua/roda/ansi.lua index 23aa529..9ba5b7a 100644 --- a/lua/roda/ansi.lua +++ b/lua/roda/ansi.lua @@ -1,4 +1,4 @@ ---- roda/ansi.lua - ANSI escape code utilities +--- roda/ansi.lua - ANSI escape code --- @module roda.ansi --- @author TJ Kolleh --- @license EUPL-1.2 diff --git a/lua/roda/init.lua b/lua/roda/init.lua index bb5e2f6..dedd750 100644 --- a/lua/roda/init.lua +++ b/lua/roda/init.lua @@ -1,15 +1,53 @@ --- roda.lua - Elegant terminal spinner for Lua ---- Roda (Portuguese for "wheel") - inspired by sindresorhus/ora +--- Roda (Portuguese for "wheel") --- --- @module roda --- @author TJ Kolleh --- @license EUPL-1.2 -local system = require("system") +local uv = require("luv") +local fp = require("roda.util") local ansi = require("roda.ansi") local spinners = require("roda.spinners") local symbols = require("roda.symbols") +--- Bracket for safely acquiring and releasing a libuv timer +--- @type function +local with_safe_timer = fp.bracket(function() + return uv.new_timer() +end, function(timer) + if timer and not timer:is_closing() then + timer:stop() + timer:close() + end +end) + +--- Bracket for safely acquiring and releasing child process pipes and handles +--- @param command string The command to execute +--- @param args table|nil Arguments for the command +--- @return function A bracket function for the process +local make_process_bracket = function(command, args) + return fp.bracket(function() -- acquire command pipes and handles + return { + stdout = uv.new_pipe(false), + stderr = uv.new_pipe(false), + command = command, + args = args or {}, + handle = nil, + } + end, function(rslt) -- release pipes and handles + if rslt.stdout and not rslt.stdout:is_closing() then + rslt.stdout:close() + end + if rslt.stderr and not rslt.stderr:is_closing() then + rslt.stderr:close() + end + if rslt.handle and not rslt.handle:is_closing() then + rslt.handle:close() + end + end) +end + local M = {} -- Re-export submodules for advanced usage @@ -36,7 +74,6 @@ M.default_spinner = spinners.default ---@field private _indent number ---@field private _is_spinning boolean ---@field private _frame_index number ----@field private _last_frame_time number local Spinner = {} Spinner.__index = Spinner @@ -69,11 +106,54 @@ function M.new(opts) self._indent = opts.indent or 0 self._is_spinning = false self._frame_index = 1 - self._last_frame_time = 0 return self end +--- Execute process asynchronously while spinning +---@param command string +---@param args table|nil +---@return function Thunk that expects an on_complete callback +function Spinner:execute(command, args) + local with_cmd = make_process_bracket(command, args) + local run_cmd = function(rslt, done_process) + local output = {} + + -- read the stdout stream (async) + uv.read_start(rslt.stdout, function(_, data) + if data then + table.insert(output, data) + end + end) + + -- evaluate the non-blocking process + rslt.handle = uv.spawn(rslt.command, { + args = rslt.args, + stdio = { nil, rslt.stdout, rslt.stderr }, + }, function(exit_code) + if exit_code == 0 then + self:succeed() + else + self:fail() + end + done_process(exit_code, table.concat(output)) + end) + end + + return function(on_complete) + with_safe_timer( + -- Use the timer with process + function(timer, done_timer) + self:start() + timer:start(0, self._interval, function() + self:render() + end) + with_cmd(run_cmd)(done_timer) + end + )(on_complete) + end +end + ------------------------------------------------------------------------------- -- Text accessors ------------------------------------------------------------------------------- @@ -177,15 +257,9 @@ function Spinner:render() return self end - local now = system.gettime() - local elapsed_ms = (now - self._last_frame_time) * 1000 - - if elapsed_ms >= self._interval then - self._frame_index = self._frame_index + 1 - if self._frame_index > #self._spinner.frames then - self._frame_index = 1 - end - self._last_frame_time = now + self._frame_index = self._frame_index + 1 + if self._frame_index > #self._spinner.frames then + self._frame_index = 1 end local indent = string.rep(" ", self._indent) @@ -230,7 +304,6 @@ function Spinner:start(text) self._is_spinning = true self._frame_index = 1 - self._last_frame_time = system.gettime() if self._hide_cursor then self._stream:write(ansi.hide_cursor) @@ -387,4 +460,10 @@ setmetatable(M, { end, }) +--- Run the libuv event loop. Blocks until all async tasks finish. +--- @return nil +function M.run() + uv.run("default") +end + return M diff --git a/lua/roda/util.lua b/lua/roda/util.lua new file mode 100644 index 0000000..5b35fa1 --- /dev/null +++ b/lua/roda/util.lua @@ -0,0 +1,33 @@ +--- roda/util.lua +--- @module roda.util +--- @author TJ Kolleh +--- @license EUPL-1.2 + +local M = {} + +--- Bracket pattern - Derived from Haskell's `Control.Exception.bracket`. +--- This uses Continuation-Passing Style (CPS) to handle async operations. +--- +--- Creates a reusable bracket for asynchronous resource lifecycles. +--- @param acquire function Function to acquire the resource +--- @param release function Function to release the resource +--- @return function A function that takes a `use` function and returns a continuation +function M.bracket(acquire, release) + return function(use) + return function(on_complete) + local resource = acquire() + use( + resource, + -- Release the resource and trigger the final continuation + function(...) + release(resource) + if on_complete then + on_complete(...) + end + end + ) + end + end +end + +return M diff --git a/lux.toml b/lux.toml index 80b779c..cd600f8 100644 --- a/lux.toml +++ b/lux.toml @@ -1,5 +1,5 @@ package = "roda" -version = "1.0.3" +version = "1.0.5" lua = ">=5.1" [description] @@ -17,7 +17,7 @@ dev = "git+https://github.com/tkolleh/roda.lua.git" [dependencies] luasystem = ">=0.4.0" -luafilesystem = ">=1.8" # Required for busted's penlight dependency (lumen-oss/lux#722) +luv = ">= 1.44.2" [test_dependencies] busted = ">=2.0"