feat(animations): add bonsai widget

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) <noreply@anthropic.com>
This commit is contained in:
CM Geldenhuys 2026-05-04 20:01:49 +02:00
commit 7b56998f7c

514
src/animations/Bonsai.zig Normal file
View file

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