From eeccb7421b5b23eda5a5a1bc03bbebcf65ae003f Mon Sep 17 00:00:00 2001 From: RadsammyT Date: Fri, 19 Jun 2026 20:05:00 +0200 Subject: [PATCH] feat: LuaJIT Animations (#1001) ## What are the changes about? TL;DR: ![ly_meme](/attachments/f4c3a93b-6ede-42ab-a351-292e2105d4e2) Slaps the entire LuaJIT runtime onto Ly, allowing for the creation of custom dynamic animations like GameOfLife, ColorWave, Doom, etc. This PR adds the [ziglua](https://github.com/natecraddock/ziglua?ref=zig-0.16) dependency for its zig bindings and considerable buildtime config (mainly lua version selection). ### Example ```lua ly.frame_delay = 5 local timer = 0 local clock = os.clock() local clock_diff = 0 function draw() timer = timer + 1 byte = string.byte(' ') clock_diff = os.clock() - clock clock = os.clock() timer = timer + clock_diff for x = 0, ly.width-1 do for y = 0, ly.height-1 do local xc = 0xFF if x < 255 then xc = ((x + math.floor(timer / 2)) * 3) % 255 else xc = 0 end local yc = 0xFF if y < 255 then yc = ((y) * 3) % 255 else yc = 0 end ly.putCell(byte, xc, bit.bor(xc, yc), x, y) end end end ``` ### The API The API that Ly gives to the user is minimal. A table is globally available, named `ly`, which provides the following: | Member | Purpose | |---------|---------| | `ly.width` & `ly.height` | Respective Width/Height from the `TerminalBuffer` | | `ly.putCell(byte, fg, bg, x, y)` | Literally `Cell.init(byte, fg, bg).put(x, y)`.| | `ly.clock()` | The current real-time, in microseconds. | ### Error Handling On a Lua Error, Ly won't quit but will instead paint the entire background red. The lua error in question can be found in the Ly log file and on-screen. ```log 2026-05-19 16:13:40 [err/Lua] Error (Cannot call draw()): attempt to call a nil value 2026-05-19 11:05:51 [err/Lua] Lua Error: ...dsammyt/programming/probe/ly/scratch/testConfig/test.lua:30: bad argument #1 to 'ipairs' (table expected, got number) ``` ## Pre-requisites - [X] I have tested & confirmed the changes work locally - [X] I have run `zig fmt` throughout my changes Reviewed-on: https://codeberg.org/fairyglade/ly/pulls/1001 Reviewed-by: AnErrupTion --- build.zig | 11 +- build.zig.zon | 4 + res/config.ini | 4 + res/example.lua | 119 ++++++++++++++++ src/animations/Lua.zig | 302 +++++++++++++++++++++++++++++++++++++++++ src/config/Config.zig | 1 + src/enums.zig | 1 + src/main.zig | 18 +++ 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 res/example.lua create mode 100644 src/animations/Lua.zig diff --git a/build.zig b/build.zig index 2de19f7..a3a5f22 100644 --- a/build.zig +++ b/build.zig @@ -71,12 +71,19 @@ pub fn build(b: *std.Build) !void { }), }); + const zlua = b.dependency("zlua", .{ + .target = target, + .optimize = optimize, + .lang = .luajit, + }); + exe.root_module.addImport("zlua", zlua.module("zlua")); + const ly_ui = b.dependency("ly_ui", .{ .target = target, .optimize = optimize, .enable_x11_support = enable_x11_support, .fallback_uid_min = fallback_uid_min, - .fallback_uid_max = fallback_uid_max + .fallback_uid_max = fallback_uid_max, }); exe.root_module.addImport("ly-ui", ly_ui.module("ly-ui")); @@ -189,6 +196,8 @@ fn install_ly(allocator: std.mem.Allocator, io: std.Io, patch_map: PatchMap, ins try installText(io, patched_setup, config_dir, ly_config_directory, "setup.sh", .{ .permissions = .fromMode(0o755) }); try installFile(io, "res/example.dur", config_dir, ly_config_directory, "example.dur", .{ .permissions = .fromMode(0o755) }); + + try installFile(io, "res/example.lua", config_dir, ly_config_directory, "example.lua", .{ .permissions = .fromMode(0o755) }); } { diff --git a/build.zig.zon b/build.zig.zon index 3c091cb..1d19b69 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -11,6 +11,10 @@ .url = "git+https://github.com/Hejsil/zig-clap#fc1e5cc3f6d9d3001112385ee6256d694e959d2f", .hash = "clap-0.11.0-oBajB7foAQC3Iyn4IVCkUdYaOVVng5IZkSncySTjNig1", }, + .zlua = .{ + .url = "git+https://github.com/natecraddock/ziglua?ref=zig-0.16#8f271c82baa5fc43aa02a72f6da020c2025d9436", + .hash = "zlua-0.1.0-hGRpC2aABQD4D9PBVH3wAW8k32-I4969MRQ0CpOwoley", + }, }, .paths = .{ "build.zig", diff --git a/res/config.ini b/res/config.ini index a290773..169f5ea 100644 --- a/res/config.ini +++ b/res/config.ini @@ -25,6 +25,7 @@ allow_empty_password = true # colormix -> Color mixing shader # gameoflife -> John Conway's Game of Life # dur_file -> .dur file format (https://github.com/cmang/durdraw/tree/master) +# lua -> user-made animation written in LuaJIT animation = none # Delay between each animation frame in milliseconds @@ -298,6 +299,9 @@ login_defs_path = /etc/login.defs # no need to add `exec "$@"` at the end logout_cmd = null +# The file pointing to the Lua file to be used when using the Lua animation option +lua_animation_file = $CONFIG_DIRECTORY/ly/example.lua + # General log file path # If null, syslog will be used instead ly_log = /var/log/ly.log diff --git a/res/example.lua b/res/example.lua new file mode 100644 index 0000000..7309da6 --- /dev/null +++ b/res/example.lua @@ -0,0 +1,119 @@ +-- [[ +-- This is an example of using LuaJIT to create a custom animation in Ly, in this case +-- bouncing squares that change colors. +-- +-- You are given the following `ly` table: +-- { +-- height: number -- The height of the terminal +-- width: number -- The width of the terminal +-- putCell(byte, fg, bg, x, y) -- Draw a cell. +-- All arguments to this function are integers, and +-- must be in the unsigned 32-bit integer range: 0 to 2^32-1. +-- If an argument cannot be converted to this range, it will throw +-- an error. +-- +-- For reference, the XY coordinates (0,0) draw a cell on the top-left +-- of the terminal, where the positive-X axis moves right and the +-- positive-Y axis moves down. +-- +-- For arguments fg and bg: they are colors in the format +-- 0xSSRRGGBB, where SS is for styling. See your +-- config.ini for more details. +-- +-- For the byte argument, you may use string.byte to fill this argument. +-- +-- putCell(byte, fg, bg, x, y, w, h) -- Draw a rectangle. +-- Arguments are the same as putCell except for w and h, which are also +-- unsigned integers. The rectangle will be drawn from the top-left, with +-- argument w extending it to the right and argument h extending downwards. +-- +-- +-- putLabel(str, fg, bg, x, y) -- Draw text in argument str. See putCell() +-- for info on the rest of the arguments. +-- +-- clock() -- The time, in microseconds. +-- } +-- +-- A function named `draw()` must be declared in the script. This is ran every +-- frame. +-- +-- In addition to the base library, you are also given the following standard +-- libraries: +-- bit (A library exclusive to LuaJIT, see https://bitop.luajit.org/api.html) +-- math +-- string +-- table +-- +-- The std libraries io and debug are NOT included. +-- +-- ]] + +-- You should probably copy FPS and FPS_COUNT into any future LuaJIT animations +-- you create. +local FPS_COUNT = 40 +local function FPS() + return (1/FPS_COUNT)*1000000 +end + + +local SQUARE_WIDTH = 10 +local SQUARE_HEIGHT = 5 + +local SQUARE_COUNT = 25 + +local squares = {} + +for i = 1, SQUARE_COUNT do + local vx = 1 + local vy = 1 + if math.random(1, 2) == 2 then vx = -vx end + if math.random(1, 2) == 2 then vy = -vy end + squares[#squares+1] = { + x = math.random(1, ly.width - SQUARE_WIDTH), + y = math.random(1, ly.height - SQUARE_HEIGHT), + vx = vx, + vy = vy, + color = math.random(0xFFFFFF) + } +end + +local timer = ly.clock() +local perf = ly.clock() + +function draw() + -- Rather than progressing the animation by frame, do it based on + -- seconds, via ly.clock(). In this timeframe, you can update the animation + -- state. + -- DO NOT DRAW CELLS IN THIS TIMEFRAME. You will get flickering. + + -- if this check passes, we can update the animation + if timer + FPS() < ly.clock() then + for i, v in ipairs(squares) do + v.x = v.x + v.vx + v.y = v.y + v.vy + if v.x == 0 then + v.vx = 1; v.color = math.random(0xFFFFFF) + end + if v.x + SQUARE_WIDTH >= ly.width-1 then + v.vx = -1; v.color = math.random(0xFFFFFF) + end + if v.y == 0 then + v.vy = 1; v.color = math.random(0xFFFFFF) + end + if v.y + SQUARE_HEIGHT >= ly.height-1 then + v.vy = -1; v.color = math.random(0xFFFFFF) + end + end + timer = ly.clock() + end + + + for i, v in ipairs(squares) do + ly.putRect(string.byte(' '), 0, v.color, v.x, v.y, SQUARE_WIDTH, SQUARE_HEIGHT) + end + + local new_perf = ly.clock() + local str = "FT: "..((new_perf - perf) / 1000).."ms" + ly.putLabel(str , 0x00FFFFFF, 0, (ly.width/2) - (string.len(str)/2), ly.height-1) + perf = new_perf +end diff --git a/src/animations/Lua.zig b/src/animations/Lua.zig new file mode 100644 index 0000000..d212e03 --- /dev/null +++ b/src/animations/Lua.zig @@ -0,0 +1,302 @@ +const std = @import("std"); +const ly_ui = @import("ly-ui"); +const LogFile = ly_ui.ly_core.LogFile; +const Widget = ly_ui.Widget; +const TerminalBuffer = ly_ui.TerminalBuffer; +const Cell = ly_ui.Cell; +const Allocator = std.mem.Allocator; +const InfoLine = @import("../components/InfoLine.zig"); +const Lang = @import("../config/Lang.zig"); + +const zlua = @import("zlua"); + +const ly_lua = @embedFile("ly.lua"); + +const Lua = @This(); + +allocator: Allocator, +instance: ?Widget = null, +lua: *zlua.Lua, +log: *LogFile, +terminal_buffer: *TerminalBuffer, +width: usize, +height: usize, +margin: usize, +io: std.Io, +animation_delay: u16, + +info_line: *InfoLine, +fg: u32, +bg: u32, + +lang: Lang, +full_color: bool, + +lua_error: bool = false, +lua_error_logged: bool = false, +lua_str: ?[:0]const u8 = null, + +pub fn init( + io: std.Io, + alloc: Allocator, + log: *LogFile, + buf: *TerminalBuffer, + file: []const u8, + margin: u8, + animation_delay: u16, + info_line: *InfoLine, + fg: u32, + bg: u32, + lang: Lang, + full_color: bool, +) !Lua { + var self: Lua = .{ + .lua = try zlua.Lua.init(alloc), + .allocator = alloc, + .terminal_buffer = buf, + .instance = null, + .log = log, + .width = 0, + .height = 0, + .margin = margin, + .io = io, + .animation_delay = animation_delay, + .info_line = info_line, + .fg = fg, + .bg = bg, + .lang = lang, + .full_color = full_color, + }; + + // exclude IO and debug libraries + self.lua.openBase(); + self.lua.openBit(); + self.lua.openMath(); + self.lua.openString(); + self.lua.openTable(); + + file_loading: { + const zf = std.mem.concatWithSentinel(alloc, u8, &[1][]const u8{file}, 0) catch |e| { + try self.log.err(self.io, "lua", "failed to allocate file path: {}", .{e}); + self.lua_str = "failed to allocate file path!"; + self.info_line.addMessage(lang.err_alloc, self.bg, self.fg) catch {}; + return e; + }; + defer alloc.free(zf); + + // create the ly table + self.lua.newTable(); + self.lua.setGlobal("ly"); + + // create ly.width and ly.height from TerminalBuffer width/height + self.propagateTerminalBounds(); + + _ = self.lua.getGlobal("ly"); + _ = self.lua.pushString("clock"); + self.lua.pushFunction(luaLyClock); + self.lua.setTable(-3); + _ = self.lua.pushString("putCell"); + self.lua.pushFunction(luaPutCell); + self.lua.setTable(-3); + _ = self.lua.pushString("putLabel"); + self.lua.pushFunction(luaPutLabel); + self.lua.setTable(-3); + _ = self.lua.pushString("putRect"); + self.lua.pushFunction(luaPutRect); + self.lua.setTable(-3); + self.lua.setGlobal("ly"); + + self.lua.doFile(zf) catch { + const errorStr = self.lua.toString(-1) catch unreachable; + self.lua_str = try self.allocator.dupeSentinel(u8, errorStr, 0); + try self.log.err(self.io, "lua", "lua error: {s}", .{errorStr}); + self.lua_error = true; + break :file_loading; + }; + } + + return self; +} + +fn draw(self: *Lua) void { + self.propagateTerminalBounds(); + if (self.lua_error) { + // Ly's Red Screen of Omega-Death:tm: + const RED: u32 = if (self.full_color) TerminalBuffer.Color.TRUE_RED else TerminalBuffer.Color.ECOL_RED; + const cell = Cell.init(0x2588, RED, RED); + for (0..self.terminal_buffer.height) |y| + for (0..self.terminal_buffer.width) |x| + cell.put(x, y) catch {}; + if (self.lua_str) |str| + for (str, 0..) |c, i| { + Cell.init(c, 0x00FFFFFF, 0).put( + @divFloor(self.width, 2) - @divFloor(str.len, 2) + i, + self.margin + 5, + ) catch {}; + }; + if (!self.lua_error_logged) { + self.info_line.addMessage("lua animation failed", self.bg, self.fg) catch {}; + self.lua_error_logged = true; + } + return; + } + + _ = self.lua.getGlobal("draw"); + self.lua.protectedCall(.{}) catch { + const errorStr = self.lua.toString(-1) catch unreachable; + self.lua_str = std.mem.concatWithSentinel( + self.allocator, + u8, + &.{ "cannot call draw(): ", errorStr }, + 0, + ) catch unreachable; + self.log.err(self.io, "lua", "error (cannot call draw()): {s}", .{errorStr}) catch unreachable; + self.lua_error = true; + }; +} + +fn calculateTimeout(self: *Lua, _: *anyopaque) !?usize { + return self.animation_delay; +} + +fn deinit(self: *Lua) void { + if (self.lua_str) |str| self.allocator.free(str); + self.lua.deinit(); +} + +pub fn widget(self: *Lua) *Widget { + if (self.instance) |*inst| return inst; + self.instance = Widget.init( + "Lua", + null, + self, + deinit, + null, + draw, + null, + null, + calculateTimeout, + ); + return &self.instance.?; +} + +fn propagateTerminalBounds(self: *Lua) void { + if (self.terminal_buffer.height == self.height and + self.terminal_buffer.width == self.width) + return; + self.width = self.terminal_buffer.width; + self.height = self.terminal_buffer.height; + _ = self.lua.getGlobal("ly"); + _ = self.lua.pushString("width"); + self.lua.pushInteger(@intCast(self.terminal_buffer.width)); + self.lua.setTable(-3); + _ = self.lua.pushString("height"); + self.lua.pushInteger(@intCast(self.terminal_buffer.height)); + self.lua.setTable(-3); + self.lua.setGlobal("ly"); +} + +fn luaLyClock(state: ?*zlua.LuaState) callconv(.c) c_int { + var threaded = std.Io.Threaded.init_single_threaded; + const lua: *zlua.Lua = @ptrCast(@alignCast(state orelse unreachable)); + lua.pushInteger(std.Io.Timestamp.now(threaded.io(), .real).toMicroseconds()); + return 1; +} + +fn luaPutCell(state: ?*zlua.LuaState) callconv(.c) c_int { + const lua: *zlua.Lua = @ptrCast(@alignCast(state orelse unreachable)); + const MSG = "ly.putCell: cannot convert %s-typed "; + const byte = lua.toNumeric(u32, 1) catch { + const t = lua.typeName(lua.typeOf(1)); + lua.raiseErrorStr(MSG ++ "byte to u32", .{t.ptr}); + }; + const fg = lua.toNumeric(u32, 2) catch { + const t = lua.typeName(lua.typeOf(2)); + lua.raiseErrorStr(MSG ++ "fg to u32", .{t.ptr}); + }; + const bg = lua.toNumeric(u32, 3) catch { + const t = lua.typeName(lua.typeOf(3)); + lua.raiseErrorStr(MSG ++ "bg to u32", .{t.ptr}); + }; + const x = lua.toNumeric(usize, 4) catch { + const t = lua.typeName(lua.typeOf(4)); + lua.raiseErrorStr(MSG ++ "x to usize", .{t.ptr}); + }; + const y = lua.toNumeric(usize, 5) catch { + const t = lua.typeName(lua.typeOf(5)); + lua.raiseErrorStr(MSG ++ "y to usize", .{t.ptr}); + }; + TerminalBuffer.setCell(x, y, .{ + .fg = fg, + .bg = bg, + .ch = byte, + }) catch {}; + return 0; +} + +fn luaPutRect(state: ?*zlua.LuaState) callconv(.c) c_int { + const lua: *zlua.Lua = @ptrCast(@alignCast(state orelse unreachable)); + const MSG = "ly.putRect: cannot convert %s-typed "; + const byte = lua.toNumeric(u32, 1) catch { + const t = lua.typeName(lua.typeOf(1)); + lua.raiseErrorStr(MSG ++ "byte to u32", .{t.ptr}); + }; + const fg = lua.toNumeric(u32, 2) catch { + const t = lua.typeName(lua.typeOf(2)); + lua.raiseErrorStr(MSG ++ "fg to u32", .{t.ptr}); + }; + const bg = lua.toNumeric(u32, 3) catch { + const t = lua.typeName(lua.typeOf(3)); + lua.raiseErrorStr(MSG ++ "bg to u32", .{t.ptr}); + }; + const x = lua.toNumeric(usize, 4) catch { + const t = lua.typeName(lua.typeOf(4)); + lua.raiseErrorStr(MSG ++ "x to usize", .{t.ptr}); + }; + const y = lua.toNumeric(usize, 5) catch { + const t = lua.typeName(lua.typeOf(5)); + lua.raiseErrorStr(MSG ++ "y to usize", .{t.ptr}); + }; + const w = lua.toNumeric(usize, 6) catch { + const t = lua.typeName(lua.typeOf(5)); + lua.raiseErrorStr(MSG ++ "w to usize", .{t.ptr}); + }; + const h = lua.toNumeric(usize, 7) catch { + const t = lua.typeName(lua.typeOf(5)); + lua.raiseErrorStr(MSG ++ "h to usize", .{t.ptr}); + }; + for (0..w) |wx| for (0..h) |hy| + TerminalBuffer.setCell(x + wx, y + hy, .{ + .fg = fg, + .bg = bg, + .ch = byte, + }) catch {}; + return 0; +} + +fn luaPutLabel(state: ?*zlua.LuaState) callconv(.c) c_int { + const lua: *zlua.Lua = @ptrCast(@alignCast(state orelse unreachable)); + const MSG = "ly.putLabel: cannot convert %s-typed "; + const str = lua.toString(1) catch { + const t = lua.typeName(lua.typeOf(2)); + lua.raiseErrorStr(MSG ++ "str to string", .{t.ptr}); + }; + const fg = lua.toNumeric(u32, 2) catch { + const t = lua.typeName(lua.typeOf(2)); + lua.raiseErrorStr(MSG ++ "fg to u32", .{t.ptr}); + }; + const bg = lua.toNumeric(u32, 3) catch { + const t = lua.typeName(lua.typeOf(3)); + lua.raiseErrorStr(MSG ++ "bg to u32", .{t.ptr}); + }; + const x = lua.toNumeric(usize, 4) catch { + const t = lua.typeName(lua.typeOf(4)); + lua.raiseErrorStr(MSG ++ "x to usize", .{t.ptr}); + }; + const y = lua.toNumeric(usize, 5) catch { + const t = lua.typeName(lua.typeOf(5)); + lua.raiseErrorStr(MSG ++ "y to usize", .{t.ptr}); + }; + TerminalBuffer.drawText(str, x, y, fg, bg) catch {}; + return 0; +} diff --git a/src/config/Config.zig b/src/config/Config.zig index a592faa..b7b258b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -74,6 +74,7 @@ lang: []const u8 = "en", login_cmd: ?[]const u8 = null, login_defs_path: []const u8 = "/etc/login.defs", logout_cmd: ?[]const u8 = null, +lua_animation_file: []const u8 = build_options.config_directory ++ "/ly/example.lua", ly_log: ?[]const u8 = "/var/log/ly.log", margin_box_h: u8 = 2, margin_box_v: u8 = 1, diff --git a/src/enums.zig b/src/enums.zig index 47771da..ff69f84 100644 --- a/src/enums.zig +++ b/src/enums.zig @@ -7,6 +7,7 @@ pub const Animation = enum { colormix, gameoflife, dur_file, + lua, }; pub const DisplayServer = enum { diff --git a/src/main.zig b/src/main.zig index 887ec41..41ba15d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -29,6 +29,7 @@ const Doom = @import("animations/Doom.zig"); const DurFile = @import("animations/DurFile.zig"); const GameOfLife = @import("animations/GameOfLife.zig"); const Matrix = @import("animations/Matrix.zig"); +const Lua = @import("animations/Lua.zig"); const auth = @import("auth.zig"); const InfoLine = @import("components/InfoLine.zig"); const Session = @import("components/Session.zig"); @@ -1093,6 +1094,23 @@ pub fn main(init: std.process.Init) !void { ); animation = dur.widget(); }, + .lua => { + var lua = try Lua.init( + state.io, + state.allocator, + &state.log_file, + &state.buffer, + state.config.lua_animation_file, + state.config.edge_margin, + state.config.animation_frame_delay, + &state.info_line, + state.config.error_fg, + state.config.error_bg, + state.lang, + state.config.full_color, + ); + animation = lua.widget(); + }, } defer if (animation) |a| a.deinit();