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
 <video src="/attachments/3f91cf72-ae24-459c-8ef6-099f71e866fd" title="Screencast_20260519_172320" controls></video>
```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 <anerruption+codeberg@disroot.org>
This commit is contained in:
RadsammyT
2026-06-19 20:05:00 +02:00
committed by AnErrupTion
parent e833c4bc1d
commit eeccb7421b
8 changed files with 459 additions and 1 deletions

302
src/animations/Lua.zig Normal file
View File

@@ -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;
}

View File

@@ -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,

View File

@@ -7,6 +7,7 @@ pub const Animation = enum {
colormix,
gameoflife,
dur_file,
lua,
};
pub const DisplayServer = enum {

View File

@@ -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();