Packages

A TUI library for Gleam: correct Unicode, minimal-diff rendering, clean terminal teardown.

Current section

Files

Jump to
etui src etui widgets canvas.gleam
Raw

src/etui/widgets/canvas.gleam

/// Braille canvas widget for high-resolution line charts.
/// Each terminal cell holds a Unicode braille character (U+2800–U+28FF)
/// providing a 2×4 pixel dot-grid per cell. Multiple series are overlaid.
/// Pixel resolution: area.width*2 × area.height*4.
import etui/braille
import etui/buffer
import etui/color
import etui/geometry
import etui/style
import gleam/int
import gleam/list
// ─────────────────────────────────────────────────────────────────
// Types
pub type SeriesFill {
/// Single solid color for all dots.
SeriesSolid(c: style.Color)
/// Left-to-right gradient across the canvas width (in pixels).
SeriesGradient(stops: List(style.Color))
/// Per-column rainbow, static.
SeriesRainbow
/// Rainbow that rotates hue over time.
SeriesAnimatedRainbow
}
pub type Series {
Series(data: List(Int), fill: SeriesFill)
}
pub type Canvas {
Canvas(
series: List(Series),
/// 0 = auto-compute max from all series data.
max_val: Int,
bg: style.Color,
/// Animation period in frames.
period: Int,
)
}
// ─────────────────────────────────────────────────────────────────
// Constructors
pub fn canvas_new(series: List(Series)) -> Canvas {
Canvas(series: series, max_val: 0, bg: style.Default, period: 60)
}
pub fn series_new(data: List(Int)) -> Series {
Series(data: data, fill: SeriesRainbow)
}
pub fn with_series_fill(s: Series, fill: SeriesFill) -> Series {
Series(..s, fill: fill)
}
pub fn with_max(c: Canvas, max: Int) -> Canvas {
Canvas(..c, max_val: int.max(1, max))
}
pub fn with_bg(c: Canvas, bg: style.Color) -> Canvas {
Canvas(..c, bg: bg)
}
pub fn with_period(c: Canvas, period: Int) -> Canvas {
Canvas(..c, period: int.max(1, period))
}
// ─────────────────────────────────────────────────────────────────
// Rendering
/// Render canvas into `area`. `frame` drives animated fills.
pub fn render(
buf: buffer.Buffer,
area: geometry.Rect,
c: Canvas,
frame: Int,
) -> buffer.Buffer {
case area.size.width <= 0 || area.size.height <= 0 {
True -> buf
False -> {
let pw = area.size.width * 2
let ph = area.size.height * 4
let max = case c.max_val {
0 ->
list.fold(c.series, 1, fn(acc, ser) {
list.fold(ser.data, acc, int.max)
})
m -> m
}
let pixels =
list.index_fold(c.series, braille.new(), fn(px_dict, ser, _) {
draw_series(px_dict, ser, pw, ph, max, frame, c.period)
})
braille.flush(buf, area, pixels, c.bg)
}
}
}
// ─────────────────────────────────────────────────────────────────
// Series drawing
fn draw_series(
pixels: braille.Pixels,
ser: Series,
pw: Int,
ph: Int,
max: Int,
frame: Int,
period: Int,
) -> braille.Pixels {
let n = list.length(ser.data)
case n {
0 -> pixels
_ -> {
let coords = data_to_coords(ser.data, n, pw, ph, max)
draw_segments(pixels, ser, coords, pw, frame, period)
}
}
}
fn data_to_coords(
data: List(Int),
n: Int,
pw: Int,
ph: Int,
max: Int,
) -> List(#(Int, Int)) {
let range = int.max(1, max)
let max_px = int.max(0, pw - 1)
let max_py = int.max(0, ph - 1)
list.index_map(data, fn(val, i) {
let px = case n <= 1 {
True -> 0
False -> i * max_px / { n - 1 }
}
let clamped = int.clamp(val, 0, range)
let py = max_py - clamped * max_py / range
#(px, py)
})
}
fn draw_segments(
pixels: braille.Pixels,
ser: Series,
coords: List(#(Int, Int)),
pw: Int,
frame: Int,
period: Int,
) -> braille.Pixels {
case coords {
[] -> pixels
[_] -> pixels
[p0, p1, ..rest] -> {
let #(x0, y0) = p0
let #(x1, y1) = p1
let pts = bresenham(x0, y0, x1, y1)
let pixels =
list.fold(pts, pixels, fn(px_dict, pt) {
let #(px, py) = pt
let fg = series_color(ser.fill, px, pw, frame, period)
braille.put(px_dict, px, py, fg)
})
draw_segments(pixels, ser, [p1, ..rest], pw, frame, period)
}
}
}
// ─────────────────────────────────────────────────────────────────
// Bresenham line algorithm
fn bresenham(x0: Int, y0: Int, x1: Int, y1: Int) -> List(#(Int, Int)) {
let dx = int.absolute_value(x1 - x0)
let dy = 0 - int.absolute_value(y1 - y0)
let sx = case x0 < x1 {
True -> 1
False -> -1
}
let sy = case y0 < y1 {
True -> 1
False -> -1
}
bresenham_loop(x0, y0, x1, y1, sx, sy, dx, dy, dx + dy, [])
}
fn bresenham_loop(
x0: Int,
y0: Int,
x1: Int,
y1: Int,
sx: Int,
sy: Int,
dx: Int,
dy: Int,
err: Int,
acc: List(#(Int, Int)),
) -> List(#(Int, Int)) {
let acc = [#(x0, y0), ..acc]
case x0 == x1 && y0 == y1 {
True -> acc
False -> {
let e2 = 2 * err
let #(err, x0) = case e2 >= dy {
True -> #(err + dy, x0 + sx)
False -> #(err, x0)
}
let #(err, y0) = case e2 <= dx {
True -> #(err + dx, y0 + sy)
False -> #(err, y0)
}
bresenham_loop(x0, y0, x1, y1, sx, sy, dx, dy, err, acc)
}
}
}
// ─────────────────────────────────────────────────────────────────
// Color dispatch
fn series_color(
fill: SeriesFill,
px: Int,
pw: Int,
frame: Int,
period: Int,
) -> style.Color {
let p = int.max(1, period)
let w = int.max(1, pw)
case fill {
SeriesSolid(c) -> c
SeriesGradient(stops) -> color.gradient(stops, px, w - 1)
SeriesRainbow -> color.hue_to_rgb(px * 360 / w)
SeriesAnimatedRainbow ->
color.hue_to_rgb({ px * 360 / w + frame * 360 / p } % 360)
}
}