From 3b66e38152d0f3bcbc8069b6368cd167792c51cc Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 25 Feb 2026 14:23:03 -0800 Subject: [PATCH] ai generated braille-based chart --- src/tui/main.zig | 208 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 159 insertions(+), 49 deletions(-) diff --git a/src/tui/main.zig b/src/tui/main.zig index 8b96d52..83aa89c 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -68,6 +68,11 @@ const StyledLine = struct { alt_style: ?vaxis.Style = null, alt_start: usize = 0, alt_end: usize = 0, + // Optional pre-encoded grapheme array for multi-byte Unicode (e.g. braille charts). + // When set, each element is a grapheme string for one column position. + graphemes: ?[]const []const u8 = null, + // Optional per-cell style array (same length as graphemes). Enables color gradients. + cell_styles: ?[]const vaxis.Style = null, }; const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put }; @@ -1168,13 +1173,22 @@ const App = struct { for (0..width) |ci| { buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style }; } - for (0..@min(line.text.len, width)) |ci| { - var s = line.style; - // Apply alt_style for the gain/loss column range - if (line.alt_style) |alt| { - if (ci >= line.alt_start and ci < line.alt_end) s = alt; + // Grapheme-based rendering (for braille / multi-byte Unicode lines) + if (line.graphemes) |graphemes| { + const cell_styles = line.cell_styles; + for (0..@min(graphemes.len, width)) |ci| { + const s = if (cell_styles) |cs| cs[ci] else line.style; + buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s }; + } + } else { + for (0..@min(line.text.len, width)) |ci| { + var s = line.style; + // Apply alt_style for the gain/loss column range + if (line.alt_style) |alt| { + if (ci >= line.alt_start and ci < line.alt_end) s = alt; + } + buf[row * width + ci] = .{ .char = .{ .grapheme = glyph(line.text[ci]) }, .style = s }; } - buf[row * width + ci] = .{ .char = .{ .grapheme = glyph(line.text[ci]) }, .style = s }; } } } @@ -1512,7 +1526,7 @@ const App = struct { } } - // Braille chart of recent 60 trading days + // Braille sparkline chart of recent 60 trading days try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const chart_days: usize = @min(c.len, 60); const chart_data = c[c.len - chart_days ..]; @@ -2088,12 +2102,56 @@ fn fmtContractLine(arena: std.mem.Allocator, prefix: []const u8, c: zfin.OptionC }); } +/// Braille dot patterns for the 2x4 matrix within each character cell. +/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08 +/// [1][4] dot1=0x02, dot4=0x10 +/// [2][5] dot2=0x04, dot5=0x20 +/// [6][7] dot6=0x40, dot7=0x80 +const braille_dots = [4][2]u8{ + .{ 0x01, 0x08 }, // row 0 (top) + .{ 0x02, 0x10 }, // row 1 + .{ 0x04, 0x20 }, // row 2 + .{ 0x40, 0x80 }, // row 3 (bottom) +}; + +/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF). +/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo. +const braille_utf8 = blk: { + var table: [256][3]u8 = undefined; + for (0..256) |i| { + const cp: u21 = 0x2800 + @as(u21, @intCast(i)); + table[i] = .{ + @as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))), + @as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))), + @as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))), + }; + } + break :blk table; +}; + +/// Return a static-lifetime grapheme slice for a braille pattern byte. +fn brailleGlyph(pattern: u8) []const u8 { + return &braille_utf8[pattern]; +} + +/// Interpolate color between two RGB values. t in [0.0, 1.0]. +fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 { + return .{ + @intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t), + @intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t), + @intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t), + }; +} + /// Build a braille sparkline chart from candle close prices. -/// Uses Unicode braille characters (U+2800..U+28FF) for 2x4 dot matrix per character. +/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell. +/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point. fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void { if (data.len < 2) return; const chart_width: usize = 60; + const chart_height: usize = 10; // terminal rows + const dot_rows: usize = chart_height * 4; // vertical dot resolution // Find min/max close prices var min_price: f64 = data[0].close; @@ -2111,64 +2169,116 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine) const max_label = std.fmt.bufPrint(&max_buf, "${d:.0}", .{max_price}) catch ""; const min_label = std.fmt.bufPrint(&min_buf, "${d:.0}", .{min_price}) catch ""; - // ASCII block chart (compatible with glyph() which is ASCII-only) - const chart_height: usize = 10; - for (0..chart_height) |row| { - var line_chars: [72]u8 = undefined; - var lpos: usize = 0; - line_chars[0] = ' '; - line_chars[1] = ' '; - lpos = 2; + // Map each data column to a dot-row position (0 = top, dot_rows-1 = bottom) + const n_cols = @min(data.len, chart_width); + const dot_y = try arena.alloc(usize, n_cols); + const col_color = try arena.alloc([3]u8, n_cols); - for (0..chart_width) |col| { - // Map column to data index - const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(chart_width - 1)); - const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1); - const close = data[data_idx].close; + for (0..n_cols) |col| { + const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1)); + const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1); + const close = data[data_idx].close; + const norm = (close - min_price) / price_range; // 0 = min, 1 = max + // Inverted: 0 = top dot row, dot_rows-1 = bottom + const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1)); + dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1); - // Map price to row (inverted) - const norm = (close - min_price) / price_range; - const fill_row: usize = @intFromFloat((1.0 - norm) * @as(f64, @floatFromInt(chart_height - 1))); + // Color: gradient from negative (bottom) to positive (top) + col_color[col] = lerpColor(th.negative, th.positive, norm); + } - if (row == fill_row) { - if (lpos < line_chars.len) { - line_chars[lpos] = '#'; - lpos += 1; - } - } else if (row > fill_row) { - if (lpos < line_chars.len) { - line_chars[lpos] = ':'; - lpos += 1; - } - } else { - if (lpos < line_chars.len) { - line_chars[lpos] = ' '; - lpos += 1; - } - } + // Build the braille grid: each cell is a pattern byte + // Grid is [chart_height][padded_width] where padded_width includes 2 leading spaces + const padded_width: usize = n_cols + 2; // 2 leading spaces + + // Allocate pattern grid + const patterns = try arena.alloc(u8, chart_height * padded_width); + @memset(patterns, 0); + + // For each column, draw a filled area from dot_y[col] down to the bottom, + // plus interpolate a line between adjacent points for smooth contour. + for (0..n_cols) |col| { + const target_y = dot_y[col]; + // Fill from target_y to bottom + for (target_y..dot_rows) |dy| { + const term_row = dy / 4; + const sub_row = dy % 4; + // Left column of the braille cell (we use col 0 of each 2-wide cell) + const grid_col = col + 2; // offset for 2 leading spaces + patterns[term_row * padded_width + grid_col] |= braille_dots[sub_row][0]; } - // Right label + // Interpolate between this point and the next for the line contour + if (col + 1 < n_cols) { + const y0 = dot_y[col]; + const y1 = dot_y[col + 1]; + const min_y = @min(y0, y1); + const max_y = @max(y0, y1); + // Fill intermediate dots on this column for vertical segments + for (min_y..max_y + 1) |dy| { + const term_row = dy / 4; + const sub_row = dy % 4; + const grid_col = col + 2; + patterns[term_row * padded_width + grid_col] |= braille_dots[sub_row][0]; + } + } + } + + // Render each terminal row as a StyledLine with graphemes + const bg = th.bg; + for (0..chart_height) |row| { + const graphemes = try arena.alloc([]const u8, padded_width + 10); // extra for label + const styles = try arena.alloc(vaxis.Style, padded_width + 10); + var gpos: usize = 0; + + // 2 leading spaces + graphemes[gpos] = " "; + styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) }; + gpos += 1; + graphemes[gpos] = " "; + styles[gpos] = styles[0]; + gpos += 1; + + // Chart columns + for (0..n_cols) |col| { + const pattern = patterns[row * padded_width + col + 2]; + graphemes[gpos] = brailleGlyph(pattern); + // Use the column's color for non-empty cells, dim for empty + if (pattern != 0) { + styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(col_color[col]), .bg = theme_mod.Theme.vcolor(bg) }; + } else { + styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(bg), .bg = theme_mod.Theme.vcolor(bg) }; + } + gpos += 1; + } + + // Right-side price labels if (row == 0) { const lbl = try std.fmt.allocPrint(arena, " {s}", .{max_label}); for (lbl) |ch| { - if (lpos < line_chars.len) { - line_chars[lpos] = ch; - lpos += 1; + if (gpos < graphemes.len) { + graphemes[gpos] = glyph(ch); + styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) }; + gpos += 1; } } } else if (row == chart_height - 1) { const lbl = try std.fmt.allocPrint(arena, " {s}", .{min_label}); for (lbl) |ch| { - if (lpos < line_chars.len) { - line_chars[lpos] = ch; - lpos += 1; + if (gpos < graphemes.len) { + graphemes[gpos] = glyph(ch); + styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) }; + gpos += 1; } } } - const chart_style: vaxis.Style = .{ .fg = theme_mod.Theme.vcolor(th.accent), .bg = theme_mod.Theme.vcolor(th.bg) }; - try lines.append(arena, .{ .text = try arena.dupe(u8, line_chars[0..lpos]), .style = chart_style }); + try lines.append(arena, .{ + .text = "", + .style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) }, + .graphemes = graphemes[0..gpos], + .cell_styles = styles[0..gpos], + }); } }