From 7b56998f7ce771a4fe627ef796b798373b02dc56 Mon Sep 17 00:00:00 2001 From: CM Geldenhuys <4649249+CMGeldenhuys@users.noreply.github.com> Date: Mon, 4 May 2026 20:01:49 +0200 Subject: [PATCH] feat(animations): add bonsai widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A procedurally-generated bonsai tree animation, inspired by cbonsai (https://gitlab.com/jallbrit/cbonsai). The growth algorithm — recursive branch / shoot / dying / dead phases with weighted-dice deltas, and the two pot styles — follows cbonsai's structure but is a Zig re-implementation, not a line-by-line port. Generation runs once at init (and on terminal resize), recording each glyph as an event in DFS order. The draw loop then plays back events at `bonsai_step_ms` per cell, freezes the finished tree for `bonsai_finish_pause_ms`, and regenerates with a fresh seed — giving the display-manager backdrop a perpetual grow/regrow cycle. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/animations/Bonsai.zig | 514 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 src/animations/Bonsai.zig diff --git a/src/animations/Bonsai.zig b/src/animations/Bonsai.zig new file mode 100644 index 0000000..8361e1d --- /dev/null +++ b/src/animations/Bonsai.zig @@ -0,0 +1,514 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const ly_ui = @import("ly-ui"); +const Cell = ly_ui.Cell; +const TerminalBuffer = ly_ui.TerminalBuffer; +const Widget = ly_ui.Widget; + +const ly_core = ly_ui.ly_core; +const interop = ly_core.interop; +const TimeOfDay = interop.TimeOfDay; + +const Bonsai = @This(); + +const BranchType = enum { trunk, shoot_left, shoot_right, dying, dead }; + +const Event = struct { + x: u16, + y: u16, + ch: u32, + fg: u32, +}; + +const Counters = struct { + shoots: u32 = 0, + shoot_counter: u32, +}; + +const PotArt = struct { + rows: []const Row, + width: usize, + + const Row = []const Span; + const Span = struct { text: []const u8, kind: Kind }; + const Kind = enum { text_color, leaf_bright, wood_bright }; +}; + +const pot_large: PotArt = .{ + .width = 31, + .rows = &.{ + &.{ + .{ .text = ":", .kind = .text_color }, + .{ .text = "___________", .kind = .leaf_bright }, + .{ .text = "./~~~\\.", .kind = .wood_bright }, + .{ .text = "___________", .kind = .leaf_bright }, + .{ .text = ":", .kind = .text_color }, + }, + &.{ + .{ .text = " \\ / ", .kind = .text_color }, + }, + &.{ + .{ .text = " \\_________________________/ ", .kind = .text_color }, + }, + &.{ + .{ .text = " (_) (_) ", .kind = .text_color }, + }, + }, +}; + +const pot_small: PotArt = .{ + .width = 15, + .rows = &.{ + &.{ + .{ .text = "(", .kind = .text_color }, + .{ .text = "---", .kind = .leaf_bright }, + .{ .text = "./~~~\\.", .kind = .wood_bright }, + .{ .text = "---", .kind = .leaf_bright }, + .{ .text = ")", .kind = .text_color }, + }, + &.{ + .{ .text = " ( ) ", .kind = .text_color }, + }, + &.{ + .{ .text = " (_________) ", .kind = .text_color }, + }, + }, +}; + +instance: ?Widget = null, +start_time: TimeOfDay, +last_step_time: TimeOfDay, +finished_at: ?TimeOfDay, + +allocator: Allocator, +terminal_buffer: *TerminalBuffer, +animate: *bool, +timeout_sec: u12, +frame_delay: u16, + +life_start: u16, +multiplier: u8, +base_type: u8, +leaves: []const u8, +leaf_dark: u32, +leaf_bright: u32, +wood_dark: u32, +wood_bright: u32, +text_color: u32, +step_ms: u16, +finish_pause_ms: u32, + +events: std.ArrayList(Event), +played: usize, +prng: std.Random.DefaultPrng, + +pub fn init( + allocator: Allocator, + terminal_buffer: *TerminalBuffer, + base_type: u8, + life_start: u16, + multiplier: u8, + leaves: []const u8, + leaf_dark: u32, + leaf_bright: u32, + wood_dark: u32, + wood_bright: u32, + text_color: u32, + step_ms: u16, + finish_pause_ms: u32, + animate: *bool, + timeout_sec: u12, + frame_delay: u16, +) !Bonsai { + const now = try interop.getTimeOfDay(); + const seed: u64 = terminal_buffer.random.int(u64); + var bonsai: Bonsai = .{ + .instance = null, + .start_time = now, + .last_step_time = now, + .finished_at = null, + .allocator = allocator, + .terminal_buffer = terminal_buffer, + .animate = animate, + .timeout_sec = timeout_sec, + .frame_delay = frame_delay, + .life_start = life_start, + .multiplier = if (multiplier == 0) 1 else multiplier, + .base_type = base_type, + .leaves = if (leaves.len == 0) "&" else leaves, + .leaf_dark = leaf_dark, + .leaf_bright = leaf_bright, + .wood_dark = wood_dark, + .wood_bright = wood_bright, + .text_color = text_color, + .step_ms = if (step_ms == 0) 1 else step_ms, + .finish_pause_ms = finish_pause_ms, + .events = .empty, + .played = 0, + .prng = std.Random.DefaultPrng.init(seed), + }; + try bonsai.generate(); + return bonsai; +} + +pub fn widget(self: *Bonsai) *Widget { + if (self.instance) |*instance| return instance; + self.instance = Widget.init( + "Bonsai", + null, + self, + deinit, + realloc, + draw, + update, + null, + calculateTimeout, + ); + return &self.instance.?; +} + +fn deinit(self: *Bonsai) void { + self.events.deinit(self.allocator); +} + +fn realloc(self: *Bonsai) !void { + self.events.clearRetainingCapacity(); + self.played = 0; + self.finished_at = null; + self.last_step_time = try interop.getTimeOfDay(); + try self.generate(); +} + +fn update(self: *Bonsai, _: *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: *Bonsai, _: *anyopaque) !?usize { + return self.frame_delay; +} + +fn draw(self: *Bonsai) void { + if (!self.animate.*) return; + + self.drawPot(); + + const now = interop.getTimeOfDay() catch return; + + if (self.played < self.events.items.len) { + const elapsed_ms = elapsedMs(self.last_step_time, now); + if (elapsed_ms >= self.step_ms) { + const advance: usize = @intCast(@divTrunc(elapsed_ms, @as(i64, @intCast(self.step_ms)))); + self.played = @min(self.played + advance, self.events.items.len); + self.last_step_time = now; + if (self.played == self.events.items.len) self.finished_at = now; + } + } else if (self.finished_at) |finish_time| { + if (elapsedMs(finish_time, now) >= self.finish_pause_ms) { + self.events.clearRetainingCapacity(); + self.played = 0; + self.finished_at = null; + self.last_step_time = now; + self.generate() catch return; + } + } + + for (self.events.items[0..self.played]) |ev| { + const cell = Cell.init(ev.ch, ev.fg, self.terminal_buffer.bg); + cell.put(ev.x, ev.y); + } +} + +fn elapsedMs(start: TimeOfDay, end: TimeOfDay) i64 { + return (end.seconds - start.seconds) * 1000 + + @divTrunc(end.microseconds - start.microseconds, 1000); +} + +fn potHeight(base_type: u8) usize { + return switch (base_type) { + 1 => pot_large.rows.len, + 2 => pot_small.rows.len, + else => 0, + }; +} + +fn potWidth(base_type: u8) usize { + return switch (base_type) { + 1 => pot_large.width, + 2 => pot_small.width, + else => 0, + }; +} + +fn drawPot(self: *Bonsai) void { + const art: ?PotArt = switch (self.base_type) { + 1 => pot_large, + 2 => pot_small, + else => null, + }; + const pot = art orelse return; + + if (self.terminal_buffer.height < pot.rows.len) return; + if (self.terminal_buffer.width < pot.width) return; + + const x_start: usize = self.terminal_buffer.width / 2 - pot.width / 2; + const y_start: usize = self.terminal_buffer.height - pot.rows.len; + + for (pot.rows, 0..) |row, row_idx| { + var x = x_start; + for (row) |span| { + const fg: u32 = switch (span.kind) { + .text_color => self.text_color, + .leaf_bright => self.leaf_bright, + .wood_bright => self.wood_bright, + }; + TerminalBuffer.drawText(span.text, x, y_start + row_idx, fg, self.terminal_buffer.bg); + x += TerminalBuffer.strWidth(span.text); + } + } +} + +fn generate(self: *Bonsai) !void { + const w = self.terminal_buffer.width; + const h = self.terminal_buffer.height; + const pot_h = potHeight(self.base_type); + if (h <= pot_h + 1 or w < 4) return; + + const tree_h = h - pot_h; + const max_life: u16 = @intCast(@min(@as(usize, self.life_start), tree_h - 1)); + if (max_life == 0) return; + + const x0: i32 = @intCast(w / 2); + const y0: i32 = @intCast(tree_h - 1); + + var counters: Counters = .{ .shoot_counter = self.prng.random().int(u32) }; + try self.branch(&counters, y0, x0, .trunk, max_life); +} + +fn branch( + self: *Bonsai, + counters: *Counters, + y_in: i32, + x_in: i32, + type_in: BranchType, + life_in: u16, +) !void { + var y = y_in; + var x = x_in; + var life: i32 = @intCast(life_in); + var shoot_cooldown: i32 = @intCast(self.multiplier); + const rng = self.prng.random(); + const max_y: i32 = @intCast(self.terminal_buffer.height - potHeight(self.base_type)); + const mult: i32 = @intCast(self.multiplier); + const life_start_i: i32 = @intCast(self.life_start); + + while (life > 0) { + life -= 1; + const age = life_start_i - life; + + var dxdy = setDeltas(type_in, life, age, mult, rng); + if (dxdy.dy > 0 and y > max_y - 2) dxdy.dy -= 1; + + if (life < 3) { + try self.branch(counters, y, x, .dead, @intCast(@max(life, 0))); + } else if (type_in == .trunk and life < mult + 2) { + try self.branch(counters, y, x, .dying, @intCast(@max(life, 0))); + } else if ((type_in == .shoot_left or type_in == .shoot_right) and life < mult + 2) { + try self.branch(counters, y, x, .dying, @intCast(@max(life, 0))); + } else if (type_in == .trunk and (rng.intRangeLessThan(u32, 0, 3) == 0 or @mod(life, mult) == 0)) { + if (rng.intRangeLessThan(u32, 0, 8) == 0 and life > 7) { + shoot_cooldown = mult * 2; + const offset = @as(i32, @intCast(rng.intRangeLessThan(u32, 0, 5))) - 2; + const new_life = life + offset; + if (new_life > 0) { + try self.branch(counters, y, x, .trunk, @intCast(new_life)); + } + } else if (shoot_cooldown <= 0) { + shoot_cooldown = mult * 2; + counters.shoots += 1; + counters.shoot_counter += 1; + const sub: BranchType = if (counters.shoot_counter % 2 == 0) .shoot_left else .shoot_right; + const shoot_life = life + @as(i32, @intCast(self.multiplier)); + try self.branch(counters, y, x, sub, @intCast(@max(shoot_life, 0))); + } + } + shoot_cooldown -= 1; + + x += dxdy.dx; + y += dxdy.dy; + + const effective_type: BranchType = if (life < 4) .dying else type_in; + const ch = chooseGlyph(effective_type, dxdy.dx, dxdy.dy, self.leaves, rng); + const fg = self.chooseColor(effective_type, rng); + + if (x >= 0 and y >= 0 and + x < @as(i32, @intCast(self.terminal_buffer.width)) and + y < @as(i32, @intCast(self.terminal_buffer.height))) + { + try self.events.append(self.allocator, .{ + .x = @intCast(x), + .y = @intCast(y), + .ch = ch, + .fg = fg, + }); + } + } +} + +const Delta = struct { dx: i32, dy: i32 }; + +fn setDeltas(branch_type: BranchType, life: i32, age: i32, multiplier: i32, rng: std.Random) Delta { + var dx: i32 = 0; + var dy: i32 = 0; + switch (branch_type) { + .trunk => { + if (age <= 2 or life < 4) { + dy = 0; + dx = @as(i32, @intCast(rng.intRangeLessThan(u32, 0, 3))) - 1; + } else if (age < multiplier * 3) { + const period: i32 = @max(1, @divTrunc(multiplier, 2)); + dy = if (@mod(age, period) == 0) -1 else 0; + const dice = rng.intRangeLessThan(u32, 0, 10); + dx = switch (dice) { + 0 => -2, + 1, 2, 3 => -1, + 4, 5 => 0, + 6, 7, 8 => 1, + 9 => 2, + else => 0, + }; + } else { + dy = if (rng.intRangeLessThan(u32, 0, 10) > 2) -1 else 0; + dx = @as(i32, @intCast(rng.intRangeLessThan(u32, 0, 3))) - 1; + } + }, + .shoot_left => { + const dice_y = rng.intRangeLessThan(u32, 0, 10); + dy = switch (dice_y) { + 0, 1 => -1, + 2, 3, 4, 5, 6, 7 => 0, + else => 1, + }; + const dice_x = rng.intRangeLessThan(u32, 0, 10); + dx = switch (dice_x) { + 0, 1 => -2, + 2, 3, 4, 5 => -1, + 6, 7, 8 => 0, + else => 1, + }; + }, + .shoot_right => { + const dice_y = rng.intRangeLessThan(u32, 0, 10); + dy = switch (dice_y) { + 0, 1 => -1, + 2, 3, 4, 5, 6, 7 => 0, + else => 1, + }; + const dice_x = rng.intRangeLessThan(u32, 0, 10); + dx = switch (dice_x) { + 0, 1 => 2, + 2, 3, 4, 5 => 1, + 6, 7, 8 => 0, + else => -1, + }; + }, + .dying => { + const dice_y = rng.intRangeLessThan(u32, 0, 10); + dy = switch (dice_y) { + 0, 1 => -1, + 2, 3, 4, 5, 6, 7, 8 => 0, + else => 1, + }; + const dice_x = rng.intRangeLessThan(u32, 0, 15); + dx = switch (dice_x) { + 0 => -3, + 1, 2 => -2, + 3, 4, 5 => -1, + 6, 7, 8 => 0, + 9, 10, 11 => 1, + 12, 13 => 2, + else => 3, + }; + }, + .dead => { + const dice_y = rng.intRangeLessThan(u32, 0, 10); + dy = switch (dice_y) { + 0, 1, 2 => -1, + 3, 4, 5, 6 => 0, + else => 1, + }; + dx = @as(i32, @intCast(rng.intRangeLessThan(u32, 0, 3))) - 1; + }, + } + return .{ .dx = dx, .dy = dy }; +} + +fn chooseGlyph( + branch_type: BranchType, + dx: i32, + dy: i32, + leaves: []const u8, + rng: std.Random, +) u32 { + return switch (branch_type) { + .trunk => if (dy == 0) + '/' + else if (dx < 0) + '\\' + else if (dx == 0) + '/' + else + '|', + .shoot_left => if (dy > 0) + '\\' + else if (dy == 0) + '\\' + else if (dx < 0) + '\\' + else if (dx == 0) + '/' + else + '/', + .shoot_right => if (dy > 0) + '/' + else if (dy == 0) + '_' + else if (dx < 0) + '\\' + else if (dx == 0) + '/' + else + '/', + .dying, .dead => pickLeaf(leaves, rng), + }; +} + +fn pickLeaf(leaves: []const u8, rng: std.Random) u32 { + const view = std.unicode.Utf8View.init(leaves) catch return '&'; + var iter = view.iterator(); + var count: usize = 0; + while (iter.nextCodepoint()) |_| count += 1; + if (count == 0) return '&'; + + const idx = rng.intRangeLessThan(usize, 0, count); + iter = view.iterator(); + var i: usize = 0; + while (iter.nextCodepoint()) |cp| : (i += 1) { + if (i == idx) return cp; + } + return '&'; +} + +fn chooseColor(self: *Bonsai, branch_type: BranchType, rng: std.Random) u32 { + return switch (branch_type) { + .trunk, .shoot_left, .shoot_right => if (rng.intRangeLessThan(u32, 0, 2) == 0) + self.wood_bright + else + self.wood_dark, + .dying => self.leaf_bright, + .dead => self.leaf_dark, + }; +}