From b9be2e73b1bd94c7fcdd9cb25048191ac14c4639 Mon Sep 17 00:00:00 2001 From: Titanium Brain Date: Fri, 7 Aug 2026 20:15:34 +0100 Subject: [PATCH] feat(TbdfFile): add custom animation format support (tbdf) Adds support for a custom animation format designed with longer animations in mind, minimising memory and processing requirements, compared to dur files. --- src/animations/TbdfFile.zig | 449 ++++++++++++++++++++++++++++++++++++ src/config/Config.zig | 5 + src/enums.zig | 13 ++ src/main.zig | 5 + 4 files changed, 472 insertions(+) create mode 100644 src/animations/TbdfFile.zig diff --git a/src/animations/TbdfFile.zig b/src/animations/TbdfFile.zig new file mode 100644 index 0000000..89aac79 --- /dev/null +++ b/src/animations/TbdfFile.zig @@ -0,0 +1,449 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Json = std.json; +const eql = std.mem.eql; +const flate = std.compress.flate; + +const ly_ui = @import("ly-ui"); +const Cell = ly_ui.Cell; +const TerminalBuffer = ly_ui.TerminalBuffer; +const Color = TerminalBuffer.Color; +const Styling = TerminalBuffer.Styling; +const Widget = ly_ui.Widget; + +const ly_core = ly_ui.ly_core; +const interop = ly_core.interop; +const TimeOfDay = interop.TimeOfDay; +const LogFile = ly_core.LogFile; + +const enums = @import("../enums.zig"); +const TbdfOffsetAlignment = enums.TbdfOffsetAlignment; + +fn readDecompressFile(allocator: Allocator, io: std.Io, file_path: []const u8) ![]u8 { + const file_buffer = std.Io.Dir.cwd().openFile(io, file_path, .{}) catch { + return error.FileNotFound; + }; + defer file_buffer.close(io); + + var file_reader_buffer: [4096]u8 = undefined; + var decompress_buffer: [flate.max_window_len]u8 = undefined; + + var file_reader = file_buffer.reader(io, &file_reader_buffer); + var decompress: flate.Decompress = .init(&file_reader.interface, .gzip, &decompress_buffer); + + const file_decompressed = decompress.reader.allocRemaining(allocator, .unlimited) catch { + return error.NotValidFile; + }; + + return file_decompressed; +} + +const Frame = struct { + /// Every 3 bytes represent fg, bg and char index. + contents: []u24, + + pub fn deinit(self: *const Frame, allocator: Allocator) void { + allocator.free(self.contents); + } +}; + +const TbdfFormat = struct { + allocator: Allocator, + formatVersion: u8, + flags: u8, + columns: u16, + lines: u16, + fps: u8, + charMap: []u21, + movie_data: []u24, + frames: std.ArrayList(Frame) = undefined, + + fn parseFromRawData(allocator: Allocator, data: []u8) !TbdfFormat { + var iter = ByteIter{ .buf = data }; + + if (!(eql(u8, iter.next(4).?, "tbdf"))) return error.NotValidFile; + const formatVersion = (iter.next(1) orelse return error.NotValidFile)[0]; + const flags = (iter.next(1) orelse return error.NotValidFile)[0]; + const columns = iter.nextU16() orelse return error.NotValidFile; + const lines = iter.nextU16() orelse return error.NotValidFile; + const frame_count = iter.nextU16() orelse return error.NotValidFile; + const fps = (iter.next(1) orelse return error.NotValidFile)[0]; + + const char_map_len = (iter.next(1) orelse return error.NotValidFile)[0]; + var utf8 = std.unicode.Utf8Iterator{ .bytes = data, .i = iter.pos }; + var char_map: []u21 = try allocator.alloc(u21, char_map_len); + errdefer allocator.free(char_map); + for (0..char_map_len) |i| { + const codepoint: u21 = utf8.nextCodepoint() orelse return error.NotValidFile; + char_map[i] = codepoint; + } + + // skip the char map unicode codepoints + iter.pos = utf8.i; + const frame_data = iter.rest() orelse return error.NotValidFile; + std.debug.print("data = {}\n", .{frame_data.len}); + // if (frame_data.len % 4 != 0) return error.NotValidFile; + var frames = try std.ArrayList(Frame).initCapacity(allocator, frame_count); + errdefer frames.deinit(allocator); + + const cells_per_frame: u32 = @as(u32, lines) * @as(u32, columns); + const total_cells = cells_per_frame * @as(u32, frame_count); + var movie_data = try allocator.alloc(u24, total_cells); + errdefer allocator.free(movie_data); + + { + var i: usize = 0; + var pos: usize = 0; + + // decompress all data into a buffer + while (i + 3 < frame_data.len) : (i += 4) { + const count = frame_data[i]; + const fg = frame_data[i + 1]; + const bg = frame_data[i + 2]; + const char_index = frame_data[i + 3]; + + const value = + (@as(u24, fg) << 16) | + (@as(u24, bg) << 8) | + @as(u24, char_index); + + if (pos + count > movie_data.len) + return error.NotValidFile; + @memset(movie_data[pos .. pos + count], value); + pos += count; + } + if (pos != movie_data.len) + return error.NotValidFile; + } + // create frames + for (0..frame_count) |i| { + const frame: Frame = .{ .contents = movie_data[i * cells_per_frame .. (i + 1) * cells_per_frame] }; + try frames.append(allocator, frame); + } + + return .{ + .allocator = allocator, + .formatVersion = formatVersion, + .flags = flags, + .columns = columns, + .lines = lines, + .fps = fps, + .charMap = char_map, + .movie_data = movie_data, + .frames = frames, + }; + } + + pub fn createFromFile(allocator: Allocator, io: std.Io, file_path: []const u8) !TbdfFormat { + const file_decompressed = try readDecompressFile(allocator, io, file_path); + defer allocator.free(file_decompressed); + + return parseFromRawData(allocator, file_decompressed); + } + + pub fn deinit(self: *TbdfFormat) void { + self.allocator.free(self.movie_data); + self.allocator.free(self.charMap); + self.frames.deinit(self.allocator); + } +}; + +const ByteIter = struct { + buf: []const u8, + pos: usize = 0, + + pub fn next(self: *ByteIter, n: usize) ?[]const u8 { + if (self.pos + n > self.buf.len) return null; + const slice = self.buf[self.pos .. self.pos + n]; + self.pos += n; + return slice; + } + + pub fn nextU16(self: *ByteIter) ?u16 { + const bytes = self.next(2) orelse return null; + return @as(u16, bytes[0]) | + (@as(u16, bytes[1]) << 8); + } + + pub fn rest(self: *ByteIter) ?[]const u8 { + return self.next(self.buf.len - self.pos); + } +}; + +const tb_color_16 = [16]u32{ + Color.ECOL_BLACK, + Color.ECOL_RED, + Color.ECOL_GREEN, + Color.ECOL_YELLOW, + Color.ECOL_BLUE, + Color.ECOL_MAGENTA, + Color.ECOL_CYAN, + Color.ECOL_WHITE, + Color.ECOL_BLACK | Styling.BOLD, + Color.ECOL_RED | Styling.BOLD, + Color.ECOL_GREEN | Styling.BOLD, + Color.ECOL_YELLOW | Styling.BOLD, + Color.ECOL_BLUE | Styling.BOLD, + Color.ECOL_MAGENTA | Styling.BOLD, + Color.ECOL_CYAN | Styling.BOLD, + Color.ECOL_WHITE | Styling.BOLD, +}; + +// Using bold for bright colors allows for all 16 colors to be rendered on tty term +const rgb_color_16 = [16]u32{ + Color.DEFAULT, // DEFAULT instead of TRUE_BLACK to not break compositors (the latter ignores transparency) + Color.TRUE_DIM_RED, + Color.TRUE_DIM_GREEN, + Color.TRUE_DIM_YELLOW, + Color.TRUE_DIM_BLUE, + Color.TRUE_DIM_MAGENTA, + Color.TRUE_DIM_CYAN, + Color.TRUE_DIM_WHITE, + Color.DEFAULT | Styling.BOLD, + Color.TRUE_RED | Styling.BOLD, + Color.TRUE_GREEN | Styling.BOLD, + Color.TRUE_YELLOW | Styling.BOLD, + Color.TRUE_BLUE | Styling.BOLD, + Color.TRUE_MAGENTA | Styling.BOLD, + Color.TRUE_CYAN | Styling.BOLD, + Color.TRUE_WHITE | Styling.BOLD, +}; + +fn sixCubeToChannel(sixcube: u32) u32 { + // Although the range top for the extended range is 0xFF, 6 is not divisible into 0xFF, + // so we use 0xF0 instead with a scaler + const equal_divisions = 0xF0 / 6; + + // Since the range is to 0xFF but 6 isn't divisible, we must add a scaler to get it to 0xFF at the last index (5) + const scaler = 0xFF - (equal_divisions * 5); + + return if (sixcube > 0) (sixcube * equal_divisions) + scaler else 0; +} + +fn convert256ToRgb(color_256: u32) u32 { + var rgb_color: u32 = 0; + + // 0 - 15 is the standard color range, map to array table + if (color_256 < 16) { + rgb_color = rgb_color_16[color_256]; + } + // 16 - 231 is the extended range + else if (color_256 < 232) { + + // For extended term range we subtract by 16 to get it in a 0..(6x6x6) cube (range of 216) + // divide by 36 gets the depth of the cube (6x6x1) + // divide by 6 gets the width of the cube (6x1) + // divide by 1 gets the height of the cube (divide 1 for clarity for what we are doing) + // each channel can be 6 levels of brightness hence remander operation of 6 + // finally bitshift to correct rgb channel (16 for red, 8 for green, 0 for blue) + rgb_color |= sixCubeToChannel(((color_256 - 16) / 36) % 6) << 16; + rgb_color |= sixCubeToChannel(((color_256 - 16) / 6) % 6) << 8; + rgb_color |= sixCubeToChannel(((color_256 - 16) / 1) % 6); + } + // 232 - 255 is the grayscale range + else { + + // For grayscale we have a space of 232 - 255 (24) + // subtract by 232 to get it into the 0..23 range + // standard colors will contain white and black, so we do not use them in the grayscale range (0 is 0x08, 23 is 0xEE) + // this results in a skip of 0x08 for the first color and divisions of 0x0A + // example: term_col 232 = scaler + equal_divisions * (232 - 232) which becomes (scaler + 0x00) == 0x08 + // example: term_col 255 = scaler + equal_divisions * (255 - 232) which becomes (scaler + 0xE6) == 0xEE + const scaler = 0x08; + + // to get equal parts, the equation is: + // 0xEE = equal_divisions * 23 + scaler | top of range is 0xEE, 23 is last element value (255 minus 232) + // reordered to solve for equal_divisions: + const equal_divisions = (0xEE - scaler) / 23; // evals to 0x0A + + const channel = scaler + equal_divisions * (color_256 - 232); + + // gray is equal value of same channel color in rgb + rgb_color = channel | (channel << 8) | (channel << 16); + } + + return rgb_color; +} + +const UVec2 = @Vector(2, u32); +const IVec2 = @Vector(2, i64); + +const VEC_X = 0; +const VEC_Y = 1; + +const TbdfFile = @This(); + +instance: ?Widget = null, +start_time: TimeOfDay, +allocator: Allocator, +io: std.Io, +terminal_buffer: *TerminalBuffer, +movie: TbdfFormat, +current_frame_index: u64, +start_pos: IVec2, +full_color: bool, +animate: *bool, +timeout_sec: u12, +frame_delay: u16, +frame_time: u32, +time_previous: i64, +offset_alignment: TbdfOffsetAlignment, +offset: IVec2, + +// if the user has an even number of columns or rows, we will default to the left or higher position (e.g. 4 columns center = .x..) +fn center(v: u32) i64 { + return @intCast((v / 2) + (v % 2)); +} + +fn calculateStartPos(terminal_buffer: *TerminalBuffer, tbdf_movie: *TbdfFormat, offset_alignment: TbdfOffsetAlignment, offset: IVec2) IVec2 { + const buf_width: u32 = @intCast(terminal_buffer.width); + const buf_height: u32 = @intCast(terminal_buffer.height); + + const movie_width: u32 = @intCast(tbdf_movie.columns); + const movie_height: u32 = @intCast(tbdf_movie.lines); + + const start_pos: IVec2 = switch (offset_alignment) { + TbdfOffsetAlignment.center => .{ center(buf_width) - center(movie_width), center(buf_height) - center(movie_height) }, + TbdfOffsetAlignment.topleft => .{ 0, 0 }, + TbdfOffsetAlignment.topcenter => .{ center(buf_width) - center(movie_width), 0 }, + TbdfOffsetAlignment.topright => .{ buf_width - movie_width, 0 }, + TbdfOffsetAlignment.centerleft => .{ 0, center(buf_height) - center(movie_height) }, + TbdfOffsetAlignment.centerright => .{ buf_width - movie_width, center(buf_height) - center(movie_height) }, + TbdfOffsetAlignment.bottomleft => .{ 0, buf_height - movie_height }, + TbdfOffsetAlignment.bottomcenter => .{ center(buf_width) - center(movie_width), buf_height - movie_height }, + TbdfOffsetAlignment.bottomright => .{ buf_width - movie_width, buf_height - movie_height }, + }; + + return start_pos + offset; +} + +pub fn init( + allocator: Allocator, + io: std.Io, + terminal_buffer: *TerminalBuffer, + log_file: *LogFile, + file_path: []const u8, + offset_alignment: TbdfOffsetAlignment, + x_offset: i32, + y_offset: i32, + full_color: bool, + animate: *bool, + timeout_sec: u12, + frame_delay: u16, +) !TbdfFile { + var movie: TbdfFormat = TbdfFormat.createFromFile(allocator, io, file_path) catch |err| switch (err) { + error.FileNotFound => { + try log_file.err(io, "tui", "tbdf_file was not found at: {s}", .{file_path}); + return err; + }, + error.NotValidFile => { + try log_file.err(io, "tui", "tbdf_file loaded was invalid or not a tbdf file!", .{}); + return err; + }, + else => return err, + }; + + const offset: IVec2 = .{ x_offset, y_offset }; + + const start_pos = calculateStartPos(terminal_buffer, &movie, offset_alignment, offset); + + // Convert tbdf fps to ms per frames + const frame_time: u32 = @as(u32, 1000) / movie.fps; + + return .{ + .instance = null, + .start_time = try interop.getTimeOfDay(), + .allocator = allocator, + .io = io, + .terminal_buffer = terminal_buffer, + .current_frame_index = 0, + .time_previous = std.Io.Timestamp.now(io, .real).toMilliseconds(), + .start_pos = start_pos, + .full_color = full_color, + .animate = animate, + .timeout_sec = timeout_sec, + .frame_delay = frame_delay, + .movie = movie, + .frame_time = frame_time, + .offset_alignment = offset_alignment, + .offset = offset, + }; +} + +pub fn widget(self: *TbdfFile) *Widget { + if (self.instance) |*instance| return instance; + self.instance = Widget.init( + "TbdfFile", + null, + self, + deinit, + realloc, + draw, + update, + null, + calculateTimeout, + ); + return &self.instance.?; +} + +fn deinit(self: *TbdfFile) void { + self.movie.deinit(); +} + +fn realloc(self: *TbdfFile) !void { + // when terminal size changes, we need to recalculate the start_pos based on the new size + self.start_pos = calculateStartPos(self.terminal_buffer, &self.movie, self.offset_alignment, self.offset); +} + +fn draw(self: *TbdfFile) void { + if (!self.animate.*) return; + + const current_frame = self.movie.frames.items[self.current_frame_index]; + + // y is used as an iterator in the tbdf format, while cell_y gives us the correct placement for the cell (same for x) + for (0..@intCast(self.movie.lines)) |y| { + const cell_y = @as(i32, @intCast(y)) + self.start_pos[VEC_Y]; + + for (0..@intCast(self.movie.columns)) |x| { + const cell_x = @as(i32, @intCast(x)) + self.start_pos[VEC_X]; + + const index = y * self.movie.columns + x; + const cell_value = current_frame.contents[index]; + + const char_index: u8 = @intCast(cell_value & 0xff); + const codepoint = self.movie.charMap[char_index]; + + const cell_fg: u8 = @intCast(cell_value >> 16); + const fg_color = if (self.full_color) convert256ToRgb(cell_fg) else tb_color_16[cell_fg]; + const cell_bg: u8 = @intCast((cell_value >> 8) & 0xff); + const bg_color = if (self.full_color) convert256ToRgb(cell_bg) else tb_color_16[cell_bg]; + + const cell = Cell{ .ch = @intCast(codepoint), .fg = fg_color, .bg = bg_color }; + + self.terminal_buffer.setCellBoundsChecked(cell_x, cell_y, cell) catch {}; + } + } + + const time_current = std.Io.Timestamp.now(self.io, .real).toMilliseconds(); + const delta_time = time_current - self.time_previous; + + // Convert delay from sec to ms + if (delta_time > (self.frame_time)) { + self.time_previous = time_current; + + const frame_count = self.movie.frames.items.len; + self.current_frame_index = (self.current_frame_index + 1) % frame_count; + } +} + +fn update(self: *TbdfFile, _: *anyopaque) !void { + const time = try interop.getTimeOfDay(); + + if (self.timeout_sec > 0 and time.seconds - self.start_time.seconds > self.timeout_sec) { + self.animate.* = false; + } +} + +fn calculateTimeout(self: *TbdfFile, _: *anyopaque) !?usize { + return self.frame_delay; +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 2ba8444..80908ba 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6,6 +6,7 @@ const Input = enums.Input; const ViMode = enums.ViMode; const Bigclock = enums.Bigclock; const DurOffsetAlignment = enums.DurOffsetAlignment; +const TbdfOffsetAlignment = enums.TbdfOffsetAlignment; allow_empty_password: bool = true, animation: Animation = .none, @@ -52,9 +53,13 @@ doom_top_color: u32 = 0x00FF0000, doom_middle_color: u32 = 0x00FFFF00, doom_bottom_color: u32 = 0x00FFFFFF, dur_file_path: []const u8 = build_options.config_directory ++ "/ly/example.dur", +tbdf_file_path: []const u8 = build_options.config_directory ++ "/ly/example.tbdf", dur_offset_alignment: DurOffsetAlignment = .center, dur_x_offset: i32 = 0, dur_y_offset: i32 = 0, +tbdf_offset_alignment: TbdfOffsetAlignment = .center, +tbdf_x_offset: i32 = 0, +tbdf_y_offset: i32 = 0, edge_margin: u8 = 0, error_bg: u32 = 0x00000000, error_fg: u32 = 0x01FF0000, diff --git a/src/enums.zig b/src/enums.zig index ff69f84..b890ce8 100644 --- a/src/enums.zig +++ b/src/enums.zig @@ -8,6 +8,7 @@ pub const Animation = enum { gameoflife, dur_file, lua, + tbdf_file, }; pub const DisplayServer = enum { @@ -67,3 +68,15 @@ pub const DurOffsetAlignment = enum { bottomcenter, bottomright, }; + +pub const TbdfOffsetAlignment = enum { + topleft, + topcenter, + topright, + centerleft, + center, + centerright, + bottomleft, + bottomcenter, + bottomright, +}; diff --git a/src/main.zig b/src/main.zig index 1d0db99..fe904c8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -30,6 +30,7 @@ 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 TbdfFile = @import("animations/TbdfFile.zig"); const auth = @import("auth.zig"); const InfoLine = @import("components/InfoLine.zig"); const Session = @import("components/Session.zig"); @@ -1100,6 +1101,10 @@ pub fn main(init: std.process.Init) !void { ); animation = lua.widget(); }, + .tbdf_file => { + var tbdf = try TbdfFile.init(state.allocator, state.io, &state.buffer, &state.log_file, state.config.tbdf_file_path, state.config.tbdf_offset_alignment, state.config.tbdf_x_offset, state.config.tbdf_y_offset, state.config.full_color, &state.animate, state.config.animation_timeout_sec, state.config.animation_frame_delay); + animation = tbdf.widget(); + }, } defer if (animation) |a| a.deinit();